Files
goodgo-platform/apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx
Ho Ngoc Hai 593d1594bd
Some checks failed
Deploy / Build API Image (push) Failing after 19s
Deploy / Build Web Image (push) Failing after 11s
Security Scanning / Trivy Filesystem Scan (push) Failing after 27s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 37s
Deploy / Build AI Services Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 10s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 47s
Security Scanning / Trivy Scan — Web Image (push) Failing after 31s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 32s
Deploy / Smoke Test Staging (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
refactor(web): replace emoji icons with lucide-react across the app
User directive: avoid emojis for UI chrome; keep the icon language
consistent with the rest of the design system (shadcn + lucide-react).

Swaps
-----
- lib/listing-personas.ts — Persona emojis (👨‍👩‍👧🏡🚇🧑‍💻🌳📈🛡️🏥)
  → Lucide icons (Baby, Home, TrainFront, Laptop, Trees, TrendingUp,
  Shield, HeartPulse). Persona type now carries `icon: LucideIcon`.
- components/neighborhood/types.ts — POI_CATEGORY_CONFIG emojis
  (🏫🏥🚇🛒🍽️🌳) → Lucide (GraduationCap, Stethoscope, TrainFront,
  ShoppingBag, UtensilsCrossed, Trees). Config type tightened to
  `icon: LucideIcon`.
- components/neighborhood/neighborhood-poi-map.tsx — filter pills
  now render <config.icon h-3.5 w-3.5>. Map markers were text-emoji
  (el.textContent = config.icon); replaced with hard-coded inline
  SVG strings per category (POI_MARKER_SVG) since lucide-static
  isn't installed. Marker bumped 28px → 32px for larger hit target.
  Popup now shows only the property name + category label (no
  emoji prefix). closeButton: true + closeOnClick: true for
  better dismissibility.
- listing-detail-client.tsx — PersonaFitCard now renders
  <p.icon h-4 w-4 aria-hidden>.
- transfer / chuyen-nhuong files — category icons (🛋️🧊🖥️🍳🛍️🏠)
  migrated to Lucide (Sofa, Refrigerator, Monitor, ChefHat, Store,
  Home) with type `icon: LucideIcon`.
- Small replacements: inquiries page 📭 → Inbox; kyc page ✓ → Check.

POI popup click fix
-------------------
The inner SVG inside each POI marker was capturing pointer events
before Mapbox's marker-click handler saw them, so clicking a marker
did nothing. Explicit `innerSvg.style.pointerEvents = 'none'` lets
clicks reach the wrapping .poi-marker div that setPopup() is bound
to. Verified via DOM dispatch: click → popup opens with property
name + category + distance + × close.

Verification
------------
- Grep across the 4 scoped files for emoji code points → 0 hits.
- pnpm -w test: 624/624 green.
- Typecheck: no new errors in touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:01:13 +07:00

231 lines
8.1 KiB
TypeScript

'use client';
import { Package, Search, X } from 'lucide-react';
import * as React from 'react';
import { TransferListingCard } from '@/components/chuyen-nhuong/transfer-listing-card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
type SearchTransferListingsParams,
type TransferCategory,
type TransferListingStatus,
CATEGORY_ICONS,
CATEGORY_LABELS,
STATUS_LABELS,
} from '@/lib/chuyen-nhuong-api';
import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong';
const PAGE_SIZE = 12;
const DISTRICTS = [
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
];
export default function ChuyenNhuongPage() {
const [filters, setFilters] = React.useState<SearchTransferListingsParams>({
page: 1,
limit: PAGE_SIZE,
});
const [searchInput, setSearchInput] = React.useState('');
const { data, isLoading, isError } = useTransferListingsSearch(filters);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setFilters((prev) => ({ ...prev, q: searchInput.trim() || undefined, page: 1 }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCategoryChange = (category: TransferCategory | undefined) => {
setFilters((prev) => ({ ...prev, category, page: 1 }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const updateFilter = (key: keyof SearchTransferListingsParams, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value || undefined, page: 1 }));
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleClear = () => {
setSearchInput('');
setFilters({ page: 1, limit: PAGE_SIZE });
};
const hasFilters = filters.q || filters.category || filters.status || filters.district;
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold md:text-3xl">Chuyển Nhượng</h1>
<p className="mt-1 text-muted-foreground">
Tìm kiếm nội thất, thiết bị mặt bằng chuyển nhượng
</p>
</div>
{/* Search bar */}
<div className="space-y-3">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Tìm kiếm theo tên, quận, loại hình..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" size="sm">Tìm</Button>
</form>
{/* Filters */}
<div className="flex flex-wrap gap-2">
<select
value={filters.district ?? ''}
onChange={(e) => updateFilter('district', e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Quận/Huyện"
>
<option value="">Quận/Huyện</option>
{DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<select
value={filters.status ?? ''}
onChange={(e) => updateFilter('status', e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Trạng thái"
>
<option value="">Trạng thái</option>
{(Object.entries(STATUS_LABELS) as [TransferListingStatus, string][]).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={handleClear} className="gap-1">
<X className="h-3.5 w-3.5" />
Xóa bộ lọc
</Button>
)}
</div>
</div>
{/* Category tabs */}
<div className="mt-4 flex gap-1 overflow-x-auto border-b" role="tablist">
<button
role="tab"
aria-selected={!filters.category}
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
!filters.category
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
onClick={() => handleCategoryChange(undefined)}
>
Tất cả
</button>
{(Object.entries(CATEGORY_LABELS) as [TransferCategory, string][]).map(([key, label]) => {
const Icon = CATEGORY_ICONS[key];
return (
<button
key={key}
role="tab"
aria-selected={filters.category === key}
className={`inline-flex shrink-0 items-center gap-1.5 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
filters.category === key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
onClick={() => handleCategoryChange(key)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
</button>
);
})}
</div>
{/* Results */}
<div className="mt-6">
{isLoading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-72 animate-pulse rounded-lg bg-muted"
/>
))}
</div>
) : isError ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Không thể tải danh sách chuyển nhượng. Vui lòng thử lại.
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setFilters({ ...filters })}
>
Thử lại
</Button>
</div>
) : data && data.data.length > 0 ? (
<>
<p className="mb-4 text-sm text-muted-foreground">
{data.total} tin chuyển nhượng đưc tìm thấy
</p>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((listing) => (
<TransferListingCard key={listing.id} listing={listing} />
))}
</div>
{/* Pagination */}
{data.totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange((filters.page || 1) - 1)}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {data.page} / {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.totalPages}
onClick={() => handlePageChange((filters.page || 1) + 1)}
>
Sau
</Button>
</div>
)}
</>
) : (
<div className="py-12 text-center">
<Package className="mx-auto h-12 w-12 text-muted-foreground/30" />
<p className="mt-4 text-lg font-medium">Không tìm thấy tin chuyển nhượng</p>
<p className="mt-1 text-sm text-muted-foreground">
Thử thay đi bộ lọc đ tìm kiếm nhiều hơn
</p>
</div>
)}
</div>
</div>
);
}