This commit is contained in:
2026-03-13 13:54:45 +07:00
parent a8e30f32a0
commit 25111ff10e
120 changed files with 4213 additions and 4859 deletions

View File

@@ -1,67 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { parse } from 'date-fns';
interface CountDownProps {
deadline: number | string;
}
const CounDown: React.FC<CountDownProps> = ({ deadline }) => {
const [days, setDays] = useState(0);
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const [seconds, setSeconds] = useState(0);
const getTime = () => {
let time: number;
if (typeof deadline == 'string') {
const parsed = parse(deadline as string, 'dd-MM-yyyy, h:mm a', new Date());
time = parsed.getTime() - Date.now();
} else {
time = Number(deadline) * 1000 - Date.now();
}
setDays(Math.floor(time / (1000 * 60 * 60 * 24)));
setHours(Math.floor((time / (1000 * 60 * 60)) % 24));
setMinutes(Math.floor((time / 1000 / 60) % 60));
setSeconds(Math.floor((time / 1000) % 60));
};
useEffect(() => {
const interval = setInterval(() => getTime(), 1000);
return () => clearInterval(interval);
}, []);
return (
<>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p> {days < 10 ? '0' + days : days} </p> <span>:</span>
</div>
<span className="blocl mt-1 text-sm">Ngày</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{hours < 10 ? '0' + hours : hours} </p> <span>:</span>
</div>
<span className="blocl mt-1 text-sm">Giờ</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{minutes < 10 ? '0' + minutes : minutes} </p> <span>:</span>
</div>
<span className="blocl mt-1 text-sm">Phút</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{seconds < 10 ? '0' + seconds : seconds} </p>
</div>
<span className="blocl mt-1 text-sm">Giây</span>
</div>
</>
);
};
export default CounDown;

View File

