From a9bfc667af5add03583c6ff250ea33cd3610ee66 Mon Sep 17 00:00:00 2001 From: Thomas Cristina de Carvalho Date: Wed, 7 Feb 2024 18:52:12 -0500 Subject: [PATCH] Add responsive layout to collection product grid --- app/components/cart/CartDetails.tsx | 4 +- app/components/cart/CartEmpty.tsx | 2 +- app/components/cart/CartLines.tsx | 39 +- app/components/collection/Filter.tsx | 242 +++++++++++ app/components/collection/Sort.tsx | 186 ++++++++ app/components/collection/SortFilter.tsx | 397 ------------------ .../collection/SortFilterLayout.tsx | 240 +++++++++++ app/components/layout/CartDrawer.tsx | 2 +- app/components/layout/Header.tsx | 112 +++-- .../navigation/DesktopNavigation.tsx | 2 +- .../navigation/MobileNavigation.tsx | 17 +- app/components/product/ProductCardGrid.tsx | 2 +- .../sections/CollectionProductGridSection.tsx | 5 +- app/components/ui/Checkbox.tsx | 28 ++ app/components/ui/RadioGroup.tsx | 42 ++ app/components/ui/ScrollArea.tsx | 45 ++ app/lib/shopifyCollection.ts | 2 +- package.json | 3 + pnpm-lock.yaml | 96 +++++ tailwind.config.ts | 3 +- 20 files changed, 1019 insertions(+), 450 deletions(-) create mode 100644 app/components/collection/Filter.tsx create mode 100644 app/components/collection/Sort.tsx delete mode 100644 app/components/collection/SortFilter.tsx create mode 100644 app/components/collection/SortFilterLayout.tsx create mode 100644 app/components/ui/Checkbox.tsx create mode 100644 app/components/ui/RadioGroup.tsx create mode 100644 app/components/ui/ScrollArea.tsx diff --git a/app/components/cart/CartDetails.tsx b/app/components/cart/CartDetails.tsx index 491c374a..96f47b04 100644 --- a/app/components/cart/CartDetails.tsx +++ b/app/components/cart/CartDetails.tsx @@ -26,9 +26,7 @@ export function CartDetails({ return ( -
- -
+ {cartHasItems && ( diff --git a/app/components/cart/CartEmpty.tsx b/app/components/cart/CartEmpty.tsx index 5fae8e50..a9ff4b32 100644 --- a/app/components/cart/CartEmpty.tsx +++ b/app/components/cart/CartEmpty.tsx @@ -18,7 +18,7 @@ export function CartEmpty({ }) { const container = { drawer: cx([ - 'p-5 content-start gap-4 pb-8 transition flex-1 overflow-y-scroll md:gap-12 md:pb-12', + 'p-5 content-start gap-4 pb-8 transition flex-1 md:gap-12 md:pb-12', ]), page: cx([ !hidden && 'grid', diff --git a/app/components/cart/CartLines.tsx b/app/components/cart/CartLines.tsx index d52405e5..e9a51abf 100644 --- a/app/components/cart/CartLines.tsx +++ b/app/components/cart/CartLines.tsx @@ -8,6 +8,7 @@ import {cx} from 'class-variance-authority'; import type {CartLayouts} from './Cart'; +import {ScrollArea} from '../ui/ScrollArea'; import {CartLineItem} from './CartLineItem'; export function CartLines({ @@ -26,20 +27,34 @@ export function CartLines({ const className = cx([ layout === 'page' ? 'flex-grow md:translate-y-4' - : 'px-6 py-6 overflow-auto transition md:px-12', + : 'pl-4 pr-2 py-6 overflow-auto transition md:px-12', ]); return ( -
-
    - {currentLines.map((line) => ( - - ))} -
-
+ +
+
    + {currentLines.map((line) => ( + + ))} +
+
+
); } + +function Layout(props: {children: React.ReactNode; layout: CartLayouts}) { + if (props.layout === 'drawer') { + return ( + + {props.children} + + ); + } + + return <>{props.children}; +} diff --git a/app/components/collection/Filter.tsx b/app/components/collection/Filter.tsx new file mode 100644 index 00000000..219578ea --- /dev/null +++ b/app/components/collection/Filter.tsx @@ -0,0 +1,242 @@ +import type {Location} from '@remix-run/react'; +import type { + Filter, + ProductFilter, +} from '@shopify/hydrogen/storefront-api-types'; +import type {SyntheticEvent} from 'react'; + +import { + PrefetchPageLinks, + useLocation, + useNavigate, + useNavigation, + useSearchParams, +} from '@remix-run/react'; +import {useCallback, useMemo, useState} from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; + +import {cn} from '~/lib/utils'; + +import {Checkbox} from '../ui/Checkbox'; +import {Input} from '../ui/Input'; +import {Label} from '../ui/Label'; +import {type AppliedFilter, FILTER_URL_PREFIX} from './SortFilterLayout'; + +export function DefaultFilter(props: { + appliedFilters: AppliedFilter[]; + option: Filter['values'][0]; +}) { + const {appliedFilters, option} = props; + const [params] = useSearchParams(); + const [prefetchPage, setPrefetchPage] = useState(null); + const navigate = useNavigate(); + const navigation = useNavigation(); + const location = useLocation(); + const addFilterLink = getFilterLink(option.input as string, params, location); + const appliedFilter = getAppliedFilter(option, appliedFilters); + const isNavigationPending = navigation.state !== 'idle'; + + const getRemoveFilterLink = useCallback(() => { + if (!appliedFilter) { + return null; + } + return getAppliedFilterLink(appliedFilter, params, location); + }, [appliedFilter, params, location]); + + const handleToggleFilter = useCallback(() => { + if (appliedFilter) { + const removeFilterLink = getRemoveFilterLink(); + if (removeFilterLink) { + navigate(removeFilterLink, { + preventScrollReset: true, + replace: true, + }); + } + return; + } + + navigate(addFilterLink, { + preventScrollReset: true, + replace: true, + }); + }, [addFilterLink, appliedFilter, navigate, getRemoveFilterLink]); + + // Prefetch the page that will be navigated to when the user hovers or touches the filter + const handleSetPrefetch = useCallback(() => { + const removeFilterLink = getRemoveFilterLink(); + if (appliedFilter) { + setPrefetchPage(removeFilterLink); + return; + } + + setPrefetchPage(addFilterLink); + }, [getRemoveFilterLink, addFilterLink, appliedFilter]); + + return ( +
+ + + {prefetchPage && } +
+ ); +} + +const PRICE_RANGE_FILTER_DEBOUNCE = 500; + +export function PriceRangeFilter() { + const location = useLocation(); + const params = useMemo( + () => new URLSearchParams(location.search), + [location.search], + ); + const priceFilter = params.get(`${FILTER_URL_PREFIX}price`); + const price = priceFilter + ? (JSON.parse(priceFilter) as ProductFilter['price']) + : undefined; + const min = isNaN(Number(price?.min)) ? undefined : Number(price?.min); + const max = isNaN(Number(price?.max)) ? undefined : Number(price?.max); + const navigate = useNavigate(); + + const [minPrice, setMinPrice] = useState(min); + const [maxPrice, setMaxPrice] = useState(max); + + useDebounce( + () => { + if (minPrice === undefined && maxPrice === undefined) { + params.delete(`${FILTER_URL_PREFIX}price`); + navigate(`${location.pathname}?${params.toString()}`, { + preventScrollReset: true, + replace: true, + }); + return; + } + + const price = { + ...(minPrice === undefined ? {} : {min: minPrice}), + ...(maxPrice === undefined ? {} : {max: maxPrice}), + }; + const newParams = filterInputToParams({price}, params); + navigate(`${location.pathname}?${newParams.toString()}`, { + preventScrollReset: true, + replace: true, + }); + }, + PRICE_RANGE_FILTER_DEBOUNCE, + [minPrice, maxPrice], + ); + + const onChangeMax = (event: SyntheticEvent) => { + const value = (event.target as HTMLInputElement).value; + const newMaxPrice = Number.isNaN(parseFloat(value)) + ? undefined + : parseFloat(value); + setMaxPrice(newMaxPrice); + }; + + const onChangeMin = (event: SyntheticEvent) => { + const value = (event.target as HTMLInputElement).value; + const newMinPrice = Number.isNaN(parseFloat(value)) + ? undefined + : parseFloat(value); + setMinPrice(newMinPrice); + }; + + return ( +
+ + +
+ ); +} + +function getAppliedFilter( + option: Filter['values'][0], + appliedFilters: AppliedFilter[], +) { + return appliedFilters.find((appliedFilter) => { + return JSON.stringify(appliedFilter.filter) === option.input; + }); +} + +function getAppliedFilterLink( + filter: AppliedFilter, + params: URLSearchParams, + location: Location, +) { + const paramsClone = new URLSearchParams(params); + Object.entries(filter.filter).forEach(([key, value]) => { + const fullKey = FILTER_URL_PREFIX + key; + paramsClone.delete(fullKey, JSON.stringify(value)); + }); + return `${location.pathname}?${paramsClone.toString()}`; +} + +function getFilterLink( + rawInput: ProductFilter | string, + params: URLSearchParams, + location: ReturnType, +) { + const paramsClone = new URLSearchParams(params); + const newParams = filterInputToParams(rawInput, paramsClone); + return `${location.pathname}?${newParams.toString()}`; +} + +function filterInputToParams( + rawInput: ProductFilter | string, + params: URLSearchParams, +) { + const input = + typeof rawInput === 'string' + ? (JSON.parse(rawInput) as ProductFilter) + : rawInput; + + Object.entries(input).forEach(([key, value]) => { + if (params.has(`${FILTER_URL_PREFIX}${key}`, JSON.stringify(value))) { + return; + } + if (key === 'price') { + // For price, we want to overwrite + params.set(`${FILTER_URL_PREFIX}${key}`, JSON.stringify(value)); + } else { + params.append(`${FILTER_URL_PREFIX}${key}`, JSON.stringify(value)); + } + }); + + return params; +} diff --git a/app/components/collection/Sort.tsx b/app/components/collection/Sort.tsx new file mode 100644 index 00000000..e413c75d --- /dev/null +++ b/app/components/collection/Sort.tsx @@ -0,0 +1,186 @@ +import type {Location} from '@remix-run/react'; + +import { + Link, + PrefetchPageLinks, + useLocation, + useNavigate, + useNavigation, + useSearchParams, +} from '@remix-run/react'; +import {useCallback, useMemo, useState} from 'react'; + +import type {CmsSectionSettings} from '~/hooks/useSettingsCssVars'; + +import {useSettingsCssVars} from '~/hooks/useSettingsCssVars'; +import {cn} from '~/lib/utils'; + +import type {SortParam} from './SortFilterLayout'; + +import {IconSort} from '../icons/IconSort'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/DropdownMenu'; +import {Label} from '../ui/Label'; +import {RadioGroup, RadioGroupItem} from '../ui/RadioGroup'; + +function useSortItems() { + const location = useLocation(); + const search = location.search; + // Todo => add strings to themeContent + const items: {key: SortParam; label: string}[] = useMemo( + () => [ + {key: 'featured', label: 'Featured'}, + { + key: 'price-low-high', + label: 'Price: Low - High', + }, + { + key: 'price-high-low', + label: 'Price: High - Low', + }, + { + key: 'best-selling', + label: 'Best Selling', + }, + { + key: 'newest', + label: 'Newest', + }, + ], + [], + ); + + const activeItem = items.find((item) => search.includes(`?sort=${item.key}`)); + return {activeItem, items}; +} + +export function SortMenu(props: {sectionSettings?: CmsSectionSettings}) { + const {activeItem, items} = useSortItems(); + const location = useLocation(); + const [params] = useSearchParams(); + const cssVars = useSettingsCssVars({settings: props.sectionSettings}); + + return ( + + + + + + {/* + // Todo => add strings to themeContent + */} + Sort by: + + {(activeItem || items[0]).label} + + + +