This commit is contained in:
2026-03-13 17:23:37 +07:00
parent 25111ff10e
commit 2d2bf85f83
43 changed files with 2094 additions and 448 deletions

View File

@@ -7,13 +7,35 @@ import type {
PagingCollection,
PriceFilter,
Product,
ProductDetailData,
ProductImageDetail,
ProductImageGallery,
ProductDetailData,
} from '@/types';
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';
import type { TypeListProduct } from '@/types/global/TypeListProduct';
import { normalizeAssetUrl, normalizeHtmlAssetUrls } from '@/lib/normalizeAssetUrl';
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;
}
function normalizeSlug(slug: string) {
return slug.replace(/^\/+/, '');
}
@@ -26,7 +48,21 @@ function normalizeSearch(search?: string) {
return search.startsWith('?') ? search : `?${search}`;
}
function normalizeProductList(productList: Product[] | Record<string, Product> | undefined) {
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) {
if (!productList) {
return [];
}
@@ -34,6 +70,13 @@ function normalizeProductList(productList: Product[] | Record<string, Product> |
return Array.isArray(productList) ? productList : Object.values(productList);
}
function toProductRecord(products: Product[]) {
return products.reduce<Record<string, Product>>((accumulator, product) => {
accumulator[String(product.id)] = product;
return accumulator;
}, {});
}
function normalizeProductImage(image: ProductImageDetail): ProductImageDetail {
return {
small: normalizeAssetUrl(image.small),
@@ -73,38 +116,6 @@ function normalizeComboSet(comboSet: ComboSet[] | undefined) {
}));
}
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);
@@ -147,12 +158,15 @@ function filterByPrice(products: Product[], priceFilter: PriceFilter | undefined
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;
});
}
@@ -193,7 +207,7 @@ function sortProducts(products: Product[], sortKey: string | null) {
}
function buildPagingCollection(
category: CategoryApiData,
basePath: string,
searchParams: URLSearchParams,
totalPages: number,
currentPage: number,
@@ -212,49 +226,103 @@ function buildPagingCollection(
return {
name: String(page),
url: queryString ? `${category.current_category.url}?${queryString}` : category.current_category.url,
url: queryString ? `${basePath}?${queryString}` : basePath,
is_active: page === currentPage ? '1' : '0',
};
});
}
function applyCategorySearchParams(category: CategoryApiData, search?: string): CategoryData {
function applyListingSearchParams<T extends ListingPageBase>(
page: T,
search: string | undefined,
basePath: string,
): T {
const searchParams = new URLSearchParams(normalizeSearch(search));
let productList = normalizeProductList(category.product_list);
const originalProducts = toProductArray(page.product_list);
let products = [...originalProducts];
if (searchParams.get('other_filter') === 'in-stock') {
productList = productList.filter((product) => toNumber(product.quantity) > 0);
products = products.filter((product) => toNumber(product.quantity) > 0);
}
const selectedBrand = category.brand_filter_list?.find((item) =>
const selectedBrand = page.brand_filter_list?.find((item) =>
isCurrentFilterUrl(searchParams, item.url),
);
productList = filterByBrand(productList, selectedBrand);
products = filterByBrand(products, selectedBrand);
const selectedPrice = category.price_filter_list?.find((item) =>
const selectedPrice = page.price_filter_list?.find((item) =>
isCurrentFilterUrl(searchParams, item.url),
);
productList = filterByPrice(productList, selectedPrice);
products = filterByPrice(products, selectedPrice);
productList = sortProducts(productList, searchParams.get('sort'));
products = sortProducts(products, 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 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));
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;
const pagedProducts = products.slice((currentPage - 1) * pageSize, currentPage * pageSize);
return {
...category,
...page,
product_count: String(totalProducts),
paging_count: String(totalPages),
paging_collection: buildPagingCollection(category, searchParams, totalPages, currentPage),
product_list: pagedProducts,
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),
},
};
}
@@ -269,31 +337,55 @@ export function getProductDetail(slug: string) {
}
export function getProductComments(slug: string) {
return apiFetch<import('@/types/Comment').ProductCommentData[]>(
`/products/${normalizeSlug(slug)}/comments`,
return apiFetch<ProductCommentData[]>(`/products/${normalizeSlug(slug)}/comments`).then(
normalizeComments,
);
}
export function getProductReviews(slug: string) {
return apiFetch<import('@/types/Review').ProductReviewData[]>(
`/products/${normalizeSlug(slug)}/reviews`,
return apiFetch<ProductReviewData[]>(`/products/${normalizeSlug(slug)}/reviews`).then(
normalizeReviews,
);
}
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),
});
}
export function getProductCategory(slug: string, search?: string) {
const normalizedSearch = normalizeSearch(search);
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,
);
return apiFetch<CategoryApiData>(`/products/category/${normalizeSlug(slug)}${normalizedSearch}`).then(
(category) => applyCategorySearchParams(category, normalizedSearch),
return {
...normalizedCategory,
product_list: toProductArray(normalizedCategory.product_list),
};
},
);
}
export function getProductHotPage(slug: string) {
return apiFetch<import('@/types/producthot').TypeProductHot>(
`/products/hot-page/${normalizeSlug(slug)}`,
export function getProductHotPage(slug: string, search?: string) {
return apiFetch<TypeProductHot>(`/products/hot-page/${normalizeSlug(slug)}`).then((page) =>
applyListingSearchParams(page, search, page.url),
);
}
export function getProductSearch(q: string) {
return apiFetch<import('@/types/product/search').TypeProductSearch>(`/products/search?q=${encodeURIComponent(q)}`);
export function getProductSearch(q: string, search?: string) {
return apiFetch<TypeProductSearch>(`/products/search?q=${encodeURIComponent(q)}`).then((page) =>
applyListingSearchParams(page, search, SEARCH_PAGE_PATH),
);
}