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/app/(main)/myhistory/[id]/novelty/page.tsx b/src/app/(main)/myhistory/[id]/novelty/page.tsx index 5d3c5db..97cc783 100644 --- a/src/app/(main)/myhistory/[id]/novelty/page.tsx +++ b/src/app/(main)/myhistory/[id]/novelty/page.tsx @@ -161,7 +161,7 @@ export default function NoveltyPage({ params }: { params: Promise<{ id: string }

diff --git a/src/app/(main)/search/loading/page.tsx b/src/app/(main)/search/loading/page.tsx index 16a344c..b903c13 100644 --- a/src/app/(main)/search/loading/page.tsx +++ b/src/app/(main)/search/loading/page.tsx @@ -107,7 +107,7 @@ function LoadingContent() { const handleStop = async () => { if (!caseId) { - router.push("/search"); + router.push("/search?resume=1"); return; } @@ -115,7 +115,7 @@ function LoadingContent() { setIsStopping(true); try { await cancelSearch(Number(caseId)); - router.push("/search"); + router.push("/search?resume=1"); } catch (err) { if (err instanceof ApiError) { setCancelError( @@ -132,7 +132,7 @@ function LoadingContent() { }; useEffect(() => { - if (!caseId) return; + if (!caseId || isStopping) return; let cancelled = false; let timer: ReturnType; @@ -141,7 +141,7 @@ function LoadingContent() { const poll = async () => { try { const result = await getSearchStatus(Number(caseId)); - if (cancelled) return; + if (cancelled || isStopping) return; consecutiveErrors = 0; setStatus(result); setPollError(null); @@ -179,7 +179,7 @@ function LoadingContent() { cancelled = true; clearTimeout(timer); }; - }, [caseId, router, title]); + }, [caseId, isStopping, router, title]); useEffect(() => { if (caseId) return; diff --git a/src/app/(main)/tech/[id]/page.tsx b/src/app/(main)/tech/[id]/page.tsx index 912c6de..c01751e 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,11 @@ 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" + disabled={!buildOriginalDocumentUrl(detail.applicationNumber)} + onClick={() => { + const url = buildOriginalDocumentUrl(detail.applicationNumber); + if (url) window.open(url, "_blank", "noopener,noreferrer"); + }} > 원문보기 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/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/Header.tsx b/src/components/myhistory/novelty/Header.tsx index 63474fb..fa6d783 100644 --- a/src/components/myhistory/novelty/Header.tsx +++ b/src/components/myhistory/novelty/Header.tsx @@ -4,16 +4,18 @@ 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; status: string; - patentNumber: string; + applicationNumber: string; organization: string; } -export default function Header({ title, status, patentNumber, organization }: HeaderProps) { - const thumbnailUrl = useKiprisThumbnail(patentNumber); +export default function Header({ title, status, applicationNumber, organization }: HeaderProps) { + const thumbnailUrl = useKiprisThumbnail(applicationNumber); + const originalDocumentUrl = buildOriginalDocumentUrl(applicationNumber); return (
@@ -32,14 +34,22 @@ export default function Header({ title, status, patentNumber, organization }: He
-

{patentNumber}

+

{applicationNumber}

|

{organization}

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