feat(web): add ErrorBoundary, PageErrorBoundary, ComponentErrorBoundary
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 4s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 6s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 35s
Deploy / Build API Image (push) Failing after 15s
Deploy / Build Web Image (push) Failing after 13s
Deploy / Build AI Services Image (push) Failing after 11s
E2E Tests / Playwright E2E (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m33s
Security Scanning / Trivy Scan — Web Image (push) Failing after 54s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 45s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Filesystem Scan (push) Failing after 46s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 4s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 6s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 35s
Deploy / Build API Image (push) Failing after 15s
Deploy / Build Web Image (push) Failing after 13s
Deploy / Build AI Services Image (push) Failing after 11s
E2E Tests / Playwright E2E (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m33s
Security Scanning / Trivy Scan — Web Image (push) Failing after 54s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 45s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Filesystem Scan (push) Failing after 46s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Implements GOO-63 audit requirement — React error boundaries with Vietnamese-language fallback UI, Sentry capture, and "Thử lại" retry. - ErrorBoundary: generic class component wrapping Sentry.captureException - PageErrorBoundary: full-page fallback for route layouts - ComponentErrorBoundary: inline widget fallback (compact + standard modes) - Applied to ListingMap, CheckoutModal, SearchResults as first targets Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
import { ListingExpiringEvent } from '../../domain/events/listing-expiring.event';
|
||||||
|
import { ListingExpiryCronService } from '../../infrastructure/cron/listing-expiry-cron.service';
|
||||||
|
|
||||||
|
describe('ListingExpiryCronService', () => {
|
||||||
|
let service: ListingExpiryCronService;
|
||||||
|
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: {
|
||||||
|
log: ReturnType<typeof vi.fn>;
|
||||||
|
debug: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrisma = { $queryRaw: vi.fn() };
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
mockLogger = { log: vi.fn(), debug: vi.fn(), error: vi.fn() };
|
||||||
|
|
||||||
|
service = new ListingExpiryCronService(
|
||||||
|
mockPrisma as any,
|
||||||
|
mockEventBus as any,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes ListingExpiringEvent for each expiring listing and logs the count', async () => {
|
||||||
|
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
|
||||||
|
mockPrisma.$queryRaw.mockResolvedValue([
|
||||||
|
{ id: 'listing-a', sellerId: 'seller-a', expiresAt },
|
||||||
|
{ id: 'listing-b', sellerId: 'seller-b', expiresAt },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.notifyExpiringListings();
|
||||||
|
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[];
|
||||||
|
expect(events[0]).toBeInstanceOf(ListingExpiringEvent);
|
||||||
|
expect(events[1]).toBeInstanceOf(ListingExpiringEvent);
|
||||||
|
expect(events.map((e) => e.aggregateId).sort()).toEqual(['listing-a', 'listing-b']);
|
||||||
|
expect(events[0].eventName).toBe('listing.expiring');
|
||||||
|
expect(events[0].sellerId).toBe('seller-a');
|
||||||
|
expect(events[0].expiresAt).toBe(expiresAt);
|
||||||
|
|
||||||
|
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('2 listing(s)'),
|
||||||
|
'ListingExpiryCronService',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when no listings are expiring (idempotent across runs)', async () => {
|
||||||
|
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.notifyExpiringListings();
|
||||||
|
|
||||||
|
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogger.log).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||||
|
'No listings expiring in the next 3 days — nothing to notify',
|
||||||
|
'ListingExpiryCronService',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches DB errors and logs them without throwing', async () => {
|
||||||
|
const boom = new Error('connection lost');
|
||||||
|
mockPrisma.$queryRaw.mockRejectedValue(boom);
|
||||||
|
|
||||||
|
await expect(service.notifyExpiringListings()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('connection lost'),
|
||||||
|
expect.any(String),
|
||||||
|
'ListingExpiryCronService',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a single atomic UPDATE ... RETURNING so concurrent runs do not double-notify', async () => {
|
||||||
|
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
|
||||||
|
// Simulate two parallel runs against the same DB; only the first sees the row.
|
||||||
|
mockPrisma.$queryRaw
|
||||||
|
.mockResolvedValueOnce([{ id: 'listing-1', sellerId: 'seller-1', expiresAt }])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
service.notifyExpiringListings(),
|
||||||
|
service.notifyExpiringListings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only one event published across both runs.
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes events with correct sellerId and expiresAt per row', async () => {
|
||||||
|
const expiresAtA = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000);
|
||||||
|
const expiresAtB = new Date(Date.now() + 2.5 * 24 * 60 * 60 * 1000);
|
||||||
|
mockPrisma.$queryRaw.mockResolvedValue([
|
||||||
|
{ id: 'listing-a', sellerId: 'seller-x', expiresAt: expiresAtA },
|
||||||
|
{ id: 'listing-b', sellerId: 'seller-y', expiresAt: expiresAtB },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.notifyExpiringListings();
|
||||||
|
|
||||||
|
const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[];
|
||||||
|
expect(events[0].aggregateId).toBe('listing-a');
|
||||||
|
expect(events[0].sellerId).toBe('seller-x');
|
||||||
|
expect(events[0].expiresAt).toBe(expiresAtA);
|
||||||
|
|
||||||
|
expect(events[1].aggregateId).toBe('listing-b');
|
||||||
|
expect(events[1].sellerId).toBe('seller-y');
|
||||||
|
expect(events[1].expiresAt).toBe(expiresAtB);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { ListingExpiringEvent } from '../../../listings/domain/events/listing-expiring.event';
|
||||||
|
import type { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
|
||||||
|
import { ListingExpiringListener } from '../listeners/listing-expiring.listener';
|
||||||
|
|
||||||
|
describe('ListingExpiringListener', () => {
|
||||||
|
let listener: ListingExpiringListener;
|
||||||
|
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||||
|
let mockPrisma: {
|
||||||
|
listing: { findUnique: ReturnType<typeof vi.fn> };
|
||||||
|
};
|
||||||
|
let mockLogger: {
|
||||||
|
log: ReturnType<typeof vi.fn>;
|
||||||
|
warn: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
mockPrisma = {
|
||||||
|
listing: { findUnique: vi.fn() },
|
||||||
|
};
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
|
||||||
|
listener = new ListingExpiringListener(
|
||||||
|
mockCommandBus as any,
|
||||||
|
mockPrisma as any,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends both email and Zalo OA notifications when seller has email and phone', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue({
|
||||||
|
id: 'listing-1',
|
||||||
|
property: { title: 'Căn hộ Quận 7' },
|
||||||
|
seller: { id: 'seller-1', email: 'seller@example.com', phone: '0901234567' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const commands = mockCommandBus.execute.mock.calls.map((c) => c[0]) as SendNotificationCommand[];
|
||||||
|
|
||||||
|
// Email notification
|
||||||
|
const emailCmd = commands.find((c) => c.channel === 'EMAIL')!;
|
||||||
|
expect(emailCmd.userId).toBe('seller-1');
|
||||||
|
expect(emailCmd.templateKey).toBe('listing.expiring');
|
||||||
|
expect(emailCmd.recipientAddress).toBe('seller@example.com');
|
||||||
|
expect(emailCmd.templateData).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
listingTitle: 'Căn hộ Quận 7',
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zalo OA notification
|
||||||
|
const zaloCmd = commands.find((c) => c.channel === 'ZALO_OA')!;
|
||||||
|
expect(zaloCmd.userId).toBe('seller-1');
|
||||||
|
expect(zaloCmd.templateKey).toBe('listing.expiring');
|
||||||
|
expect(zaloCmd.recipientAddress).toBe('0901234567');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends only email when seller has no phone', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue({
|
||||||
|
id: 'listing-1',
|
||||||
|
property: { title: 'Nhà phố Quận 1' },
|
||||||
|
seller: { id: 'seller-1', email: 'seller@example.com', phone: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
|
||||||
|
expect(cmd.channel).toBe('EMAIL');
|
||||||
|
expect(cmd.recipientAddress).toBe('seller@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends only Zalo OA when seller has no email', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue({
|
||||||
|
id: 'listing-1',
|
||||||
|
property: { title: 'Biệt thự Thảo Điền' },
|
||||||
|
seller: { id: 'seller-1', email: null, phone: '0909876543' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
|
||||||
|
expect(cmd.channel).toBe('ZALO_OA');
|
||||||
|
expect(cmd.recipientAddress).toBe('0909876543');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips notification when listing not found', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const event = new ListingExpiringEvent('nonexistent', 'seller-1', expiresAt);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no notifications when seller has neither email nor phone', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue({
|
||||||
|
id: 'listing-1',
|
||||||
|
property: { title: 'Căn hộ mini' },
|
||||||
|
seller: { id: 'seller-1', email: null, phone: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes correct daysRemaining in templateData', async () => {
|
||||||
|
const twoDaysFromNow = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue({
|
||||||
|
id: 'listing-1',
|
||||||
|
property: { title: 'Căn hộ' },
|
||||||
|
seller: { id: 'seller-1', email: 'test@example.com', phone: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new ListingExpiringEvent('listing-1', 'seller-1', twoDaysFromNow);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
|
||||||
|
expect(cmd.templateData).toHaveProperty('daysRemaining');
|
||||||
|
expect(cmd.templateData.daysRemaining).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(cmd.templateData.daysRemaining).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Promise.allSettled so one notification failure does not block the other', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue({
|
||||||
|
id: 'listing-1',
|
||||||
|
property: { title: 'Căn hộ' },
|
||||||
|
seller: { id: 'seller-1', email: 'seller@example.com', phone: '0901234567' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email fails, Zalo succeeds
|
||||||
|
mockCommandBus.execute
|
||||||
|
.mockRejectedValueOnce(new Error('SMTP down'))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
|
||||||
|
|
||||||
|
// Should not throw even though email notification failed
|
||||||
|
await expect(listener.handle(event)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// Both notifications were attempted
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries listing with correct include shape', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(mockPrisma.listing.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'listing-1' },
|
||||||
|
include: {
|
||||||
|
property: { select: { title: true } },
|
||||||
|
seller: { select: { id: true, email: true, phone: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
120
apps/web/components/error-boundary/component-error-boundary.tsx
Normal file
120
apps/web/components/error-boundary/component-error-boundary.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
import { ErrorBoundary, type ErrorBoundaryFallbackProps } from './error-boundary';
|
||||||
|
|
||||||
|
interface ComponentErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Short label identifying the widget (e.g. "bản đồ", "thanh toán", "tìm kiếm").
|
||||||
|
* Shown in the fallback UI so users understand which section failed.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
/**
|
||||||
|
* Compact mode renders a smaller fallback suited to narrow widgets like cards
|
||||||
|
* or sidebars. Defaults to false.
|
||||||
|
*/
|
||||||
|
compact?: boolean;
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline error boundary for critical widgets (map, payment form, search).
|
||||||
|
*
|
||||||
|
* Renders a contained, non-intrusive fallback that does not take over the page.
|
||||||
|
* Captures errors to Sentry via the base `ErrorBoundary`.
|
||||||
|
*/
|
||||||
|
export function ComponentErrorBoundary({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
compact = false,
|
||||||
|
onError,
|
||||||
|
}: ComponentErrorBoundaryProps) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
onError={onError}
|
||||||
|
fallback={({ error, reset }) => (
|
||||||
|
<ComponentFallback error={error} reset={reset} label={label} compact={compact} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentFallback({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
label,
|
||||||
|
compact,
|
||||||
|
}: ErrorBoundaryFallbackProps & { label?: string; compact: boolean }) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 shrink-0 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="flex-1 text-xs text-muted-foreground">
|
||||||
|
{label ? `Lỗi ${label}` : 'Đã xảy ra lỗi'}
|
||||||
|
{process.env.NODE_ENV !== 'production' && error.message
|
||||||
|
? `: ${error.message}`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="shrink-0 text-xs font-medium text-primary underline-offset-2 hover:underline focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-[160px] flex-col items-center justify-center rounded-lg border border-destructive/20 bg-destructive/5 px-6 py-6 text-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mb-2 h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{label ? `Không thể tải ${label}` : 'Đã xảy ra lỗi'}
|
||||||
|
</p>
|
||||||
|
{process.env.NODE_ENV !== 'production' && error.message && (
|
||||||
|
<p className="mt-1 max-w-xs truncate text-xs text-muted-foreground">{error.message}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="mt-4 inline-flex h-8 items-center justify-center rounded-md bg-primary px-4 text-xs font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/web/components/error-boundary/error-boundary.tsx
Normal file
97
apps/web/components/error-boundary/error-boundary.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface ErrorBoundaryFallbackProps {
|
||||||
|
error: Error;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Custom fallback component. Receives the caught error and a reset callback. */
|
||||||
|
fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode;
|
||||||
|
/** Called when an error is caught. Useful for additional logging / side effects. */
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic class-based React Error Boundary.
|
||||||
|
*
|
||||||
|
* Captures exceptions via Sentry and renders a Vietnamese-language fallback UI
|
||||||
|
* with a "Thử lại" (retry) button when no custom fallback is provided.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
override state: ErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.error('[ErrorBoundary] caught:', error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.onError?.(error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = () => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const { error } = this.state;
|
||||||
|
const { children, fallback } = this.props;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (fallback) {
|
||||||
|
return fallback({ error, reset: this.reset });
|
||||||
|
}
|
||||||
|
return <DefaultErrorFallback error={error} reset={this.reset} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-[200px] flex-col items-center justify-center rounded-lg border border-destructive/20 bg-destructive/5 px-6 py-8 text-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mx-auto mb-3 h-8 w-8 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-medium text-foreground">Đã xảy ra lỗi</p>
|
||||||
|
{process.env.NODE_ENV !== 'production' && error.message && (
|
||||||
|
<p className="mt-1 max-w-xs truncate text-xs text-muted-foreground">{error.message}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="mt-4 inline-flex h-9 items-center justify-center rounded-md bg-primary px-5 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
apps/web/components/error-boundary/index.ts
Normal file
4
apps/web/components/error-boundary/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ErrorBoundary } from './error-boundary';
|
||||||
|
export type { ErrorBoundaryProps, ErrorBoundaryFallbackProps } from './error-boundary';
|
||||||
|
export { PageErrorBoundary } from './page-error-boundary';
|
||||||
|
export { ComponentErrorBoundary } from './component-error-boundary';
|
||||||
84
apps/web/components/error-boundary/page-error-boundary.tsx
Normal file
84
apps/web/components/error-boundary/page-error-boundary.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
import { ErrorBoundary, type ErrorBoundaryFallbackProps } from './error-boundary';
|
||||||
|
|
||||||
|
interface PageErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Page title shown in the full-page error UI. */
|
||||||
|
pageName?: string;
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-page error boundary for route layouts.
|
||||||
|
*
|
||||||
|
* Renders a centred Vietnamese-language error screen with a retry button and a
|
||||||
|
* link back to the home page. Wraps the generic `ErrorBoundary` with a
|
||||||
|
* page-appropriate fallback size.
|
||||||
|
*/
|
||||||
|
export function PageErrorBoundary({ children, pageName, onError }: PageErrorBoundaryProps) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
onError={onError}
|
||||||
|
fallback={({ error, reset }) => (
|
||||||
|
<PageFallback error={error} reset={reset} pageName={pageName} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageFallback({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
pageName,
|
||||||
|
}: ErrorBoundaryFallbackProps & { pageName?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-[60vh] flex-col items-center justify-center bg-background px-4 text-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-bold tracking-tight">
|
||||||
|
{pageName ? `Lỗi tải trang: ${pageName}` : 'Đã xảy ra lỗi'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||||
|
Trang này gặp sự cố. Vui lòng thử lại hoặc quay về trang chủ.
|
||||||
|
</p>
|
||||||
|
{process.env.NODE_ENV !== 'production' && error.message && (
|
||||||
|
<p className="mt-1 max-w-sm truncate text-xs text-muted-foreground">{error.message}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-8 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-6 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
Trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
import { ComponentErrorBoundary } from '@/components/error-boundary';
|
||||||
import type { ListingDetail } from '@/lib/listings-api';
|
import type { ListingDetail } from '@/lib/listings-api';
|
||||||
import { useMapboxStyle } from '@/lib/mapbox-style';
|
import { useMapboxStyle } from '@/lib/mapbox-style';
|
||||||
|
|
||||||
@@ -50,7 +51,15 @@ function getMarkerCoords(listing: ListingDetail, index: number): { lat: number;
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListingMap({ listings, onMarkerClick, selectedListingId, className }: ListingMapProps) {
|
export function ListingMap(props: ListingMapProps) {
|
||||||
|
return (
|
||||||
|
<ComponentErrorBoundary label="bản đồ">
|
||||||
|
<ListingMapInner {...props} />
|
||||||
|
</ComponentErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListingMapInner({ listings, onMarkerClick, selectedListingId, className }: ListingMapProps) {
|
||||||
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
||||||
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
|
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { ComponentErrorBoundary } from '@/components/error-boundary';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import type { ListingDetail, PaginatedResult } from '@/lib/listings-api';
|
import type { ListingDetail, PaginatedResult } from '@/lib/listings-api';
|
||||||
@@ -17,7 +18,15 @@ interface SearchResultsProps {
|
|||||||
onSortChange: (sort: string) => void;
|
onSortChange: (sort: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchResults({
|
export function SearchResults(props: SearchResultsProps) {
|
||||||
|
return (
|
||||||
|
<ComponentErrorBoundary label="kết quả tìm kiếm">
|
||||||
|
<SearchResultsInner {...props} />
|
||||||
|
</ComponentErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultsInner({
|
||||||
result,
|
result,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { AlertCircle, CreditCard, Loader2, Smartphone, Wallet } from 'lucide-react';
|
import { AlertCircle, CreditCard, Loader2, Smartphone, Wallet } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { ComponentErrorBoundary } from '@/components/error-boundary';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -78,7 +79,15 @@ const PLAN_TIER_LABELS: Record<string, string> = {
|
|||||||
// Component
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function CheckoutModal({
|
export function CheckoutModal(props: CheckoutModalProps) {
|
||||||
|
return (
|
||||||
|
<ComponentErrorBoundary label="thanh toán">
|
||||||
|
<CheckoutModalInner {...props} />
|
||||||
|
</ComponentErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckoutModalInner({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
plan,
|
plan,
|
||||||
|
|||||||
Reference in New Issue
Block a user