|
| 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