diff --git a/src/features/chat/client/MainChatPageClient.tsx b/src/features/chat/client/MainChatPageClient.tsx index 6d96b7e..f59b9ab 100644 --- a/src/features/chat/client/MainChatPageClient.tsx +++ b/src/features/chat/client/MainChatPageClient.tsx @@ -25,7 +25,7 @@ import { useMainChatStore, } from '../model/use-main-chat-store'; import { formatReverseOfferApprovalProgress } from '../model/types'; -import { isSupporterForSpot } from '../model/mock'; +import { isOwnedSpotRoom, isSupporterForSpot } from '../model/mock'; import { buildScheduleSubtitle, findSpotActionItem, @@ -289,10 +289,6 @@ function FileListRow({ files }: { files: SharedFile[] }) { } /* ── 스팟 컨텍스트 아이템 목록 ────────────────────────────── */ -function isOwnedSpotRoom(room: SpotChatRoom) { - return room.spot.authorId === room.currentUserId; -} - function SpotItemList({ room, onOpenRoom, @@ -822,6 +818,9 @@ export function MainChatPageClient({ ) : ( { beforeEach(() => { + vi.restoreAllMocks(); useMainChatStore.getState().reset(); useMainChatStore.setState({ rooms: getChatRooms() }); }); @@ -170,6 +172,99 @@ describe('useMainChatStore', () => { ).toHaveLength(1); }); + it('casts a vote through the spot API and syncs room/thread vote state', async () => { + const room = useMainChatStore + .getState() + .rooms.find( + (candidate) => + candidate.id === 'spot-room-spot-6' && + candidate.category === 'spot', + ); + + expect(room?.category).toBe('spot'); + + if (!room || room.category !== 'spot') { + throw new Error('Expected an owner spot room with votes.'); + } + + const vote = room.spot.votes[0]; + const option = vote.options.find( + (candidate) => !candidate.voterIds.includes(room.currentUserId), + ); + + expect(option).toBeDefined(); + + if (!option) { + throw new Error('Expected an unselected vote option.'); + } + + const apiVote = { + ...vote, + options: vote.options.map((candidate) => ({ + ...candidate, + voterIds: + candidate.id === option.id + ? [room.currentUserId] + : candidate.voterIds.filter( + (id) => id !== room.currentUserId, + ), + })), + }; + const castSpy = vi + .spyOn(spotsApi, 'castVote') + .mockResolvedValue({ data: apiVote }); + + const updatedRoom = await useMainChatStore + .getState() + .castTeamVote(room.id, vote.id, option.id); + + expect(castSpy).toHaveBeenCalledWith(room.spot.id, vote.id, [ + option.id, + ]); + expect( + updatedRoom?.spot.votes + .find((candidate) => candidate.id === vote.id) + ?.options.find((candidate) => candidate.id === option.id) + ?.voterIds, + ).toContain(room.currentUserId); + + const updatedVoteMessage = updatedRoom?.messages.find( + (message) => message.kind === 'vote' && message.vote.id === vote.id, + ); + + expect(updatedVoteMessage?.kind).toBe('vote'); + + if (updatedVoteMessage?.kind !== 'vote') { + throw new Error('Expected updated vote message.'); + } + + expect( + updatedVoteMessage.vote.options.find( + (candidate) => candidate.id === option.id, + )?.voterIds, + ).toContain(room.currentUserId); + }); + + it('keeps owner-only actions blocked for non-owner supporter rooms', async () => { + useMainChatStore.getState().setSelectedContextId('spot-room-spot-2'); + + await expect( + useMainChatStore.getState().createTeamVote('안건', ['A', 'B']), + ).resolves.toBeNull(); + await expect( + useMainChatStore.getState().createTeamScheduleVote(), + ).resolves.toBeNull(); + await expect( + useMainChatStore + .getState() + .createTeamFileShare( + 'owner-only.pdf', + 12, + 'https://example.com/owner-only.pdf', + ), + ).resolves.toBeNull(); + }); + it('creates reverse-offer approval counts on room and thread message', () => { useMainChatStore.getState().setSelectedContextId('spot-room-spot-2'); diff --git a/src/features/chat/model/use-main-chat-store.ts b/src/features/chat/model/use-main-chat-store.ts index 263315f..3a7653b 100644 --- a/src/features/chat/model/use-main-chat-store.ts +++ b/src/features/chat/model/use-main-chat-store.ts @@ -2,13 +2,15 @@ import { create } from 'zustand'; import type { SpotDetailFull } from '@/entities/spot/types'; -import type { SharedFile, SpotVote } from '@/entities/spot/types'; +import type { SpotVote } from '@/entities/spot/types'; +import { spotsApi } from '@/features/spot/api/spot-api'; import { chatApi } from '../api/chat-api'; import { CHAT_CURRENT_USER_ID, CHAT_CURRENT_USER_NAME, getChatDirectoryCandidateById, getChatFriends, + isSupporterForSpot, } from './mock'; import type { ChatFriend, @@ -50,12 +52,18 @@ type MainChatState = { question?: string, options?: string[], multiSelect?: boolean, - ) => SpotChatRoom | null; - createTeamScheduleVote: () => SpotChatRoom | null; + ) => Promise; + castTeamVote: ( + roomId: string, + voteId: string, + optionId: string, + ) => Promise; + createTeamScheduleVote: () => Promise; createTeamFileShare: ( - fileName?: string, - fileSize?: number, - ) => SpotChatRoom | null; + fileName: string, + fileSize: number | undefined, + fileUrl: string, + ) => Promise; createTeamReverseOffer: ( priorAgreementReachedInChat: boolean, ) => SpotChatRoom | null; @@ -63,7 +71,7 @@ type MainChatState = { updateSpotSchedule: ( roomId: string, slots: import('@/entities/spot/types').ScheduleSlot[], - ) => void; + ) => Promise; applyRouteIntent: (intent: ChatRouteIntent) => ResolvedChatRoom; loadRooms: () => Promise; loadRoom: (roomId: string) => Promise; @@ -200,6 +208,113 @@ function upsertBackendRoom(currentRooms: ChatRoom[], backendRoom: ChatRoom) { return upsertBackendRooms(currentRooms, [backendRoom]); } +function isOwnedSpotRoom(room: SpotChatRoom): boolean { + return room.spot.authorId === room.currentUserId; +} + +function canManageOwnerActions(room: SpotChatRoom): boolean { + return isOwnedSpotRoom(room); +} + +function canCreateReverseOffer(room: SpotChatRoom): boolean { + return ( + !isOwnedSpotRoom(room) && isSupporterForSpot(room, room.currentUserId) + ); +} + +function replaceVoteInRoom(room: SpotChatRoom, vote: SpotVote): SpotChatRoom { + return { + ...room, + spot: { + ...room.spot, + votes: room.spot.votes.map((candidate) => + candidate.id === vote.id ? vote : candidate, + ), + }, + messages: room.messages.map((message) => + message.kind === 'vote' && message.vote.id === vote.id + ? { ...message, vote } + : message, + ), + }; +} + +function applyLocalVoteSelection( + vote: SpotVote, + userId: string, + optionId: string, +): SpotVote { + const selected = vote.options + .filter((option) => option.voterIds.includes(userId)) + .map((option) => option.id); + const alreadySelected = selected.includes(optionId); + const nextSelected = vote.multiSelect + ? alreadySelected + ? selected.filter((id) => id !== optionId) + : [...selected, optionId] + : [optionId]; + + return { + ...vote, + options: vote.options.map((option) => ({ + ...option, + voterIds: nextSelected.includes(option.id) + ? Array.from(new Set([...option.voterIds, userId])) + : option.voterIds.filter((id) => id !== userId), + })), + }; +} + +async function enrichSpotRoomWithBackend( + room: SpotChatRoom, +): Promise { + const spotId = room.spot.id; + const [participants, schedule, votes, files] = await Promise.all([ + spotsApi + .getParticipants(spotId) + .then((response) => response.data) + .catch(() => room.spot.participants), + spotsApi + .getSchedule(spotId) + .then((response) => response.data) + .catch(() => room.spot.schedule ?? null), + spotsApi + .getVotes(spotId) + .then((response) => response.data) + .catch(() => room.spot.votes), + spotsApi + .getFiles(spotId) + .then((response) => response.data) + .catch(() => room.spot.files), + ]); + const owner = participants.find( + (participant) => participant.role === 'AUTHOR', + ); + + return { + ...room, + spot: { + ...room.spot, + authorId: room.spot.authorId || owner?.userId || '', + authorNickname: room.spot.authorNickname || owner?.nickname || '', + participants, + schedule: schedule ?? undefined, + votes, + files, + }, + }; +} + +async function enrichSpotRoomsWithBackend( + rooms: ChatRoom[], +): Promise { + return Promise.all( + rooms.map((room) => + room.category === 'spot' ? enrichSpotRoomWithBackend(room) : room, + ), + ); +} + function createReverseOfferSummary(payload: { spotId: string; authorId: string; @@ -459,7 +574,7 @@ export const useMainChatStore = create()((set, get) => ({ return nextRoom.room; }, - createTeamVote: (question?, options?, multiSelect?) => { + createTeamVote: async (question?, options?, multiSelect?) => { const { selectedContextId, rooms } = get(); if (selectedContextId === PERSONAL_CHAT_CONTEXT_ID) { @@ -471,29 +586,25 @@ export const useMainChatStore = create()((set, get) => ({ room.id === selectedContextId && room.category === 'spot', ); - if (!targetRoom) { + if (!targetRoom || !canManageOwnerActions(targetRoom)) { return null; } const now = new Date().toISOString(); - const ts = Date.now(); const resolvedOptions = options && options.length >= 2 ? options : ['준비물 먼저 정리', '역할 분담 먼저 정리']; - const vote: SpotVote = { - id: `chat-vote-${ts}`, - spotId: targetRoom.spot.id, - question: - question ?? - `${targetRoom.spot.title}에서 먼저 정할 안건은 무엇인가요?`, - options: resolvedOptions.map((label, i) => ({ - id: `chat-vote-option-${ts}-${i}`, - label, - voterIds: i === 0 ? [CHAT_CURRENT_USER_ID] : [], - })), - multiSelect: multiSelect ?? false, - }; + const resolvedQuestion = + question ?? + `${targetRoom.spot.title}에서 먼저 정할 안건은 무엇인가요?`; + const vote = await spotsApi + .createVote(targetRoom.spot.id, { + question: resolvedQuestion, + options: resolvedOptions, + multiSelect, + }) + .then((response) => response.data); const message: ChatMessage = { id: `chat-vote-message-${Date.now()}`, @@ -521,7 +632,42 @@ export const useMainChatStore = create()((set, get) => ({ return updatedRoom; }, - createTeamScheduleVote: () => { + castTeamVote: async (roomId, voteId, optionId) => { + const { rooms } = get(); + const targetRoom = rooms.find( + (room): room is SpotChatRoom => + room.id === roomId && room.category === 'spot', + ); + const targetVote = targetRoom?.spot.votes.find( + (vote) => vote.id === voteId, + ); + + if (!targetRoom || !targetVote || targetVote.closedAt) { + return null; + } + + const optimisticVote = applyLocalVoteSelection( + targetVote, + targetRoom.currentUserId, + optionId, + ); + const selectedOptionIds = optimisticVote.options + .filter((option) => + option.voterIds.includes(targetRoom.currentUserId), + ) + .map((option) => option.id); + const vote = await spotsApi + .castVote(targetRoom.spot.id, voteId, selectedOptionIds) + .then((response) => response.data); + const updatedRoom = replaceVoteInRoom(targetRoom, vote); + + set({ + rooms: updateSpotRoom(rooms, targetRoom.id, () => updatedRoom), + }); + + return updatedRoom; + }, + createTeamScheduleVote: async () => { const { selectedContextId, rooms } = get(); if (selectedContextId === PERSONAL_CHAT_CONTEXT_ID) { @@ -533,7 +679,7 @@ export const useMainChatStore = create()((set, get) => ({ room.id === selectedContextId && room.category === 'spot', ); - if (!targetRoom) { + if (!targetRoom || !canManageOwnerActions(targetRoom)) { return null; } @@ -575,7 +721,7 @@ export const useMainChatStore = create()((set, get) => ({ messages: [...targetRoom.messages, message], }; }, - createTeamFileShare: (fileName?, fileSize?) => { + createTeamFileShare: async (fileName, fileSize, fileUrl) => { const { selectedContextId, rooms } = get(); if (selectedContextId === PERSONAL_CHAT_CONTEXT_ID) { @@ -587,20 +733,18 @@ export const useMainChatStore = create()((set, get) => ({ room.id === selectedContextId && room.category === 'spot', ); - if (!targetRoom) { + if (!targetRoom || !canManageOwnerActions(targetRoom)) { return null; } const now = new Date().toISOString(); - const file: SharedFile = { - id: `chat-file-${Date.now()}`, - spotId: targetRoom.spot.id, - uploaderNickname: targetRoom.currentUserName, - name: fileName ?? '준비물_정리.pdf', - url: 'https://example.com/files/chat-shared-file.pdf', - sizeBytes: fileSize ?? 128 * 1024, - uploadedAt: now, - }; + const file = await spotsApi + .uploadFile(targetRoom.spot.id, { + fileName, + fileUrl, + sizeBytes: fileSize, + }) + .then((response) => response.data); const message: ChatMessage = { id: `chat-file-message-${Date.now()}`, @@ -640,7 +784,7 @@ export const useMainChatStore = create()((set, get) => ({ room.id === selectedContextId && room.category === 'spot', ); - if (!targetRoom) { + if (!targetRoom || !canCreateReverseOffer(targetRoom)) { return null; } @@ -747,19 +891,28 @@ export const useMainChatStore = create()((set, get) => ({ return updatedRoom; }, - updateSpotSchedule: (roomId, slots) => { + updateSpotSchedule: async (roomId, slots) => { const { rooms } = get(); + const targetRoom = rooms.find( + (candidate): candidate is SpotChatRoom => + candidate.id === roomId && candidate.category === 'spot', + ); + + if (!targetRoom || !canManageOwnerActions(targetRoom)) { + return; + } + + const schedule = await spotsApi + .upsertSchedule(targetRoom.spot.id, slots) + .then((response) => response.data); + set({ rooms: updateSpotRoom(rooms, roomId, (room) => ({ ...room, updatedAt: new Date().toISOString(), spot: { ...room.spot, - schedule: { - spotId: room.spot.id, - proposedSlots: slots, - confirmedSlot: room.spot.schedule?.confirmedSlot, - }, + schedule, }, })), }); @@ -767,9 +920,12 @@ export const useMainChatStore = create()((set, get) => ({ loadRooms: async () => { try { const response = await chatApi.listRooms(); + const backendRooms = await enrichSpotRoomsWithBackend( + response.data, + ); set(({ rooms }) => ({ - rooms: upsertBackendRooms(rooms, response.data), + rooms: upsertBackendRooms(rooms, backendRooms), })); } catch { set({ rooms: [] }); @@ -782,12 +938,16 @@ export const useMainChatStore = create()((set, get) => ({ try { const response = await chatApi.getRoom(roomId); + const room = + response.data.category === 'spot' + ? await enrichSpotRoomWithBackend(response.data) + : response.data; set(({ rooms }) => ({ - rooms: upsertBackendRoom(rooms, response.data), + rooms: upsertBackendRoom(rooms, room), })); - return response.data; + return room; } catch { return null; } diff --git a/src/features/chat/ui/ChatBottomNav.tsx b/src/features/chat/ui/ChatBottomNav.tsx index 3f1e574..84543ff 100644 --- a/src/features/chat/ui/ChatBottomNav.tsx +++ b/src/features/chat/ui/ChatBottomNav.tsx @@ -25,6 +25,7 @@ interface ChatBottomNavPersonalProps { interface ChatBottomNavTeamProps { mode: 'team'; onAddItem: (step: CreationStep) => void; + showOwnerActions: boolean; showReverseOffer: boolean; disabled?: boolean; } @@ -109,24 +110,37 @@ export function ChatBottomNav(props: ChatBottomNavProps) { ) : ( <> - } - label="일정 추가" - onClick={() => props.onAddItem('schedule')} - disabled={props.disabled} - /> - } - label="투표 추가" - onClick={() => props.onAddItem('vote')} - disabled={props.disabled} - /> - } - label="파일 추가" - onClick={() => props.onAddItem('file')} - disabled={props.disabled} - /> + {props.showOwnerActions && ( + <> + + } + label="일정 추가" + onClick={() => props.onAddItem('schedule')} + disabled={props.disabled} + /> + + } + label="투표 추가" + onClick={() => props.onAddItem('vote')} + disabled={props.disabled} + /> + + } + label="파일 추가" + onClick={() => props.onAddItem('file')} + disabled={props.disabled} + /> + + )} {props.showReverseOffer && ( (null); function addOption() { if (options.length < 6) setOptions((p) => [...p, '']); @@ -230,16 +233,25 @@ function VoteCreatePanel({ const filledOptions = options.filter((o) => o.trim()); const canSubmit = question.trim() && filledOptions.length >= 2; - function handleSubmit() { - if (!canSubmit) return; - // store에 직접 투표 데이터 주입 + async function handleSubmit() { + if (!canSubmit || isSaving) return; + + setIsSaving(true); + setSaveError(null); setSelectedContextId(room.id); - createTeamVote( - question.trim(), - options.filter((o) => o.trim()), - multiSelect, - ); - onClose(); + + try { + const createdRoom = await createTeamVote( + question.trim(), + filledOptions, + multiSelect, + ); + if (createdRoom) onClose(); + } catch { + setSaveError('투표 생성에 실패했어요. 잠시 후 다시 시도해주세요.'); + } finally { + setIsSaving(false); + } } return ( @@ -323,14 +335,20 @@ function VoteCreatePanel({ 복수 선택 허용 + {saveError && ( +

+ {saveError} +

+ )} + ); @@ -417,9 +435,23 @@ function ScheduleCreatePanel({ }); } - function handleSave() { - updateSpotSchedule(room.id, slots); - onClose(); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + async function handleSave() { + if (isSaving) return; + + setIsSaving(true); + setSaveError(null); + + try { + await updateSpotSchedule(room.id, slots); + onClose(); + } catch { + setSaveError('일정 저장에 실패했어요. 잠시 후 다시 시도해주세요.'); + } finally { + setIsSaving(false); + } } const myCount = slots.filter((s) => @@ -553,14 +585,20 @@ function ScheduleCreatePanel({ + {saveError && ( +

+ {saveError} +

+ )} + ); @@ -570,9 +608,9 @@ function ScheduleCreatePanel({ 파일 업로드 패널 ───────────────────────────────────────────────────────────── */ function FileCreatePanel({ onClose }: { onClose: () => void }) { - const { createTeamFileShare } = useMainChatStore(); const inputRef = useRef(null); const [files, setFiles] = useState([]); + const [uploadError, setUploadError] = useState(null); function handleFiles(incoming: FileList | null) { if (!incoming) return; @@ -610,13 +648,12 @@ function FileCreatePanel({ onClose }: { onClose: () => void }) { return '📎'; } - function handleUpload() { + async function handleUpload() { if (files.length === 0) return; - // mock: 파일 이름/크기만 store에 반영 (실제 업로드 없음) - for (const file of files) { - createTeamFileShare(file.name, file.size); - } - onClose(); + + setUploadError( + '파일 업로드는 저장소 URL 계약이 필요해서 아직 실제 등록하지 않았어요.', + ); } return ( @@ -678,6 +715,12 @@ function FileCreatePanel({ onClose }: { onClose: () => void }) { )} + {uploadError && ( +

+ {uploadError} +

+ )} + + ); } @@ -823,6 +873,26 @@ function TeamCreationPanel({ ); } + const isOwner = + selectedSpotRoom.spot.authorId === selectedSpotRoom.currentUserId; + const isSupporter = isSupporterForSpot(selectedSpotRoom); + + if (['vote', 'schedule', 'file'].includes(step) && !isOwner) { + return ( +
+ 투표·일정·파일은 스팟 오너만 추가할 수 있어요. +
+ ); + } + + if (step === 'reverse-offer' && (isOwner || !isSupporter)) { + return ( +
+ 역제안은 참여 중인 서포터만 등록할 수 있어요. +
+ ); + } + if (step === 'vote') return ; if (step === 'schedule') @@ -1064,7 +1134,18 @@ function VoteActionPanel({ item: Extract; onClose: () => void; }) { - const totalVotes = item.vote.options.reduce( + const { castTeamVote, rooms } = useMainChatStore(); + const spotRoom = rooms.find( + (room) => room.id === item.roomId && room.category === 'spot', + ); + const currentUserId = + spotRoom?.category === 'spot' ? spotRoom.currentUserId : ''; + const liveVote = + spotRoom?.category === 'spot' + ? (spotRoom.spot.votes.find((vote) => vote.id === item.vote.id) ?? + item.vote) + : item.vote; + const totalVotes = liveVote.options.reduce( (sum, o) => sum + o.voterIds.length, 0, ); @@ -1080,21 +1161,28 @@ function VoteActionPanel({

{item.roomTitle} · 총 {totalVotes}표 ·{' '} - {item.vote.multiSelect ? '복수 선택' : '단일 선택'} + {liveVote.multiSelect ? '복수 선택' : '단일 선택'}

- {item.vote.options.map((option) => { + {liveVote.options.map((option) => { const pct = totalVotes > 0 ? Math.round( (option.voterIds.length / totalVotes) * 100, ) : 0; - const voted = option.voterIds.includes('user-me'); + const voted = option.voterIds.includes(currentUserId); return (
+ {saveError && ( +

+ {saveError} +

+ )} + ); diff --git a/src/features/chat/ui/ChatDetail.tsx b/src/features/chat/ui/ChatDetail.tsx index bd0c32a..495e388 100644 --- a/src/features/chat/ui/ChatDetail.tsx +++ b/src/features/chat/ui/ChatDetail.tsx @@ -30,7 +30,7 @@ import { useMainChatStore, } from '../model/use-main-chat-store'; import { chatApi } from '../api/chat-api'; -import { isSupporterForSpot } from '../model/mock'; +import { isOwnedSpotRoom, isSupporterForSpot } from '../model/mock'; import { getShareableSpotActionItems, getSpotScheduleActionId, @@ -581,6 +581,50 @@ export function ChatDetail({ roomId }: ChatDetailProps) { const messageCount = messages.filter( (message) => message.kind !== 'system', ).length; + const canManageOwnerActions = + currentRoom.category === 'spot' && isOwnedSpotRoom(currentRoom); + const canCreateReverseOffer = + currentRoom.category === 'spot' && + !canManageOwnerActions && + isSupporterForSpot(currentRoom); + const creationItems = [ + ...(canManageOwnerActions + ? [ + { + step: 'vote' as const, + label: '투표', + description: '선택지를 제안해요', + icon: , + tone: 'bg-amber-50 text-amber-700', + }, + { + step: 'schedule' as const, + label: '일정', + description: '가능한 시간 조율', + icon: , + tone: 'bg-brand-50 text-brand-800', + }, + { + step: 'file' as const, + label: '파일', + description: '첨부 파일 공유', + icon: , + tone: 'bg-muted text-text-secondary', + }, + ] + : []), + ...(canCreateReverseOffer + ? [ + { + step: 'reverse-offer' as const, + label: '역제안', + description: '파트너에게 역제안', + icon: , + tone: 'bg-emerald-50 text-emerald-700', + }, + ] + : []), + ]; const showMobileChatNavPanel = chatNavExpanded && (chatNavMode.kind === 'room-info' || @@ -987,87 +1031,53 @@ export function ChatDetail({ roomId }: ChatDetailProps) { snapPoint="half" >
-
-

- 새로 만들기 -

-
- {[ - { - step: 'vote' as const, - label: '투표', - description: '선택지를 제안해요', - icon: , - tone: 'bg-amber-50 text-amber-700', - }, - { - step: 'schedule' as const, - label: '일정', - description: '가능한 시간 조율', - icon: , - tone: 'bg-brand-50 text-brand-800', - }, - ...(isSupporterForSpot(currentRoom) - ? [ - { - step: 'reverse-offer' as const, - label: '역제안', - description: - '파트너에게 역제안', - icon: ( - - ), - tone: 'bg-emerald-50 text-emerald-700', - }, - ] - : []), - { - step: 'file' as const, - label: '파일', - description: '첨부 파일 공유', - icon: , - tone: 'bg-muted text-text-secondary', - }, - ].map( - ({ - step, - label, - description, - icon, - tone, - }) => ( -
-
-

- {label} -

-

- {description} -

-
- - ), - )} -
- +
+ {icon} +
+
+

+ {label} +

+

+ {description} +

+
+ + ), + )} + + + ) : null}

바로가기 공유 diff --git a/src/features/post/model/types.ts b/src/features/post/model/types.ts index aafbd3c..00d8aa8 100644 --- a/src/features/post/model/types.ts +++ b/src/features/post/model/types.ts @@ -14,6 +14,12 @@ export type PostSpotCategory = | '음악' | '기타'; +export type SelectedPostLocation = { + lat: number; + lng: number; + label: string; +}; + export interface PostBaseFormData { spotName: string; title: string; diff --git a/src/features/post/model/use-post-base-form.ts b/src/features/post/model/use-post-base-form.ts index 14d2ff1..f94bf55 100644 --- a/src/features/post/model/use-post-base-form.ts +++ b/src/features/post/model/use-post-base-form.ts @@ -1,8 +1,7 @@ 'use client'; import { useEffect, useState, useSyncExternalStore } from 'react'; -import type { PostSpotCategory } from './types'; -import type { SelectedPostLocation } from '../ui/post-form/MapLocationPicker'; +import type { PostSpotCategory, SelectedPostLocation } from './types'; const DRAFT_KEY = 'post-base-form-draft'; const EMPTY_DRAFT: BaseFormDraft = { diff --git a/src/features/post/ui/post-form/MapLocationPicker.test.tsx b/src/features/post/ui/post-form/MapLocationPicker.test.tsx index 9d057b7..37fea70 100644 --- a/src/features/post/ui/post-form/MapLocationPicker.test.tsx +++ b/src/features/post/ui/post-form/MapLocationPicker.test.tsx @@ -58,4 +58,82 @@ describe('MapLocationPicker', () => { screen.getByText(/lat\s+37\.263600\s+· lng\s+127\.028600/), ).toBeTruthy(); }); + + it('selects the current center with a keyboard-accessible button', () => { + 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('selects a required feed location from manual coordinates', () => { + const handleChange = vi.fn(); + + render(); + + fireEvent.change(screen.getByLabelText('선택할 위치의 위도'), { + target: { value: '37.5123' }, + }); + fireEvent.change(screen.getByLabelText('선택할 위치의 경도'), { + target: { value: '127.0456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '좌표로 선택' })); + + expect(handleChange).toHaveBeenCalledWith({ + lat: 37.5123, + lng: 127.0456, + label: '지도 선택 위치 (37.51230, 127.04560)', + }); + }); + + it('shows an error and does not select empty manual coordinates', () => { + const handleChange = vi.fn(); + + render(); + + fireEvent.change(screen.getByLabelText('선택할 위치의 위도'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('선택할 위치의 경도'), { + target: { value: '127.0456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '좌표로 선택' })); + + expect(handleChange).not.toHaveBeenCalled(); + expect( + screen.getByText( + '위도는 -90~90, 경도는 -180~180 사이 숫자로 입력해주세요.', + ), + ).toBeTruthy(); + }); + + it('shows an error and does not select out-of-range manual coordinates', () => { + const handleChange = vi.fn(); + + render(); + + fireEvent.change(screen.getByLabelText('선택할 위치의 위도'), { + target: { value: '91' }, + }); + fireEvent.change(screen.getByLabelText('선택할 위치의 경도'), { + target: { value: '200' }, + }); + fireEvent.click(screen.getByRole('button', { name: '좌표로 선택' })); + + expect(handleChange).not.toHaveBeenCalled(); + expect( + screen.getByText( + '위도는 -90~90, 경도는 -180~180 사이 숫자로 입력해주세요.', + ), + ).toBeTruthy(); + }); }); diff --git a/src/features/post/ui/post-form/MapLocationPicker.tsx b/src/features/post/ui/post-form/MapLocationPicker.tsx index 866b19a..95bc30b 100644 --- a/src/features/post/ui/post-form/MapLocationPicker.tsx +++ b/src/features/post/ui/post-form/MapLocationPicker.tsx @@ -1,14 +1,9 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { IconMapPin } from '@tabler/icons-react'; import { MapV3Canvas } from '@/features/map/ui/MapV3Canvas'; - -export type SelectedPostLocation = { - lat: number; - lng: number; - label: string; -}; +import type { SelectedPostLocation } from '../../model/types'; type MapLocationPickerProps = { value: SelectedPostLocation | null; @@ -23,6 +18,9 @@ function formatLocationLabel(lat: number, lng: number) { export function MapLocationPicker({ value, onChange }: MapLocationPickerProps) { const center = value ? { lat: value.lat, lng: value.lng } : DEFAULT_CENTER; + const [manualLat, setManualLat] = useState(() => String(center.lat)); + const [manualLng, setManualLng] = useState(() => String(center.lng)); + const [manualError, setManualError] = useState(null); const overlays = useMemo( () => @@ -48,6 +46,9 @@ export function MapLocationPicker({ value, onChange }: MapLocationPickerProps) { ); const handleSelect = (lat: number, lng: number) => { + setManualError(null); + setManualLat(String(lat)); + setManualLng(String(lng)); onChange({ lat, lng, @@ -55,6 +56,37 @@ export function MapLocationPicker({ value, onChange }: MapLocationPickerProps) { }); }; + const handleManualSelect = () => { + const trimmedLat = manualLat.trim(); + const trimmedLng = manualLng.trim(); + + if (!trimmedLat || !trimmedLng) { + setManualError( + '위도는 -90~90, 경도는 -180~180 사이 숫자로 입력해주세요.', + ); + return; + } + + const lat = Number(trimmedLat); + const lng = Number(trimmedLng); + + if ( + !Number.isFinite(lat) || + !Number.isFinite(lng) || + lat < -90 || + lat > 90 || + lng < -180 || + lng > 180 + ) { + setManualError( + '위도는 -90~90, 경도는 -180~180 사이 숫자로 입력해주세요.', + ); + return; + } + + handleSelect(lat, lng); + }; + return (

@@ -71,6 +103,68 @@ export function MapLocationPicker({ value, onChange }: MapLocationPickerProps) {
+
+

+ 지도 조작이 어려우면 현재 중심점을 선택하거나 위도와 경도를 + 직접 입력해서 활동 위치를 선택할 수 있어요. +

+ + +
+ + +
+ {manualError ? ( +

+ {manualError} +

+ ) : null} +
+