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

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:
Ho Ngoc Hai
2026-04-19 17:55:26 +07:00
parent 66eae72f62
commit 283984b2f2
5 changed files with 348 additions and 10 deletions

View File

@@ -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', () => {

View File

@@ -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>
);
}