feat(listings): Mapbox location picker in create + edit forms
Some checks failed
Deploy / Build Web Image (push) Failing after 19s
E2E Tests / Playwright E2E (push) Failing after 25s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 22s
Deploy / Build AI Services Image (push) Failing after 18s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Dependency Audit (pnpm) (push) Failing after 15s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m31s
Security Scanning / Trivy Scan — Web Image (push) Failing after 40s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 34s
Security Scanning / Trivy Filesystem Scan (push) Failing after 21s
Security Scanning / Security Gate (push) Failing after 1s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m4s
Some checks failed
Deploy / Build Web Image (push) Failing after 19s
E2E Tests / Playwright E2E (push) Failing after 25s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 22s
Deploy / Build AI Services Image (push) Failing after 18s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Dependency Audit (pnpm) (push) Failing after 15s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m31s
Security Scanning / Trivy Scan — Web Image (push) Failing after 40s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 34s
Security Scanning / Trivy Filesystem Scan (push) Failing after 21s
Security Scanning / Security Gate (push) Failing after 1s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m4s
User feedback: typing lat/lng by hand is painful — wire a real map
picker.
New component apps/web/components/map/location-picker.tsx:
- Mapbox map with theme-synced style (uses useMapboxStyle).
- Draggable primary marker (custom pin, inner-wrapped so Mapbox's
translate isn't clobbered — follows the hover-fix pattern we shipped
last commit).
- Click anywhere on the map → marker jumps + onChange fires.
- Dragend → onChange fires.
- Search box using Mapbox Geocoding API
(/geocoding/v5/mapbox.places) scoped to country=vn, language=vi,
limit=5, debounced 350ms with AbortController. Clicking a suggestion
centers the map + fills the resolved { address, ward, district,
city } from feature.context.
- Graceful fallback when NEXT_PUBLIC_MAPBOX_TOKEN is missing.
- Inline help "Nhấp vào bản đồ hoặc kéo pin để chọn vị trí".
StepLocation (listing-form-steps.tsx):
- New optional `setValue` + `watch` props. When both are passed the
picker renders and wires lat/lng (+ address/ward/district/city from
geocoder) into the form. Without them, the Step falls back to the
manual-only layout (kept for callers that don't want the picker).
- Dynamic-import the picker with ssr:false so mapbox-gl stays out of
the server bundle.
Wired into:
- /listings/new page — picker enabled on Step 2 (Vị trí).
- /listings/[id]/edit page — picker enabled on the Location tab, with
latitude/longitude now hydrated from property.latitude/longitude.
Test fixture update: listing-form-steps.spec.tsx no longer asserts the
placeholder string — instead verifies the lat/lng inputs still render
when the picker is absent (setValue not supplied), matching the new
opt-in contract.
Verification
- Typecheck clean across touched files.
- 624 / 624 web tests pass.
- Preview smoke: /listings/<id>/edit → Vị trí tab renders map +
draggable pin + search, lat/lng prefilled from listing data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,8 @@ export default function EditListingPage() {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<CreateListingFormData>({
|
||||
resolver: zodResolver(createListingSchema),
|
||||
@@ -134,6 +136,8 @@ export default function EditListingPage() {
|
||||
ward: property.ward,
|
||||
district: property.district,
|
||||
city: property.city,
|
||||
latitude: property.latitude != null ? String(property.latitude) : '',
|
||||
longitude: property.longitude != null ? String(property.longitude) : '',
|
||||
areaM2: String(property.areaM2),
|
||||
bedrooms: property.bedrooms != null ? String(property.bedrooms) : '',
|
||||
bathrooms: property.bathrooms != null ? String(property.bathrooms) : '',
|
||||
@@ -221,7 +225,7 @@ export default function EditListingPage() {
|
||||
<StepBasicInfo register={register} errors={errors} />
|
||||
</TabsContent>
|
||||
<TabsContent value="location">
|
||||
<StepLocation register={register} errors={errors} />
|
||||
<StepLocation register={register} errors={errors} setValue={setValue} watch={watch} />
|
||||
</TabsContent>
|
||||
<TabsContent value="details">
|
||||
<StepDetails register={register} errors={errors} />
|
||||
|
||||
@@ -55,6 +55,8 @@ export default function CreateListingPage() {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<CreateListingFormData>({
|
||||
resolver: zodResolver(createListingSchema),
|
||||
@@ -208,7 +210,9 @@ export default function CreateListingPage() {
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{currentStep === 0 && <StepBasicInfo register={register} errors={errors} />}
|
||||
{currentStep === 1 && <StepLocation register={register} errors={errors} />}
|
||||
{currentStep === 1 && (
|
||||
<StepLocation register={register} errors={errors} setValue={setValue} watch={watch} />
|
||||
)}
|
||||
{currentStep === 2 && <StepDetails register={register} errors={errors} />}
|
||||
{currentStep === 3 && <StepPricing register={register} errors={errors} />}
|
||||
{currentStep === 4 && (
|
||||
|
||||
@@ -108,9 +108,11 @@ describe('StepLocation', () => {
|
||||
expect(screen.getByLabelText('Kinh độ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders map placeholder text', () => {
|
||||
it('renders latitude + longitude fields without the picker when setValue is not passed', () => {
|
||||
render(<StepLocation register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText(/Bản đồ chọn vị trí sẽ được tích hợp/)).toBeInTheDocument();
|
||||
// Picker is opt-in: only mounts when setValue/watch are provided.
|
||||
expect(screen.getByLabelText('Vĩ độ')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Kinh độ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error for address', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { UseFormRegister, FieldErrors } from 'react-hook-form';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormWatch, FieldErrors } from 'react-hook-form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
@@ -14,11 +15,34 @@ import {
|
||||
type CreateListingFormData,
|
||||
} from '@/lib/validations/listings';
|
||||
|
||||
// Mapbox picker is client-only + imports `mapbox-gl` which pulls WebGL
|
||||
// utilities — dynamic-import with ssr:false to keep it out of the server
|
||||
// bundle.
|
||||
const LocationPicker = dynamic(
|
||||
() => import('@/components/map/location-picker').then((m) => m.LocationPicker),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[320px] items-center justify-center rounded-lg border bg-muted text-sm text-muted-foreground">
|
||||
Đang tải bản đồ...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
interface StepProps {
|
||||
register: UseFormRegister<CreateListingFormData>;
|
||||
errors: FieldErrors<CreateListingFormData>;
|
||||
}
|
||||
|
||||
interface StepLocationProps extends StepProps {
|
||||
/** Optional — when provided, StepLocation renders the Mapbox picker and
|
||||
* writes latitude/longitude (+ auto-fills address/ward/district/city on
|
||||
* geocoder pick) into the form state. */
|
||||
setValue?: UseFormSetValue<CreateListingFormData>;
|
||||
watch?: UseFormWatch<CreateListingFormData>;
|
||||
}
|
||||
|
||||
function FieldError({ message }: { message?: string }) {
|
||||
if (!message) return null;
|
||||
return <p className="mt-1 text-xs text-destructive">{message}</p>;
|
||||
@@ -81,11 +105,38 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
||||
|
||||
// ─── Step 2: Location ────────────────────────────────────
|
||||
|
||||
export function StepLocation({ register, errors }: StepProps) {
|
||||
export function StepLocation({ register, errors, setValue, watch }: StepLocationProps) {
|
||||
// Watch lat/lng so the picker stays in sync when the user edits the text
|
||||
// inputs manually.
|
||||
const latStr = watch?.('latitude') ?? '';
|
||||
const lngStr = watch?.('longitude') ?? '';
|
||||
const latNum = latStr ? Number(latStr) : null;
|
||||
const lngNum = lngStr ? Number(lngStr) : null;
|
||||
const latValid = latNum != null && Number.isFinite(latNum) && latNum >= -90 && latNum <= 90;
|
||||
const lngValid = lngNum != null && Number.isFinite(lngNum) && lngNum >= -180 && lngNum <= 180;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Vị trí</h3>
|
||||
|
||||
{setValue && (
|
||||
<LocationPicker
|
||||
lat={latValid ? latNum : null}
|
||||
lng={lngValid ? lngNum : null}
|
||||
onChange={(coords, resolved) => {
|
||||
setValue('latitude', coords.lat.toFixed(6), { shouldValidate: true, shouldDirty: true });
|
||||
setValue('longitude', coords.lng.toFixed(6), { shouldValidate: true, shouldDirty: true });
|
||||
if (resolved) {
|
||||
if (resolved.address) setValue('address', resolved.address, { shouldDirty: true });
|
||||
if (resolved.ward) setValue('ward', resolved.ward, { shouldDirty: true });
|
||||
if (resolved.district) setValue('district', resolved.district, { shouldDirty: true });
|
||||
if (resolved.city) setValue('city', resolved.city, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
height="360px"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="address">Địa chỉ *</Label>
|
||||
<Input id="address" placeholder="Số nhà, tên đường" {...register('address')} />
|
||||
@@ -134,10 +185,6 @@ export function StepLocation({ register, errors }: StepProps) {
|
||||
<FieldError message={errors.longitude?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
Bản đồ chọn vị trí sẽ được tích hợp trong phiên bản tiếp theo
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
281
apps/web/components/map/location-picker.tsx
Normal file
281
apps/web/components/map/location-picker.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
/* eslint-disable import-x/no-named-as-default-member */
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { MapPin, Search, X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import { useMapboxStyle } from '@/lib/mapbox-style';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Lightweight Mapbox-based location picker. Click the map (or drag the
|
||||
* marker) to set lat/lng; optional search box geocodes via Mapbox Places
|
||||
* (Vietnam-scoped). Emits both the new coords and, when available, the
|
||||
* parsed address components from the geocoder feature context — consumers
|
||||
* can hydrate address/ward/district/city fields without extra typing.
|
||||
*/
|
||||
export interface ResolvedAddress {
|
||||
address?: string;
|
||||
ward?: string;
|
||||
district?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
interface LocationPickerProps {
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
onChange: (coords: { lat: number; lng: number }, resolved?: ResolvedAddress) => void;
|
||||
height?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CENTER: [number, number] = [106.7009, 10.7769]; // HCMC
|
||||
const DEFAULT_ZOOM = 12;
|
||||
const PICKED_ZOOM = 15;
|
||||
|
||||
interface MapboxFeature {
|
||||
id: string;
|
||||
place_name: string;
|
||||
text: string;
|
||||
center: [number, number];
|
||||
place_type?: string[];
|
||||
context?: Array<{ id: string; text: string }>;
|
||||
}
|
||||
|
||||
function parseContext(feature: MapboxFeature): ResolvedAddress {
|
||||
const ctx = feature.context ?? [];
|
||||
const out: ResolvedAddress = {};
|
||||
// `feature.text` is the matched leaf (street / POI / locality).
|
||||
out.address = feature.place_name;
|
||||
for (const c of ctx) {
|
||||
if (c.id.startsWith('locality')) out.ward = c.text;
|
||||
else if (c.id.startsWith('district')) out.district = c.text;
|
||||
else if (c.id.startsWith('place')) {
|
||||
// Mapbox uses "place" for cities in VN
|
||||
out.city = c.text;
|
||||
} else if (c.id.startsWith('region') && !out.city) {
|
||||
out.city = c.text;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function LocationPicker({
|
||||
lat,
|
||||
lng,
|
||||
onChange,
|
||||
height = '320px',
|
||||
className,
|
||||
}: LocationPickerProps) {
|
||||
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
||||
const markerRef = React.useRef<mapboxgl.Marker | null>(null);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const mapStyle = useMapboxStyle();
|
||||
|
||||
// Keep the latest onChange in a ref so our marker/map listeners don't
|
||||
// trigger a re-initialisation every render.
|
||||
React.useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||
|
||||
// Init map + marker once.
|
||||
React.useEffect(() => {
|
||||
if (!mapContainerRef.current || !token) return;
|
||||
mapboxgl.accessToken = token;
|
||||
|
||||
const initial: [number, number] =
|
||||
typeof lat === 'number' && typeof lng === 'number' ? [lng, lat] : DEFAULT_CENTER;
|
||||
const initialZoom =
|
||||
typeof lat === 'number' && typeof lng === 'number' ? PICKED_ZOOM : DEFAULT_ZOOM;
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: mapContainerRef.current,
|
||||
style: mapStyle,
|
||||
center: initial,
|
||||
zoom: initialZoom,
|
||||
attributionControl: false,
|
||||
});
|
||||
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
|
||||
map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right');
|
||||
|
||||
// Draggable primary marker.
|
||||
const markerEl = document.createElement('div');
|
||||
markerEl.style.cssText = `width: 32px; height: 32px; cursor: grab;`;
|
||||
const pin = document.createElement('div');
|
||||
pin.style.cssText = `
|
||||
width: 100%; height: 100%;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
background: hsl(var(--primary));
|
||||
border: 3px solid hsl(var(--card));
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
transform: rotate(-45deg);
|
||||
pointer-events: none;
|
||||
`;
|
||||
markerEl.appendChild(pin);
|
||||
|
||||
const marker = new mapboxgl.Marker({ element: markerEl, draggable: true, anchor: 'bottom' })
|
||||
.setLngLat(initial)
|
||||
.addTo(map);
|
||||
|
||||
marker.on('dragend', () => {
|
||||
const lngLat = marker.getLngLat();
|
||||
onChangeRef.current({ lat: lngLat.lat, lng: lngLat.lng });
|
||||
});
|
||||
|
||||
map.on('click', (e) => {
|
||||
marker.setLngLat(e.lngLat);
|
||||
onChangeRef.current({ lat: e.lngLat.lat, lng: e.lngLat.lng });
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
markerRef.current = marker;
|
||||
|
||||
return () => {
|
||||
marker.remove();
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
markerRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sync theme.
|
||||
React.useEffect(() => {
|
||||
mapRef.current?.setStyle(mapStyle);
|
||||
}, [mapStyle]);
|
||||
|
||||
// When parent updates lat/lng externally (e.g. hydrating the edit form),
|
||||
// fly the map and move the marker.
|
||||
React.useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const marker = markerRef.current;
|
||||
if (!map || !marker) return;
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') return;
|
||||
const current = marker.getLngLat();
|
||||
if (Math.abs(current.lat - lat) < 1e-7 && Math.abs(current.lng - lng) < 1e-7) return;
|
||||
marker.setLngLat([lng, lat]);
|
||||
map.flyTo({ center: [lng, lat], zoom: Math.max(map.getZoom(), PICKED_ZOOM - 1) });
|
||||
}, [lat, lng]);
|
||||
|
||||
// Geocoding search.
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [results, setResults] = React.useState<MapboxFeature[]>([]);
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
const abortRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) return;
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < 3) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(async () => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setSearching(true);
|
||||
try {
|
||||
const url =
|
||||
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(trimmed)}.json` +
|
||||
`?access_token=${token}&country=vn&language=vi&limit=5`;
|
||||
const res = await fetch(url, { signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const body = (await res.json()) as { features?: MapboxFeature[] };
|
||||
setResults(body.features ?? []);
|
||||
} catch {
|
||||
if (!ctrl.signal.aborted) setResults([]);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) setSearching(false);
|
||||
}
|
||||
}, 350);
|
||||
return () => clearTimeout(handle);
|
||||
}, [query, token]);
|
||||
|
||||
const pickResult = (feature: MapboxFeature) => {
|
||||
const [fLng, fLat] = feature.center;
|
||||
markerRef.current?.setLngLat([fLng, fLat]);
|
||||
mapRef.current?.flyTo({ center: [fLng, fLat], zoom: PICKED_ZOOM });
|
||||
onChangeRef.current({ lat: fLat, lng: fLng }, parseContext(feature));
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-lg border border-dashed bg-muted text-sm text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để chọn vị trí trên bản đồ
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative overflow-hidden rounded-lg border', className)}>
|
||||
<div ref={mapContainerRef} style={{ height }} className="w-full" />
|
||||
|
||||
{/* Search box */}
|
||||
<div className="absolute left-3 top-3 z-10 w-[min(360px,calc(100%-1.5rem))]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Tìm địa chỉ hoặc địa điểm..."
|
||||
className="h-9 w-full rounded-md border bg-card pl-8 pr-8 text-sm text-card-foreground shadow-sm outline-none focus:ring-2 focus:ring-primary/30"
|
||||
aria-label="Tìm địa chỉ trên bản đồ"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
}}
|
||||
aria-label="Xoá tìm kiếm"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<ul className="mt-1 max-h-60 overflow-y-auto rounded-md border bg-card text-sm shadow-lg">
|
||||
{results.map((f) => (
|
||||
<li key={f.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickResult(f)}
|
||||
className="flex w-full items-start gap-2 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" aria-hidden="true" />
|
||||
<span className="line-clamp-2">{f.place_name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{searching && query.trim().length >= 3 && results.length === 0 && (
|
||||
<p className="mt-1 rounded bg-card/90 px-2 py-1 text-xs text-muted-foreground shadow">
|
||||
Đang tìm...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint badge */}
|
||||
<div className="pointer-events-none absolute bottom-3 left-3 rounded bg-card/90 px-2 py-1 text-xs text-muted-foreground shadow">
|
||||
Nhấp vào bản đồ hoặc kéo pin để chọn vị trí
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user