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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user