update
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@fancyapps/ui": "^6.1.7",
|
"@fancyapps/ui": "^6.1.7",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
|
"lightgallery": "^2.9.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
@@ -4626,6 +4627,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@fancyapps/ui": "^6.1.7",
|
"@fancyapps/ui": "^6.1.7",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
|
"lightgallery": "^2.9.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
|
|||||||
@@ -4,16 +4,12 @@ import 'tippy.js/dist/tippy.css';
|
|||||||
import { Product } from '@/types';
|
import { Product } from '@/types';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { formatCurrency } from '@/lib/formatPrice';
|
||||||
|
|
||||||
type ProductItemProps = {
|
type ProductItemProps = {
|
||||||
item: Product;
|
item: Product;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number | string) => {
|
|
||||||
const num = typeof value === 'string' ? parseInt(value) : value;
|
|
||||||
return num.toLocaleString('vi-VN');
|
|
||||||
};
|
|
||||||
|
|
||||||
const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
|
const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
|
||||||
const offers = item.specialOffer?.all ?? [];
|
const offers = item.specialOffer?.all ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Tippy from '@tippyjs/react';
|
|
||||||
import 'tippy.js/dist/tippy.css';
|
|
||||||
import { DealType } from '@/types';
|
import { DealType } from '@/types';
|
||||||
|
import { formatCurrency } from '@/lib/formatPrice';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
type ProductItemProps = {
|
type ProductItemProps = {
|
||||||
item: DealType;
|
item: DealType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number | string) => {
|
|
||||||
const num = typeof value === 'string' ? parseInt(value) : value;
|
|
||||||
return num.toLocaleString('vi-VN');
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProductItem: React.FC<ProductItemProps> = ({ item }) => {
|
const ProductItem: React.FC<ProductItemProps> = ({ item }) => {
|
||||||
const { product_info } = item;
|
const { product_info } = item;
|
||||||
const offers = product_info.specialOffer?.all ?? [];
|
const offers = product_info.specialOffer?.all ?? [];
|
||||||
@@ -20,7 +15,7 @@ const ProductItem: React.FC<ProductItemProps> = ({ item }) => {
|
|||||||
<div className="product-item">
|
<div className="product-item">
|
||||||
<a href={product_info.productUrl} className="product-image relative">
|
<a href={product_info.productUrl} className="product-image relative">
|
||||||
{product_info.productImage.large ? (
|
{product_info.productImage.large ? (
|
||||||
<img
|
<Image
|
||||||
src={product_info.productImage.large}
|
src={product_info.productImage.large}
|
||||||
width="164"
|
width="164"
|
||||||
height="164"
|
height="164"
|
||||||
@@ -28,7 +23,7 @@ const ProductItem: React.FC<ProductItemProps> = ({ item }) => {
|
|||||||
className="lazy"
|
className="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<Image
|
||||||
src="/static/assets/nguyencong_2023/images/not-image.png"
|
src="/static/assets/nguyencong_2023/images/not-image.png"
|
||||||
width="164"
|
width="164"
|
||||||
height="164"
|
height="164"
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import { FaCaretRight } from 'react-icons/fa';
|
import { FaCaretRight } from 'react-icons/fa';
|
||||||
|
|
||||||
|
import { productDealData } from './productDealData';
|
||||||
|
|
||||||
import CounDown from './CounDown';
|
import CounDown from './CounDown';
|
||||||
|
import ProductItem from './ProductItem';
|
||||||
|
|
||||||
const BoxProductDeal: React.FC = () => {
|
const BoxProductDeal: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -18,9 +22,9 @@ const BoxProductDeal: React.FC = () => {
|
|||||||
<CounDown />
|
<CounDown />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/deal" className="button-deal color-white mb-10 flex items-center">
|
<Link href="/deal" className="button-deal color-white mb-10 flex items-center">
|
||||||
Xem thêm khuyến mãi <FaCaretRight size={16} />
|
Xem thêm khuyến mãi <FaCaretRight size={16} />
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="box-list-item-deal swiper-box-deal">
|
<div className="box-list-item-deal swiper-box-deal">
|
||||||
<Swiper
|
<Swiper
|
||||||
@@ -29,7 +33,13 @@ const BoxProductDeal: React.FC = () => {
|
|||||||
slidesPerView={6}
|
slidesPerView={6}
|
||||||
loop={true}
|
loop={true}
|
||||||
navigation={true}
|
navigation={true}
|
||||||
></Swiper>
|
>
|
||||||
|
{productDealData.map((Item, index) => (
|
||||||
|
<SwiperSlide key={index}>
|
||||||
|
<ProductItem item={Item} />
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ProductDetailData } from '@/types';
|
||||||
|
|
||||||
|
export const BoxPrice = (item: ProductDetailData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{item.product_info.sale_rules.type == 'deal' && (
|
||||||
|
<div className="box-flash-sale boder-radius-10 flex items-center">
|
||||||
|
<div className="box-left relative flex items-center">
|
||||||
|
<i className="sprite sprite-flashsale-detail"></i>
|
||||||
|
<p className="title-deal font-weight-800">flash sale</p>
|
||||||
|
</div>
|
||||||
|
<div className="box-middle product-time-holder global-time-deal flex gap-2"></div>
|
||||||
|
<div className="box-right">
|
||||||
|
<div className="box-product-deal"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
130
src/components/product/ProductDetail/BoxInfoRight/index.tsx
Normal file
130
src/components/product/ProductDetail/BoxInfoRight/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import type { ProductDetailData } from '@/types';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { BoxPrice } from './BoxPrice';
|
||||||
|
|
||||||
|
export const BoxInfoRight = (item: ProductDetailData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="product-name color-black line-clamp-3">{item.product_info.productName}</h1>
|
||||||
|
|
||||||
|
<div className="list-basic-product-info flex flex-wrap items-center">
|
||||||
|
<div className="item-basic">
|
||||||
|
Mã SP: <span className="color-primary">{item.product_info.productSKU}</span>
|
||||||
|
</div>
|
||||||
|
<div className="item-basic">
|
||||||
|
Đánh giá: <span className="color-primary">{item.product_info.review.summary.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="item-basic">
|
||||||
|
Bình luận:{' '}
|
||||||
|
<span className="color-primary">{item.product_info.comment.summary.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="item-basic">
|
||||||
|
Lượt xem: <span className="color-primary">{item.product_info.visit}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.product_info.extend.buy_count?.length > 0 && (
|
||||||
|
<div className="item-basic last-item-basic position-relative">
|
||||||
|
Đã bán: <span className="color-primary">{item.product_info.extend.buy_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tình trạng */}
|
||||||
|
<div className="list-basic-product-info flex flex-wrap items-center gap-6">
|
||||||
|
<div className="item-basic">
|
||||||
|
Bảo hành: <span className="color-red">{item.product_info.warranty}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.product_info.quantity > '0' && (
|
||||||
|
<div className="item-basic last-item-basic position-relative">
|
||||||
|
Tình trạng: <span className="color-green">Còn hàng</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* giá */}
|
||||||
|
|
||||||
|
<BoxPrice {...item} />
|
||||||
|
|
||||||
|
{item.product_info.specialOffer.all.length > 0 && (
|
||||||
|
<div className="box-offer-detail border-radius-10">
|
||||||
|
<div className="title-offer-detail flex items-center">
|
||||||
|
<i className="sprite sprite-gift-detail"></i>
|
||||||
|
<p className="font-weight-600">Khuyến mãi</p>
|
||||||
|
</div>
|
||||||
|
<div className="list-info-offter">
|
||||||
|
{item.product_info.specialOffer.all.map((_item, idx) => (
|
||||||
|
<div key={idx} className="item-offer">
|
||||||
|
<i className="icon"></i>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: _item.title }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* mua hàng */}
|
||||||
|
{(item.product_info.quantity > '0' || item.product_info.price > '0') && (
|
||||||
|
<>
|
||||||
|
<div className="product-buy-quantity flex items-center">
|
||||||
|
<p className="title-quantity">Số lượng:</p>
|
||||||
|
<div className="cart-quantity-select flex items-center justify-center">
|
||||||
|
<p className="js-quantity-change" data-value="-1">
|
||||||
|
{' '}
|
||||||
|
−{' '}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="js-buy-quantity js-quantity-change bk-product-qty font-bold"
|
||||||
|
defaultValue={1}
|
||||||
|
/>
|
||||||
|
<p className="js-quantity-change" data-value="1">
|
||||||
|
{' '}
|
||||||
|
+{' '}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={() => addProductToCart(item.product_info.id, 0, '')}
|
||||||
|
className="addCart flex flex-wrap items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<i className="sprite sprite-cart-detail"></i>
|
||||||
|
<p className="title-cart">Thêm vào giỏ hàng</p>
|
||||||
|
</Link>
|
||||||
|
<input type="hidden" className="js-buy-quantity-temp" value="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="detail-buy-ads" className="detail-buy grid grid-cols-2 gap-2">
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="detail-buy-now col-span-2"
|
||||||
|
onClick={() => buyNow(item.product_info.id, 0, '')}
|
||||||
|
>
|
||||||
|
<span>ĐẶT MUA NGAY</span>
|
||||||
|
Giao hàng tận nơi nhanh chóng
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="detail-add-cart"
|
||||||
|
onClick={() => buyPayInstall(item.product_info.id, 0, '')}
|
||||||
|
>
|
||||||
|
<span>TRẢ GÓP QUA HỒ SƠ</span>
|
||||||
|
Chỉ từ 2.665.000₫/ tháng
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="detail-add-cart"
|
||||||
|
onClick={() => buyAlepay(item.product_info.id, 0, '')}
|
||||||
|
>
|
||||||
|
<span>TRẢ GÓP QUA THẺ</span>
|
||||||
|
Chỉ từ 1.332.500₫/ tháng
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<ChangePopupProps> = ({
|
||||||
|
titleGroup,
|
||||||
|
products,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
if (!open) return null; // chỉ render khi open = true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog open className="modal">
|
||||||
|
<div className="modal-box max-w-5xl bg-white">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-bold">Chọn {titleGroup} khác</h3>
|
||||||
|
<button className="btn btn-sm btn-circle btn-ghost" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danh sách sản phẩm */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{products.map((p) => (
|
||||||
|
<div key={p.id} className="product-item c-pro-item">
|
||||||
|
<Link href={p.url} className="product-image">
|
||||||
|
<Image
|
||||||
|
src={p.images.large || '/static/assets/not-image.png'}
|
||||||
|
alt={p.title}
|
||||||
|
className="mb-2 object-cover"
|
||||||
|
height={170}
|
||||||
|
width={170}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="product-info">
|
||||||
|
<Link href={p.url}>
|
||||||
|
<h3 className="product-title line-clamp-2">{p.title}</h3>
|
||||||
|
</Link>
|
||||||
|
<div className="product-price-main flex items-center justify-between">
|
||||||
|
<div className='class="product-price"'>
|
||||||
|
<b className="price font-[600]">
|
||||||
|
{Number(p.price) > 0 ? `${formatCurrency(p.price)} đ` : 'Liên hệ'}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="c-btn js-c-btn"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(p);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chọn mua
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button onClick={onClose}>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<ItemComboProps> = ({
|
||||||
|
item,
|
||||||
|
keyGroup,
|
||||||
|
titleGroup,
|
||||||
|
setId,
|
||||||
|
products,
|
||||||
|
onOpenPopup,
|
||||||
|
}) => {
|
||||||
|
const hasDiscount = Number(item.normal_price) > Number(item.price) && Number(item.price) > 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`product-item c-pro-item ${
|
||||||
|
item.is_free === 'yes' ? 'w-select' : ''
|
||||||
|
} js-pro-${item.id}`}
|
||||||
|
>
|
||||||
|
<Link href={item.url} className="product-image">
|
||||||
|
{item.images?.large ? (
|
||||||
|
<Image src={item.images.large} alt={item.title} width={175} height={175} />
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src="/static/assets/nguyencong_2023/images/not-image.png"
|
||||||
|
width={175}
|
||||||
|
height={175}
|
||||||
|
alt={item.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="product-info">
|
||||||
|
<Link href={item.url}>
|
||||||
|
<h3 className="product-title line-clamp-2">{item.title}</h3>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="product-price-main d-flex align-items-center justify-content-between">
|
||||||
|
<div className="product-price">
|
||||||
|
<b className="price font-weight-600">
|
||||||
|
{Number(item.price) > 0 ? `${formatCurrency(item.price)} đ` : 'Liên hệ'}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasDiscount ? (
|
||||||
|
<div className="product-martket-main d-flex align-items-center flex-wrap gap-4">
|
||||||
|
<p className="product-market-price">{item.normal_price} đ</p>
|
||||||
|
{item.discount.includes('%') ? (
|
||||||
|
<div
|
||||||
|
className="product-percent-price"
|
||||||
|
style={{ fontSize: '10px', padding: '0 8px' }}
|
||||||
|
>
|
||||||
|
-{item.discount}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '10px', color: '#BE1F2D' }}>(-{item.discount} đ)</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="product-martket-main d-flex align-items-center"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="c-pro-change js-chagne-pro"
|
||||||
|
data-id={item.id}
|
||||||
|
onClick={() => onOpenPopup(titleGroup, products, item)}
|
||||||
|
>
|
||||||
|
Chọn {titleGroup} khác
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="check-box-comboset">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={`position-relative js-price js-check-select js-combo-set js-combo-set-select-product cursor-pointer ${
|
||||||
|
item.is_free === 'yes' ? 'product_free' : ''
|
||||||
|
}`}
|
||||||
|
data-price={item.price}
|
||||||
|
data-unprice={item.normal_price}
|
||||||
|
data-idpk={item.id}
|
||||||
|
data-set-id={setId}
|
||||||
|
data-group-key={keyGroup}
|
||||||
|
data-product-id={item.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
114
src/components/product/ProductDetail/ComboSet/index.tsx
Normal file
114
src/components/product/ProductDetail/ComboSet/index.tsx
Normal file
@@ -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<ComboProps> = ({ combo_set }) => {
|
||||||
|
const [openPopup, setOpenPopup] = useState(false);
|
||||||
|
const [popupGroup, setPopupGroup] = useState<PopupGroup>({
|
||||||
|
title: '',
|
||||||
|
products: [],
|
||||||
|
});
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<ComboProduct | null>(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 (
|
||||||
|
<div className="box-comboset mb-8">
|
||||||
|
<p className="title-comboset font-weight-600">Mua theo combo</p>
|
||||||
|
<div id="comboset">
|
||||||
|
<Swiper
|
||||||
|
className="list-product-comboset swiper-comboset"
|
||||||
|
modules={[Autoplay, Navigation, Pagination]}
|
||||||
|
spaceBetween={16}
|
||||||
|
slidesPerView={3}
|
||||||
|
navigation
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<SwiperSlide key={index}>
|
||||||
|
<ItemComboSet
|
||||||
|
item={getDisplayedProduct(group)}
|
||||||
|
keyGroup={group.key}
|
||||||
|
titleGroup={group.title}
|
||||||
|
setId={setInfo.id}
|
||||||
|
products={group.product_list}
|
||||||
|
onOpenPopup={handleOpenPopup}
|
||||||
|
/>
|
||||||
|
</SwiperSlide>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Swiper>
|
||||||
|
|
||||||
|
<div className="comboset-info mt-4 flex justify-between">
|
||||||
|
<div className="box-left">
|
||||||
|
<div className="total-comboset flex items-center gap-2">
|
||||||
|
<p>Tạm tính:</p>
|
||||||
|
<p className="js-pass-price price text-red font-weight-600">
|
||||||
|
{/* giả sử lấy giá từ product_info */}
|
||||||
|
3.050.000 đ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="save-price-combo">
|
||||||
|
Tiết kiệm thêm <span className="save-price">215.000đ</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="box-right flex items-center justify-end gap-2">
|
||||||
|
<p className="js-combo-set js-combo-set-checkout buy_combo" data-set-id={setInfo.id}>
|
||||||
|
Mua thêm <span id="count-pro-selected">0</span> sản phẩm
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChangeProductPopup
|
||||||
|
titleGroup={popupGroup.title}
|
||||||
|
products={popupGroup.products}
|
||||||
|
open={openPopup}
|
||||||
|
onClose={() => setOpenPopup(false)}
|
||||||
|
onSelect={handleReplaceProduct}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,8 +5,7 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ProductImageGallery } from '@/types';
|
import { ProductImageGallery } from '@/types';
|
||||||
import type { Swiper as SwiperType } from 'swiper';
|
import type { Swiper as SwiperType } from 'swiper';
|
||||||
import '@fancyapps/ui/dist/fancybox/fancybox.css';
|
import useFancybox from '@/hooks/useFancybox';
|
||||||
import { Fancybox, type FancyboxOptions } from '@fancyapps/ui';
|
|
||||||
|
|
||||||
interface ImageProps {
|
interface ImageProps {
|
||||||
ItemImage: ProductImageGallery[];
|
ItemImage: ProductImageGallery[];
|
||||||
@@ -15,30 +14,14 @@ interface ImageProps {
|
|||||||
export const ImageProduct: React.FC<ImageProps> = ({ ItemImage }) => {
|
export const ImageProduct: React.FC<ImageProps> = ({ ItemImage }) => {
|
||||||
const [thumbsSwiper, setThumbsSwiper] = useState<SwiperType | null>(null);
|
const [thumbsSwiper, setThumbsSwiper] = useState<SwiperType | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const [fancyboxRef] = useFancybox({
|
||||||
if (typeof window === 'undefined') return;
|
closeButton: 'auto',
|
||||||
|
|
||||||
Fancybox.bind("[data-fancybox='gallery']", {
|
|
||||||
dragToClose: true,
|
dragToClose: true,
|
||||||
Toolbar: {
|
});
|
||||||
display: {
|
|
||||||
left: [],
|
|
||||||
middle: ['counter'],
|
|
||||||
right: ['zoom', 'close'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Thumbs: { autoStart: false },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
Fancybox.unbind("[data-fancybox='gallery']");
|
|
||||||
Fancybox.close();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="product-images-show">
|
<div className="product-images-show">
|
||||||
<div className="gallery-top product-info-image">
|
<div className="gallery-top product-info-image" ref={fancyboxRef}>
|
||||||
<Swiper
|
<Swiper
|
||||||
modules={[Autoplay, Navigation, Pagination, Thumbs]}
|
modules={[Autoplay, Navigation, Pagination, Thumbs]}
|
||||||
spaceBetween={12}
|
spaceBetween={12}
|
||||||
@@ -48,7 +31,7 @@ export const ImageProduct: React.FC<ImageProps> = ({ ItemImage }) => {
|
|||||||
>
|
>
|
||||||
{ItemImage?.map((item, index) => (
|
{ItemImage?.map((item, index) => (
|
||||||
<SwiperSlide key={index}>
|
<SwiperSlide key={index}>
|
||||||
<Link href={item.size.original} data-fancybox="gallery" className="bigImage">
|
<Link href={item.size.original} className="bigImage" data-fancybox>
|
||||||
<Image src={item.size.original} alt={''} width="595" height="595" />
|
<Image src={item.size.original} alt={''} width="595" height="595" />
|
||||||
</Link>
|
</Link>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
|
|||||||
@@ -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<SummaryProps> = ({ 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 (
|
||||||
|
<div className="box-product-summary boder-radius-10">
|
||||||
|
<p className="title font-weight-600">Thông số sản phẩm</p>
|
||||||
|
<ul className="list-product-summary">
|
||||||
|
{visibleItems.map((item, index) => (
|
||||||
|
<li key={index}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{summaryArray.length > 3 && (
|
||||||
|
<div
|
||||||
|
className="button-showmore flex cursor-pointer items-center gap-2 font-[500]"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<span>{expanded ? 'Thu gọn' : 'Xem thêm'}</span>
|
||||||
|
{expanded ? <FaAngleUp /> : <FaAngleDown />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,9 @@ import { ErrorLink } from '@components/common/error';
|
|||||||
|
|
||||||
import { Breadcrumb } from '@components/common/Breadcrumb';
|
import { Breadcrumb } from '@components/common/Breadcrumb';
|
||||||
import { ImageProduct } from './ImageProduct';
|
import { ImageProduct } from './ImageProduct';
|
||||||
|
import { ProductSummary } from './ProductSummary';
|
||||||
|
import { ComboSetBox } from './ComboSet';
|
||||||
|
import { BoxInfoRight } from './BoxInfoRight';
|
||||||
|
|
||||||
interface ProductDetailPageProps {
|
interface ProductDetailPageProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -39,8 +42,14 @@ const ProductDetailPage: React.FC<ProductDetailPageProps> = ({ slug }) => {
|
|||||||
<div className="box-left">
|
<div className="box-left">
|
||||||
{/* image product */}
|
{/* image product */}
|
||||||
<ImageProduct ItemImage={Products.product_info.productImageGallery} />
|
<ImageProduct ItemImage={Products.product_info.productImageGallery} />
|
||||||
|
|
||||||
|
<ProductSummary ItemSummary={Products.product_info.productSummary} />
|
||||||
|
|
||||||
|
<ComboSetBox combo_set={Products.combo_set} />
|
||||||
|
</div>
|
||||||
|
<div className="box-right">
|
||||||
|
<BoxInfoRight {...Products} />
|
||||||
</div>
|
</div>
|
||||||
<div className="box-right"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
17
src/hooks/useFancybox.ts
Normal file
17
src/hooks/useFancybox.ts
Normal file
@@ -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<FancyboxOptions> = {}) {
|
||||||
|
const [root, setRoot] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (root) {
|
||||||
|
Fancybox.bind(root, '[data-fancybox]', options);
|
||||||
|
return () => Fancybox.unbind(root);
|
||||||
|
}
|
||||||
|
}, [root, options]);
|
||||||
|
|
||||||
|
return [setRoot];
|
||||||
|
}
|
||||||
4
src/lib/formatPrice.ts
Normal file
4
src/lib/formatPrice.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const formatCurrency = (value: number | string) => {
|
||||||
|
const num = typeof value === 'string' ? parseInt(value) : value;
|
||||||
|
return num.toLocaleString('vi-VN');
|
||||||
|
};
|
||||||
@@ -3426,7 +3426,6 @@ textarea::placeholder {
|
|||||||
transition: color 0.2s ease-out;
|
transition: color 0.2s ease-out;
|
||||||
}
|
}
|
||||||
.page-product-detail .box-content-product-detail .detail-buy a {
|
.page-product-detail .box-content-product-detail .detail-buy a {
|
||||||
width: calc(50% - 6px);
|
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #0a76e4;
|
background: #0a76e4;
|
||||||
@@ -3541,20 +3540,7 @@ textarea::placeholder {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-left: 10px;
|
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 {
|
.page-product-detail .box-content-product-detail .list-product-comboset {
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -4308,7 +4294,7 @@ textarea::placeholder {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.popup-change-pro .popup-main .c-btn {
|
.c-pro-item .c-btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: block;
|
display: block;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export interface ProductInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combo Set
|
// Combo Set
|
||||||
interface ComboProduct {
|
export interface ComboProduct {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -216,14 +216,14 @@ interface ComboProduct {
|
|||||||
is_recommended: string;
|
is_recommended: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComboGroup {
|
export interface ComboGroup {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
product_list: ComboProduct[];
|
product_list: ComboProduct[];
|
||||||
product_count: string;
|
product_count: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComboSet {
|
export interface ComboSet {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user