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,
- }) => (
-
+ ),
+ )}
+
+
+ ) : 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) {
+
+
+ 지도 조작이 어려우면 현재 중심점을 선택하거나 위도와 경도를
+ 직접 입력해서 활동 위치를 선택할 수 있어요.
+
+
+
+
+ handleSelect(center.lat, center.lng)}
+ className="rounded-xl border border-gray-200 px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50 focus:ring-2 focus:ring-primary/20 focus:outline-none"
+ >
+ 현재 중심점 선택
+
+
+ 좌표로 선택
+
+
+ {manualError ? (
+
+ {manualError}
+
+ ) : null}
+
+