update
This commit is contained in:
@@ -5,8 +5,8 @@ import Image from 'next/image';
|
||||
|
||||
import type { TypeArticleCatePage } from '@/types/article/TypeArticleCatePage';
|
||||
|
||||
import { Breadcrumb } from '@components/Common/Breadcrumb';
|
||||
import { ErrorLink } from '@components/Common/Error';
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import { ErrorLink } from '@/components/Common/Error';
|
||||
import { ArticleTopLeft } from '../ArticleTopLeft';
|
||||
import { ArticleTopRight } from '../ArticleTopRight';
|
||||
import ItemArticle from '@/components/Common/ItemArticle';
|
||||
@@ -85,7 +85,7 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href="/tin-cong-nghe"
|
||||
href={currentCategory.category_info.request_path}
|
||||
className="btn-article-col flex items-center justify-center gap-2 font-[500]"
|
||||
>
|
||||
Xem tất cả
|
||||
|
||||
@@ -92,18 +92,18 @@ export default function TocBox({ htmlContent }: { htmlContent: string }) {
|
||||
};
|
||||
}, [htmlContent]);
|
||||
|
||||
if (!headingsTree.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="archor-text-group">
|
||||
<div className="toc_title flex items-center justify-between gap-2">
|
||||
<b className="text-fint-toc flex items-center text-base font-bold">
|
||||
<span>Nội dung chính</span>
|
||||
</b>
|
||||
{headingsTree.length > 0 && (
|
||||
<div className="archor-text-group">
|
||||
<div className="toc_title flex items-center justify-between gap-2">
|
||||
<b className="text-fint-toc flex items-center text-base font-bold">
|
||||
<span>Nội dung chính</span>
|
||||
</b>
|
||||
</div>
|
||||
<div id="js-outp">{renderTree(headingsTree)}</div>
|
||||
</div>
|
||||
<div id="js-outp">{renderTree(headingsTree)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="box-article-detail-ct nd js_find"
|
||||
dangerouslySetInnerHTML={{ __html: contentWithIds }}
|
||||
|
||||
@@ -4,9 +4,9 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
|
||||
import { ErrorLink } from '@components/Common/Error';
|
||||
import { ErrorLink } from '@/components/Common/Error';
|
||||
|
||||
import { Breadcrumb } from '@components/Common/Breadcrumb';
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import TocBox from './TocBox';
|
||||
import PreLoader from '@/components/Common/PreLoader';
|
||||
import { getArticleCategories, getArticleDetail } from '@/lib/api/article';
|
||||
@@ -93,9 +93,9 @@ const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
|
||||
/>
|
||||
</Link>
|
||||
<div className="content-article flex-1">
|
||||
<a href={item.url} className="title-article">
|
||||
<Link href={item.url} className="title-article">
|
||||
<h3 className="font-weight-400 line-clamp-2">{item.title}</h3>
|
||||
</a>
|
||||
</Link>
|
||||
<p className="time-article d-flex align-items-center gap-4">
|
||||
<i className="sprite sprite-clock-item-article"></i>
|
||||
<span>{item.createDate}</span>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Breadcrumb } from '@components/Common/Breadcrumb';
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
import { ArticleTopLeft } from '../ArticleTopLeft';
|
||||
import { ArticleTopRight } from '../ArticleTopRight';
|
||||
@@ -13,14 +14,45 @@ import { getArticleCategories } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
|
||||
|
||||
const ArticleHomeSkeleton = () => (
|
||||
<section className="page-article pb-10">
|
||||
<div className="container">
|
||||
<Skeleton className="mb-4 h-5 w-48" />
|
||||
<div className="tabs-category-article flex items-center gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-24" />
|
||||
))}
|
||||
</div>
|
||||
<div className="box-article-home-top mt-4 grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2 flex gap-3">
|
||||
<Skeleton className="aspect-video flex-1" />
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const ArticleHome = () => {
|
||||
const breadcrumbItems = [{ name: 'Tin tức', url: '/tin-tuc' }];
|
||||
const { data: categories } = useApiData(
|
||||
const { data: categories, isLoading } = useApiData(
|
||||
() => getArticleCategories(),
|
||||
[],
|
||||
{ initialData: [] as TypeArticleCategory[] },
|
||||
);
|
||||
|
||||
if (isLoading) return <ArticleHomeSkeleton />;
|
||||
|
||||
return (
|
||||
<section className="page-article pb-10">
|
||||
<div className="container">
|
||||
|
||||
@@ -6,9 +6,10 @@ import ItemArticleVideo from './ItemArticleVideo';
|
||||
import { getArticleVideos } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { Article } from '@/types';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
const BoxArticleVideo: React.FC = () => {
|
||||
const { data: videos } = useApiData(
|
||||
const { data: videos, isLoading } = useApiData(
|
||||
() => getArticleVideos(),
|
||||
[],
|
||||
{ initialData: [] as Article[] },
|
||||
@@ -31,9 +32,17 @@ const BoxArticleVideo: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="list-videos-group list-article-group flex items-center gap-10">
|
||||
{videos.slice(0, 4).map((item) => (
|
||||
<ItemArticleVideo item={item} key={item.id} />
|
||||
))}
|
||||
{isLoading
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
))
|
||||
: videos.slice(0, 4).map((item) => (
|
||||
<ItemArticleVideo item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,10 @@ import ItemArticle from './ItemArticle';
|
||||
import { getArticles } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { Article } from '@/types';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
const BoxArticle: React.FC = () => {
|
||||
const { data: articles } = useApiData(
|
||||
const { data: articles, isLoading } = useApiData(
|
||||
() => getArticles(),
|
||||
[],
|
||||
{ initialData: [] as Article[] },
|
||||
@@ -26,9 +27,18 @@ const BoxArticle: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="list-article-group flex items-center gap-10">
|
||||
{articles.slice(0, 4).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
{isLoading
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
))
|
||||
: articles.slice(0, 4).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,17 +6,51 @@ import { FaCaretDown } from 'react-icons/fa';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import ItemProduct from '@/components/Common/ItemProduct';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
import { menuData } from '@/components/Other/Header/menuData';
|
||||
import { getProductHot } from '@/lib/api/product';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { TypeListProduct } from '@/types/global/TypeListProduct';
|
||||
|
||||
const CategorySkeleton = () => (
|
||||
<div className="box-product-category boder-radius-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<div className="flex gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-5 gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BoxListCategory: React.FC = () => {
|
||||
const { data: products } = useApiData(() => getProductHot(), [], {
|
||||
const { data: products, isLoading } = useApiData(() => getProductHot(), [], {
|
||||
initialData: [] as TypeListProduct,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
{menuData[0].product.all_category.map((_, index) => (
|
||||
<CategorySkeleton key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuData[0].product.all_category.map((item, index) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { menuData } from '@components/Other/Header/menuData';
|
||||
import { menuData } from '@/components/Other/Header/menuData';
|
||||
import ItemCategory from './ItemCategory';
|
||||
import { InfoCategory } from '@/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
@@ -8,14 +8,35 @@ import { TypeListProductDeal } from '@/types';
|
||||
import { getDeals } from '@/lib/api/deal';
|
||||
import CountDown from '@/components/Common/CountDown';
|
||||
import ProductItem from './ProductItem';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
|
||||
const BoxProductDeal: React.FC = () => {
|
||||
const [deals, setDeals] = useState<TypeListProductDeal>([]);
|
||||
const [expired, setExpired] = useState(false);
|
||||
const { data: deals, isLoading } = useApiData(() => getDeals(), [], {
|
||||
initialData: [] as TypeListProductDeal,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getDeals().then(setDeals).catch(console.error);
|
||||
}, []);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="box-product-deal boder-radius-10">
|
||||
<div className="box-title-deal flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-6 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (expired) return null;
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import ItemReview from './ItemReview';
|
||||
import { getHomeReviews } from '@/lib/api/home';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { HomeReview } from '@/types';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
const BoxReviewCustomer: React.FC = () => {
|
||||
const { data: reviews } = useApiData(
|
||||
const { data: reviews, isLoading } = useApiData(
|
||||
() => getHomeReviews(),
|
||||
[],
|
||||
{ initialData: [] as HomeReview[] },
|
||||
@@ -19,21 +20,40 @@ const BoxReviewCustomer: React.FC = () => {
|
||||
<div className="title-box">
|
||||
<h2 className="title-box font-[600]">Đánh giá từ khách hàng về Nguyễn Công PC</h2>
|
||||
</div>
|
||||
<div className="list-review-customer-homepage">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={15}
|
||||
slidesPerView={3}
|
||||
loop={true}
|
||||
pagination={{ clickable: true }}
|
||||
>
|
||||
{reviews.map((item, index) => (
|
||||
<SwiperSlide key={`${item.author}-${index}`} className="item">
|
||||
<ItemReview item={item} />
|
||||
</SwiperSlide>
|
||||
{isLoading ? (
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-3 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="list-review-customer-homepage">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={15}
|
||||
slidesPerView={3}
|
||||
loop={true}
|
||||
pagination={{ clickable: true }}
|
||||
>
|
||||
{reviews.map((item, index) => (
|
||||
<SwiperSlide key={`${item.author}-${index}`} className="item">
|
||||
<ItemReview item={item} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getBanners } from '@/lib/api/banner';
|
||||
import { TemplateBanner } from '@/types';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
|
||||
const SliderHome: React.FC = () => {
|
||||
const [banners, setBanners] = useState<TemplateBanner | null>(null);
|
||||
const { data: banners, isLoading } = useApiData(() => getBanners(), [], {
|
||||
initialData: null as TemplateBanner | null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getBanners().then(setBanners).catch(console.error);
|
||||
}, []);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-100 w-full" />
|
||||
<div className="mt-3 flex gap-3">
|
||||
<Skeleton className="h-40 flex-1" />
|
||||
<Skeleton className="h-40 flex-1" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const dataSlider = banners?.homepage;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
@@ -11,7 +12,6 @@ const NotFound = () => {
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full max-w-md rounded-3xl bg-white p-8 text-center shadow-xl"
|
||||
>
|
||||
{/* Icon lỗi link */}
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 1.8 }}
|
||||
@@ -38,13 +38,12 @@ const NotFound = () => {
|
||||
Bạn truy cập không tồn tại hoặc đường dẫn đã bị thay đổi.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
>
|
||||
← Về trang chủ
|
||||
Về trang chủ
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { CategoryData } from '@/types';
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import BannerCategory from './BannerCategory';
|
||||
import ItemCategoryChild from './ItemCategoryChild';
|
||||
import BoxFilter from '@components/Product/BoxFilter';
|
||||
import BoxSort from '@components/Product/BoxSort';
|
||||
import BoxFilter from '@/components/Product/BoxFilter';
|
||||
import BoxSort from '@/components/Product/BoxSort';
|
||||
import ItemProduct from '@/components/Common/ItemProduct';
|
||||
import PreLoader from '@/components/Common/PreLoader';
|
||||
import { getProductCategory } from '@/lib/api/product';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { submitProductReview } from '@/lib/api/product';
|
||||
|
||||
const STAR_OPTIONS = [5, 4, 3, 2, 1] as const;
|
||||
|
||||
@@ -8,36 +10,61 @@ interface ReviewFormError {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const FormReview: React.FC = () => {
|
||||
interface Props {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export const FormReview: React.FC<Props> = ({ slug }) => {
|
||||
const [rate, setRate] = useState<number>(5);
|
||||
const [content, setContent] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [errors, setErrors] = useState<ReviewFormError>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newErrors: ReviewFormError = {};
|
||||
|
||||
if (!content.trim() || content.trim().length < 10) {
|
||||
newErrors.content = 'Nội dung đánh giá tối thiểu 10 ký tự';
|
||||
}
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Vui lòng nhập tên của bạn';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
// TODO: gọi API gửi đánh giá
|
||||
console.log({ rate, content, name, email });
|
||||
setSubmitted(true);
|
||||
setErrors(newErrors);
|
||||
setSubmitError('');
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await submitProductReview(slug, {
|
||||
content: content.trim(),
|
||||
email: email.trim(),
|
||||
name: name.trim(),
|
||||
rate,
|
||||
});
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setSubmitError('Không thể gửi đánh giá lúc này. Vui lòng thử lại sau.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="box-form-review">
|
||||
<p className="font-medium text-green-600">
|
||||
Cảm ơn bạn đã gửi đánh giá! Đánh giá của bạn đang chờ duyệt.
|
||||
Cảm ơn bạn đã gửi đánh giá. Đánh giá của bạn đang chờ duyệt.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -51,8 +78,8 @@ export const FormReview: React.FC = () => {
|
||||
placeholder="Mời bạn để lại đánh giá..."
|
||||
name="user_post[content]"
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value);
|
||||
onChange={(event) => {
|
||||
setContent(event.target.value);
|
||||
setErrors((prev) => ({ ...prev, content: undefined }));
|
||||
}}
|
||||
/>
|
||||
@@ -103,8 +130,8 @@ export const FormReview: React.FC = () => {
|
||||
name="user_post[user_name]"
|
||||
className="form-control"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setErrors((prev) => ({ ...prev, name: undefined }));
|
||||
}}
|
||||
/>
|
||||
@@ -123,7 +150,7 @@ export const FormReview: React.FC = () => {
|
||||
name="user_post[user_email]"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -131,8 +158,10 @@ export const FormReview: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-review send_form mb-10 mt-12">
|
||||
Gửi đánh giá
|
||||
{submitError && <p className="mb-3 text-sm text-red-500">{submitError}</p>}
|
||||
|
||||
<button type="submit" className="btn-review send_form mb-10 mt-12" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Đang gửi...' : 'Gửi đánh giá'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Review } from '@/types';
|
||||
import { FaStar } from 'react-icons/fa6';
|
||||
import { Review } from '@/types';
|
||||
import { FormReview } from './FormReview';
|
||||
import { ListReview } from './ListReview';
|
||||
|
||||
@@ -15,14 +15,16 @@ export const ProductReview: React.FC<Props> = ({ review, slug }) => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const { summary } = review;
|
||||
const totalRate = summary.list_rate.reduce((acc, item) => acc + Number(item.total), 0);
|
||||
const totalRate = summary.list_rate.reduce((accumulator, item) => {
|
||||
return accumulator + Number(item.total);
|
||||
}, 0);
|
||||
|
||||
const rates = {
|
||||
rate1: Number(summary.list_rate.find((r) => r.rate === '1')?.total || 0),
|
||||
rate2: Number(summary.list_rate.find((r) => r.rate === '2')?.total || 0),
|
||||
rate3: Number(summary.list_rate.find((r) => r.rate === '3')?.total || 0),
|
||||
rate4: Number(summary.list_rate.find((r) => r.rate === '4')?.total || 0),
|
||||
rate5: Number(summary.list_rate.find((r) => r.rate === '5')?.total || 0),
|
||||
rate1: Number(summary.list_rate.find((item) => item.rate === '1')?.total || 0),
|
||||
rate2: Number(summary.list_rate.find((item) => item.rate === '2')?.total || 0),
|
||||
rate3: Number(summary.list_rate.find((item) => item.rate === '3')?.total || 0),
|
||||
rate4: Number(summary.list_rate.find((item) => item.rate === '4')?.total || 0),
|
||||
rate5: Number(summary.list_rate.find((item) => item.rate === '5')?.total || 0),
|
||||
};
|
||||
const percents = {
|
||||
percent1: totalRate > 0 ? (rates.rate1 / totalRate) * 100 : 0,
|
||||
@@ -48,16 +50,14 @@ export const ProductReview: React.FC<Props> = ({ review, slug }) => {
|
||||
{[5, 4, 3, 2, 1].map((rate) => {
|
||||
const percent = percents[`percent${rate}` as keyof typeof percents];
|
||||
const total = rates[`rate${rate}` as keyof typeof rates];
|
||||
|
||||
return (
|
||||
<div key={rate} className="avg-rate-item mt-2 flex items-center justify-center">
|
||||
<span className="rate-number flex items-center gap-1">
|
||||
{rate} <FaStar className="text-yellow-500" />
|
||||
</span>
|
||||
<div className="nhan-xet-bar">
|
||||
<div
|
||||
className={`percent percent${rate}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
></div>
|
||||
<div className={`percent percent${rate}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<span className="total-avg-rate">
|
||||
<strong>{total}</strong> đánh giá
|
||||
@@ -68,24 +68,18 @@ export const ProductReview: React.FC<Props> = ({ review, slug }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-danh-gia mb-5">Bạn đánh giá sao sản phẩm này</p>
|
||||
{!showForm ? (
|
||||
<div
|
||||
className="button-review mx-auto flex cursor-pointer items-center justify-center"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
Đánh giá ngay
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="button-review mx-auto flex cursor-pointer items-center justify-center"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
Đóng lại
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && <FormReview />}
|
||||
<p className="text-danh-gia mb-5">Bạn đánh giá sao sản phẩm này</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="button-review mx-auto flex cursor-pointer items-center justify-center"
|
||||
onClick={() => setShowForm((currentValue) => !currentValue)}
|
||||
>
|
||||
{showForm ? 'Đóng lại' : 'Đánh giá ngay'}
|
||||
</button>
|
||||
|
||||
{showForm && <FormReview slug={slug} />}
|
||||
</div>
|
||||
</div>
|
||||
<ListReview slug={slug} />
|
||||
|
||||
@@ -81,10 +81,11 @@ const ProductDetailPage: React.FC<ProductDetailPageProps> = ({ slug }) => {
|
||||
return <ErrorLink />;
|
||||
}
|
||||
|
||||
const breadcrumbItems = product.product_info.productPath?.[0]?.path.map((item) => ({
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
})) ?? [{ name: 'Trang chủ', url: '/' }];
|
||||
const breadcrumbItems =
|
||||
product.product_info.productPath?.[0]?.path.map((item) => ({
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
})) ?? [{ name: 'Trang chủ', url: '/' }];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,26 +2,28 @@
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ErrorLink } from '@/components/Common/Error';
|
||||
import type { TypeProductHot } from '@/types/producthot';
|
||||
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import BoxFilter from '@components/Product/BoxFilter';
|
||||
import BoxSort from '@components/Product/BoxSort';
|
||||
import BoxFilter from '@/components/Product/BoxFilter';
|
||||
import BoxSort from '@/components/Product/BoxSort';
|
||||
import ItemProduct from '@/components/Common/ItemProduct';
|
||||
import PreLoader from '@/components/Common/PreLoader';
|
||||
import { getProductHotPage } from '@/lib/api/product';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import { getProductHotPage } from '@/lib/api/product';
|
||||
import type { TypeProductHot } from '@/types/producthot';
|
||||
|
||||
interface ProductHotPageProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const search = searchParams.toString();
|
||||
const productDisplayType = searchParams.get('display') === 'list' ? 'list' : 'grid';
|
||||
const { data: page, isLoading } = useApiData(
|
||||
() => getProductHotPage(slug),
|
||||
[slug],
|
||||
() => getProductHotPage(slug, search),
|
||||
[slug, search],
|
||||
{ initialData: null as TypeProductHot | null },
|
||||
);
|
||||
|
||||
@@ -37,7 +39,6 @@ const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
|
||||
{ name: 'Trang chủ', url: '/' },
|
||||
{ name: page.title, url: page.url },
|
||||
];
|
||||
|
||||
const products = Object.values(page.product_list);
|
||||
|
||||
return (
|
||||
@@ -52,11 +53,16 @@ const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
|
||||
<span className="current-cate-total">(Tổng {page.product_count} sản phẩm)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="box-content-category">
|
||||
<BoxFilter filters={page} />
|
||||
|
||||
<div className="box-list-product-category boder-radius-10">
|
||||
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
|
||||
<BoxSort
|
||||
sort_by_collection={page.sort_by_collection}
|
||||
display_by_collection={page.display_by_collection}
|
||||
product_display_type={productDisplayType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="list-product-category grid grid-cols-5 gap-3">
|
||||
@@ -64,10 +70,11 @@ const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
|
||||
<ItemProduct key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="paging flex items-center justify-center">
|
||||
{page.paging_collection.map((item, index) => (
|
||||
{page.paging_collection.map((item) => (
|
||||
<Link
|
||||
key={index}
|
||||
key={item.url}
|
||||
href={item.url}
|
||||
className={`item ${item.is_active === '1' ? 'current' : ''}`}
|
||||
>
|
||||
|
||||
@@ -3,25 +3,24 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { ErrorLink } from '@/components/Common/Error';
|
||||
import type { TypeProductSearch } from '@/types/product/search';
|
||||
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import BoxFilter from '@components/Product/BoxFilter';
|
||||
import BoxSort from '@components/Product/BoxSort';
|
||||
import BoxFilter from '@/components/Product/BoxFilter';
|
||||
import BoxSort from '@/components/Product/BoxSort';
|
||||
import ItemProduct from '@/components/Common/ItemProduct';
|
||||
import PreLoader from '@/components/Common/PreLoader';
|
||||
import { getProductSearch } from '@/lib/api/product';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import { getProductSearch } from '@/lib/api/product';
|
||||
import type { TypeProductSearch } from '@/types/product/search';
|
||||
|
||||
const ProductSearchPage: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const keyword = searchParams.get('q') ?? '';
|
||||
|
||||
const search = searchParams.toString();
|
||||
const productDisplayType = searchParams.get('display') === 'list' ? 'list' : 'grid';
|
||||
const { data: page, isLoading } = useApiData(
|
||||
() => getProductSearch(keyword),
|
||||
[keyword],
|
||||
() => getProductSearch(keyword, search),
|
||||
[keyword, search],
|
||||
{
|
||||
initialData: null as TypeProductSearch | null,
|
||||
enabled: keyword.length > 0,
|
||||
@@ -40,7 +39,6 @@ const ProductSearchPage: React.FC = () => {
|
||||
{ name: 'Trang chủ', url: '/' },
|
||||
{ name: `Tìm kiếm "${keyword}"`, url: `/tim?q=${page.keywords}` },
|
||||
];
|
||||
|
||||
const products = Object.values(page.product_list);
|
||||
|
||||
return (
|
||||
@@ -57,8 +55,13 @@ const ProductSearchPage: React.FC = () => {
|
||||
{products.length > 0 ? (
|
||||
<div className="box-content-category">
|
||||
<BoxFilter filters={page} />
|
||||
|
||||
<div className="box-list-product-category boder-radius-10">
|
||||
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
|
||||
<BoxSort
|
||||
sort_by_collection={page.sort_by_collection}
|
||||
display_by_collection={page.display_by_collection}
|
||||
product_display_type={productDisplayType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="list-product-category grid grid-cols-5 gap-3">
|
||||
@@ -66,10 +69,11 @@ const ProductSearchPage: React.FC = () => {
|
||||
<ItemProduct key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="paging flex items-center justify-center">
|
||||
{page.paging_collection.map((item, index) => (
|
||||
{page.paging_collection.map((item) => (
|
||||
<Link
|
||||
key={index}
|
||||
key={item.url}
|
||||
href={item.url}
|
||||
className={`item ${item.is_active === '1' ? 'current' : ''}`}
|
||||
>
|
||||
@@ -104,9 +108,7 @@ const ProductSearchPage: React.FC = () => {
|
||||
<li>Thử lại bằng các từ khóa ngắn gọn hơn</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link href="/">
|
||||
<i className="fa fa-long-arrow-alt-left"></i> Quay lại trang chủ
|
||||
</Link>
|
||||
<Link href="/">Quay lại trang chủ</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user