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
2 changes: 1 addition & 1 deletion app/(protected)/reviews/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const ReviewNewPage = async ({ searchParams }: ReviewNewPageProps) => {
});

return (
<div className="flex min-h-[calc(100vh-73px-168px)] flex-col items-center bg-white px-4 py-15">
<div className="flex min-h-[calc(100vh-73px-168px)] flex-col items-center bg-white px-5 py-15 lg:px-4">
<HydrationBoundary state={dehydrate(queryClient)}>
<WriteReviewFlow projectId={projectId} revieweeId={revieweeId} revieweeName={revieweeName} />
</HydrationBoundary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useMyProfile } from '@/features/profile';
import { ReviewSubmittedToast } from '@/features/review/components/review-submitted-toast';
import { PageContainer } from '@/shared/components/page-container';

import { ProjectDetailHeading } from './project-detail-heading';
Expand Down Expand Up @@ -61,6 +62,7 @@ export const ProjectDetailPageContent = ({ projectId }: Props) => {
isLeader={isLeader}
myUserId={myProfile.userId}
/>
<ReviewSubmittedToast />
</PageContainer>
);
};
84 changes: 39 additions & 45 deletions features/project/components/project-invite-accept-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import { PROJECT_INVITE_MAX_POSITIONS, PROJECT_POSITION_LABELS, PROJECT_POSITION

type ProjectInviteAcceptModalProps = {
open: boolean;
/** 링크를 공유한 사람의 이름. 백엔드 조회 없이 표시할 수 있는 유일한 프로젝트 맥락이다. */
inviterName?: string;
onOpenChange: (open: boolean) => void;
onConfirm: (projectPositions: ProjectPositionValue[]) => void | Promise<void>;
isConfirming?: boolean;
Expand All @@ -24,7 +22,6 @@ type ProjectInviteAcceptModalProps = {

export const ProjectInviteAcceptModal = ({
open,
inviterName,
onOpenChange,
onConfirm,
isConfirming = false,
Expand All @@ -49,57 +46,54 @@ export const ProjectInviteAcceptModal = ({
<DialogAnimatedPortal
open={open}
className={cn(
'border-border-gray bg-surface-white fixed top-1/2 left-1/2 z-50 flex w-md max-w-[calc(100vw-32px)] flex-col',
'border-border-gray bg-surface-white fixed top-1/2 left-1/2 z-50 flex w-full max-w-[min(28rem,calc(100vw-40px))] flex-col',
'gap-4 overflow-hidden rounded-xl border p-6 outline-none',
)}
>
<div className="flex w-full shrink-0 flex-col gap-2 px-4 py-2">
<Dialog.Title className="text-heading-base text-text-basic leading-normal font-bold">후기 작성</Dialog.Title>
<Dialog.Description asChild>
<div className="text-body-base text-text-subtle flex flex-col leading-normal">
<span>
{inviterName ? <span className="text-text-basic font-bold">{inviterName}님이 초대한 </span> : null}
프로젝트의 후기 작성에 참여하시겠습니까?
</span>
</div>
<div className="flex w-full min-w-0 flex-col gap-2 py-2">
<Dialog.Title className="text-heading-base text-text-basic leading-normal font-bold">
프로젝트 참여
</Dialog.Title>
<Dialog.Description className="text-body-base text-text-subtle leading-normal">
이번 프로젝트에서 본인의 역할을 선택해주세요.
</Dialog.Description>
</div>

<div className="flex flex-col gap-2 px-4">
<p className="text-body-sm text-text-basic font-medium">
이 프로젝트에서 맡은 직군을 선택해주세요. (최대 {PROJECT_INVITE_MAX_POSITIONS}개)
</p>
<MultiSelect.Root
value={selectedPositions}
onValueChange={(value) => setSelectedPositions(value as ProjectPositionValue[])}
>
<MultiSelect.Trigger disabled={isConfirming}>
{selectedPositions.length === 0 ? (
<span className="text-text-disabled">{'\uc9c1\uad70\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694'}</span>
) : (
selectedPositions.map((position) => (
<MultiSelect.Tag key={position} value={position}>
<div className="flex w-full min-w-0 flex-col gap-2">
<MultiSelect.Root
value={selectedPositions}
onValueChange={(value) => setSelectedPositions(value as ProjectPositionValue[])}
>
<MultiSelect.Trigger disabled={isConfirming}>
{selectedPositions.length === 0 ? (
<span className="text-text-disabled">직군을 선택해주세요</span>
) : (
selectedPositions.map((position) => (
<MultiSelect.Tag key={position} value={position}>
{PROJECT_POSITION_LABELS[position]}
</MultiSelect.Tag>
))
)}
</MultiSelect.Trigger>
<MultiSelect.Content className="w-(--radix-popover-trigger-width)">
{PROJECT_POSITIONS.map((position) => (
<MultiSelect.Item
key={position}
value={position}
disabled={isConfirming || (isMaxPositionReached && !selectedPositions.includes(position))}
>
{PROJECT_POSITION_LABELS[position]}
</MultiSelect.Tag>
))
)}
</MultiSelect.Trigger>
<MultiSelect.Content className="w-(--radix-popover-trigger-width)">
{PROJECT_POSITIONS.map((position) => (
<MultiSelect.Item
key={position}
value={position}
disabled={isConfirming || (isMaxPositionReached && !selectedPositions.includes(position))}
>
{PROJECT_POSITION_LABELS[position]}
</MultiSelect.Item>
))}
</MultiSelect.Content>
</MultiSelect.Root>
</MultiSelect.Item>
))}
</MultiSelect.Content>
</MultiSelect.Root>
<p className="text-body-sm text-text-subtle leading-normal">
최대 {PROJECT_INVITE_MAX_POSITIONS}개까지 등록 가능해요.
</p>
</div>
</div>

{!!errorMessage && (
<p className="text-body-sm text-text-error px-4" role="alert">
<p className="text-body-sm text-text-error" role="alert">
{errorMessage}
</p>
)}
Expand Down
8 changes: 0 additions & 8 deletions features/project/components/project-invite-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import { saveLoginReturnPath, useLoginGate } from '@/features/auth';
import { useMyProfile } from '@/features/profile';
import { ProjectInviteAcceptModal } from '@/features/project/components/project-invite-accept-modal';
import { ConfirmationDialog } from '@/shared/components/dialog/confirmation-dialog';
import { SHARE_INVITER_NAME_PARAM } from '@/shared/constants/share-query';
import { ApiError } from '@/shared/lib/api';
import { parseShareDisplayName } from '@/shared/lib/parse-share-display-name';

import type { ProjectPositionValue } from '../project.types';

Expand Down Expand Up @@ -40,11 +38,6 @@ export const ProjectInviteHandler = () => {

const hasSession = profile != null;

const inviterName = useMemo(
() => parseShareDisplayName(searchParams.get(SHARE_INVITER_NAME_PARAM) ?? undefined) ?? '',
[searchParams],
);

const clearInviteParam = useCallback(() => {
if (searchParams.get(PROJECT_INVITE_QUERY_KEY) == null) {
return;
Expand Down Expand Up @@ -127,7 +120,6 @@ export const ProjectInviteHandler = () => {
<ProjectInviteAcceptModal
key={projectLink}
open={acceptModalOpen}
inviterName={inviterName}
onOpenChange={handleAcceptOpenChange}
onConfirm={handleJoin}
isConfirming={isJoining}
Expand Down
65 changes: 65 additions & 0 deletions features/review/components/review-submitted-toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';

import { SuccessIcon, XIcon } from '@/shared/assets/icons';

export const REVIEW_SUBMITTED_QUERY_KEY = 'reviewSubmitted';

/** 후기 제출 완료 후 목적지에서 노출하는 하단 토스트 */
export const ReviewSubmittedToast = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const shouldShow = searchParams.get(REVIEW_SUBMITTED_QUERY_KEY) === '1';
const [open, setOpen] = useState(shouldShow);

useEffect(() => {
setOpen(shouldShow);
}, [shouldShow]);

const close = useCallback(() => {
setOpen(false);
if (!shouldShow) {
return;
}
const next = new URLSearchParams(searchParams.toString());
next.delete(REVIEW_SUBMITTED_QUERY_KEY);
const query = next.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [pathname, router, searchParams, shouldShow]);

useEffect(() => {
if (!open) {
return;
}
const timerId = window.setTimeout(close, 4000);
return () => window.clearTimeout(timerId);
}, [close, open]);

if (!open) {
return null;
}

return (
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed inset-x-0 bottom-6 z-50 flex justify-center px-5"
>
<div className="bg-surface-inverse pointer-events-auto flex w-full max-w-[350px] items-center gap-2 rounded-lg px-4 py-3 shadow-[0px_4px_16px_rgba(0,0,0,0.2)]">
<SuccessIcon aria-hidden className="size-6 shrink-0" />
<p className="text-body-sm text-text-basic-inverse min-w-0 flex-1 font-medium">후기 제출이 완료되었습니다.</p>
<button
type="button"
className="text-text-basic-inverse flex size-5 shrink-0 items-center justify-center"
aria-label="알림 닫기"
onClick={close}
>
<XIcon className="size-4" aria-hidden />
</button>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
'use client';

import { Fragment } from 'react';

import { cn } from '@/shared/lib/cn';

import { REVIEW_DEFAULT_SPECTRUM_STEP, REVIEW_SPECTRUM_STEP_COUNT } from '../../review.constants';
Expand Down Expand Up @@ -97,8 +95,11 @@ export const ReviewSpectrumSlider = ({ leftLabel, rightLabel, value, onChange }:
};

return (
<Fragment>
<span className="text-detail-sm text-text-subtle shrink-0 font-medium whitespace-nowrap">{leftLabel}</span>
<div className="flex w-full flex-col">
<div className="flex items-center justify-between gap-2">
<span className="text-detail-xs text-text-subtle shrink-0 font-medium">{leftLabel}</span>
<span className="text-detail-xs text-text-subtle shrink-0 font-medium">{rightLabel}</span>
</div>

<div
role="slider"
Expand All @@ -107,7 +108,7 @@ export const ReviewSpectrumSlider = ({ leftLabel, rightLabel, value, onChange }:
aria-valuemin={1}
aria-valuemax={MAX_STEP_INDEX}
aria-valuenow={value < CENTER_STEP ? value + 1 : value}
className="relative flex h-5 w-full cursor-pointer touch-none items-center outline-none select-none"
className="relative flex h-7 w-full cursor-pointer touch-none items-center outline-none select-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
Expand All @@ -125,12 +126,10 @@ export const ReviewSpectrumSlider = ({ leftLabel, rightLabel, value, onChange }:
</div>
<span
aria-hidden
className="bg-input-surface absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[0px_4px_16px_rgba(96,96,96,0.3)] transition-[left] duration-75"
className="bg-input-surface absolute top-1/2 size-6 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[0px_4px_16px_rgba(96,96,96,0.3)] transition-[left] duration-75 lg:size-5"
style={{ left: `${percent}%` }}
/>
</div>

<span className="text-detail-sm text-text-subtle shrink-0 font-medium whitespace-nowrap">{rightLabel}</span>
</Fragment>
</div>
);
};
86 changes: 49 additions & 37 deletions features/review/components/write-review/review-step-feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,60 +22,72 @@ export const ReviewStepFeedback = ({ isSubmitting = false, onPrev }: Props) => {
const errorMessage = formState.errors.feedback?.message;

return (
<div className="mt-13 flex w-full max-w-[480px] flex-col">
<h3 className="text-heading-sm text-text-basic font-bold">후기 작성</h3>
<p className="text-body-sm text-text-subtle mt-2">
(필수) 최소 {REVIEW_FEEDBACK_MIN_LENGTH}자 이상 작성해주세요.
</p>
<div className="mt-4 flex w-full max-w-[480px] flex-col gap-10 pb-10">
<div className="flex w-full flex-col gap-2">
<h3 className="text-heading-sm text-text-basic font-bold">후기 작성</h3>
<p className="text-body-sm text-text-subtle">(필수) 최소 {REVIEW_FEEDBACK_MIN_LENGTH}자이상 작성해주세요.</p>

<Controller
control={control}
name="feedback"
render={({ field }) => (
<Textarea
name={field.name}
className="min-h-[168px]"
wrapperClassName="mt-3"
placeholder="ex) 적극적이고 배려심이 깊다."
value={field.value}
maxLength={REVIEW_FEEDBACK_MAX_LENGTH}
error={!!errorMessage}
helperText={errorMessage}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
<Controller
control={control}
name="feedback"
render={({ field }) => (
<Textarea
name={field.name}
className="border-input-border text-body-sm placeholder:text-text-subtler min-h-[152px] px-3 py-2"
wrapperClassName="mt-0"
placeholder="ex) 적극적이고 배려심이 깊다."
value={field.value}
maxLength={REVIEW_FEEDBACK_MAX_LENGTH}
error={!!errorMessage}
helperText={errorMessage}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</div>

<div className="bg-surface-gray-subtler mt-6 rounded-lg px-4 py-4">
<p className="text-body-sm text-text-basic font-medium">이런 내용으로 후기를 작성해보세요.</p>
<ul className="mt-3 flex flex-col gap-2">
<div className="bg-bg-gray-subtler flex w-full flex-col gap-2 rounded-md px-4 py-6">
<div className="flex items-center gap-0.5">
<span aria-hidden className="text-body-base leading-none">
💡
</span>
<p className="text-body-base text-text-basic font-bold">이런 내용으로 후기를 작성해보세요.</p>
</div>
<ul className="flex flex-col pl-5">
{REVIEW_FEEDBACK_TIPS.map((tip) => (
<li key={tip.text} className="flex gap-1.5">
<span aria-hidden className="text-text-subtle">
<li key={tip.text} className="relative">
<span aria-hidden className="text-body-sm text-text-subtle absolute top-0 -left-5">
•
</span>
<div className="flex flex-col gap-0.5">
<span className="text-body-sm text-text-subtle">{tip.text}</span>
<span className="text-body-sm text-text-subtler">예: {tip.example}</span>
</div>
<p className="text-body-sm text-text-subtle leading-normal">
{tip.text}
<br />
{tip.examplePrefix}
{tip.example}
</p>
</li>
))}
</ul>
</div>

<div className="mt-8 flex w-full gap-3">
<div className="flex w-full gap-4">
<Button
type="button"
variant="tertiary"
size="medium"
size="large"
onClick={onPrev}
className="border-border-gray-light w-full border"
className="border-border-gray-light h-14 min-h-12 w-full self-stretch rounded-md border"
>
뒤로가기
</Button>
<Button type="submit" size="medium" disabled={!canSubmit || isSubmitting} className="w-full">
후기 제출
<Button
type="submit"
size="large"
disabled={!canSubmit || isSubmitting}
className="h-14 min-h-12 w-full self-stretch rounded-md"
>
후기제출
</Button>
</div>
</div>
Expand Down
Loading
Loading