Skip to content
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { hasActiveSprint } from '@lib/api/sprint';
import { callApi } from '@lib/apiClient';
import UserInfoPage from '@pages/UserInfoPage';
import StopCommentPage from '@pages/StopCommentPage';
import StartCommentPage from '@pages/StartCommentPage';
import ContinueCommentPage from '@pages/ContinueCommentPage';

const router = createBrowserRouter([
{
Expand All @@ -24,6 +26,8 @@ const router = createBrowserRouter([
{ path: '/sprint-intro', element: <SprintIntroPage /> },
{ path: '/user-info', element: <UserInfoPage /> },
{ path: '/stop-comment', element: <StopCommentPage /> },
{ path: '/start-comment', element: <StartCommentPage /> },
{ path: '/continue-comment', element: <ContinueCommentPage /> }
]);

function App() {
Expand Down
9 changes: 9 additions & 0 deletions src/assets/continue_comment_example.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions src/assets/continue_comment_explanation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions src/assets/start_comment_example.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions src/assets/start_comment_explanation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ export { default as InputField } from './common/form/InputField';
export { default as SprintCodeInput, SPRINT_CODE_LENGTH } from './sprint-code/SprintCodeInput';
export { default as PeerCommentRepeater } from './peer-comment/PeerCommentRepeater';
export { default as PeerCommentStepTemplate } from './peer-comment/PeerCommentStepTemplate';
export type { PeerCommentStepTemplateProps, PeerCommentStepContent } from './peer-comment/PeerCommentStepTemplate';
export type { PeerCommentStepContent } from '@types';
3 changes: 1 addition & 2 deletions src/components/peer-comment/PeerCommentBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { IconTrash } from '@sopt-makers/icons';
import type { PeerCommentRowState } from '@types';
import type { PeerCommentStepContent } from './PeerCommentStepTemplate';
import type { PeerCommentRowState, PeerCommentStepContent } from '@types';
import type { PeerMember } from '@types';
import PeerCommentRecipientBlock from './PeerCommentRecipientBlock';
import InputField from '../common/form/InputField';
Expand Down
16 changes: 12 additions & 4 deletions src/components/peer-comment/PeerCommentRepeater.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Button } from '@sopt-makers/ui';
import { IconPlus } from '@sopt-makers/icons';
import type { PeerCommentRowState } from '@types';
import type { PeerCommentStepContent } from './PeerCommentStepTemplate';
import type { PeerCommentRowState, PeerCommentStepContent } from '@types';
import { createEmptyPeerCommentRow } from '@utils/peerCommentUtils';
import PeerCommentBlock from './PeerCommentBlock';
import type { PeerMember } from '@types';
Expand All @@ -14,8 +13,12 @@ interface PeerCommentRepeaterProps {
peerMembers: PeerMember[];
}

function PeerCommentRepeater({ content, rows, onRowsChange, peerMembers }: PeerCommentRepeaterProps) {

function PeerCommentRepeater({
content,
rows,
onRowsChange,
peerMembers,
}: PeerCommentRepeaterProps) {
const updateRow = (index: number, next: PeerCommentRowState) => {
onRowsChange(rows.map((r, i) => (i === index ? next : r)));
};
Expand All @@ -30,6 +33,10 @@ function PeerCommentRepeater({ content, rows, onRowsChange, peerMembers }: PeerC
onRowsChange([{ id: only.id, memberIds: [], text: '' }]);
};

const isAddDisabled = rows.some(
(row) => row.memberIds.length === 0 || row.text.trim().length === 0,
);

return (
<div className={styles.repeaterRoot}>
<div className={styles.list}>
Expand All @@ -53,6 +60,7 @@ function PeerCommentRepeater({ content, rows, onRowsChange, peerMembers }: PeerC
size="md"
theme="white"
LeftIcon={IconPlus}
disabled={isAddDisabled}
onClick={() => onRowsChange([...rows, createEmptyPeerCommentRow()])}
>
추가
Expand Down
48 changes: 16 additions & 32 deletions src/components/peer-comment/PeerCommentStepTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,28 @@ import ContentHeading from '../common/ui/ContentHeading';
import ImageSection from '../common/ui/ImageSection';
import StepLayout from '../common/layout/StepLayout';
import PeerCommentRepeater from './PeerCommentRepeater';
import { useCommentForm } from '@hooks';
import { usePeerMembers } from '@hooks';
import { useCommentForm, usePeerMembers } from '@hooks';
import {
createEmptyPeerCommentRow,
expandPeerRowsToComments,
hasAtLeastOneCompletePeerRow,
isPeerRowValid,
} from '@utils/peerCommentUtils';
import type { Comment, PeerCommentRowState, CommentsKey } from '@types';
import type { Comment, PeerCommentRowState, CommentsKey, PeerCommentStepContent } from '@types';
import * as styles from './PeerCommentStepTemplate.css';

export interface PeerCommentStepContent {
commentKey: CommentsKey;
title: string;
description: string;
/** 설명·예시 등 안내 이미지 2장 (순서대로 세로 배치). */
guideImages?: readonly [string, string];
/** 블록 상단 제목 (예: Stop Comment를 전달하고 싶은 동료) */
sectionTitle: string;
questionLabel: string;
textPlaceholder: string;
}

export interface PeerCommentStepTemplateProps {
interface PeerCommentStepTemplateProps {
content: PeerCommentStepContent;
currentStep: number;
totalSteps?: number;
nextPath: string;
}

function submissionPatch(commentsKey: CommentsKey, comments: Comment[]) {
switch (commentsKey) {
case 'stop_comments':
return { stop_comments: comments };
case 'continue_comments':
return { continue_comments: comments };
case 'start_comments':
return { start_comments: comments };
default: {
const _exhaustive: never = commentsKey;
return _exhaustive;
}
}
function submissionPatch<K extends CommentsKey>(
commentsKey: K,
comments: Comment[],
): Record<K, Comment[]> {
return { [commentsKey]: comments } as Record<K, Comment[]>;
}

function PeerCommentStepTemplate({
Expand Down Expand Up @@ -87,11 +66,16 @@ function PeerCommentStepTemplate({
</div>
{guideImages ? (
<ImageSection>
<ImageSection.Image src={guideImages[0]} alt="comment 작성 설명 이미지"/>
<ImageSection.Image src={guideImages[1]} alt="comment 작성 예시 이미지"/>
<ImageSection.Image src={guideImages[0]} alt="comment 작성 설명 이미지" />
<ImageSection.Image src={guideImages[1]} alt="comment 작성 예시 이미지" />
</ImageSection>
) : null}
<PeerCommentRepeater content={content} rows={rows} onRowsChange={setRows} peerMembers={peerMembers} />
<PeerCommentRepeater
content={content}
rows={rows}
onRowsChange={setRows}
peerMembers={peerMembers}
/>
</div>
</StepLayout>
);
Expand Down
16 changes: 8 additions & 8 deletions src/components/peer-comment/PeerMemberPicker.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ export const pickerTriggerButton = style({
});

export const sheetDialogSurface = style({
margin: 0,
minWidth: 0,
width: '100%',
overflow: 'hidden',
});

globalStyle(`body > div:has(.${sheetDialogSurface})`, {
width: 'min(386px, calc(100vw - 44px))',
maxWidth: 'min(386px, calc(100vw - 44px))',
position: 'fixed',
left: '50%',
bottom: 46,
top: 'auto',
transform: 'translateX(-50%)',
margin: '0 auto',
padding: '12px 0 12px',
borderRadius: 16,
overflow: 'hidden',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: colors.gray900,
});
Comment thread
sung-silver marked this conversation as resolved.
Outdated

Expand Down
134 changes: 65 additions & 69 deletions src/components/peer-comment/PeerMemberPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useId, useState } from 'react';
import { Button, Dialog } from '@sopt-makers/ui';
import { useState } from 'react';
import { BottomSheetContent, BottomSheetRoot, Button } from '@sopt-makers/ui';
import { IconCheck, IconChevronLeft, IconUser } from '@sopt-makers/icons';
import * as styles from './PeerMemberPicker.css';
import MemberChip from '../common/ui/MemberChip';
Expand All @@ -26,14 +26,11 @@ function PeerMemberPicker({
showRemoveButton = true,
}: PeerMemberPickerProps) {
const [open, setOpen] = useState(false);
const titleId = useId();
const selectedSet = new Set(memberIds);
const peerOptions: PeerOption[] = peerMembers.map((m) => ({ label: m.name, value: m.userId }));
const labelById = new Map(peerOptions.map((o) => [o.value, o.label]));

const dialogLabelProps = {
'aria-labelledby': titleId,
} as const;
const isConfirmDisabled = memberIds.length === 0;

return (
<div className={styles.pickerRoot}>
Expand All @@ -48,71 +45,70 @@ function PeerMemberPicker({
>
누구에게 전달할까?
</Button>
<Dialog
isOpen={open}
onClose={() => setOpen(false)}
{...dialogLabelProps}
>
<div className={styles.sheetDialogSurface}>
<div className={styles.sheetHeader}>
<button
type="button"
className={styles.sheetBackButton}
aria-label="멤버 선택 닫기"
onClick={() => setOpen(false)}
>
<IconChevronLeft style={{ width: 24, height: 24 }} />
</button>
<span id={titleId}>멤버 선택</span>
</div>
<div className={styles.sheetBody}>
{peerOptions.length === 0 ? (
<p className={styles.sheetEmpty}>선택할 수 있는 멤버가 없어요.</p>
) : (
peerOptions.map((o) => {
const isSelected = selectedSet.has(o.value);
return (
<button
key={o.value}
type="button"
className={styles.sheetMemberButton}
onClick={() => {
if (isSelected) {
onRemoveMember(o.value);
return;
}
onAddMember(o.value);
}}
>
<span className={styles.memberAvatar} aria-hidden>
<IconUser />
</span>
<span className={styles.memberName}>{o.label}</span>
{isSelected ? (
<span className={styles.memberCheckSelected} aria-hidden>
<IconCheck />
<BottomSheetRoot open={open} onOpenChange={setOpen}>
<BottomSheetContent>
<div className={styles.sheetDialogSurface}>
<div className={styles.sheetHeader}>
<button
type="button"
className={styles.sheetBackButton}
aria-label="멤버 선택 닫기"
onClick={() => setOpen(false)}
>
<IconChevronLeft style={{ width: 24, height: 24 }} />
</button>
<span>멤버 선택</span>
</div>
<div className={styles.sheetBody}>
{peerOptions.length === 0 ? (
<p className={styles.sheetEmpty}>선택할 수 있는 멤버가 없어요.</p>
) : (
peerOptions.map((o) => {
const isSelected = selectedSet.has(o.value);
return (
<button
key={o.value}
type="button"
className={styles.sheetMemberButton}
onClick={() => {
if (isSelected) {
onRemoveMember(o.value);
return;
}
onAddMember(o.value);
}}
>
<span className={styles.memberAvatar} aria-hidden>
<IconUser />
</span>
) : null}
</button>
);
})
)}
</div>
<div className={styles.sheetConfirmArea}>
<Button
type="button"
variant="fill"
theme="white"
size="md"
rounded="md"
className={styles.sheetConfirmButton}
onClick={() => setOpen(false)}
>
선택 완료
</Button>
<span className={styles.memberName}>{o.label}</span>
{isSelected ? (
<span className={styles.memberCheckSelected} aria-hidden>
<IconCheck />
</span>
) : null}
</button>
);
})
)}
</div>
<div className={styles.sheetConfirmArea}>
<Button
type="button"
variant="fill"
theme="white"
size="md"
rounded="md"
className={styles.sheetConfirmButton}
disabled={isConfirmDisabled}
onClick={() => setOpen(false)}
>
선택 완료
</Button>
</div>
</div>
</div>
</Dialog>
</BottomSheetContent>
</BottomSheetRoot>
{memberIds.length > 0 ? (
<ul className={styles.chipList}>
{memberIds.map((id) => (
Expand Down
37 changes: 37 additions & 0 deletions src/constant/peerCommentStepContents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import stopCommentExample from '@assets/stop_comment_example.svg';
import stopCommentExplanation from '@assets/stop_comment_explanation.svg';
import startCommentExample from '@assets/start_comment_example.svg';
import startCommentExplanation from '@assets/start_comment_explanation.svg';
import continueCommentExample from '@assets/continue_comment_example.svg';
import continueCommentExplanation from '@assets/continue_comment_explanation.svg';
import type { PeerCommentStepContent } from '@types';

export const STOP_COMMENT_STEP_CONTENT: PeerCommentStepContent = {
commentKey: 'stop_comments',
title: 'Stop 코멘트 작성',
description: '이번 스프린트에서 협업하며 그만했으면 하는 점에 대해 전달해요.',
guideImages: [stopCommentExplanation, stopCommentExample],
sectionTitle: 'Stop Comment를 전달하고 싶은 동료',
questionLabel: '해당 동료가 그만했으면 하는 점은 무엇인가요?',
textPlaceholder: '그만했으면 하는 점',
};

export const START_COMMENT_STEP_CONTENT: PeerCommentStepContent = {
commentKey: 'start_comments',
title: 'Start 코멘트 작성',
description: '이번 스프린트에서 협업하며 시작했으면 하는 점에 대해 전달해요.',
guideImages: [startCommentExplanation, startCommentExample],
sectionTitle: 'Start Comment를 전달하고 싶은 동료',
questionLabel: '해당 동료가 시작했으면 하는 점은 무엇인가요?',
textPlaceholder: '시작했으면 하는 점',
};

export const CONTINUE_COMMENT_STEP_CONTENT: PeerCommentStepContent = {
commentKey: 'continue_comments',
title: 'Continue 코멘트 작성',
description: '이번 스프린트에서 협업하며 계속했으면 하는 점에 대해 전달해요.',
guideImages: [continueCommentExplanation, continueCommentExample],
sectionTitle: 'Continue Comment를 전달하고 싶은 동료',
questionLabel: '해당 동료가 계속했으면 하는 점은 무엇인가요?',
textPlaceholder: '계속했으면 하는 점',
};
14 changes: 14 additions & 0 deletions src/pages/ContinueCommentPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { PeerCommentStepTemplate } from '@components';
import { CONTINUE_COMMENT_STEP_CONTENT } from '../constant/peerCommentStepContents';

function ContinueCommentPage() {
return (
<PeerCommentStepTemplate
content={CONTINUE_COMMENT_STEP_CONTENT}
currentStep={4}
nextPath="/next"
/>
);
}

export default ContinueCommentPage;
Loading