@@ -0,0 +1,77 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { parse } from 'date-fns';
interface CountDownProps {
deadline: number | string;
onExpire?: () => void;
}
const CountDown: React.FC<CountDownProps> = ({ deadline, onExpire }) => {
const [days, setDays] = useState(0);
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const [seconds, setSeconds] = useState(0);
const getDeadlineMs = useCallback((): number => {
if (typeof deadline === 'string') {
return parse(deadline, 'dd-MM-yyyy, h:mm a', new Date()).getTime();
}
return Number(deadline) * 1000;
}, [deadline]);
useEffect(() => {
const tick = () => {
const time = getDeadlineMs() - Date.now();
if (time <= 0) {
setDays(0);
setHours(0);
setMinutes(0);
setSeconds(0);
onExpire?.();
return;
}
setDays(Math.floor(time / (1000 * 60 * 60 * 24)));
setHours(Math.floor((time / (1000 * 60 * 60)) % 24));
setMinutes(Math.floor((time / 1000 / 60) % 60));
setSeconds(Math.floor((time / 1000) % 60));
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [getDeadlineMs, onExpire]);
const pad = (n: number) => String(n).padStart(2, '0');
return (
<>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{pad(days)}</p> <span>:</span>
</div>
<span className="mt-1 block text-sm">Ngày</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{pad(hours)}</p> <span>:</span>
</div>
<span className="mt-1 block text-sm">Giờ</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{pad(minutes)}</p> <span>:</span>
</div>
<span className="mt-1 block text-sm">Phút</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{pad(seconds)}</p>
</div>
<span className="mt-1 block text-sm">Giây</span>
</div>
</>
);
};
export default CountDown;

View File

@@ -0,0 +1,46 @@
'use client';
import React from 'react';
interface State {
hasError: boolean;
error: Error | null;
}
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('[ErrorBoundary]', error, info.componentStack);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl bg-red-50 p-6 text-center">
<p className="font-semibold text-red-600">Đã xảy ra lỗi hiển thị.</p>
<p className="text-sm text-gray-500">{this.state.error?.message}</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="rounded-lg bg-red-100 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-200"
>
Thử lại
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,27 +1,29 @@
'use client';
import React from 'react';
import Tippy from '@tippyjs/react';
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';
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
type ProductItemProps = {
item: Product;
};
const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
const offers = item.specialOffer?.all ?? [];
const firstOffer = item.specialOffer?.all?.[0];
return (
<div className="product-item js-p-item">
<a href={item.productUrl} className="product-image relative">
{item.productImage.large ? (
<Image src={item.productImage.large} width="203" height="203" alt={item.productName} />
<Image src={item.productImage.large} width={203} height={203} alt={item.productName} />
) : (
<Image
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/not-image.png"
alt={item.productName}
width={203}
height={203}
/>
)}
@@ -38,39 +40,35 @@ const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
<Link href={item.productUrl}>
<h3 className="product-title line-clamp-3">{item.productName}</h3>
</Link>
{item.marketPrice > 0 ? (
{Number(item.marketPrice) > 0 ? (
<div className="product-martket-main flex items-center">
<p className="product-market-price">
{item.marketPrice.toLocaleString()}
{formatCurrency(item.marketPrice)}
<u>đ</u>
</p>
<div className="product-percent-price">-{Math.round(Number(item.price_off))} %</div>
</div>
) : (
<div className="product-martket-main flex items-center"></div>
<div className="product-martket-main flex items-center" />
)}
<div className="product-price-main font-[600]">
{item.price > '0' ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
<div className="product-price-main font-semibold">
{Number(item.price) > 0 ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
</div>
{item.specialOffer?.all?.length ? (
<div
className="product-offer line-clamp-2"
dangerouslySetInnerHTML={{
__html: item.specialOffer!.all![0].title,
}}
/>
{firstOffer ? (
<SanitizedHtml html={firstOffer.title} className="product-offer line-clamp-2" />
) : (
<div className="product-offer line-clamp-2"></div>
<div className="product-offer line-clamp-2" />
)}
{item.extend?.buy_count ? (
<div style={{ height: 18 }}>
{' '}
<b>Đã bán: </b> <span>{item.extend.buy_count}</span>{' '}
<div className="h-4.5">
<b>Đã bán: </b>
<span>{item.extend.buy_count}</span>
</div>
) : (
<div style={{ height: 18, display: 'block' }}> </div>
<div className="h-4.5" />
)}
</div>
</div>

View File

@@ -0,0 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Khởi động MSW browser worker trong development.
* Block render cho đến khi worker ready để tránh race condition
* (requests gửi trước khi MSW sẵn sàng sẽ bị passthrough → "Failed to fetch").
*/
const MSWProvider = ({ children }: { children: React.ReactNode }) => {
// Production: render ngay (NODE_ENV !== 'development' → ready = true)
// Development: chờ worker.start() xong rồi mới render
const [ready, setReady] = useState(process.env.NODE_ENV !== 'development');
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return;
import('@/mocks/browser')
.then(({ worker }) =>
worker.start({
onUnhandledRequest: 'bypass',
quiet: true,
}),
)
.then(() => setReady(true))
.catch((err) => {
console.error('[MSW] Failed to start worker:', err);
setReady(true); // vẫn render dù worker fail
});
}, []);
if (!ready) return null;
return <>{children}</>;
};
export default MSWProvider;

View File

@@ -0,0 +1,35 @@
'use client';
import { useMemo } from 'react';
import { normalizeHtmlAssetUrls } from '@/lib/normalizeAssetUrl';
const PURIFY_CONFIG = {
USE_PROFILES: { html: true },
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
};
interface Props {
html: string;
className?: string;
}
/**
* Render HTML an toan.
* Tren server se giu nguyen HTML, tren client se sanitize truoc khi render.
*/
export function SanitizedHtml({ html, className }: Props) {
const sanitized = useMemo(() => {
const normalizedHtml = normalizeHtmlAssetUrls(html);
if (typeof window === 'undefined') {
return normalizedHtml;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const DOMPurify = require('dompurify');
return DOMPurify.sanitize(normalizedHtml, PURIFY_CONFIG);
}, [html]);
return <div className={className} dangerouslySetInnerHTML={{ __html: sanitized }} />;
}

View File

@@ -1,163 +0,0 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { TypeListProductDeal } from '@types/TypeListProductDeal';
const formatCurrency = (price: number | string) => {
return Number(price).toLocaleString('vi-VN');
};
const DealProductItem = ({ item }: { item: TypeListProductDeal }) => {
const product = item.product_info;
const quantityLeft = item.quantity - item.sale_quantity;
return (
<div
className="product-item"
data-id={product.id}
data-time={item.deal_time_left}
data-type={product.sale_rules.type}
>
<Link href={product.productUrl || '#'} className="product-image relative">
<Image
src={product.productImage?.large || '/static/assets/nguyencong_2023/images/not-image.png'}
width={164}
height={164}
alt={product.productName}
className="lazy"
unoptimized // Thêm nếu dùng ảnh từ domain bên ngoài chưa config
/>
<span className="p-type-holder">
{product.productType?.isHot === 1 && <i className="p-icon-type p-icon-hot"></i>}
{product.productType?.isNew === 1 && <i className="p-icon-type p-icon-new"></i>}
</span>
</Link>
<div className="product-info">
<Link href={product.productUrl || '#'}>
<h3 className="product-title line-clamp-3">{product.productName}</h3>
</Link>
<div className="product-martket-main flex items-center">
{product.marketPrice > 0 ? (
<>
<p className="product-market-price">{product.marketPrice.toLocaleString()} </p>
<div className="product-percent-price">-{parseInt(product.price_off || '0')}%</div>
</>
) : product.sale_rules?.type === 'deal' ? (
<>
<p className="product-market-price">
{product.sale_rules.normal_price.toLocaleString()}
</p>
<div className="product-percent-price">0%</div>
</>
) : null}
</div>
<div className="product-price-main font-semibold">
{Number(item.price) > 0 ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
</div>
<div
className="p-quantity-sale"
data-quantity-left={quantityLeft}
data-quantity-sale-total={item.quantity}
>
<i className="sprite sprite-fire-deal"></i>
<div className="bg-gradient"></div>
<p className="js-line-deal-left"></p>
<span>
Còn {quantityLeft}/ {item.quantity} sản phẩm
</span>
</div>
{product.specialOffer?.all?.length > 0 ? (
<div
className="product-offer line-clamp-2"
dangerouslySetInnerHTML={{ __html: product.specialOffer.all[0].title }}
/>
) : (
<div className="product-offer line-clamp-2"></div>
)}
</div>
{/* TOOLTIP */}
<div className="tooltip p-tooltip tippy-box">
<div className="tooltip-name">{product.productName}</div>
<div className="tooltip-descreption">
<div className="tooltip-descreption-price">
{product.marketPrice > 0 ? (
<p>Giá niêm yết</p>
) : (
product.sale_rules?.type === 'deal' && <p>Giá gốc</p>
)}
<p>Giá bán</p>
{product.warranty !== '' && <p>Bảo hành</p>}
<p>Tình trạng</p>
</div>
<div className="tooltip-descreption-info">
{product.marketPrice > 0 ? (
<div className="d-flex align-items-center">
<p className="card-price-origin color-black" style={{ position: 'relative' }}>
{product.marketPrice.toLocaleString()}
<span className="card-price-origin-line-through"></span>
</p>
<span className="color-red" style={{ marginLeft: '4px' }}>
-{product.price_off}%
</span>
</div>
) : product.sale_rules?.type === 'deal' ? (
<div className="d-flex align-items-center">
<p className="card-price-origin color-black" style={{ position: 'relative' }}>
{product.sale_rules.normal_price.toLocaleString()}
<span className="card-price-origin-line-through"></span>
</p>
<span className="color-red" style={{ marginLeft: '4px' }}>
-
{Math.floor(
100 -
(Number(product.sale_rules.price) / product.sale_rules.normal_price) * 100,
)}
%
</span>
</div>
) : null}
<p>{Number(product.price) > 0 ? `${formatCurrency(product.price)}đ` : 'Liên hệ'}</p>
<p className="color-primary">{product.warranty}</p>
<p className="color-secondary">{quantityLeft > 0 ? 'Còn DEAL' : 'Hết DEAL'}</p>
</div>
</div>
{product.productSummary && (
<>
<div className="tooltip-input">
<i className="fa-solid fa-database icon-database"></i>
<span>Thông số sản phẩm</span>
</div>
<div className="tooltip-list">
<span dangerouslySetInnerHTML={{ __html: product.productSummary }} />
</div>
</>
)}
{product.specialOffer?.all?.length > 0 && (
<div className="box-tooltip-gift">
<div className="tooltip-input tooltip-gift">
<p className="icon-gift">
<i className="fa-solid fa-gift"></i> Khuyến mãi
</p>
</div>
<div className="tooltip-list tooltip-list-gift">
<ul dangerouslySetInnerHTML={{ __html: product.specialOffer.all[0].title }} />
</div>
</div>
)}
</div>
</div>
);
};
export default DealProductItem;