update
This commit is contained in:
@@ -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;
|
||||
77
src/components/common/CountDown/index.tsx
Normal file
77
src/components/common/CountDown/index.tsx
Normal 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;
|
||||
46
src/components/common/ErrorBoundary.tsx
Normal file
46
src/components/common/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
34
src/components/common/MSWProvider.tsx
Normal file
34
src/components/common/MSWProvider.tsx
Normal 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;
|
||||
35
src/components/common/SanitizedHtml/index.tsx
Normal file
35
src/components/common/SanitizedHtml/index.tsx
Normal 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 }} />;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user