improve YMM experience

Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
Chloe 2024-07-10 11:29:15 +07:00
parent 4fec476285
commit 589efe5699
No known key found for this signature in database
GPG Key ID: CFD53CE570D42DF5
4 changed files with 157 additions and 127 deletions

View File

@ -9,10 +9,8 @@ import {
} from '@headlessui/react'; } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/16/solid'; import { ChevronDownIcon } from '@heroicons/react/16/solid';
import Spinner from 'components/spinner'; import Spinner from 'components/spinner';
import { useDebounce } from 'hooks/use-debounce';
import get from 'lodash.get'; import get from 'lodash.get';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useState } from 'react';
import { useInView } from 'react-intersection-observer';
type FilterFieldProps<T extends { [key: string]: unknown }> = { type FilterFieldProps<T extends { [key: string]: unknown }> = {
options: T[]; options: T[];
@ -26,9 +24,6 @@ type FilterFieldProps<T extends { [key: string]: unknown }> = {
disabled?: boolean; disabled?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
isLoading?: boolean; isLoading?: boolean;
// eslint-disable-next-line no-unused-vars
loadMore?: (reset?: boolean) => void;
hasNextPage?: boolean;
}; };
const FilterField = <T extends { [key: string]: unknown }>({ const FilterField = <T extends { [key: string]: unknown }>({
@ -40,9 +35,7 @@ const FilterField = <T extends { [key: string]: unknown }>({
getId, getId,
disabled, disabled,
isLoading, isLoading,
autoFocus = false, autoFocus = false
loadMore,
hasNextPage
}: FilterFieldProps<T>) => { }: FilterFieldProps<T>) => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const getDisplayValue = useCallback( const getDisplayValue = useCallback(
@ -57,7 +50,6 @@ const FilterField = <T extends { [key: string]: unknown }>({
}, },
[displayKey] [displayKey]
); );
const [scrollTrigger, isInView] = useInView();
const filteredOptions = const filteredOptions =
query === '' query === ''
@ -66,26 +58,6 @@ const FilterField = <T extends { [key: string]: unknown }>({
return getDisplayValue(option).toLocaleLowerCase().includes(query.toLowerCase()); return getDisplayValue(option).toLocaleLowerCase().includes(query.toLowerCase());
}); });
const loadMoreFnRef = useRef<Function>();
useEffect(() => {
loadMoreFnRef.current = loadMore;
}, [loadMore]);
useEffect(() => {
if (isInView && hasNextPage) {
loadMoreFnRef.current?.();
}
}, [isInView, hasNextPage]);
const debouncedQuery = useDebounce(query);
useEffect(() => {
if (debouncedQuery && !filteredOptions.length) {
loadMoreFnRef.current?.(true);
}
}, [debouncedQuery, filteredOptions.length]);
return ( return (
<div className="w-full"> <div className="w-full">
<Combobox <Combobox
@ -94,7 +66,7 @@ const FilterField = <T extends { [key: string]: unknown }>({
onChange={onChange} onChange={onChange}
onClose={() => setQuery('')} onClose={() => setQuery('')}
immediate immediate
disabled={disabled || isLoading} disabled={disabled}
> >
<div className="relative"> <div className="relative">
<ComboboxInput <ComboboxInput
@ -109,7 +81,7 @@ const FilterField = <T extends { [key: string]: unknown }>({
{isLoading ? ( {isLoading ? (
<Spinner className="fill-black/60" /> <Spinner className="fill-black/60" />
) : ( ) : (
<ChevronDownIcon className="fill-black/60 group-data-[hover]:fill-black size-5" /> <ChevronDownIcon className="size-5 fill-black/60 group-data-[hover]:fill-black" />
)} )}
</ComboboxButton> </ComboboxButton>
</div> </div>
@ -122,7 +94,6 @@ const FilterField = <T extends { [key: string]: unknown }>({
key={getId(option)} key={getId(option)}
value={option} value={option}
className="flex cursor-default select-none items-center gap-2 rounded-lg px-3 py-1.5 text-sm/6 data-[focus]:bg-secondary/10" className="flex cursor-default select-none items-center gap-2 rounded-lg px-3 py-1.5 text-sm/6 data-[focus]:bg-secondary/10"
ref={scrollTrigger}
> >
{getDisplayValue(option)} {getDisplayValue(option)}
</ComboboxOption> </ComboboxOption>

View File

@ -6,7 +6,7 @@ import { Menu, Metaobject, PageInfo } from 'lib/shopify/types';
import { createUrl, findParentCollection } from 'lib/utils'; import { createUrl, findParentCollection } from 'lib/utils';
import get from 'lodash.get'; import get from 'lodash.get';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState, useTransition } from 'react';
import { fetchMetaobjectReferences } from './actions'; import { fetchMetaobjectReferences } from './actions';
import FilterField from './field'; import FilterField from './field';
@ -35,13 +35,14 @@ const sortOptions = (options: Metaobject[], displayField: string) => {
return modelA.localeCompare(modelB); return modelA.localeCompare(modelB);
}); });
}; };
const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) => { const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) => {
const params = useParams<{ collection?: string }>(); const params = useParams<{ collection?: string }>();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const makeIdFromSearchParams = searchParams.get(MAKE_FILTER_ID); const makeIdFromSearchParams = searchParams.get(MAKE_FILTER_ID);
// const modelIdFromSearchParams = searchParams.get(MODEL_FILTER_ID); const modelIdFromSearchParams = searchParams.get(MODEL_FILTER_ID);
// const yearIdFromSearchParams = searchParams.get(YEAR_FILTER_ID); const yearIdFromSearchParams = searchParams.get(YEAR_FILTER_ID);
const parentCollection = params.collection ? findParentCollection(menu, params.collection) : null; const parentCollection = params.collection ? findParentCollection(menu, params.collection) : null;
// get the active collection (if any) to identify the default part type. // get the active collection (if any) to identify the default part type.
@ -54,6 +55,8 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
(partTypeCollection && partTypeCollection.includes(type.value)) (partTypeCollection && partTypeCollection.includes(type.value))
) || null ) || null
); );
const [, initialMake, modelFromHandle, yearFromHandle] = params.collection?.split('_') || [];
const [make, setMake] = useState<Metaobject | null>(null); const [make, setMake] = useState<Metaobject | null>(null);
const [model, setModel] = useState<Metaobject | null>(null); const [model, setModel] = useState<Metaobject | null>(null);
const [year, setYear] = useState<Metaobject | null>(null); const [year, setYear] = useState<Metaobject | null>(null);
@ -61,58 +64,10 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
const [models, setModels] = useState<Options>({ options: [], pageInfo: null }); const [models, setModels] = useState<Options>({ options: [], pageInfo: null });
const [years, setYears] = useState<Options>({ options: [], pageInfo: null }); const [years, setYears] = useState<Options>({ options: [], pageInfo: null });
const [loadingAttribute, setLoadingAttribute] = useState<'models' | 'years'>();
const disabled = !partType || !make || !model || !year; const disabled = !partType || !make || !model || !year;
const [, initialMake] = params.collection?.split('_') || [];
const handleFetchModels = useCallback( const [isLoadingModels, startLoadingModels] = useTransition();
async (params: { makeId?: string; reset?: boolean; after?: string }) => { const [isLoadingYears, startLoadingYears] = useTransition();
const { makeId, reset, after } = params;
setLoadingAttribute('models');
const modelsResponse = await fetchMetaobjectReferences(makeId, after);
setModels((models) => {
if (reset) {
return {
options: modelsResponse?.references || [],
pageInfo: modelsResponse?.pageInfo || null
};
}
return {
options: models.options.concat(modelsResponse?.references || []),
pageInfo: modelsResponse?.pageInfo || models.pageInfo
};
});
setLoadingAttribute(undefined);
},
[]
);
const handleFetchYears = useCallback(
async (params: { modelId?: string; after?: string; reset?: boolean }) => {
const { modelId, after, reset } = params;
setLoadingAttribute('years');
const yearsResponse = await fetchMetaobjectReferences(modelId, after);
setYears((years) => {
if (reset) {
return {
options: yearsResponse?.references || [],
pageInfo: yearsResponse?.pageInfo || null
};
}
return {
options: years.options.concat(yearsResponse?.references || []),
pageInfo: yearsResponse?.pageInfo || years.pageInfo
};
});
setLoadingAttribute(undefined);
},
[]
);
useEffect(() => { useEffect(() => {
if (partType) { if (partType) {
@ -134,17 +89,152 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
} }
}, [initialMake, makeIdFromSearchParams, makes, partType]); }, [initialMake, makeIdFromSearchParams, makes, partType]);
useEffect(() => { const loadRestModels = useCallback(
if (make?.id) { async (pageInfo?: PageInfo | null) => {
handleFetchModels({ makeId: make?.id, reset: true }); let _pageInfo = pageInfo;
} const results = [] as Metaobject[];
}, [make?.id, handleFetchModels]); while (_pageInfo?.hasNextPage) {
const modelsResponse = await fetchMetaobjectReferences(make?.id, _pageInfo?.endCursor);
results.push(...(modelsResponse?.references || []));
_pageInfo = modelsResponse?.pageInfo || null;
}
setModels((models) => {
return {
options: models.options.concat(results),
pageInfo: _pageInfo || null
};
});
},
[make?.id]
);
const loadRestYears = useCallback(
async (pageInfo?: PageInfo | null) => {
let _pageInfo = pageInfo;
const results = [] as Metaobject[];
while (_pageInfo?.hasNextPage) {
const yearsResponse = await fetchMetaobjectReferences(model?.id, _pageInfo?.endCursor);
results.push(...(yearsResponse?.references || []));
_pageInfo = yearsResponse?.pageInfo || null;
}
setYears((years) => {
return {
options: years.options.concat(results),
pageInfo: _pageInfo || null
};
});
},
[model?.id]
);
useEffect(() => { useEffect(() => {
if (model?.id) { const handleFetchModels = async (params: {
handleFetchYears({ modelId: model?.id, reset: true }); makeId?: string;
reset?: boolean;
after?: string;
}) => {
const { makeId, reset, after } = params;
const modelsResponse = await fetchMetaobjectReferences(makeId, after);
setModels((models) => {
if (reset) {
return {
options: modelsResponse?.references || [],
pageInfo: modelsResponse?.pageInfo || null
};
}
return {
options: models.options.concat(modelsResponse?.references || []),
pageInfo: modelsResponse?.pageInfo || models.pageInfo
};
});
if (modelsResponse?.pageInfo?.hasNextPage) {
loadRestModels(modelsResponse?.pageInfo);
}
};
if (make?.id) {
startLoadingModels(async () => {
await handleFetchModels({ makeId: make?.id, reset: true });
});
} }
}, [handleFetchYears, model?.id]); }, [make?.id, loadRestModels]);
useEffect(() => {
const handleFetchYears = async (params: {
modelId?: string;
after?: string;
reset?: boolean;
}) => {
const { modelId, after, reset } = params;
const yearsResponse = await fetchMetaobjectReferences(modelId, after);
setYears((years) => {
if (reset) {
return {
options: yearsResponse?.references || [],
pageInfo: yearsResponse?.pageInfo || null
};
}
return {
options: years.options.concat(yearsResponse?.references || []),
pageInfo: yearsResponse?.pageInfo || years.pageInfo
};
});
if (yearsResponse?.pageInfo?.hasNextPage) {
loadRestYears(yearsResponse?.pageInfo);
}
};
if (model?.id) {
startLoadingYears(async () => {
await handleFetchYears({ modelId: model?.id, reset: true });
});
}
}, [loadRestYears, model?.id]);
// compute the initial model from the url
useEffect(() => {
if (modelIdFromSearchParams || modelFromHandle) {
const selectedModel =
models.options.find((model) =>
modelIdFromSearchParams
? model.id === modelIdFromSearchParams
: modelFromHandle === model.name!.toLowerCase()
) || null;
setModel((currentModel) =>
currentModel?.id !== selectedModel?.id ? selectedModel : currentModel
);
}
}, [modelFromHandle, modelIdFromSearchParams, models.options]);
// compute the initial year from the url
useEffect(() => {
if (yearIdFromSearchParams || yearFromHandle) {
const selectedYear =
years.options.find((year) =>
yearIdFromSearchParams
? year.id === yearIdFromSearchParams
: yearFromHandle === year.name!.toLowerCase()
) || null;
setYear((currentYear) => (currentYear?.id !== selectedYear?.id ? selectedYear : currentYear));
}
}, [yearFromHandle, yearIdFromSearchParams, years.options]);
useEffect(() => {
const selectedModel = models.options.find((model) =>
modelIdFromSearchParams
? model.id === modelIdFromSearchParams
: modelFromHandle === model.name!.toLowerCase()
);
setModel(selectedModel || null);
}, [modelFromHandle, modelIdFromSearchParams, models.options]);
const onChangeMake = async (value: Metaobject | null) => { const onChangeMake = async (value: Metaobject | null) => {
setMake(value); setMake(value);
@ -176,14 +266,6 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
router.push(createUrl(`/search/${partType?.value}`, newSearchParams), { scroll: false }); router.push(createUrl(`/search/${partType?.value}`, newSearchParams), { scroll: false });
}; };
const handleLoadMoreModels = (reset?: boolean) => {
return handleFetchModels({ makeId: make?.id, after: models.pageInfo?.endCursor, reset });
};
const handleLoadMoreYears = (reset?: boolean) => {
return handleFetchYears({ modelId: model?.id, after: years.pageInfo?.endCursor, reset });
};
const sortedyear = sortYears(years.options); const sortedyear = sortYears(years.options);
const sortedModels = sortOptions(models.options, 'name'); const sortedModels = sortOptions(models.options, 'name');
@ -216,9 +298,7 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
getId={(option) => option.id} getId={(option) => option.id}
disabled={!make} disabled={!make}
autoFocus={autoFocusField === 'model'} autoFocus={autoFocusField === 'model'}
isLoading={loadingAttribute === 'models'} isLoading={isLoadingModels}
loadMore={handleLoadMoreModels}
hasNextPage={models.pageInfo?.hasNextPage}
/> />
<FilterField <FilterField
label="Year" label="Year"
@ -228,9 +308,7 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
getId={(option) => option.id} getId={(option) => option.id}
disabled={!model || !make} disabled={!model || !make}
autoFocus={autoFocusField === 'year'} autoFocus={autoFocusField === 'year'}
isLoading={loadingAttribute === 'years'} isLoading={isLoadingYears}
loadMore={handleLoadMoreYears}
hasNextPage={years.pageInfo?.hasNextPage}
/> />
<Button <Button
onClick={onSearch} onClick={onSearch}

