change to admin api to fetch YMM options

This commit is contained in:
Chloe 2024-07-09 11:49:38 +07:00
parent eb9b6a9e59
commit 5ef8470526
No known key found for this signature in database
GPG Key ID: CFD53CE570D42DF5
10 changed files with 222 additions and 87 deletions

View File

@ -1,34 +1,18 @@
'use server';
import { getAllMetaobjects } from 'lib/shopify';
import { getAllMetaobjects, getMetaobjectReferences } from 'lib/shopify';
import get from 'lodash.get';
import { cache } from 'react';
export const fetchModels = cache(async () => {
try {
const data = await getAllMetaobjects('make_model_composite');
return data.toSorted((a, b) => {
const modelA = get(a, 'name').toLowerCase();
const modelB = get(b, 'name').toLowerCase();
return modelA.localeCompare(modelB);
});
} catch (error) {
console.log('fetchModels action', error);
export const fetchMetaobjectReferences = cache(async (id?: string, after?: string) => {
if (!id) {
return null;
}
});
export const fetchYears = cache(async () => {
try {
const data = await getAllMetaobjects('make_model_year_composite');
return data.toSorted((a, b) => {
const yearA = parseInt(get(a, 'name'), 10);
const yearB = parseInt(get(b, 'name'), 10);
return yearB - yearA; // Descending order for years
});
const data = await getMetaobjectReferences(id, after);
return data;
} catch (error) {
console.log('fetchYears action', error);
console.log('fetchMetaobjectReferences action', error);
}
});

View File

@ -9,8 +9,10 @@ import {
} from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/16/solid';
import Spinner from 'components/spinner';
import { useDebounce } from 'hooks/use-debounce';
import get from 'lodash.get';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useInView } from 'react-intersection-observer';
type FilterFieldProps<T extends { [key: string]: unknown }> = {
options: T[];
@ -24,6 +26,9 @@ type FilterFieldProps<T extends { [key: string]: unknown }> = {
disabled?: boolean;
autoFocus?: boolean;
isLoading?: boolean;
// eslint-disable-next-line no-unused-vars
loadMore?: (reset?: boolean) => void;
hasNextPage?: boolean;
};
const FilterField = <T extends { [key: string]: unknown }>({
@ -35,7 +40,9 @@ const FilterField = <T extends { [key: string]: unknown }>({
getId,
disabled,
isLoading,
autoFocus = false
autoFocus = false,
loadMore,
hasNextPage
}: FilterFieldProps<T>) => {
const [query, setQuery] = useState('');
const getDisplayValue = useCallback(
@ -50,6 +57,7 @@ const FilterField = <T extends { [key: string]: unknown }>({
},
[displayKey]
);
const [scrollTrigger, isInView] = useInView();
const filteredOptions =
query === ''
@ -58,6 +66,26 @@ const FilterField = <T extends { [key: string]: unknown }>({
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 (
<div className="w-full">
<Combobox
@ -81,7 +109,7 @@ const FilterField = <T extends { [key: string]: unknown }>({
{isLoading ? (
<Spinner className="fill-black/60" />
) : (
<ChevronDownIcon className="size-5 fill-black/60 group-data-[hover]:fill-black" />
<ChevronDownIcon className="fill-black/60 group-data-[hover]:fill-black size-5" />
)}
</ComboboxButton>
</div>
@ -94,6 +122,7 @@ const FilterField = <T extends { [key: string]: unknown }>({
key={getId(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"
ref={scrollTrigger}
>
{getDisplayValue(option)}
</ComboboxOption>

View File

@ -2,27 +2,46 @@
import { Button } from '@headlessui/react';
import { MAKE_FILTER_ID, MODEL_FILTER_ID, PART_TYPES, YEAR_FILTER_ID } from 'lib/constants';
import { Menu, Metaobject } from 'lib/shopify/types';
import { Menu, Metaobject, PageInfo } from 'lib/shopify/types';
import { createUrl, findParentCollection } from 'lib/utils';
import get from 'lodash.get';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { fetchModels, fetchYears } from './actions';
import { useCallback, useEffect, useState } from 'react';
import { fetchMetaobjectReferences } from './actions';
import FilterField from './field';
type Options = {
options: Metaobject[];
pageInfo: PageInfo | null;
};
type FiltersListProps = {
makes?: Metaobject[];
menu: Menu[];
autoFocusField?: string;
};
const sortYears = (years: Metaobject[]) => {
return years.toSorted((a, b) => {
const yearA = parseInt(get(a, 'name'), 10);
const yearB = parseInt(get(b, 'name'), 10);
return yearB - yearA; // Descending order for years
});
};
const sortOptions = (options: Metaobject[], displayField: string) => {
return options.toSorted((a, b) => {
const modelA = get(a, displayField).toLowerCase();
const modelB = get(b, displayField).toLowerCase();
return modelA.localeCompare(modelB);
});
};
const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) => {
const params = useParams<{ collection?: string }>();
const router = useRouter();
const searchParams = useSearchParams();
const makeIdFromSearchParams = searchParams.get(MAKE_FILTER_ID);
const modelIdFromSearchParams = searchParams.get(MODEL_FILTER_ID);
const yearIdFromSearchParams = searchParams.get(YEAR_FILTER_ID);
// const modelIdFromSearchParams = searchParams.get(MODEL_FILTER_ID);
// const yearIdFromSearchParams = searchParams.get(YEAR_FILTER_ID);
const parentCollection = params.collection ? findParentCollection(menu, params.collection) : null;
// get the active collection (if any) to identify the default part type.
@ -39,15 +58,61 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
const [model, setModel] = useState<Metaobject | null>(null);
const [year, setYear] = useState<Metaobject | null>(null);
const [models, setModels] = useState<Metaobject[]>([]);
const [years, setYears] = useState<Metaobject[]>([]);
const [models, setModels] = useState<Options>({ options: [], pageInfo: null });
const [years, setYears] = useState<Options>({ options: [], pageInfo: null });
const [loadingAttribute, setLoadingAttribute] = useState<'models' | 'years'>();
const modelOptions = make ? models.filter((m) => get(m, 'make') === make.id) : models;
const yearOptions = model ? years.filter((y) => get(y, 'make_model') === model.id) : years;
const disabled = !partType || !make || !model || !year;
const [, initialMake, initialModel, initialYear] = params.collection?.split('_') || [];
const [, initialMake] = params.collection?.split('_') || [];
const handleFetchModels = useCallback(
async (params: { makeId?: string; reset?: boolean; after?: string }) => {
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(() => {
if (partType) {
@ -67,48 +132,19 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
});
}
}
}, [makeIdFromSearchParams, makes, params.collection, partType]);
}, [initialMake, makeIdFromSearchParams, makes, partType]);
useEffect(() => {
const getModels = async () => {
setLoadingAttribute('models');
const modelsResponse = await fetchModels();
setModel(
(currentModel) =>
modelsResponse?.find((model) =>
modelIdFromSearchParams
? model.id === modelIdFromSearchParams
: initialModel === model.name!.toLowerCase()
) || currentModel
);
setModels(modelsResponse || []);
setLoadingAttribute(undefined);
};
if (models.length === 0) {
getModels();
if (make?.id) {
handleFetchModels({ makeId: make?.id, reset: true });
}
}, [modelIdFromSearchParams, models.length]);
}, [make?.id, handleFetchModels]);
useEffect(() => {
const getYears = async () => {
setLoadingAttribute('years');
const yearsResponse = await fetchYears();
setYear(
(currentYear) =>
yearsResponse?.find((year) =>
yearIdFromSearchParams ? year.id === yearIdFromSearchParams : initialYear === year.name
) || currentYear
);
setYears(yearsResponse || []);
setLoadingAttribute(undefined);
};
if (years.length === 0) {
getYears();
if (model?.id) {
handleFetchYears({ modelId: model?.id, reset: true });
}
}, [yearIdFromSearchParams, years.length]);
}, [handleFetchYears, model?.id]);
const onChangeMake = async (value: Metaobject | null) => {
setMake(value);
@ -140,6 +176,17 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
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 sortedModels = sortOptions(models.options, 'name');
return (
<>
<FilterField
@ -165,21 +212,25 @@ const FiltersList = ({ makes = [], menu, autoFocusField }: FiltersListProps) =>
label="Model"
onChange={onChangeModel}
selectedValue={model}
options={modelOptions}
options={sortedModels}
getId={(option) => option.id}
disabled={!make}
autoFocus={autoFocusField === 'model'}
isLoading={loadingAttribute === 'models'}
loadMore={handleLoadMoreModels}
hasNextPage={models.pageInfo?.hasNextPage}
/>
<FilterField
label="Year"
onChange={onChangeYear}
selectedValue={year}
options={yearOptions}
options={sortedyear}
getId={(option) => option.id}
disabled={!model || !make}
autoFocus={autoFocusField === 'year'}
isLoading={loadingAttribute === 'years'}
loadMore={handleLoadMoreYears}
hasNextPage={years.pageInfo?.hasNextPage}
/>
<Button
onClick={onSearch}

View File

@ -1,5 +1,5 @@
import { getMenu } from 'lib/shopify';
import { fetchMakes, fetchModels, fetchYears } from './actions';
import { fetchMakes } from './actions';
import FiltersList from './filters-list';
const title: Record<string, string> = {
@ -13,10 +13,6 @@ const title: Record<string, string> = {
const { STORE_PREFIX } = process.env;
const HomePageFilters = async () => {
// preload models and years
fetchModels();
fetchYears();
const makes = await fetchMakes();
const menu = await getMenu('main-menu');

View File

@ -1,6 +1,6 @@
import { getMenu } from 'lib/shopify';
import { ReactNode } from 'react';
import { fetchMakes, fetchModels, fetchYears } from './actions';
import { fetchMakes } from './actions';
import FiltersList from './filters-list';
const YMMFiltersContainer = ({ children }: { children: ReactNode }) => {
@ -15,10 +15,6 @@ const YMMFiltersContainer = ({ children }: { children: ReactNode }) => {
};
const YMMFilters = async () => {
// preload models and years
fetchModels();
fetchYears();
const makes = await fetchMakes();
const menu = await getMenu('main-menu');

View File

@ -34,6 +34,7 @@ import {
} from './mutations/cart';
import { createFileMutation, createStageUploads } from './mutations/file';
import { updateOrderMetafieldsMutation } from './mutations/order';
import { getMetaobjectReferencesQuery } from './queries/admin/metaobjects';
import { getCartQuery } from './queries/cart';
import {
getCollectionProductsQuery,
@ -926,6 +927,34 @@ export async function getAllMetaobjects(type: string) {
return allMetaobjects;
}
export async function getMetaobjectReferences(
id: string,
after?: string
): Promise<{ references: Metaobject[]; pageInfo: PageInfo | null }> {
const res = await shopifyAdminFetch<{
variables: {
id: string;
after?: string;
};
data: { metaobject: ShopifyMetaobject };
}>({
query: getMetaobjectReferencesQuery,
variables: { id, after }
});
const metaobject = res.body.data.metaobject;
if (!metaobject || !metaobject.referencedBy) {
return { references: [], pageInfo: null };
}
const references = removeEdgesAndNodes(metaobject.referencedBy).map(
({ referencer }) => referencer
);
const pageInfo = metaobject.referencedBy.pageInfo;
return { references: reshapeMetaobjects(references), pageInfo };
}
export async function getMetaobjectsByIds(ids: string[]) {
if (!ids.length) return [];

View File

@ -0,0 +1,30 @@
export const getMetaobjectReferencesQuery = /* GraphQL */ `
query getMetaobjectReferences($id: ID!, $after: String) {
metaobject(id: $id) {
id
referencedBy(first: 20, after: $after) {
edges {
node {
key
referencer {
... on Metaobject {
id
type
fields {
key
value
}
}
}
}
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
}
}
`;

View File

@ -390,6 +390,7 @@ export type ShopifyMetaobject = {
image?: Image;
};
}>;
referencedBy?: Connection<{ referencer: ShopifyMetaobject }> & { pageInfo: PageInfo };
};
export type ShopifyMetafield = {

View File

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

24
pnpm-lock.yaml generated
View File

@ -53,6 +53,9 @@ importers:
react-hook-form:
specifier: ^7.51.5
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:
specifier: ^5.26.3
version: 5.26.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -1915,6 +1918,15 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -3315,7 +3327,7 @@ snapshots:
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@ -3343,7 +3355,7 @@ snapshots:
enhanced-resolve: 5.16.0
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.3
is-core-module: 2.13.1
@ -3365,7 +3377,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5
@ -4327,6 +4339,12 @@ snapshots:
dependencies:
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-tooltip@5.26.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0):