Skip to content

Commit 818cc43

Browse files
authored
Merge pull request #53 from JECT-Study/feat/35-agreement-screen
Feat/35 agreement screen
2 parents c736931 + fe86b27 commit 818cc43

28 files changed

Lines changed: 1740 additions & 28 deletions
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
"use client";
2+
3+
import { use, useEffect, useMemo, useRef, useState } from "react";
4+
5+
import { useRouter, useSearchParams } from "next/navigation";
6+
7+
import Header from "@/components/common/Header";
8+
import AgreementContentSection from "@/components/exhibition-consent/AgreementContentSection";
9+
import ExhibitionProgressInfoCard from "@/components/exhibition-consent/ExhibitionProgressInfoCard";
10+
import SignatureSection from "@/components/exhibition-consent/SignatureSection";
11+
import Toast from "@/components/mypage/Toast";
12+
import { useExhibitionConsent, useSubmitExhibitionConsent } from "@/hooks/useExhibitionConsent";
13+
import { useSession } from "@/services/session";
14+
import type { ConsentMode, ExhibitionConsent } from "@/types/exhibitionConsent";
15+
16+
interface ConsentPageProps {
17+
params: Promise<{ exhibitionId: string }>;
18+
}
19+
20+
interface ConsentDraftState {
21+
exhibitionId: number;
22+
checkedMap: Record<string, boolean>;
23+
signatureDataUrl: string | null | undefined;
24+
}
25+
26+
function getErrorMessage(error: unknown) {
27+
return error instanceof Error ? error.message : "요청 처리 중 오류가 발생했습니다.";
28+
}
29+
30+
function getPreviewSignatureDataUrl() {
31+
return (
32+
"data:image/svg+xml;charset=UTF-8," +
33+
encodeURIComponent(
34+
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="152" viewBox="0 0 320 152"><path d="M86 91c22-41 37 39 61-3 17-30 27 25 47-1 12-15 27 1 38 18" fill="none" stroke="#1A1A1E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
35+
)
36+
);
37+
}
38+
39+
function createPreviewConsent(exhibitionId: number, readOnly: boolean): ExhibitionConsent {
40+
return {
41+
exhibitionId,
42+
mode: readOnly ? "READONLY" : "WRITE",
43+
canSubmit: !readOnly,
44+
exhibition: {
45+
title: "작성된 전시 이름",
46+
startDate: "2026-06-20",
47+
endDate: "2026-06-30",
48+
spaceName: "전시 공간 이름",
49+
spaceAddress: "공간 도로명 주소",
50+
spaceOwnerNickname: "공간 파트너 닉네임",
51+
spaceOwnerProfileImageUrl: null,
52+
spaceThumbnailUrl: null,
53+
creatorNickname: "크리에이터 닉네임",
54+
creatorProfileImageUrl: null,
55+
artworkTitle: "전시 작품 이름",
56+
artworkType: "작품 유형",
57+
artworkThumbnailUrl: null,
58+
},
59+
agreements: [
60+
{
61+
id: "progress_terms",
62+
title: "전시 진행 약관",
63+
required: true,
64+
content:
65+
"제1조 (목적)\n본 약관은 리핏 플랫폼을 통해 크리에이터와 공간 파트너가 전시 진행 및 공간 이용에 관한 제반 사항을 규정함을 목적으로 합니다.\n\n제2조 (전시 위탁)\n전시 기간 동안 작품 설치, 관리, 철수 일정은 양 당사자가 합의한 내용을 기준으로 진행합니다.",
66+
checked: readOnly,
67+
},
68+
{
69+
id: "notice",
70+
title: "전시 주의사항",
71+
required: true,
72+
content:
73+
"원활한 전시 진행을 위해 공간 파트너와 크리에이터가 작성한 안내사항을 확인해주세요.\n\n[공간 파트너가 작성한 내용입니다.]\n\n[크리에이터가 작성한 내용입니다.]",
74+
checked: readOnly,
75+
},
76+
],
77+
signature: {
78+
signed: readOnly,
79+
signedAt: readOnly ? "2026-06-03T12:00:00Z" : null,
80+
imageUrl: readOnly ? getPreviewSignatureDataUrl() : null,
81+
},
82+
};
83+
}
84+
85+
function ConsentPageSkeleton() {
86+
return (
87+
<div className="px-4 pt-5">
88+
<div className="bg-object-disabled h-6 w-32 rounded" />
89+
<div className="bg-bg-primary-darker mt-4 h-80 rounded-lg" />
90+
<div className="bg-object-disabled mt-8 h-6 w-28 rounded" />
91+
<div className="bg-bg-primary-darker mt-4 h-24 rounded-lg" />
92+
<div className="bg-bg-primary-darker mt-4 h-24 rounded-lg" />
93+
<div className="bg-object-disabled mt-8 h-6 w-32 rounded" />
94+
<div className="bg-bg-primary-darker mt-4 h-[152px] rounded-lg" />
95+
</div>
96+
);
97+
}
98+
99+
export default function ConsentPage({ params }: ConsentPageProps) {
100+
const { exhibitionId } = use(params);
101+
const searchParams = useSearchParams();
102+
const router = useRouter();
103+
const { accessToken } = useSession();
104+
const redirectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
105+
106+
const id = Number(exhibitionId);
107+
const isValidId = Number.isFinite(id);
108+
const requestedReadOnly = searchParams.get("mode") === "readonly";
109+
const isPreviewMode = process.env.NODE_ENV === "development" && !accessToken && isValidId;
110+
111+
const { data, isLoading, error, refetch } = useExhibitionConsent(id);
112+
const submitMutation = useSubmitExhibitionConsent(id);
113+
const previewData = useMemo(
114+
() => (isPreviewMode ? createPreviewConsent(id, requestedReadOnly) : null),
115+
[id, isPreviewMode, requestedReadOnly]
116+
);
117+
const consentData = data ?? previewData;
118+
119+
const [draft, setDraft] = useState<ConsentDraftState>({
120+
exhibitionId: id,
121+
checkedMap: {},
122+
signatureDataUrl: undefined,
123+
});
124+
const [toastOpen, setToastOpen] = useState(false);
125+
126+
const mode: ConsentMode = useMemo(() => {
127+
if (requestedReadOnly) return "readonly";
128+
if (consentData?.mode === "READONLY" || consentData?.canSubmit === false) return "readonly";
129+
return "write";
130+
}, [consentData?.canSubmit, consentData?.mode, requestedReadOnly]);
131+
132+
const isReadOnly = mode === "readonly";
133+
134+
useEffect(() => {
135+
return () => {
136+
if (redirectTimerRef.current) clearTimeout(redirectTimerRef.current);
137+
};
138+
}, []);
139+
140+
const serverCheckedMap = useMemo(
141+
() =>
142+
Object.fromEntries(
143+
consentData?.agreements.map(agreement => [agreement.id, agreement.checked]) ?? []
144+
),
145+
[consentData?.agreements]
146+
);
147+
148+
const activeDraft = draft.exhibitionId === id ? draft : null;
149+
150+
const checkedMap = useMemo(
151+
() => ({
152+
...serverCheckedMap,
153+
...(activeDraft?.checkedMap ?? {}),
154+
}),
155+
[activeDraft?.checkedMap, serverCheckedMap]
156+
);
157+
158+
const signatureDataUrl = isReadOnly
159+
? (consentData?.signature.imageUrl ?? null)
160+
: activeDraft?.signatureDataUrl === undefined
161+
? null
162+
: activeDraft.signatureDataUrl;
163+
164+
const requiredAgreements = useMemo(
165+
() => consentData?.agreements.filter(agreement => agreement.required) ?? [],
166+
[consentData?.agreements]
167+
);
168+
169+
const allRequiredAgreementsChecked =
170+
requiredAgreements.length > 0 &&
171+
requiredAgreements.every(agreement => checkedMap[agreement.id]);
172+
173+
const canSubmit =
174+
!isReadOnly &&
175+
allRequiredAgreementsChecked &&
176+
Boolean(signatureDataUrl) &&
177+
!submitMutation.isPending;
178+
179+
const handleCheckedChange = (agreementId: string, checked: boolean) => {
180+
if (isReadOnly) return;
181+
182+
setDraft(prev => ({
183+
exhibitionId: id,
184+
checkedMap: {
185+
...(prev.exhibitionId === id ? prev.checkedMap : {}),
186+
[agreementId]: checked,
187+
},
188+
signatureDataUrl: prev.exhibitionId === id ? prev.signatureDataUrl : undefined,
189+
}));
190+
};
191+
192+
const handleSignatureChange = (value: string | null) => {
193+
if (isReadOnly) return;
194+
195+
setDraft(prev => ({
196+
exhibitionId: id,
197+
checkedMap: prev.exhibitionId === id ? prev.checkedMap : {},
198+
signatureDataUrl: value,
199+
}));
200+
};
201+
202+
const handleSubmit = () => {
203+
if (!consentData || !signatureDataUrl || !canSubmit) return;
204+
205+
const agreementIds = consentData.agreements
206+
.filter(agreement => checkedMap[agreement.id])
207+
.map(agreement => agreement.id);
208+
209+
if (isPreviewMode) {
210+
setToastOpen(true);
211+
return;
212+
}
213+
214+
submitMutation.mutate(
215+
{ agreementIds, signatureDataUrl },
216+
{
217+
onSuccess: () => {
218+
setToastOpen(true);
219+
redirectTimerRef.current = setTimeout(() => {
220+
router.push("/exhibitions/status");
221+
}, 900);
222+
},
223+
}
224+
);
225+
};
226+
227+
const title = isReadOnly ? "동의서 확인" : "동의서 작성";
228+
229+
if (!isValidId) {
230+
return (
231+
<div className="bg-bg-primary min-h-dvh">
232+
<Header title="동의서 작성" showBack />
233+
<main className="px-4 py-10">
234+
<p role="alert" className="text-body-1 text-error-default">
235+
전시 정보를 찾을 수 없습니다.
236+
</p>
237+
</main>
238+
</div>
239+
);
240+
}
241+
242+
return (
243+
<div className="bg-bg-primary min-h-dvh">
244+
<Header title={title} showBack />
245+
246+
<main className="mx-auto min-h-[calc(100dvh-60px)] w-full max-w-[430px] min-w-[320px]">
247+
{isLoading && !consentData ? (
248+
<ConsentPageSkeleton />
249+
) : error && !consentData ? (
250+
<section className="flex min-h-[calc(100dvh-120px)] flex-col items-center justify-center px-4 text-center">
251+
<p role="alert" className="text-body-1 text-error-default">
252+
{getErrorMessage(error)}
253+
</p>
254+
<button
255+
type="button"
256+
onClick={() => void refetch()}
257+
className="border-border-primary text-body-1 text-text-primary mt-5 h-11 rounded-lg border px-5 font-medium"
258+
>
259+
다시 불러오기
260+
</button>
261+
</section>
262+
) : consentData ? (
263+
<>
264+
<ExhibitionProgressInfoCard exhibition={consentData.exhibition} />
265+
<AgreementContentSection
266+
agreements={consentData.agreements}
267+
checkedMap={checkedMap}
268+
readOnly={isReadOnly}
269+
onCheckedChange={handleCheckedChange}
270+
/>
271+
<SignatureSection
272+
mode={mode}
273+
signatureDataUrl={signatureDataUrl}
274+
signedAt={consentData.signature.signedAt}
275+
onSignatureChange={handleSignatureChange}
276+
/>
277+
278+
{submitMutation.error && (
279+
<div
280+
role="alert"
281+
className="bg-error-light text-body-2 text-error-default fixed right-4 bottom-24 left-4 z-40 mx-auto max-w-[398px] rounded-lg px-4 py-3"
282+
>
283+
{getErrorMessage(submitMutation.error)}
284+
</div>
285+
)}
286+
287+
{!isReadOnly && (
288+
<footer className="border-border-primary bg-bg-primary fixed right-0 bottom-0 left-0 mx-auto w-full max-w-[430px] min-w-[320px] border-t px-4 py-4">
289+
<button
290+
type="button"
291+
disabled={!canSubmit}
292+
onClick={handleSubmit}
293+
className="bg-object-primary hover:bg-object-primary-hover active:bg-object-primary-pressed text-body-1 text-text-invert disabled:bg-object-disabled disabled:text-text-disabled h-12 w-full rounded-lg font-semibold transition-colors disabled:cursor-not-allowed"
294+
>
295+
{submitMutation.isPending ? "처리 중" : "동의 완료"}
296+
</button>
297+
</footer>
298+
)}
299+
</>
300+
) : (
301+
<section className="flex min-h-[calc(100dvh-120px)] items-center justify-center px-4 text-center">
302+
<p className="text-body-1 text-text-secondary">동의서 정보가 없습니다.</p>
303+
</section>
304+
)}
305+
</main>
306+
307+
<Toast open={toastOpen} message="동의서 작성이 완료되었습니다." />
308+
</div>
309+
);
310+
}

0 commit comments

Comments
 (0)