Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/app/(detail)/feed/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getServerFeedDetail,
} from '@/features/feed/api/feed-server-api';
import { FeedParticipationActions } from '@/features/feed/ui/detail/FeedParticipationActions';
import { FeedRecentViewRecorder } from '@/features/feed/ui/detail/FeedRecentViewRecorder';
import { EditPlanPreparationCard } from '@/features/feed/ui/detail/EditPlanPreparationCard';
import { PlanSection } from '@/features/feed/ui/detail/PlanSection';
import { PriceSection } from '@/features/feed/ui/detail/PriceSection';
Expand Down Expand Up @@ -229,7 +230,7 @@ function OfferDetailContent({ item }: { item: FeedItem }) {
</span>
)}
</div>
<SpotPromotionProgress item={item} />
{!item.isAi && <SpotPromotionProgress item={item} />}
</div>

{/* 본문 */}
Expand Down Expand Up @@ -317,7 +318,7 @@ function RequestDetailContent({
서포터와 매칭된 후 채팅을 통해 금액과 일정을 함께
조율합니다.
</p>
<SpotPromotionProgress item={item} />
{!item.isAi && <SpotPromotionProgress item={item} />}
</div>

{/* 서포터 지원 현황 */}
Expand Down Expand Up @@ -523,6 +524,7 @@ export default async function FeedDetailPage({ params }: Props) {

return (
<>
<FeedRecentViewRecorder item={item} />
<DetailHeader showShare />
<DetailPageShell
as="main"
Expand Down
44 changes: 37 additions & 7 deletions src/features/chat/api/chat-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { buildQueryString, clientApiFetch } from '@/lib/client-api';
import { backendProxyEndpoint, endpoints } from '@/lib/endpoint';
import type { SpotStatus, SpotType } from '@/entities/spot/types';
import type { ChatMessage, ChatRoom } from '../model/types';

export type ChatRoomsQuery = {
Expand Down Expand Up @@ -42,6 +43,17 @@ type BackendRoom = {
feedId?: number | string | null;
spotId?: number | string | null;
type?: 'GROUP' | 'PERSONAL';
feedType?: SpotType | 'RENT' | null;
spotType?: SpotType | 'RENT' | null;
status?: SpotStatus | null;
feedStatus?: SpotStatus | null;
spotStatus?: SpotStatus | null;
currentUserRole?: 'OWNER' | 'SUPPORTER' | 'PARTNER' | null;
participationRole?: 'SUPPORTER' | 'PARTNER' | null;
owner?: boolean | null;
isOwner?: boolean | null;
authorId?: string | null;
authorNickname?: string | null;
title?: string;
subtitle?: string;
currentUserId?: string;
Expand Down Expand Up @@ -108,6 +120,15 @@ function toChatRoom(room: BackendRoom): ChatRoom {
const id = String(room.id);
const feedId = room.feedId == null ? undefined : String(room.feedId);
const spotId = room.spotId == null ? undefined : String(room.spotId);
const sourceKind = feedId && !spotId ? 'feed' : 'spot';
const rawType = room.spotType ?? room.feedType ?? 'REQUEST';
const spotType: SpotType = rawType === 'RENT' ? 'OFFER' : rawType;
const status =
room.spotStatus ??
room.feedStatus ??
room.status ??
(sourceKind === 'feed' ? 'OPEN' : 'MATCHED');
const currentUserRole = room.currentUserRole ?? room.participationRole;
const isSpotRoom =
room.type === 'GROUP' || Boolean(feedId) || Boolean(spotId);
const updatedAt =
Expand All @@ -124,22 +145,31 @@ function toChatRoom(room: BackendRoom): ChatRoom {
(room.spotId ? `스팟 ${room.spotId}` : `팀 채팅 ${id}`),
subtitle: room.subtitle ?? '팀 채팅',
description: room.lastMessagePreview ?? '아직 메시지가 없어요.',
metaLabel: '팀 채팅',
metaLabel: sourceKind === 'feed' ? '피드 대화' : '스팟 대화',
updatedAt,
unreadCount: room.unreadCount ?? 0,
messages: [],
sourceFeedId: feedId,
sourceKind,
participationRole:
currentUserRole === 'SUPPORTER' || currentUserRole === 'PARTNER'
? currentUserRole
: undefined,
spot: {
id: spotId ?? id,
type: 'REQUEST',
status: 'OPEN',
title: room.spotId ? `스팟 ${room.spotId}` : `팀 채팅 ${id}`,
description: '아직 메시지가 없어요.',
type: spotType,
status,
title:
room.title ??
(spotId ? `스팟 ${spotId}` : `피드 ${feedId ?? id}`),
description: room.lastMessagePreview ?? '아직 메시지가 없어요.',
pointCost: 0,
authorId: '',
authorNickname: '',
authorId: room.authorId ?? '',
authorNickname: room.authorNickname ?? '',
createdAt: updatedAt,
updatedAt,
isOwner:
room.isOwner ?? room.owner ?? currentUserRole === 'OWNER',
timeline: [],
participants: [],
votes: [],
Expand Down
8 changes: 7 additions & 1 deletion src/features/chat/model/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,10 @@ function buildSpotRoom(spotId: string): SpotChatRoom | null {
new Date(right.createdAt).getTime(),
)
: baseMessages;
const isCurrentUserSupporter =
spot.type === 'OFFER'
? spot.authorId === CHAT_CURRENT_USER_ID
: spot.authorId !== CHAT_CURRENT_USER_ID;

return {
id: `spot-room-${spot.id}`,
Expand All @@ -517,7 +521,9 @@ function buildSpotRoom(spotId: string): SpotChatRoom | null {
updatedAt: spot.updatedAt,
spot,
reverseOffer: reverseOfferScenario?.reverseOffer,
sourceFeedId: spot.type === 'OFFER' ? `feed-${spot.id}` : undefined,
sourceFeedId: spot.status === 'OPEN' ? `feed-${spot.id}` : undefined,
sourceKind: spot.status === 'OPEN' ? 'feed' : 'spot',
participationRole: isCurrentUserSupporter ? 'SUPPORTER' : 'PARTNER',
messages,
};
}
Expand Down
51 changes: 51 additions & 0 deletions src/features/chat/model/room-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { SpotChatRoom } from './types';

export type ChatRoomLifecycleSource = 'feed' | 'spot';
export type ChatRoomUserRole = 'OWNER' | 'SUPPORTER' | 'PARTNER';

export type ChatRoomRoleContext = {
roomLifecycleSource: ChatRoomLifecycleSource;
userFeedRole: ChatRoomUserRole;
canManageOwnerActions: boolean;
canCreateReverseOffer: boolean;
};

export function computeRoomLifecycleSource(
room: SpotChatRoom,
): ChatRoomLifecycleSource {
return (
room.sourceKind ??
(room.sourceFeedId && room.spot.status === 'OPEN' ? 'feed' : 'spot')
);
}

export function computeUserFeedRole(room: SpotChatRoom): ChatRoomUserRole {
if (room.participationRole) return room.participationRole;
if (room.spot.isOwner || room.spot.authorId === room.currentUserId) {
return 'OWNER';
}

const isSupporter =
room.spot.type === 'OFFER'
? room.spot.authorId === room.currentUserId
: room.spot.authorId !== room.currentUserId;

return isSupporter ? 'SUPPORTER' : 'PARTNER';
}

export function computeChatRoomRoleContext(
room: SpotChatRoom,
): ChatRoomRoleContext {
const roomLifecycleSource = computeRoomLifecycleSource(room);
const userFeedRole = computeUserFeedRole(room);
const canManageOwnerActions = userFeedRole === 'OWNER';
const canCreateReverseOffer =
roomLifecycleSource === 'feed' && userFeedRole === 'SUPPORTER';

return {
roomLifecycleSource,
userFeedRole,
canManageOwnerActions,
canCreateReverseOffer,
};
}
1 change: 1 addition & 0 deletions src/features/chat/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export interface SpotChatRoom extends ChatRoomBase {
spot: SpotDetailFull;
reverseOffer?: ChatReverseOfferSummary;
sourceFeedId?: string;
sourceKind?: 'feed' | 'spot';
participationRole?: 'SUPPORTER' | 'PARTNER';
}

Expand Down
15 changes: 9 additions & 6 deletions src/features/chat/ui/ChatCreationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ import { formatReverseOfferApprovalProgress } from '../model/types';
import {
CHAT_CURRENT_USER_ID,
getChatDirectoryCandidates,
isSupporterForSpot,
} from '../model/mock';
import { computeChatRoomRoleContext } from '../model/room-role';
import type {
ChatReverseOfferFinancialSnapshot,
ChatReverseOfferStatus,
Expand Down Expand Up @@ -875,9 +875,9 @@ function TeamCreationPanel({
);
}

const isOwner =
selectedSpotRoom.spot.authorId === selectedSpotRoom.currentUserId;
const isSupporter = isSupporterForSpot(selectedSpotRoom);
const roleContext = computeChatRoomRoleContext(selectedSpotRoom);
const isOwner = roleContext.userFeedRole === 'OWNER';
const isSupporter = roleContext.userFeedRole === 'SUPPORTER';

if (['vote', 'schedule', 'file'].includes(step) && !isOwner) {
return (
Expand All @@ -887,10 +887,13 @@ function TeamCreationPanel({
);
}

if (step === 'reverse-offer' && (isOwner || !isSupporter)) {
if (
step === 'reverse-offer' &&
(!roleContext.canCreateReverseOffer || isOwner || !isSupporter)
) {
return (
<div className="rounded-2xl bg-white/10 px-4 py-4 text-center text-sm text-white/60 pb-2">
역제안은 참여 중인 서포터만 등록할 수 있어요.
역제안은 피드 상태에서 참여 중인 서포터만 등록할 수 있어요.
</div>
);
}
Expand Down
36 changes: 24 additions & 12 deletions src/features/chat/ui/ChatDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
useMainChatStore,
} from '../model/use-main-chat-store';
import { chatApi } from '../api/chat-api';
import { isOwnedSpotRoom, isSupporterForSpot } from '../model/mock';
import { computeChatRoomRoleContext } from '../model/room-role';
import {
getShareableSpotActionItems,
getSpotScheduleActionId,
Expand Down Expand Up @@ -597,12 +597,26 @@ 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 roleContext =
currentRoom.category === 'spot'
? computeChatRoomRoleContext(currentRoom)
: null;
const roomLifecycleSource = roleContext?.roomLifecycleSource ?? null;
const userFeedRole = roleContext?.userFeedRole ?? null;
const canManageOwnerActions = Boolean(roleContext?.canManageOwnerActions);
const canCreateReverseOffer = Boolean(roleContext?.canCreateReverseOffer);
const conversationTitle =
currentRoom.category === 'spot' && roomLifecycleSource === 'feed'
? '피드 대화'
: currentRoom.category === 'spot'
? '스팟 대화'
: '개인 대화';
const roleInfoLabel =
userFeedRole === 'OWNER'
? '오너 정보'
: userFeedRole === 'SUPPORTER'
? '서포터 정보'
: '파트너 정보';
const creationItems = [
...(canManageOwnerActions
? [
Expand Down Expand Up @@ -634,7 +648,7 @@ export function ChatDetail({ roomId }: ChatDetailProps) {
{
step: 'reverse-offer' as const,
label: '역제안',
description: '파트너에게 역제안',
description: '오너에게 역제안',
icon: <IconHeartHandshake size={18} />,
tone: 'bg-emerald-50 text-emerald-700',
},
Expand Down Expand Up @@ -785,7 +799,7 @@ export function ChatDetail({ roomId }: ChatDetailProps) {
variant="ghost"
size="sm"
onClick={() => openRoomInfo(currentRoom.id)}
label="참여자 정보"
label={roleInfoLabel}
className="h-9 w-9 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"
icon={<IconUsers size={18} stroke={1.75} />}
/>
Expand Down Expand Up @@ -820,9 +834,7 @@ export function ChatDetail({ roomId }: ChatDetailProps) {
<section className="pb-2">
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<h2 className="text-[15px] font-bold tracking-[-0.01em] text-zinc-900">
{currentRoom.category === 'spot'
? '스팟 대화'
: '개인 대화'}
{conversationTitle}
</h2>
<span className="text-[11px] font-medium text-zinc-400 tabular-nums">
{messageCount}개 메시지
Expand Down
1 change: 1 addition & 0 deletions src/features/feed/model/use-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export function useToggleFeedBookmark() {
queryClient.invalidateQueries({
queryKey: feedKeys.detail(feedId),
});
queryClient.invalidateQueries({ queryKey: ['my', 'favorites'] });
},
});
}
Expand Down
36 changes: 27 additions & 9 deletions src/features/feed/ui/FeedCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ function FeedRow({

function createAuthorProfile(item: FeedItem): FeedParticipantProfile {
return {
id: `author-${item.id}`,
nickname: item.authorNickname,
id: item.authorProfile?.id ?? `author-${item.id}`,
nickname: item.authorProfile?.nickname ?? item.authorNickname,
avatarUrl: item.authorProfile?.avatarUrl,
};
}

Expand Down Expand Up @@ -195,8 +196,8 @@ function ExploreSlotToken({ slot }: { slot: ExploreSlot }) {
avatarUrl={slot.profile.avatarUrl}
size="sm"
/>
<span className="text-[10px] font-medium text-muted-foreground">
{slot.label}
<span className="max-w-14 truncate text-[10px] font-medium text-muted-foreground">
{slot.profile.nickname}
</span>
</div>
);
Expand All @@ -223,10 +224,22 @@ function AutoScrollList({ children }: { children: React.ReactNode }) {
const rafRef = useRef<number>(0);
const pausedRef = useRef(false);
const posRef = useRef(0);
const childArray = React.Children.toArray(children);
const shouldLoop = childArray.length > 3;
const loopChildren = shouldLoop
? childArray.map((child, index) =>
React.isValidElement(child)
? React.cloneElement(child, {
key: `loop-${child.key ?? index}`,
})
: child,
)
: null;

useEffect(() => {
const el = ref.current;
if (!el) return;
if (!shouldLoop) return;

const SPEED = 0.4;

Expand Down Expand Up @@ -268,14 +281,19 @@ function AutoScrollList({ children }: { children: React.ReactNode }) {
el.removeEventListener('touchstart', pause);
el.removeEventListener('touchend', resume);
};
}, []);

const childArray = React.Children.toArray(children);
}, [shouldLoop]);

return (
<div ref={ref} className="flex gap-2 overflow-x-hidden pb-1">
{childArray}
<div
ref={ref}
className={
shouldLoop
? 'flex gap-2 overflow-x-hidden pb-1'
: 'flex gap-2 overflow-x-auto pb-1'
}
>
{childArray}
{loopChildren}
</div>
);
}
Expand Down
Loading
Loading