diff --git a/src/features/post/api/post-api.test.ts b/src/features/post/api/post-api.test.ts index 7728d39..213971d 100644 --- a/src/features/post/api/post-api.test.ts +++ b/src/features/post/api/post-api.test.ts @@ -56,6 +56,8 @@ describe('postApi', () => { photoUrls: ['https://cdn.example.com/offer.jpg'], pointCost: 25000, location: '여의도', + lat: 37.5283, + lng: 126.9326, deadline: '2026-06-30', detailDescription: '그늘막 포함', desiredPrice: 50000, @@ -71,6 +73,8 @@ describe('postApi', () => { expect(url).toBe('/api/backend/v1/feeds/offer'); expect(body).toMatchObject({ categories: ['음식_요리'], + lat: 37.5283, + lng: 126.9326, desiredPrice: 50000, maxPartnerCount: 4, plan, @@ -102,6 +106,8 @@ describe('postApi', () => { photoUrls: [], pointCost: 15000, location: '합정동', + lat: 37.5496, + lng: 126.9136, deadline: '2026-06-30', detailDescription: '야간 이용 가능', serviceStylePhotoUrl: 'https://cdn.example.com/style.jpg', @@ -118,6 +124,8 @@ describe('postApi', () => { expect(url).toBe('/api/backend/v1/feeds/request'); expect(body).toMatchObject({ categories: ['BBQ_조개'], + lat: 37.5496, + lng: 126.9136, priceCapPerPerson: 30000, maxPartnerCount: 3, plan, diff --git a/src/features/post/api/post-api.ts b/src/features/post/api/post-api.ts index a41b4db..72acb56 100644 --- a/src/features/post/api/post-api.ts +++ b/src/features/post/api/post-api.ts @@ -45,8 +45,8 @@ type BasePostPayload = { deadline: string; detailDescription: string; maxPartnerCount?: number; - lat?: number; - lng?: number; + lat: number; + lng: number; plan?: PlanV3; preparation?: Preparation; priceBreakdown?: PriceBreakdown; diff --git a/src/features/post/client/OfferFormClient.tsx b/src/features/post/client/OfferFormClient.tsx index 08a2f31..e8a6f71 100644 --- a/src/features/post/client/OfferFormClient.tsx +++ b/src/features/post/client/OfferFormClient.tsx @@ -56,12 +56,13 @@ export function OfferFormClient() { categories, photoPreviews, location, + selectedLocation, deadline, setSpotName, setTitle, setContent, setCategories, - setLocation, + setSelectedLocation, setDeadline, handleAddPhoto, handleRemovePhoto, @@ -87,7 +88,8 @@ export function OfferFormClient() { const isStep0Valid = spotName.trim() !== '' && title.trim() !== '' && - location.trim() !== '' && + content.trim() !== '' && + selectedLocation !== null && deadline !== ''; const isStep1Valid = detailDescription.trim() !== ''; const isStep2Valid = @@ -122,6 +124,13 @@ export function OfferFormClient() { setStep((s) => s + 1); window.scrollTo({ top: 0, behavior: 'smooth' }); } else { + const locationForSubmit = selectedLocation; + + if (!locationForSubmit) { + setSubmitError('지도에서 활동 위치를 선택해주세요.'); + return; + } + setIsSubmitting(true); setSubmitError(null); @@ -135,6 +144,8 @@ export function OfferFormClient() { photoUrls: photoPreviews, pointCost: POINT_COST, location, + lat: locationForSubmit.lat, + lng: locationForSubmit.lng, deadline, detailDescription, supporterPhotoUrl: supporterPhotoPreview ?? undefined, @@ -210,7 +221,7 @@ export function OfferFormClient() { content={content} categories={categories} photoPreviews={photoPreviews} - location={location} + selectedLocation={selectedLocation} deadline={deadline} onSpotNameChange={setSpotName} onTitleChange={setTitle} @@ -218,7 +229,7 @@ export function OfferFormClient() { onCategoriesChange={setCategories} onAddPhoto={handleAddPhoto} onRemovePhoto={handleRemovePhoto} - onLocationChange={setLocation} + onLocationChange={setSelectedLocation} onDeadlineChange={setDeadline} /> )} diff --git a/src/features/post/client/RequestFormClient.tsx b/src/features/post/client/RequestFormClient.tsx index 89870e4..c6a880b 100644 --- a/src/features/post/client/RequestFormClient.tsx +++ b/src/features/post/client/RequestFormClient.tsx @@ -56,12 +56,13 @@ export function RequestFormClient() { categories, photoPreviews, location, + selectedLocation, deadline, setSpotName, setTitle, setContent, setCategories, - setLocation, + setSelectedLocation, setDeadline, handleAddPhoto, handleRemovePhoto, @@ -87,7 +88,8 @@ export function RequestFormClient() { const isStep0Valid = spotName.trim() !== '' && title.trim() !== '' && - location.trim() !== '' && + content.trim() !== '' && + selectedLocation !== null && deadline !== ''; const isStep1Valid = detailDescription.trim() !== ''; // step 2 (플랜·준비물) 는 선택. 항상 다음 진행 가능. @@ -111,6 +113,13 @@ export function RequestFormClient() { setStep((s) => s + 1); window.scrollTo({ top: 0, behavior: 'smooth' }); } else { + const locationForSubmit = selectedLocation; + + if (!locationForSubmit) { + setSubmitError('지도에서 활동 위치를 선택해주세요.'); + return; + } + setIsSubmitting(true); setSubmitError(null); @@ -124,6 +133,8 @@ export function RequestFormClient() { photoUrls: photoPreviews, pointCost: POINT_COST, location, + lat: locationForSubmit.lat, + lng: locationForSubmit.lng, deadline, detailDescription, serviceStylePhotoUrl: stylePhotoPreview ?? undefined, @@ -199,7 +210,7 @@ export function RequestFormClient() { content={content} categories={categories} photoPreviews={photoPreviews} - location={location} + selectedLocation={selectedLocation} deadline={deadline} onSpotNameChange={setSpotName} onTitleChange={setTitle} @@ -207,7 +218,7 @@ export function RequestFormClient() { onCategoriesChange={setCategories} onAddPhoto={handleAddPhoto} onRemovePhoto={handleRemovePhoto} - onLocationChange={setLocation} + onLocationChange={setSelectedLocation} onDeadlineChange={setDeadline} /> )} diff --git a/src/features/post/model/types.ts b/src/features/post/model/types.ts index 5f7cea9..aafbd3c 100644 --- a/src/features/post/model/types.ts +++ b/src/features/post/model/types.ts @@ -23,6 +23,8 @@ export interface PostBaseFormData { photoPreviews: string[]; pointCost: number; location: string; + lat: number; + lng: number; deadline: string; // ISO date YYYY-MM-DD } diff --git a/src/features/post/model/use-post-base-form.ts b/src/features/post/model/use-post-base-form.ts index 3eb8b1a..14d2ff1 100644 --- a/src/features/post/model/use-post-base-form.ts +++ b/src/features/post/model/use-post-base-form.ts @@ -2,6 +2,7 @@ import { useEffect, useState, useSyncExternalStore } from 'react'; import type { PostSpotCategory } from './types'; +import type { SelectedPostLocation } from '../ui/post-form/MapLocationPicker'; const DRAFT_KEY = 'post-base-form-draft'; const EMPTY_DRAFT: BaseFormDraft = { @@ -10,6 +11,8 @@ const EMPTY_DRAFT: BaseFormDraft = { content: '', categories: [], location: '', + locationLat: undefined, + locationLng: undefined, deadline: '', }; @@ -19,6 +22,8 @@ interface BaseFormDraft { content: string; categories: PostSpotCategory[]; location: string; + locationLat?: number; + locationLng?: number; deadline: string; } @@ -56,6 +61,8 @@ function getInitialDraft(): BaseFormDraft { content: draft.content ?? '', categories: draft.categories ?? [], location: draft.location ?? '', + locationLat: draft.locationLat, + locationLng: draft.locationLng, deadline: draft.deadline ?? '', }; } catch { @@ -81,7 +88,27 @@ export function usePostBaseForm(prefill?: PostBaseFormPrefill) { ); const [photoPreviews, setPhotoPreviews] = useState([]); const draft = draftOverride ?? initialDraft; - const { spotName, title, content, categories, location, deadline } = draft; + const { + spotName, + title, + content, + categories, + location, + locationLat, + locationLng, + deadline, + } = draft; + const selectedLocation: SelectedPostLocation | null = + typeof locationLat === 'number' && + Number.isFinite(locationLat) && + typeof locationLng === 'number' && + Number.isFinite(locationLng) + ? { + lat: locationLat, + lng: locationLng, + label: location, + } + : null; // prefill 이 시뮬→포스트 전환에서 변경되면 draft 를 한번 덮어씀 (localStorage 는 그대로). // prefill 의 내용 변화를 단순 JSON 직렬화로 감지. @@ -121,6 +148,7 @@ export function usePostBaseForm(prefill?: PostBaseFormPrefill) { categories, photoPreviews, location, + selectedLocation, deadline, setSpotName: (value: string) => setDraftOverride((prev) => ({ @@ -147,6 +175,13 @@ export function usePostBaseForm(prefill?: PostBaseFormPrefill) { ...(prev ?? initialDraft), location: value, })), + setSelectedLocation: (value: SelectedPostLocation) => + setDraftOverride((prev) => ({ + ...(prev ?? initialDraft), + location: value.label, + locationLat: value.lat, + locationLng: value.lng, + })), setDeadline: (value: string) => setDraftOverride((prev) => ({ ...(prev ?? initialDraft), diff --git a/src/features/post/ui/post-form/MapLocationPicker.test.tsx b/src/features/post/ui/post-form/MapLocationPicker.test.tsx new file mode 100644 index 0000000..9d057b7 --- /dev/null +++ b/src/features/post/ui/post-form/MapLocationPicker.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MapLocationPicker } from './MapLocationPicker'; + +vi.mock('@/features/map/ui/MapV3Canvas', () => ({ + MapV3Canvas: ({ + onMapClickAction, + }: { + onMapClickAction?: (lat: number, lng: number) => void; + }) => ( + + ), +})); + +afterEach(() => { + cleanup(); +}); + +describe('MapLocationPicker', () => { + it('selects a required feed location as lat/lng from a map click', () => { + const handleChange = vi.fn(); + + render(); + + fireEvent.click( + screen.getByRole('button', { name: '테스트 지도 좌표 선택' }), + ); + + expect(handleChange).toHaveBeenCalledWith({ + lat: 37.2636, + lng: 127.0286, + label: '지도 선택 위치 (37.26360, 127.02860)', + }); + }); + + it('renders the selected coordinate summary', () => { + render( + , + ); + + expect( + screen.getByText('지도 선택 위치 (37.26360, 127.02860)'), + ).toBeTruthy(); + expect( + screen.getByText(/lat\s+37\.263600\s+· lng\s+127\.028600/), + ).toBeTruthy(); + }); +}); diff --git a/src/features/post/ui/post-form/MapLocationPicker.tsx b/src/features/post/ui/post-form/MapLocationPicker.tsx new file mode 100644 index 0000000..866b19a --- /dev/null +++ b/src/features/post/ui/post-form/MapLocationPicker.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useMemo } from 'react'; +import { IconMapPin } from '@tabler/icons-react'; +import { MapV3Canvas } from '@/features/map/ui/MapV3Canvas'; + +export type SelectedPostLocation = { + lat: number; + lng: number; + label: string; +}; + +type MapLocationPickerProps = { + value: SelectedPostLocation | null; + onChange: (value: SelectedPostLocation) => void; +}; + +const DEFAULT_CENTER = { lat: 37.2636, lng: 127.0286 }; + +function formatLocationLabel(lat: number, lng: number) { + return `지도 선택 위치 (${lat.toFixed(5)}, ${lng.toFixed(5)})`; +} + +export function MapLocationPicker({ value, onChange }: MapLocationPickerProps) { + const center = value ? { lat: value.lat, lng: value.lng } : DEFAULT_CENTER; + + const overlays = useMemo( + () => + value + ? [ + { + key: 'selected-location', + position: { lat: value.lat, lng: value.lng }, + render: () => ( +
+
+ +
+ + 선택한 위치 + +
+ ), + }, + ] + : [], + [value], + ); + + const handleSelect = (lat: number, lng: number) => { + onChange({ + lat, + lng, + label: formatLocationLabel(lat, lng), + }); + }; + + return ( +
+
+ +
+ 지도를 눌러 활동 위치를 선택해주세요 +
+
+ +
+ {value ? ( +
+ {value.label} + + lat {value.lat.toFixed(6)} · lng{' '} + {value.lng.toFixed(6)} + +
+ ) : ( + '아직 위치가 선택되지 않았어요. 지도에서 장소를 눌러주세요.' + )} +
+
+ ); +} diff --git a/src/features/post/ui/post-form/PostBaseInfoSection.tsx b/src/features/post/ui/post-form/PostBaseInfoSection.tsx index d6fe5b7..79f3e2d 100644 --- a/src/features/post/ui/post-form/PostBaseInfoSection.tsx +++ b/src/features/post/ui/post-form/PostBaseInfoSection.tsx @@ -4,6 +4,10 @@ import { CategoryTagSelector } from '../CategoryTagSelector'; import { FormCard } from '../FormCard'; import { FormField } from '../FormField'; import { ImageUploadGrid } from '../ImageUploadGrid'; +import { + MapLocationPicker, + type SelectedPostLocation, +} from './MapLocationPicker'; type PostBaseInfoSectionProps = { spotName: string; @@ -11,7 +15,7 @@ type PostBaseInfoSectionProps = { content: string; categories: PostSpotCategory[]; photoPreviews: string[]; - location: string; + selectedLocation: SelectedPostLocation | null; deadline: string; onSpotNameChange: (value: string) => void; onTitleChange: (value: string) => void; @@ -19,7 +23,7 @@ type PostBaseInfoSectionProps = { onCategoriesChange: (value: PostSpotCategory[]) => void; onAddPhoto: (file: File, preview: string) => void; onRemovePhoto: (index: number) => void; - onLocationChange: (value: string) => void; + onLocationChange: (value: SelectedPostLocation) => void; onDeadlineChange: (value: string) => void; }; @@ -29,7 +33,7 @@ export function PostBaseInfoSection({ content, categories, photoPreviews, - location, + selectedLocation, deadline, onSpotNameChange, onTitleChange, @@ -60,7 +64,7 @@ export function PostBaseInfoSection({ /> - +