Files
nguyencongpc_nextjs/src/lib/api/product.ts

392 lines
12 KiB
TypeScript
Raw Normal View History

2026-03-13 13:54:45 +07:00
import { apiFetch } from './client';
import type {
BrandFilter,
CategoryApiData,
CategoryData,
ComboSet,
PagingCollection,
PriceFilter,
Product,
2026-03-13 17:23:37 +07:00
ProductDetailData,
2026-03-13 13:54:45 +07:00
ProductImageDetail,
ProductImageGallery,
} from '@/types';
2026-03-13 17:23:37 +07:00
import type { ProductCommentData } from '@/types/Comment';
import type { ProductReviewData } from '@/types/Review';
import type { TypeProductHot } from '@/types/producthot';
import type { TypeProductSearch } from '@/types/product/search';
2026-03-13 13:54:45 +07:00
import type { TypeListProduct } from '@/types/global/TypeListProduct';
import { normalizeAssetUrl, normalizeHtmlAssetUrls } from '@/lib/normalizeAssetUrl';
2026-03-13 17:23:37 +07:00
const SEARCH_PAGE_PATH = '/tim';
interface ListingPageBase {
brand_filter_list?: BrandFilter[];
paging_collection: PagingCollection[];
paging_count: string;
price_filter_list?: PriceFilter[];
product_count: string;
product_list: Product[] | Record<string, Product>;
}
export interface SubmitProductReviewPayload {
content: string;
email: string;
name: string;
rate: number;
}
2026-03-13 13:54:45 +07:00
function normalizeSlug(slug: string) {
return slug.replace(/^\/+/, '');
}
function normalizeSearch(search?: string) {
if (!search) {
return '';
}
return search.startsWith('?') ? search : `?${search}`;
}
2026-03-13 17:23:37 +07:00
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 toProductArray(productList: Product[] | Record<string, Product> | undefined) {
2026-03-13 13:54:45 +07:00
if (!productList) {
return [];
}
return Array.isArray(productList) ? productList : Object.values(productList);
}
2026-03-13 17:23:37 +07:00
function toProductRecord(products: Product[]) {
return products.reduce<Record<string, Product>>((accumulator, product) => {
accumulator[String(product.id)] = product;
return accumulator;
}, {});
}
2026-03-13 13:54:45 +07:00
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 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);
2026-03-13 17:23:37 +07:00
2026-03-13 13:54:45 +07:00
if (min > 0 && price < min) {
return false;
}
2026-03-13 17:23:37 +07:00
2026-03-13 13:54:45 +07:00
if (max > 0 && price > max) {
return false;
}
2026-03-13 17:23:37 +07:00
2026-03-13 13:54:45 +07:00
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(
2026-03-13 17:23:37 +07:00
basePath: string,
2026-03-13 13:54:45 +07:00
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),
2026-03-13 17:23:37 +07:00
url: queryString ? `${basePath}?${queryString}` : basePath,
2026-03-13 13:54:45 +07:00
is_active: page === currentPage ? '1' : '0',
};
});
}
2026-03-13 17:23:37 +07:00
function applyListingSearchParams<T extends ListingPageBase>(
page: T,
search: string | undefined,
basePath: string,
): T {
2026-03-13 13:54:45 +07:00
const searchParams = new URLSearchParams(normalizeSearch(search));
2026-03-13 17:23:37 +07:00
const originalProducts = toProductArray(page.product_list);
let products = [...originalProducts];
2026-03-13 13:54:45 +07:00
if (searchParams.get('other_filter') === 'in-stock') {
2026-03-13 17:23:37 +07:00
products = products.filter((product) => toNumber(product.quantity) > 0);
2026-03-13 13:54:45 +07:00
}
2026-03-13 17:23:37 +07:00
const selectedBrand = page.brand_filter_list?.find((item) =>
2026-03-13 13:54:45 +07:00
isCurrentFilterUrl(searchParams, item.url),
);
2026-03-13 17:23:37 +07:00
products = filterByBrand(products, selectedBrand);
2026-03-13 13:54:45 +07:00
2026-03-13 17:23:37 +07:00
const selectedPrice = page.price_filter_list?.find((item) =>
2026-03-13 13:54:45 +07:00
isCurrentFilterUrl(searchParams, item.url),
);
2026-03-13 17:23:37 +07:00
products = filterByPrice(products, selectedPrice);
2026-03-13 13:54:45 +07:00
2026-03-13 17:23:37 +07:00
products = sortProducts(products, searchParams.get('sort'));
2026-03-13 13:54:45 +07:00
2026-03-13 17:23:37 +07:00
const pageSize =
toNumber(page.paging_count) > 0
? Math.max(1, Math.ceil(originalProducts.length / toNumber(page.paging_count)))
: Math.max(1, originalProducts.length);
const totalProducts = products.length;
const totalPages = Math.max(1, Math.ceil(totalProducts / pageSize));
2026-03-13 13:54:45 +07:00
const requestedPage = toNumber(searchParams.get('page'));
const currentPage = Math.min(Math.max(requestedPage || 1, 1), totalPages);
2026-03-13 17:23:37 +07:00
const pagedProducts = products.slice((currentPage - 1) * pageSize, currentPage * pageSize);
2026-03-13 13:54:45 +07:00
return {
2026-03-13 17:23:37 +07:00
...page,
2026-03-13 13:54:45 +07:00
product_count: String(totalProducts),
paging_count: String(totalPages),
2026-03-13 17:23:37 +07:00
paging_collection: buildPagingCollection(basePath, searchParams, totalPages, currentPage),
product_list: (
Array.isArray(page.product_list) ? pagedProducts : toProductRecord(pagedProducts)
) as T['product_list'],
};
}
function normalizeReviews(reviews: ProductReviewData[]) {
return reviews.map((review) => ({
...review,
user_avatar: review.user_avatar !== '0' ? normalizeAssetUrl(review.user_avatar) : review.user_avatar,
files: review.files.map((file) => ({
...file,
file_path: normalizeAssetUrl(file.file_path),
})),
new_replies: review.new_replies.map((reply) => ({
...reply,
user_avatar: reply.user_avatar !== '0' ? normalizeAssetUrl(reply.user_avatar) : reply.user_avatar,
})),
}));
}
function normalizeComments(comments: ProductCommentData[]) {
return comments.map((comment) => ({
...comment,
user_avatar: comment.user_avatar ? normalizeAssetUrl(comment.user_avatar) : comment.user_avatar,
files: comment.files.map((file) => ({
...file,
file_path: normalizeAssetUrl(file.file_path),
})),
new_replies: comment.new_replies.map((reply) => ({
...reply,
user_avatar: reply.user_avatar !== '0' ? normalizeAssetUrl(reply.user_avatar) : reply.user_avatar,
})),
}));
}
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),
},
2026-03-13 13:54:45 +07:00
};
}
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) {
2026-03-13 17:23:37 +07:00
return apiFetch<ProductCommentData[]>(`/products/${normalizeSlug(slug)}/comments`).then(
normalizeComments,
2026-03-13 13:54:45 +07:00
);
}
export function getProductReviews(slug: string) {
2026-03-13 17:23:37 +07:00
return apiFetch<ProductReviewData[]>(`/products/${normalizeSlug(slug)}/reviews`).then(
normalizeReviews,
2026-03-13 13:54:45 +07:00
);
}
2026-03-13 17:23:37 +07:00
export function submitProductReview(slug: string, payload: SubmitProductReviewPayload) {
return apiFetch<{ message: string; success: boolean }>(`/products/${normalizeSlug(slug)}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
}
2026-03-13 13:54:45 +07:00
export function getProductCategory(slug: string, search?: string) {
2026-03-13 17:23:37 +07:00
return apiFetch<CategoryApiData>(`/products/category/${normalizeSlug(slug)}${normalizeSearch(search)}`).then(
(category): CategoryData => {
const normalizedCategory = applyListingSearchParams(
{
...category,
product_list: toProductArray(category.product_list),
},
search,
category.current_category.url,
);
2026-03-13 13:54:45 +07:00
2026-03-13 17:23:37 +07:00
return {
...normalizedCategory,
product_list: toProductArray(normalizedCategory.product_list),
};
},
2026-03-13 13:54:45 +07:00
);
}
2026-03-13 17:23:37 +07:00
export function getProductHotPage(slug: string, search?: string) {
return apiFetch<TypeProductHot>(`/products/hot-page/${normalizeSlug(slug)}`).then((page) =>
applyListingSearchParams(page, search, page.url),
2026-03-13 13:54:45 +07:00
);
}
2026-03-13 17:23:37 +07:00
export function getProductSearch(q: string, search?: string) {
return apiFetch<TypeProductSearch>(`/products/search?q=${encodeURIComponent(q)}`).then((page) =>
applyListingSearchParams(page, search, SEARCH_PAGE_PATH),
);
2026-03-13 13:54:45 +07:00
}