300 lines
9.0 KiB
TypeScript
300 lines
9.0 KiB
TypeScript
|
|
import { apiFetch } from './client';
|
||
|
|
import type {
|
||
|
|
BrandFilter,
|
||
|
|
CategoryApiData,
|
||
|
|
CategoryData,
|
||
|
|
ComboSet,
|
||
|
|
PagingCollection,
|
||
|
|
PriceFilter,
|
||
|
|
Product,
|
||
|
|
ProductImageDetail,
|
||
|
|
ProductImageGallery,
|
||
|
|
ProductDetailData,
|
||
|
|
} from '@/types';
|
||
|
|
import type { TypeListProduct } from '@/types/global/TypeListProduct';
|
||
|
|
import { normalizeAssetUrl, normalizeHtmlAssetUrls } from '@/lib/normalizeAssetUrl';
|
||
|
|
|
||
|
|
function normalizeSlug(slug: string) {
|
||
|
|
return slug.replace(/^\/+/, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeSearch(search?: string) {
|
||
|
|
if (!search) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
return search.startsWith('?') ? search : `?${search}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeProductList(productList: Product[] | Record<string, Product> | undefined) {
|
||
|
|
if (!productList) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
return Array.isArray(productList) ? productList : Object.values(productList);
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeProductImage(image: ProductImageDetail): ProductImageDetail {
|
||
|
|
return {
|
||
|
|
small: normalizeAssetUrl(image.small),
|
||
|
|
large: normalizeAssetUrl(image.large),
|
||
|
|
original: normalizeAssetUrl(image.original),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeProductGallery(images: ProductImageGallery[] | undefined) {
|
||
|
|
if (!images) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
return images.map((image) => ({
|
||
|
|
...image,
|
||
|
|
size: normalizeProductImage(image.size),
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeComboSet(comboSet: ComboSet[] | undefined) {
|
||
|
|
if (!comboSet) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
return comboSet.map((set) => ({
|
||
|
|
...set,
|
||
|
|
group_list: set.group_list.map((group) => ({
|
||
|
|
...group,
|
||
|
|
product_list: group.product_list.map((product) => ({
|
||
|
|
...product,
|
||
|
|
images: {
|
||
|
|
small: normalizeAssetUrl(product.images.small),
|
||
|
|
large: normalizeAssetUrl(product.images.large),
|
||
|
|
},
|
||
|
|
})),
|
||
|
|
})),
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeProductDetail(data: ProductDetailData): ProductDetailData {
|
||
|
|
return {
|
||
|
|
...data,
|
||
|
|
image: normalizeAssetUrl(data.image),
|
||
|
|
combo_set: normalizeComboSet(data.combo_set),
|
||
|
|
product_info: {
|
||
|
|
...data.product_info,
|
||
|
|
productImage: normalizeProductImage(data.product_info.productImage),
|
||
|
|
productImageGallery: normalizeProductGallery(data.product_info.productImageGallery),
|
||
|
|
productDescription: normalizeHtmlAssetUrls(data.product_info.productDescription),
|
||
|
|
productSpec: normalizeHtmlAssetUrls(data.product_info.productSpec),
|
||
|
|
multipartSpec: normalizeHtmlAssetUrls(data.product_info.multipartSpec),
|
||
|
|
productSummary: normalizeHtmlAssetUrls(data.product_info.productSummary),
|
||
|
|
thum_poster: normalizeAssetUrl(data.product_info.thum_poster),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function toNumber(value: string | number | null | undefined) {
|
||
|
|
const normalizedValue =
|
||
|
|
typeof value === 'string' ? value.replace(/[^\d.-]/g, '') : (value ?? 0).toString();
|
||
|
|
const parsedValue = Number(normalizedValue);
|
||
|
|
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeText(value: string | number | undefined) {
|
||
|
|
return String(value ?? '')
|
||
|
|
.normalize('NFD')
|
||
|
|
.replace(/[\u0300-\u036f]/g, '')
|
||
|
|
.toLowerCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
function isCurrentFilterUrl(searchParams: URLSearchParams, targetUrl: string) {
|
||
|
|
const target = new URL(targetUrl, 'http://local');
|
||
|
|
const targetParams = new URLSearchParams(target.search);
|
||
|
|
|
||
|
|
if ([...targetParams.keys()].length !== [...searchParams.keys()].length) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return [...targetParams.entries()].every(
|
||
|
|
([key, value]) => (searchParams.get(key) ?? '') === value,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function filterByBrand(products: Product[], brandFilter: BrandFilter | undefined) {
|
||
|
|
if (!brandFilter) {
|
||
|
|
return products;
|
||
|
|
}
|
||
|
|
|
||
|
|
const expectedBrand = normalizeText(brandFilter.brand_index || brandFilter.name);
|
||
|
|
|
||
|
|
return products.filter((product) => {
|
||
|
|
const brandCandidates = [
|
||
|
|
product.brand?.brand_index,
|
||
|
|
product.brand?.name,
|
||
|
|
product.brand?.url,
|
||
|
|
product.categories?.[0]?.name,
|
||
|
|
];
|
||
|
|
|
||
|
|
return brandCandidates.some((candidate) => normalizeText(candidate).includes(expectedBrand));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function filterByPrice(products: Product[], priceFilter: PriceFilter | undefined) {
|
||
|
|
if (!priceFilter || (!priceFilter.min && !priceFilter.max)) {
|
||
|
|
return products;
|
||
|
|
}
|
||
|
|
|
||
|
|
const min = toNumber(priceFilter.min);
|
||
|
|
const max = toNumber(priceFilter.max);
|
||
|
|
|
||
|
|
return products.filter((product) => {
|
||
|
|
const price = toNumber(product.price);
|
||
|
|
if (min > 0 && price < min) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (max > 0 && price > max) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function sortProducts(products: Product[], sortKey: string | null) {
|
||
|
|
const sortedProducts = [...products];
|
||
|
|
|
||
|
|
switch (sortKey) {
|
||
|
|
case 'price-desc':
|
||
|
|
sortedProducts.sort((left, right) => toNumber(right.price) - toNumber(left.price));
|
||
|
|
break;
|
||
|
|
case 'price-asc':
|
||
|
|
sortedProducts.sort((left, right) => toNumber(left.price) - toNumber(right.price));
|
||
|
|
break;
|
||
|
|
case 'view':
|
||
|
|
sortedProducts.sort((left, right) => toNumber(right.visit) - toNumber(left.visit));
|
||
|
|
break;
|
||
|
|
case 'comment':
|
||
|
|
sortedProducts.sort(
|
||
|
|
(left, right) => toNumber(right.comment?.total) - toNumber(left.comment?.total),
|
||
|
|
);
|
||
|
|
break;
|
||
|
|
case 'rating':
|
||
|
|
sortedProducts.sort((left, right) => toNumber(right.rating) - toNumber(left.rating));
|
||
|
|
break;
|
||
|
|
case 'name':
|
||
|
|
sortedProducts.sort((left, right) => left.productName.localeCompare(right.productName, 'vi'));
|
||
|
|
break;
|
||
|
|
case 'new':
|
||
|
|
default:
|
||
|
|
sortedProducts.sort(
|
||
|
|
(left, right) => new Date(right.lastUpdate).getTime() - new Date(left.lastUpdate).getTime(),
|
||
|
|
);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
return sortedProducts;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildPagingCollection(
|
||
|
|
category: CategoryApiData,
|
||
|
|
searchParams: URLSearchParams,
|
||
|
|
totalPages: number,
|
||
|
|
currentPage: number,
|
||
|
|
): PagingCollection[] {
|
||
|
|
return Array.from({ length: totalPages }, (_, index) => {
|
||
|
|
const page = index + 1;
|
||
|
|
const nextSearchParams = new URLSearchParams(searchParams);
|
||
|
|
|
||
|
|
if (page === 1) {
|
||
|
|
nextSearchParams.delete('page');
|
||
|
|
} else {
|
||
|
|
nextSearchParams.set('page', String(page));
|
||
|
|
}
|
||
|
|
|
||
|
|
const queryString = nextSearchParams.toString();
|
||
|
|
|
||
|
|
return {
|
||
|
|
name: String(page),
|
||
|
|
url: queryString ? `${category.current_category.url}?${queryString}` : category.current_category.url,
|
||
|
|
is_active: page === currentPage ? '1' : '0',
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyCategorySearchParams(category: CategoryApiData, search?: string): CategoryData {
|
||
|
|
const searchParams = new URLSearchParams(normalizeSearch(search));
|
||
|
|
let productList = normalizeProductList(category.product_list);
|
||
|
|
|
||
|
|
if (searchParams.get('other_filter') === 'in-stock') {
|
||
|
|
productList = productList.filter((product) => toNumber(product.quantity) > 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedBrand = category.brand_filter_list?.find((item) =>
|
||
|
|
isCurrentFilterUrl(searchParams, item.url),
|
||
|
|
);
|
||
|
|
productList = filterByBrand(productList, selectedBrand);
|
||
|
|
|
||
|
|
const selectedPrice = category.price_filter_list?.find((item) =>
|
||
|
|
isCurrentFilterUrl(searchParams, item.url),
|
||
|
|
);
|
||
|
|
productList = filterByPrice(productList, selectedPrice);
|
||
|
|
|
||
|
|
productList = sortProducts(productList, searchParams.get('sort'));
|
||
|
|
|
||
|
|
const totalProducts = productList.length;
|
||
|
|
const configuredPageCount = toNumber(category.paging_count);
|
||
|
|
const perPage =
|
||
|
|
configuredPageCount > 0 ? Math.max(1, Math.ceil(totalProducts / configuredPageCount)) : totalProducts;
|
||
|
|
const totalPages =
|
||
|
|
perPage > 0 ? Math.max(1, Math.ceil(totalProducts / perPage)) : Math.max(1, configuredPageCount);
|
||
|
|
const requestedPage = toNumber(searchParams.get('page'));
|
||
|
|
const currentPage = Math.min(Math.max(requestedPage || 1, 1), totalPages);
|
||
|
|
const pagedProducts =
|
||
|
|
perPage > 0 ? productList.slice((currentPage - 1) * perPage, currentPage * perPage) : productList;
|
||
|
|
|
||
|
|
return {
|
||
|
|
...category,
|
||
|
|
product_count: String(totalProducts),
|
||
|
|
paging_count: String(totalPages),
|
||
|
|
paging_collection: buildPagingCollection(category, searchParams, totalPages, currentPage),
|
||
|
|
product_list: pagedProducts,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getProductHot() {
|
||
|
|
return apiFetch<TypeListProduct>('/products/hot');
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getProductDetail(slug: string) {
|
||
|
|
return apiFetch<ProductDetailData>(`/products/${normalizeSlug(slug)}`).then((product) =>
|
||
|
|
normalizeProductDetail(product),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getProductComments(slug: string) {
|
||
|
|
return apiFetch<import('@/types/Comment').ProductCommentData[]>(
|
||
|
|
`/products/${normalizeSlug(slug)}/comments`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getProductReviews(slug: string) {
|
||
|
|
return apiFetch<import('@/types/Review').ProductReviewData[]>(
|
||
|
|
`/products/${normalizeSlug(slug)}/reviews`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getProductCategory(slug: string, search?: string) {
|
||
|
|
const normalizedSearch = normalizeSearch(search);
|
||
|
|
|
||
|
|
return apiFetch<CategoryApiData>(`/products/category/${normalizeSlug(slug)}${normalizedSearch}`).then(
|
||
|
|
(category) => applyCategorySearchParams(category, normalizedSearch),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getProductHotPage(slug: string) {
|
||
|
|
return apiFetch<import('@/types/producthot').TypeProductHot>(
|
||
|
|
`/products/hot-page/${normalizeSlug(slug)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getProductSearch(q: string) {
|
||
|
|
return apiFetch<import('@/types/product/search').TypeProductSearch>(`/products/search?q=${encodeURIComponent(q)}`);
|
||
|
|
}
|