View File

@ -37,7 +37,6 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.51.5", "react-hook-form": "^7.51.5",
"react-intersection-observer": "^9.10.3",
"react-tooltip": "^5.26.3", "react-tooltip": "^5.26.3",
"tailwind-merge": "^2.2.2", "tailwind-merge": "^2.2.2",
"tailwind-variants": "^0.2.1", "tailwind-variants": "^0.2.1",

18
pnpm-lock.yaml generated
View File

@ -53,9 +53,6 @@ importers:
react-hook-form: react-hook-form:
specifier: ^7.51.5 specifier: ^7.51.5
version: 7.51.5(react@18.2.0) version: 7.51.5(react@18.2.0)
react-intersection-observer:
specifier: ^9.10.3
version: 9.10.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-tooltip: react-tooltip:
specifier: ^5.26.3 specifier: ^5.26.3
version: 5.26.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 5.26.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -1918,15 +1915,6 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 react: ^16.8.0 || ^17 || ^18
react-intersection-observer@9.10.3:
resolution: {integrity: sha512-9NYfKwPZRovB6QJee7fDg0zz/SyYrqXtn5xTZU0vwLtLVBtfu9aZt1pVmr825REE49VPDZ7Lm5SNHjJBOTZHpA==}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react-dom:
optional: true
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -4339,12 +4327,6 @@ snapshots:
dependencies: dependencies:
react: 18.2.0 react: 18.2.0
react-intersection-observer@9.10.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
react: 18.2.0
optionalDependencies:
react-dom: 18.2.0(react@18.2.0)
react-is@16.13.1: {} react-is@16.13.1: {}
react-tooltip@5.26.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0): react-tooltip@5.26.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0):