import { apiFetch } from './client'; import type { BrandFilter, CategoryApiData, CategoryData, ComboSet, PagingCollection, PriceFilter, Product, ProductDetailData, ProductImageDetail, ProductImageGallery, } 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; } export interface SubmitProductReviewPayload { content: string; email: string; name: string; rate: number; } function normalizeSlug(slug: string) { return slug.replace(/^\/+/, ''); } function normalizeSearch(search?: string) { if (!search) { return ''; } return search.startsWith('?') ? search : `?${search}`; } 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 | undefined) { if (!productList) { return []; } return Array.isArray(productList) ? productList : Object.values(productList); } function toProductRecord(products: Product[]) { return products.reduce>((accumulator, product) => { accumulator[String(product.id)] = product; return accumulator; }, {}); } 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); 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( basePath: string, 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 ? `${basePath}?${queryString}` : basePath, is_active: page === currentPage ? '1' : '0', }; }); } function applyListingSearchParams( page: T, search: string | undefined, basePath: string, ): T { const searchParams = new URLSearchParams(normalizeSearch(search)); const originalProducts = toProductArray(page.product_list); let products = [...originalProducts]; if (searchParams.get('other_filter') === 'in-stock') { products = products.filter((product) => toNumber(product.quantity) > 0); } const selectedBrand = page.brand_filter_list?.find((item) => isCurrentFilterUrl(searchParams, item.url), ); products = filterByBrand(products, selectedBrand); const selectedPrice = page.price_filter_list?.find((item) => isCurrentFilterUrl(searchParams, item.url), ); products = filterByPrice(products, selectedPrice); products = sortProducts(products, searchParams.get('sort')); 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 = products.slice((currentPage - 1) * pageSize, currentPage * pageSize); return { ...page, product_count: String(totalProducts), paging_count: String(totalPages), 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), }, }; } export function getProductHot() { return apiFetch('/products/hot'); } export function getProductDetail(slug: string) { return apiFetch(`/products/${normalizeSlug(slug)}`).then((product) => normalizeProductDetail(product), ); } export function getProductComments(slug: string) { return apiFetch(`/products/${normalizeSlug(slug)}/comments`).then( normalizeComments, ); } export function getProductReviews(slug: string) { return apiFetch(`/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) { return apiFetch(`/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 { ...normalizedCategory, product_list: toProductArray(normalizedCategory.product_list), }; }, ); } export function getProductHotPage(slug: string, search?: string) { return apiFetch(`/products/hot-page/${normalizeSlug(slug)}`).then((page) => applyListingSearchParams(page, search, page.url), ); } export function getProductSearch(q: string, search?: string) { return apiFetch(`/products/search?q=${encodeURIComponent(q)}`).then((page) => applyListingSearchParams(page, search, SEARCH_PAGE_PATH), ); }