From e2063bce4c231b5d3116c4927e64efdbb73179ba Mon Sep 17 00:00:00 2001 From: tieptk Date: Sat, 27 Dec 2025 10:03:53 +0700 Subject: [PATCH] update --- package-lock.json | 10 ++ package.json | 1 + src/components/common/ItemProduct/index.tsx | 6 +- .../home/Deal/ProductItem/index.tsx | 13 +- src/components/home/Deal/index.tsx | 16 ++- .../BoxInfoRight/BoxPrice/index.tsx | 20 +++ .../BoxInfoRight/TimeDeal/index.tsx | 0 .../ProductDetail/BoxInfoRight/index.tsx | 130 ++++++++++++++++++ .../ComboSet/ChangeProductPopup/index.tsx | 78 +++++++++++ .../ComboSet/ItemComboset/index.tsx | 102 ++++++++++++++ .../product/ProductDetail/ComboSet/index.tsx | 114 +++++++++++++++ .../ProductDetail/ImageProduct/index.tsx | 31 +---- .../ProductDetail/ProductSummary/index.tsx | 33 +++++ .../product/ProductDetail/index.tsx | 11 +- src/hooks/useFancybox.ts | 17 +++ src/lib/formatPrice.ts | 4 + src/styles/globals.css | 18 +-- src/types/product/detail/index.ts | 6 +- 18 files changed, 549 insertions(+), 61 deletions(-) create mode 100644 src/components/product/ProductDetail/BoxInfoRight/BoxPrice/index.tsx create mode 100644 src/components/product/ProductDetail/BoxInfoRight/TimeDeal/index.tsx create mode 100644 src/components/product/ProductDetail/BoxInfoRight/index.tsx create mode 100644 src/components/product/ProductDetail/ComboSet/ChangeProductPopup/index.tsx create mode 100644 src/components/product/ProductDetail/ComboSet/ItemComboset/index.tsx create mode 100644 src/components/product/ProductDetail/ComboSet/index.tsx create mode 100644 src/components/product/ProductDetail/ProductSummary/index.tsx create mode 100644 src/hooks/useFancybox.ts create mode 100644 src/lib/formatPrice.ts diff --git a/package-lock.json b/package-lock.json index 272031c..6fe7804 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@fancyapps/ui": "^6.1.7", "@tippyjs/react": "^4.2.6", "framer-motion": "^12.23.26", + "lightgallery": "^2.9.0", "next": "16.0.10", "postcss": "^8.5.6", "react": "19.2.1", @@ -4626,6 +4627,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lightgallery": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/lightgallery/-/lightgallery-2.9.0.tgz", + "integrity": "sha512-58Ud1DyhD2ao58t+kPEqSZrjFxg23tGd5ZKr75erm7q31g5xhUtWUJH3sTUkhHzlyJAKHj5eTrJ37HQRXG4Wbg==", + "license": "GPLv3", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", diff --git a/package.json b/package.json index 96b59d1..9382727 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@fancyapps/ui": "^6.1.7", "@tippyjs/react": "^4.2.6", "framer-motion": "^12.23.26", + "lightgallery": "^2.9.0", "next": "16.0.10", "postcss": "^8.5.6", "react": "19.2.1", diff --git a/src/components/common/ItemProduct/index.tsx b/src/components/common/ItemProduct/index.tsx index 9dd8805..2a346a4 100644 --- a/src/components/common/ItemProduct/index.tsx +++ b/src/components/common/ItemProduct/index.tsx @@ -4,16 +4,12 @@ import 'tippy.js/dist/tippy.css'; import { Product } from '@/types'; import Image from 'next/image'; import Link from 'next/link'; +import { formatCurrency } from '@/lib/formatPrice'; type ProductItemProps = { item: Product; }; -const formatCurrency = (value: number | string) => { - const num = typeof value === 'string' ? parseInt(value) : value; - return num.toLocaleString('vi-VN'); -}; - const ItemProduct: React.FC = ({ item }) => { const offers = item.specialOffer?.all ?? []; diff --git a/src/components/home/Deal/ProductItem/index.tsx b/src/components/home/Deal/ProductItem/index.tsx index 239b383..61a7154 100644 --- a/src/components/home/Deal/ProductItem/index.tsx +++ b/src/components/home/Deal/ProductItem/index.tsx @@ -1,17 +1,12 @@ import React from 'react'; -import Tippy from '@tippyjs/react'; -import 'tippy.js/dist/tippy.css'; import { DealType } from '@/types'; +import { formatCurrency } from '@/lib/formatPrice'; +import Image from 'next/image'; type ProductItemProps = { item: DealType; }; -const formatCurrency = (value: number | string) => { - const num = typeof value === 'string' ? parseInt(value) : value; - return num.toLocaleString('vi-VN'); -}; - const ProductItem: React.FC = ({ item }) => { const { product_info } = item; const offers = product_info.specialOffer?.all ?? []; @@ -20,7 +15,7 @@ const ProductItem: React.FC = ({ item }) => {
{product_info.productImage.large ? ( - = ({ item }) => { className="lazy" /> ) : ( - { return ( @@ -18,9 +22,9 @@ const BoxProductDeal: React.FC = () => {
- + Xem thêm khuyến mãi - +
{ slidesPerView={6} loop={true} navigation={true} - > + > + {productDealData.map((Item, index) => ( + + + + ))} +
); diff --git a/src/components/product/ProductDetail/BoxInfoRight/BoxPrice/index.tsx b/src/components/product/ProductDetail/BoxInfoRight/BoxPrice/index.tsx new file mode 100644 index 0000000..d6ce7f9 --- /dev/null +++ b/src/components/product/ProductDetail/BoxInfoRight/BoxPrice/index.tsx @@ -0,0 +1,20 @@ +import type { ProductDetailData } from '@/types'; + +export const BoxPrice = (item: ProductDetailData) => { + return ( + <> + {item.product_info.sale_rules.type == 'deal' && ( +
+
+ +

flash sale

+
+
+
+
+
+
+ )} + + ); +}; diff --git a/src/components/product/ProductDetail/BoxInfoRight/TimeDeal/index.tsx b/src/components/product/ProductDetail/BoxInfoRight/TimeDeal/index.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/product/ProductDetail/BoxInfoRight/index.tsx b/src/components/product/ProductDetail/BoxInfoRight/index.tsx new file mode 100644 index 0000000..7eedc6f --- /dev/null +++ b/src/components/product/ProductDetail/BoxInfoRight/index.tsx @@ -0,0 +1,130 @@ +import type { ProductDetailData } from '@/types'; +import Link from 'next/link'; + +import { BoxPrice } from './BoxPrice'; + +export const BoxInfoRight = (item: ProductDetailData) => { + return ( + <> +

{item.product_info.productName}

+ +
+
+ Mã SP: {item.product_info.productSKU} +
+
+ Đánh giá: {item.product_info.review.summary.total} +
+
+ Bình luận:{' '} + {item.product_info.comment.summary.total} +
+
+ Lượt xem: {item.product_info.visit} +
+ + {item.product_info.extend.buy_count?.length > 0 && ( +
+ Đã bán: {item.product_info.extend.buy_count} +
+ )} +
+ + {/* tình trạng */} +
+
+ Bảo hành: {item.product_info.warranty} +
+ + {item.product_info.quantity > '0' && ( +
+ Tình trạng: Còn hàng +
+ )} +
+ + {/* giá */} + + + + {item.product_info.specialOffer.all.length > 0 && ( +
+
+ +

Khuyến mãi

+
+
+ {item.product_info.specialOffer.all.map((_item, idx) => ( +
+ +
+
+ ))} +
+
+ )} + + {/* mua hàng */} + {(item.product_info.quantity > '0' || item.product_info.price > '0') && ( + <> +
+

Số lượng:

+
+

+ {' '} + −{' '} +

+ +

+ {' '} + +{' '} +

+
+ addProductToCart(item.product_info.id, 0, '')} + className="addCart flex flex-wrap items-center justify-center gap-3" + > + +

Thêm vào giỏ hàng

+ + +
+ +
+ buyNow(item.product_info.id, 0, '')} + > + ĐẶT MUA NGAY + Giao hàng tận nơi nhanh chóng + + + buyPayInstall(item.product_info.id, 0, '')} + > + TRẢ GÓP QUA HỒ SƠ + Chỉ từ 2.665.000₫/ tháng + + + buyAlepay(item.product_info.id, 0, '')} + > + TRẢ GÓP QUA THẺ + Chỉ từ 1.332.500₫/ tháng + +
+ + )} + + ); +}; diff --git a/src/components/product/ProductDetail/ComboSet/ChangeProductPopup/index.tsx b/src/components/product/ProductDetail/ComboSet/ChangeProductPopup/index.tsx new file mode 100644 index 0000000..c172273 --- /dev/null +++ b/src/components/product/ProductDetail/ComboSet/ChangeProductPopup/index.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { ComboProduct } from '@/types'; +import { formatCurrency } from '@/lib/formatPrice'; + +interface ChangePopupProps { + titleGroup: string; + products: ComboProduct[]; + open: boolean; // nhận trạng thái mở + onClose: () => void; // hàm đóng popup + onSelect: (product: ComboProduct) => void; +} + +export const ChangeProductPopup: React.FC = ({ + titleGroup, + products, + open, + onClose, + onSelect, +}) => { + if (!open) return null; // chỉ render khi open = true + + return ( + +
+
+

Chọn {titleGroup} khác

+ +
+ + {/* Danh sách sản phẩm */} +
+ {products.map((p) => ( +
+ + {p.title} + +
+ +

{p.title}

+ +
+
+ + {Number(p.price) > 0 ? `${formatCurrency(p.price)} đ` : 'Liên hệ'} + +
+
+ { + onSelect(p); + }} + > + Chọn mua + +
+
+ ))} +
+
+ + {/* Overlay */} +
+ +
+
+ ); +}; diff --git a/src/components/product/ProductDetail/ComboSet/ItemComboset/index.tsx b/src/components/product/ProductDetail/ComboSet/ItemComboset/index.tsx new file mode 100644 index 0000000..600a973 --- /dev/null +++ b/src/components/product/ProductDetail/ComboSet/ItemComboset/index.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { ComboProduct } from '@/types'; +import { formatCurrency } from '@/lib/formatPrice'; + +interface ItemComboProps { + item: ComboProduct; + keyGroup: string; + titleGroup: string; + setId: string; + products: ComboProduct[]; + onOpenPopup: (titleGroup: string, products: ComboProduct[], item: ComboProduct) => void; +} + +export const ItemComboSet: React.FC = ({ + item, + keyGroup, + titleGroup, + setId, + products, + onOpenPopup, +}) => { + const hasDiscount = Number(item.normal_price) > Number(item.price) && Number(item.price) > 0; + return ( + <> +
+ + {item.images?.large ? ( + {item.title} + ) : ( + {item.title} + )} + + +
+ +

{item.title}

+ + +
+
+ + {Number(item.price) > 0 ? `${formatCurrency(item.price)} đ` : 'Liên hệ'} + +
+
+ + {hasDiscount ? ( +
+

{item.normal_price} đ

+ {item.discount.includes('%') ? ( +
+ -{item.discount} +
+ ) : ( +

(-{item.discount} đ)

+ )} +
+ ) : ( +
+ )} + +

onOpenPopup(titleGroup, products, item)} + > + Chọn {titleGroup} khác +

+ +
+ +
+
+
+ + ); +}; diff --git a/src/components/product/ProductDetail/ComboSet/index.tsx b/src/components/product/ProductDetail/ComboSet/index.tsx new file mode 100644 index 0000000..bb16830 --- /dev/null +++ b/src/components/product/ProductDetail/ComboSet/index.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Autoplay, Navigation, Pagination } from 'swiper/modules'; +import 'swiper/css'; +import { ComboSet, ComboProduct, ComboGroup } from '@/types'; +import { ItemComboSet } from './ItemComboset'; +import { ChangeProductPopup } from './ChangeProductPopup'; + +interface ComboProps { + combo_set: ComboSet[]; +} + +interface PopupGroup { + title: string; + products: ComboProduct[]; +} + +export const ComboSetBox: React.FC = ({ combo_set }) => { + const [openPopup, setOpenPopup] = useState(false); + const [popupGroup, setPopupGroup] = useState({ + title: '', + products: [], + }); + const [selectedProduct, setSelectedProduct] = useState(null); + + const handleOpenPopup = ( + titleGroup: string, + products: ComboProduct[], + currentItem: ComboProduct, + ) => { + setPopupGroup({ title: titleGroup, products }); + setSelectedProduct(currentItem); // lưu sản phẩm đang hiển thị + setOpenPopup(true); + }; + + const handleReplaceProduct = (newProduct: ComboProduct) => { + // cập nhật selectedProduct bằng sản phẩm mới + setSelectedProduct(newProduct); + setOpenPopup(false); + }; + + const getDisplayedProduct = (group: ComboGroup) => { + // Nếu selectedProduct thuộc group này thì hiển thị nó + if (selectedProduct && group.product_list.some((p) => p.id === selectedProduct.id)) { + return selectedProduct; + } + // Ngược lại lấy sản phẩm mặc định + return group.product_list.find((p) => p.is_first === 'yes') || group.product_list[0]; + }; + + if (!combo_set || combo_set.length === 0) return null; + const setInfo = combo_set[0]; + + return ( +
+

Mua theo combo

+
+ + {setInfo.group_list.map((group, index) => { + // lấy sản phẩm đầu tiên theo logic "is_first" hoặc mặc định + const firstProduct = + group.product_list.find((p) => p.is_first === 'yes') || group.product_list[0]; + + return ( + + + + ); + })} + + +
+
+
+

Tạm tính:

+

+ {/* giả sử lấy giá từ product_info */} + 3.050.000 đ +

+
+

+ Tiết kiệm thêm 215.000đ +

+
+
+

+ Mua thêm 0 sản phẩm +

+
+
+
+ setOpenPopup(false)} + onSelect={handleReplaceProduct} + /> +
+ ); +}; diff --git a/src/components/product/ProductDetail/ImageProduct/index.tsx b/src/components/product/ProductDetail/ImageProduct/index.tsx index a4a5848..9b4757a 100644 --- a/src/components/product/ProductDetail/ImageProduct/index.tsx +++ b/src/components/product/ProductDetail/ImageProduct/index.tsx @@ -5,8 +5,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { ProductImageGallery } from '@/types'; import type { Swiper as SwiperType } from 'swiper'; -import '@fancyapps/ui/dist/fancybox/fancybox.css'; -import { Fancybox, type FancyboxOptions } from '@fancyapps/ui'; +import useFancybox from '@/hooks/useFancybox'; interface ImageProps { ItemImage: ProductImageGallery[]; @@ -15,30 +14,14 @@ interface ImageProps { export const ImageProduct: React.FC = ({ ItemImage }) => { const [thumbsSwiper, setThumbsSwiper] = useState(null); - useEffect(() => { - if (typeof window === 'undefined') return; - - Fancybox.bind("[data-fancybox='gallery']", { - dragToClose: true, - Toolbar: { - display: { - left: [], - middle: ['counter'], - right: ['zoom', 'close'], - }, - }, - Thumbs: { autoStart: false }, - } as any); - - return () => { - Fancybox.unbind("[data-fancybox='gallery']"); - Fancybox.close(); - }; - }, []); + const [fancyboxRef] = useFancybox({ + closeButton: 'auto', + dragToClose: true, + }); return (
-
+
= ({ ItemImage }) => { > {ItemImage?.map((item, index) => ( - + {''} diff --git a/src/components/product/ProductDetail/ProductSummary/index.tsx b/src/components/product/ProductDetail/ProductSummary/index.tsx new file mode 100644 index 0000000..9975be1 --- /dev/null +++ b/src/components/product/ProductDetail/ProductSummary/index.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import { FaAngleDown, FaAngleUp } from 'react-icons/fa6'; + +interface SummaryProps { + ItemSummary: string; +} + +export const ProductSummary: React.FC = ({ ItemSummary }) => { + const summaryArray = ItemSummary.split('\r\n'); + const [expanded, setExpanded] = useState(false); + // Nếu chưa expanded thì chỉ hiển thị 3 dòng đầu + const visibleItems = expanded ? summaryArray : summaryArray.slice(0, 3); + + return ( +
+

Thông số sản phẩm

+
    + {visibleItems.map((item, index) => ( +
  • {item}
  • + ))} +
+ {summaryArray.length > 3 && ( +
setExpanded(!expanded)} + > + {expanded ? 'Thu gọn' : 'Xem thêm'} + {expanded ? : } +
+ )} +
+ ); +}; diff --git a/src/components/product/ProductDetail/index.tsx b/src/components/product/ProductDetail/index.tsx index 02e898e..2deee7b 100644 --- a/src/components/product/ProductDetail/index.tsx +++ b/src/components/product/ProductDetail/index.tsx @@ -8,6 +8,9 @@ import { ErrorLink } from '@components/common/error'; import { Breadcrumb } from '@components/common/Breadcrumb'; import { ImageProduct } from './ImageProduct'; +import { ProductSummary } from './ProductSummary'; +import { ComboSetBox } from './ComboSet'; +import { BoxInfoRight } from './BoxInfoRight'; interface ProductDetailPageProps { slug: string; @@ -39,8 +42,14 @@ const ProductDetailPage: React.FC = ({ slug }) => {
{/* image product */} + + + + +
+
+
-
diff --git a/src/hooks/useFancybox.ts b/src/hooks/useFancybox.ts new file mode 100644 index 0000000..7910656 --- /dev/null +++ b/src/hooks/useFancybox.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +import { type FancyboxOptions, Fancybox } from '@fancyapps/ui/dist/fancybox/'; +import '@fancyapps/ui/dist/fancybox/fancybox.css'; + +export default function useFancybox(options: Partial = {}) { + const [root, setRoot] = useState(null); + + useEffect(() => { + if (root) { + Fancybox.bind(root, '[data-fancybox]', options); + return () => Fancybox.unbind(root); + } + }, [root, options]); + + return [setRoot]; +} diff --git a/src/lib/formatPrice.ts b/src/lib/formatPrice.ts new file mode 100644 index 0000000..3b07d84 --- /dev/null +++ b/src/lib/formatPrice.ts @@ -0,0 +1,4 @@ +export const formatCurrency = (value: number | string) => { + const num = typeof value === 'string' ? parseInt(value) : value; + return num.toLocaleString('vi-VN'); +}; diff --git a/src/styles/globals.css b/src/styles/globals.css index f02c8a3..ef7daaf 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -3426,7 +3426,6 @@ textarea::placeholder { transition: color 0.2s ease-out; } .page-product-detail .box-content-product-detail .detail-buy a { - width: calc(50% - 6px); padding: 8px 12px; text-align: center; background: #0a76e4; @@ -3541,20 +3540,7 @@ textarea::placeholder { font-size: 13px; margin-left: 10px; } -.page-product-detail - .box-content-product-detail - .box-product-summary - .list-product-summary - li:nth-child(n + 4) { - display: none; -} -.page-product-detail - .box-content-product-detail - .box-product-summary - .list-product-summary.active - li:nth-child(n + 4) { - display: block; -} + .page-product-detail .box-content-product-detail .list-product-comboset { padding: 2px; margin-bottom: 16px; @@ -4308,7 +4294,7 @@ textarea::placeholder { border-radius: 50%; text-align: center; } -.popup-change-pro .popup-main .c-btn { +.c-pro-item .c-btn { cursor: pointer; display: block; text-transform: uppercase; diff --git a/src/types/product/detail/index.ts b/src/types/product/detail/index.ts index 43264b5..256a4a0 100644 --- a/src/types/product/detail/index.ts +++ b/src/types/product/detail/index.ts @@ -199,7 +199,7 @@ export interface ProductInfo { } // Combo Set -interface ComboProduct { +export interface ComboProduct { id: string; title: string; url: string; @@ -216,14 +216,14 @@ interface ComboProduct { is_recommended: string; } -interface ComboGroup { +export interface ComboGroup { key: string; title: string; product_list: ComboProduct[]; product_count: string; } -interface ComboSet { +export interface ComboSet { id: string; title: string; description: string;