From 32e4414af7015426d1d2fd6ffe7dd298157c64d3 Mon Sep 17 00:00:00 2001 From: girimNam Date: Wed, 29 Jul 2026 11:46:50 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9D=B4=EC=9A=A9=EC=95=BD=EA=B4=80=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(auth)/layout.tsx | 6 +- .../(auth)/signup/components/ProfileStep.tsx | 51 ++++++-- src/app/(auth)/signup/google/page.tsx | 24 ++++ src/components/auth/TermsModal.tsx | 86 ++++++++++++ src/constants/auth/terms.ts | 122 ++++++++++++++++++ 5 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 src/components/auth/TermsModal.tsx create mode 100644 src/constants/auth/terms.ts diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index e684abd..e877b6a 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -37,11 +37,13 @@ export default function AuthLayout({ children }: { children: React.ReactNode }) -
+
-
{children}
+
+
{children}
+
); diff --git a/src/app/(auth)/signup/components/ProfileStep.tsx b/src/app/(auth)/signup/components/ProfileStep.tsx index 606370a..c25abfd 100644 --- a/src/app/(auth)/signup/components/ProfileStep.tsx +++ b/src/app/(auth)/signup/components/ProfileStep.tsx @@ -7,8 +7,10 @@ import { z } from "zod"; import { AgreementItem } from "@/components/auth/AgreementItem"; import { PasswordField } from "@/components/auth/PasswordField"; +import { TermsModal } from "@/components/auth/TermsModal"; import { Button } from "@/components/ui/Button"; import { TextField } from "@/components/ui/TextField"; +import { PRIVACY_POLICY_CONTENT, SERVICE_TERMS_CONTENT } from "@/constants/auth/terms"; import { signup } from "@/lib/api/auth"; import { ApiError } from "@/lib/api/error"; import { TERMS_TYPE } from "@/types/auth.type"; @@ -68,6 +70,7 @@ export const ProfileStep = ({ email, verificationToken, onSubmit, onBack }: Prof }); const [submitError, setSubmitError] = useState(null); + const [openTerms, setOpenTerms] = useState<"service" | "privacy" | null>(null); const agreementError = errors.agreedTerms ?? errors.agreedPrivacy; @@ -152,24 +155,48 @@ export const ProfileStep = ({ email, verificationToken, onSubmit, onBack }: Prof control={control} name="agreedTerms" render={({ field: { value, onChange } }) => ( - onChange(!value)} - /> + <> + onChange(!value)} + onDetail={() => setOpenTerms("service")} + /> + {openTerms === "service" && ( + onChange(!value)} + onClose={() => setOpenTerms(null)} + /> + )} + )} /> ( - onChange(!value)} - /> + <> + onChange(!value)} + onDetail={() => setOpenTerms("privacy")} + /> + {openTerms === "privacy" && ( + onChange(!value)} + onClose={() => setOpenTerms(null)} + /> + )} + )} /> diff --git a/src/app/(auth)/signup/google/page.tsx b/src/app/(auth)/signup/google/page.tsx index 1f35c38..6e1b737 100644 --- a/src/app/(auth)/signup/google/page.tsx +++ b/src/app/(auth)/signup/google/page.tsx @@ -4,8 +4,10 @@ import { Suspense, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { AgreementItem } from "@/components/auth/AgreementItem"; +import { TermsModal } from "@/components/auth/TermsModal"; import { Button } from "@/components/ui/Button"; import { TextField } from "@/components/ui/TextField"; +import { PRIVACY_POLICY_CONTENT, SERVICE_TERMS_CONTENT } from "@/constants/auth/terms"; import { signupWithGoogle } from "@/lib/api/auth"; import { ApiError } from "@/lib/api/error"; import { useAuthStore } from "@/store/authStore"; @@ -33,6 +35,7 @@ function GoogleSignupForm() { const [agreedPrivacy, setAgreedPrivacy] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); + const [openTerms, setOpenTerms] = useState<"service" | "privacy" | null>(null); const canSubmit = agreedTerms && agreedPrivacy && !isSubmitting; @@ -98,15 +101,36 @@ function GoogleSignupForm() { label="IPX의 이용약관에 동의합니다" checked={agreedTerms} onToggle={() => setAgreedTerms((checked) => !checked)} + onDetail={() => setOpenTerms("service")} /> setAgreedPrivacy((checked) => !checked)} + onDetail={() => setOpenTerms("privacy")} /> + {openTerms === "service" && ( + setAgreedTerms((checked) => !checked)} + onClose={() => setOpenTerms(null)} + /> + )} + {openTerms === "privacy" && ( + setAgreedPrivacy((checked) => !checked)} + onClose={() => setOpenTerms(null)} + /> + )} + {submitError && (

{submitError} diff --git a/src/components/auth/TermsModal.tsx b/src/components/auth/TermsModal.tsx new file mode 100644 index 0000000..b3614b6 --- /dev/null +++ b/src/components/auth/TermsModal.tsx @@ -0,0 +1,86 @@ +"use client"; + +import CancelIcon from "@/components/icons/icon-cancel.svg"; +import { Radio } from "@/components/ui/Radio"; +import type { TermsContent } from "@/constants/auth/terms"; + +type TermsModalProps = { + content: TermsContent; + agreementLabel: string; + checked: boolean; + onToggle: () => void; + onClose: () => void; +}; + +export function TermsModal({ + content, + agreementLabel, + checked, + onToggle, + onClose, +}: TermsModalProps) { + return ( +

+
e.stopPropagation()} + > +
+

{content.title}

+ +
+ +
+ {content.sections.map((section) => ( +
+

{section.heading}

+ {section.paragraphs?.map((paragraph) => ( +

+ {paragraph} +

+ ))} + {section.bullets && ( +
    + {section.bullets.map((bullet) => ( +
  • + {bullet} +
  • + ))} +
+ )} + {section.numbered && ( +
    + {section.numbered.map((item) => ( +
  1. + {item} +
  2. + ))} +
+ )} +
+ ))} +
+ +
+ + +
+
+ ); +} diff --git a/src/constants/auth/terms.ts b/src/constants/auth/terms.ts new file mode 100644 index 0000000..21bed4d --- /dev/null +++ b/src/constants/auth/terms.ts @@ -0,0 +1,122 @@ +export type TermsSection = { + heading: string; + paragraphs?: string[]; + bullets?: string[]; + numbered?: string[]; +}; + +export type TermsContent = { + title: string; + sections: TermsSection[]; +}; + +export const SERVICE_TERMS_CONTENT: TermsContent = { + title: "IPX 서비스 이용약관", + sections: [ + { + heading: "제1조 (목적)", + paragraphs: [ + '본 약관은 IPX(이하 "서비스")가 제공하는 특허·기술 탐색 및 정보 제공 서비스의 이용과 관련하여 서비스와 이용자 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.', + ], + }, + { + heading: "제2조 (서비스의 내용)", + paragraphs: ["IPX는 다음과 같은 서비스를 제공합니다."], + bullets: [ + "특허 및 기술 정보 탐색 기능", + "기술 및 시장 정보 제공", + "사용자 맞춤형 검색 및 추천 기능", + "기타 IPX가 추가 개발하거나 제휴를 통해 제공하는 서비스", + ], + }, + { + heading: "제4조 (이용자의 의무)", + paragraphs: ["이용자는 다음 행위를 해서는 안 됩니다."], + bullets: [ + "타인의 계정 도용", + "서비스 내 정보 무단 복제 및 배포", + "불법적 목적의 서비스 이용", + "서비스 운영을 방해하는 행위", + "허위 정보 등록", + ], + }, + { + heading: "제5조 (서비스의 변경 및 중단)", + numbered: [ + "IPX는 운영상 또는 기술상의 필요에 따라 서비스 내용을 변경할 수 있습니다.", + "서비스 점검, 장애, 기타 불가피한 사유 발생 시 서비스 제공이 일시 중단될 수 있습니다.", + ], + }, + { + heading: "제6조 (지식재산권)", + paragraphs: [ + "서비스 내 제공되는 콘텐츠 및 자료에 대한 저작권과 지식재산권은 IPX 또는 원저작권자에게 귀속됩니다.", + ], + }, + { + heading: "제7조 (책임의 제한)", + numbered: [ + "IPX는 제공되는 정보의 정확성 및 완전성을 보장하지 않습니다.", + "이용자의 판단 및 활동으로 인해 발생한 손해에 대해 책임지지 않습니다.", + ], + }, + { + heading: "제8조 (약관의 변경)", + paragraphs: [ + "IPX는 관련 법령을 위반하지 않는 범위에서 본 약관을 변경할 수 있으며, 변경 시 서비스 내 공지합니다.", + ], + }, + ], +}; + +export const PRIVACY_POLICY_CONTENT: TermsContent = { + title: "IPX 개인정보 처리방침", + sections: [ + { + heading: "1. 수집하는 개인정보 항목", + paragraphs: ["IPX는 다음과 같은 개인정보를 수집할 수 있습니다."], + bullets: [ + "이메일 주소", + "이름 또는 닉네임", + "로그인 정보", + "서비스 이용 기록", + "접속 로그 및 기기 정보", + ], + }, + { + heading: "2. 개인정보 수집 목적", + paragraphs: ["수집한 개인정보는 다음 목적을 위해 사용됩니다."], + bullets: [ + "회원 식별 및 계정 관리", + "서비스 제공 및 운영", + "사용자 문의 대응", + "서비스 개선 및 통계 분석", + "부정 이용 방지", + ], + }, + { + heading: "3. 개인정보 보관 및 이용 기간", + paragraphs: [ + "개인정보는 회원 탈퇴 시까지 보관하며, 관련 법령에 따라 일정 기간 보관이 필요한 경우 해당 기간 동안 보관됩니다.", + ], + }, + { + heading: "4. 개인정보 제3자 제공", + paragraphs: [ + "IPX는 이용자의 개인정보를 외부에 제공하지 않습니다. 단, 법령에 따른 요청이 있는 경우 예외로 합니다.", + ], + }, + { + heading: "5. 개인정보 보호", + paragraphs: ["IPX는 개인정보 보호를 위해 합리적인 보안 조치를 시행합니다."], + }, + { + heading: "6. 이용자의 권리", + paragraphs: ["이용자는 언제든지 자신의 개인정보를 조회, 수정, 삭제 요청할 수 있습니다."], + }, + { + heading: "7. 개인정보처리방침 변경", + paragraphs: ["본 방침은 변경될 수 있으며, 변경 시 서비스 내 공지합니다."], + }, + ], +}; From 5f4759514c14d9eb93ab15620777214e3ebc87e7 Mon Sep 17 00:00:00 2001 From: girimNam Date: Wed, 29 Jul 2026 11:58:47 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=9B=90=EB=AC=B8=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=97=B0=EA=B2=B0(=ED=99=95=EC=A0=95=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20=EC=88=98=EC=A0=95=20=EC=98=88=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/tech/[id]/page.tsx | 8 ++++++++ src/components/myhistory/novelty/Header.tsx | 9 ++++++++- src/lib/patentOriginalDocument.ts | 5 +++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/lib/patentOriginalDocument.ts diff --git a/src/app/(main)/tech/[id]/page.tsx b/src/app/(main)/tech/[id]/page.tsx index 912c6de..1de91be 100644 --- a/src/app/(main)/tech/[id]/page.tsx +++ b/src/app/(main)/tech/[id]/page.tsx @@ -14,6 +14,7 @@ import { getPriorArtDetail } from "@/lib/api/search"; import { ApiError } from "@/lib/api/error"; import { useKiprisThumbnail } from "@/hooks/useKiprisThumbnail"; import { formatPeriod } from "@/lib/priorArtFormat"; +import { buildOriginalDocumentUrl } from "@/lib/patentOriginalDocument"; import { RELEVANCE_LABEL, RELEVANCE_VARIANT, scoreToRelevance } from "@/lib/priorArtRelevance"; import type { PriorArtDetail } from "@/types/search.type"; @@ -139,6 +140,13 @@ export default function TechDetailPage() { size="sm" variant="secondary" className="h-10.25 shrink-0 gap-1 rounded-md py-2.5 pr-4 pl-3" + onClick={() => + window.open( + buildOriginalDocumentUrl(detail.applicationNumber), + "_blank", + "noopener,noreferrer" + ) + } > 원문보기 diff --git a/src/components/myhistory/novelty/Header.tsx b/src/components/myhistory/novelty/Header.tsx index 63474fb..8b3611b 100644 --- a/src/components/myhistory/novelty/Header.tsx +++ b/src/components/myhistory/novelty/Header.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/Button"; import ExternalIcon from "@/components/icons/icon-external.svg"; import { Chip } from "@/components/myhistory/ProjectCardChip"; import { useKiprisThumbnail } from "@/hooks/useKiprisThumbnail"; +import { buildOriginalDocumentUrl } from "@/lib/patentOriginalDocument"; interface HeaderProps { title: string; @@ -39,7 +40,13 @@ export default function Header({ title, status, patentNumber, organization }: He - diff --git a/src/lib/patentOriginalDocument.ts b/src/lib/patentOriginalDocument.ts new file mode 100644 index 0000000..3ef1a67 --- /dev/null +++ b/src/lib/patentOriginalDocument.ts @@ -0,0 +1,5 @@ +import { normalizeApplicationNumber } from "@/lib/kiprisThumbnail"; + +export function buildOriginalDocumentUrl(applicationNumber: string): string { + return `https://doi.org/10.8080/${normalizeApplicationNumber(applicationNumber)}`; +} From 3dee90225d5276db997a671d32483aa6e8563d16 Mon Sep 17 00:00:00 2001 From: girimNam Date: Wed, 29 Jul 2026 14:03:10 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=8B=A0=EA=B7=9C=EC=84=B1=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=98=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20ui=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../novelty/ComparisonResultDropdown.tsx | 99 +++++++++++++ .../myhistory/novelty/NoveltyRow.tsx | 139 ++++-------------- .../myhistory/novelty/NoveltyTable.tsx | 129 ++++++++++++++-- 3 files changed, 249 insertions(+), 118 deletions(-) create mode 100644 src/components/myhistory/novelty/ComparisonResultDropdown.tsx diff --git a/src/components/myhistory/novelty/ComparisonResultDropdown.tsx b/src/components/myhistory/novelty/ComparisonResultDropdown.tsx new file mode 100644 index 0000000..9570f3b --- /dev/null +++ b/src/components/myhistory/novelty/ComparisonResultDropdown.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import ExpandIcon from "@/components/icons/icon-expand.svg"; +import { cn } from "@/lib/cn"; +import type { NoveltyComparisonResult } from "@/types/novelty.type"; + +const COMPARISON_RESULT_OPTIONS: Array<{ + value: NoveltyComparisonResult; + label: string; +}> = [ + { value: "IDENTICAL", label: "동일" }, + { value: "SIMILAR", label: "유사" }, + { value: "NOVEL", label: "신규" }, +]; + +interface ComparisonResultDropdownProps { + value: NoveltyComparisonResult; + disabled?: boolean; + onChange: (value: NoveltyComparisonResult) => void; +} + +// SortingTag(검색결과 정렬 드롭다운)와 동일한 스타일 -> 값 동기화가 필요해서(취소 시 원래 값으로 복원 등) 완전히 controlled로 구현 +export function ComparisonResultDropdown({ + value, + disabled, + onChange, +}: ComparisonResultDropdownProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const selectedLabel = COMPARISON_RESULT_OPTIONS.find((option) => option.value === value)?.label; + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, []); + + return ( +
+ + + {open && ( +
+ {COMPARISON_RESULT_OPTIONS.map((option) => { + const active = option.value === value; + + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/myhistory/novelty/NoveltyRow.tsx b/src/components/myhistory/novelty/NoveltyRow.tsx index abe04f5..4c6d9f7 100644 --- a/src/components/myhistory/novelty/NoveltyRow.tsx +++ b/src/components/myhistory/novelty/NoveltyRow.tsx @@ -1,14 +1,8 @@ "use client"; -import { useState } from "react"; -import { Button } from "@/components/ui/Button"; -import EditIcon from "@/components/icons/icon-edit.svg"; +import { ComparisonResultDropdown } from "./ComparisonResultDropdown"; import { MatchStatusChip, type MatchStatus } from "./MatchStatusChip"; -import type { - NoveltyComparison, - NoveltyComparisonResult, - UpdateNoveltyComparisonRequest, -} from "@/types/novelty.type"; +import type { NoveltyComparison, NoveltyComparisonResult } from "@/types/novelty.type"; const MATCH_STATUS_BY_RESULT: Record = { IDENTICAL: "identical", @@ -16,54 +10,28 @@ const MATCH_STATUS_BY_RESULT: Record = { NOVEL: "novel", }; -const COMPARISON_RESULT_OPTIONS: Array<{ - value: NoveltyComparisonResult; - label: string; -}> = [ - { value: "IDENTICAL", label: "동일" }, - { value: "SIMILAR", label: "유사" }, - { value: "NOVEL", label: "신규" }, -]; - interface NoveltyRowProps { comparison: NoveltyComparison; - onSave: (comparisonId: number, body: UpdateNoveltyComparisonRequest) => Promise; + isEditing: boolean; + disabled?: boolean; + draftResult: NoveltyComparisonResult; + draftCitation: string; + onResultChange: (value: NoveltyComparisonResult) => void; + onCitationChange: (value: string) => void; } -export function NoveltyRow({ comparison, onSave }: NoveltyRowProps) { - const [isEditing, setIsEditing] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [comparisonResult, setComparisonResult] = useState(comparison.comparisonResult); - const [citation, setCitation] = useState(comparison.citation ?? ""); - const [saveError, setSaveError] = useState(null); - - const handleCancel = () => { - setComparisonResult(comparison.comparisonResult); - setCitation(comparison.citation ?? ""); - setSaveError(null); - setIsEditing(false); - }; - - const handleSave = async () => { - setIsSaving(true); - setSaveError(null); - - try { - await onSave(comparison.comparisonId, { - comparisonResult, - citation: citation.trim() || null, - }); - setCitation(citation.trim()); - setIsEditing(false); - } catch (error) { - setSaveError(error instanceof Error ? error.message : "수정 내용을 저장하지 못했습니다."); - } finally { - setIsSaving(false); - } - }; - +// 일괄 수정 모드(NoveltyTable에서 관리)로 바뀌면서 이 행은 편집 상태를 직접 갖지 않고, 상위에서 받은 draft 값을 그대로 보여주기만 함 +export function NoveltyRow({ + comparison, + isEditing, + disabled, + draftResult, + draftCitation, + onResultChange, + onCitationChange, +}: NoveltyRowProps) { return ( -
+
{comparison.componentLabel} @@ -76,73 +44,28 @@ export function NoveltyRow({ comparison, onSave }: NoveltyRowProps) {

{comparison.disclosureText}

{isEditing ? (