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
21 changes: 16 additions & 5 deletions jobdri/src/app/apply/apply-type/ApplyTypePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import clsx from "clsx";
import Header from "@/components/common/header/Header";
import { Footer } from "@/components/common/footer";
import { ApplyOptionCard } from "@/components/common/cards";
import { saveSelectedApplyType } from "@/lib/api/mockApplies";

type ApplyType = "real" | "virtual";

const applyTypes: Array<{
id: ApplyType;
title: string;
description: string;
disabled?: boolean;
}> = [
{
id: "real",
Expand All @@ -22,7 +24,8 @@ const applyTypes: Array<{
{
id: "virtual",
title: "가상 공고 지원",
description: "과거 공고를 기반으로\n모의 서류 평가를 제공합니다.",
description: "가상 공고 지원은\n아직 준비 중입니다.",
disabled: true,
},
];

Expand All @@ -33,12 +36,13 @@ export default function ApplyTypePageClient() {

const handleSubmit = () => {
if (selectedType === "real") {
saveSelectedApplyType("ACTUAL");
router.push("/apply/virtual/new/jd-input");
return;
}

if (selectedType === "virtual") {
router.push("/apply/virtual/new/jd");
setSelectedType(null);
}
};

Expand All @@ -63,10 +67,12 @@ export default function ApplyTypePageClient() {
{applyTypes.map((applyType) => {
const isSelected = selectedType === applyType.id;
const isHovered = hoveredType === applyType.id;
const isUnavailable = applyType.disabled === true;
const isDisabled =
hoveredType !== null
isUnavailable ||
(hoveredType !== null
? !isHovered
: selectedType !== null && !isSelected;
: selectedType !== null && !isSelected);

return (
<ApplyOptionCard
Expand All @@ -75,9 +81,14 @@ export default function ApplyTypePageClient() {
description={applyType.description}
selected={isSelected}
disabled={isDisabled}
onMouseEnter={() => setHoveredType(applyType.id)}
onMouseEnter={() => {
if (!isUnavailable) {
setHoveredType(applyType.id);
}
}}
onMouseLeave={() => setHoveredType(null)}
onClick={() =>
!isUnavailable &&
setSelectedType((prevSelectedType) =>
prevSelectedType === applyType.id
? null
Expand Down
149 changes: 137 additions & 12 deletions jobdri/src/app/apply/virtual/[id]/(jd)/jd-input/JdInputPageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
"use client";

import { forwardRef, useImperativeHandle, useState } from "react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Header from "@/components/common/header/Header";
import { Method1Card, Method2Card } from "@/components/common/cards";
import { InputFileSummary } from "@/components/common/input";
import { ModalFileUpload, ModalInput } from "@/components/common/modal";
import {
ingestJobPosting,
waitForJobPostingIngest,
type JobPostingIngestStatus,
} from "@/lib/api/jobPostings";
import {
createJdReviewSectionsFromJobPosting,
getJdReviewMetadataStorageKey,
getJdReviewSavedStorageKey,
getJdReviewStorageKey,
} from "@/components/mock-application/jdReviewSections";

type JdInputMethod = "link" | "image" | "manual";
type LinkModalStep = "input" | "reading" | "failed";
type ImageModalStep = "upload" | "reading" | "failed";

function isUrlFormat(value: string) {
const trimmedValue = value.trim();
const normalizedUrl = normalizeUrl(value);

if (!trimmedValue || /\s/.test(trimmedValue)) {
if (!normalizedUrl || /\s/.test(value.trim())) {
return false;
}

const valueWithProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmedValue)
? trimmedValue
: `https://${trimmedValue}`;

try {
const url = new URL(valueWithProtocol);
const url = new URL(normalizedUrl);

return (
["http:", "https:"].includes(url.protocol) && url.hostname.includes(".")
Expand All @@ -33,6 +40,18 @@ function isUrlFormat(value: string) {
}
}

function normalizeUrl(value: string) {
const trimmedValue = value.trim();

if (!trimmedValue) {
return "";
}

return /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmedValue)
? trimmedValue
: `https://${trimmedValue}`;
}

export interface JdInputPageClientHandle {
handleCtaClick: () => void;
}
Expand All @@ -49,6 +68,7 @@ const JdInputPageClient = forwardRef<
const { id } = useParams<{ id: string }>();
const router = useRouter();
const manualJdReviewPath = `/apply/virtual/${id}/jd-review?mode=manual`;
const activeRequestIdRef = useRef(0);

const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
Expand All @@ -57,6 +77,7 @@ const JdInputPageClient = forwardRef<
useState<ImageModalStep>("upload");
const [jdLink, setJdLink] = useState("");
const [selectedImageFile, setSelectedImageFile] = useState<File | null>(null);
const [processingErrorMessage, setProcessingErrorMessage] = useState("");
const hasLinkText = jdLink.trim().length > 0;

const handleCtaClick = () => {
Expand Down Expand Up @@ -89,31 +110,115 @@ const JdInputPageClient = forwardRef<
};

const resetToUploadStart = () => {
activeRequestIdRef.current += 1;
setIsLinkModalOpen(false);
setIsImageModalOpen(false);
setLinkModalStep("input");
setImageModalStep("upload");
onMethodChange(null);
setJdLink("");
setSelectedImageFile(null);
setProcessingErrorMessage("");
};

const cancelImageReading = () => {
activeRequestIdRef.current += 1;
setIsLinkModalOpen(false);
setIsImageModalOpen(false);
setImageModalStep("upload");
setProcessingErrorMessage("");

if (document.fullscreenElement) {
void document.exitFullscreen();
}
};

const restartImageUpload = () => {
activeRequestIdRef.current += 1;
setIsLinkModalOpen(false);
setIsImageModalOpen(true);
onMethodChange("image");
setSelectedImageFile(null);
setImageModalStep("upload");
setProcessingErrorMessage("");
};

const moveToJdReviewWithResult = (status: JobPostingIngestStatus) => {
const result = status.result;
const jobPosting = result?.generated ?? result?.extracted;

if (!jobPosting) {
throw new Error("추출된 공고 정보를 확인할 수 없습니다.");
}

const detailClassificationId =
result?.saved?.detailClassificationId ??
result?.classification?.detailClassificationId ??
result?.candidates?.[0]?.detailClassificationId ??
0;

window.sessionStorage.setItem(
getJdReviewStorageKey(id),
JSON.stringify(createJdReviewSectionsFromJobPosting(jobPosting)),
);
window.sessionStorage.setItem(
getJdReviewMetadataStorageKey(id),
JSON.stringify({
companySize: result?.saved?.companySize ?? "STARTUP",
detailClassificationId,
}),
);

if (result?.saved) {
window.sessionStorage.setItem(
getJdReviewSavedStorageKey(id),
JSON.stringify(result.saved),
);
} else {
window.sessionStorage.removeItem(getJdReviewSavedStorageKey(id));
}

router.push(`/apply/virtual/${id}/jd-review`);
};

const processJobPosting = async ({
sourceUrl,
image,
}: {
sourceUrl?: string;
image?: File | null;
}) => {
const requestId = activeRequestIdRef.current + 1;
activeRequestIdRef.current = requestId;
setProcessingErrorMessage("");

try {
const accepted = await ingestJobPosting({ sourceUrl, image });
const status = await waitForJobPostingIngest(accepted.taskId);

if (activeRequestIdRef.current !== requestId) {
return;
}

moveToJdReviewWithResult(status);
} catch (error) {
if (activeRequestIdRef.current !== requestId) {
return;
}

setProcessingErrorMessage(
error instanceof Error
? error.message
: "공고 입력에 실패했습니다.",
);

if (sourceUrl) {
setLinkModalStep("failed");
return;
}

setImageModalStep("failed");
}
};

const submitLinkInput = () => {
Expand All @@ -122,11 +227,22 @@ const JdInputPageClient = forwardRef<
}

if (!isUrlFormat(jdLink)) {
setProcessingErrorMessage("올바른 공고 링크를 입력해주세요.");
setLinkModalStep("failed");
return;
}

setLinkModalStep("reading");
void processJobPosting({ sourceUrl: normalizeUrl(jdLink) });
};

const submitImageInput = () => {
if (!selectedImageFile) {
return;
}

setImageModalStep("reading");
void processJobPosting({ image: selectedImageFile });
};

const selectMethodFromFailure = (method: JdInputMethod) => {
Expand All @@ -137,6 +253,7 @@ const JdInputPageClient = forwardRef<
setLinkModalStep("input");
setIsImageModalOpen(false);
setIsLinkModalOpen(true);
setProcessingErrorMessage("");
return;
}

Expand All @@ -145,6 +262,7 @@ const JdInputPageClient = forwardRef<
setImageModalStep("upload");
setIsLinkModalOpen(false);
setIsImageModalOpen(true);
setProcessingErrorMessage("");
return;
}

Expand Down Expand Up @@ -227,7 +345,10 @@ const JdInputPageClient = forwardRef<
variant="alert"
value={jdLink}
onChange={setJdLink}
onSubmit={() => setLinkModalStep("input")}
onSubmit={() => {
activeRequestIdRef.current += 1;
setLinkModalStep("input");
}}
onCancel={resetToUploadStart}
onClose={resetToUploadStart}
title="링크를 읽고 있습니다"
Expand All @@ -245,7 +366,9 @@ const JdInputPageClient = forwardRef<
onSubmit={() => undefined}
onClose={resetToUploadStart}
title="공고 입력에 실패했습니다"
description="다른 방법으로 공고 내용을 입력해주세요"
description={
processingErrorMessage || "다른 방법으로 공고 내용을 입력해주세요"
}
showInputField={false}
showDescription
showLoadMotion={false}
Expand Down Expand Up @@ -277,7 +400,7 @@ const JdInputPageClient = forwardRef<
<ModalFileUpload
selectedFile={selectedImageFile}
onFileSelect={setSelectedImageFile}
onSubmit={() => setImageModalStep("reading")}
onSubmit={submitImageInput}
onClose={closeImageModal}
/>
) : imageModalStep === "reading" ? (
Expand Down Expand Up @@ -308,7 +431,9 @@ const JdInputPageClient = forwardRef<
onSubmit={() => undefined}
onClose={resetToUploadStart}
title="공고 입력에 실패했습니다"
description="다른 방법으로 공고 내용을 입력해주세요"
description={
processingErrorMessage || "다른 방법으로 공고 내용을 입력해주세요"
}
showInputField={false}
showDescription
showLoadMotion={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import type { JdReviewSection } from "@/components/mock-application/jdReviewSect

interface JdReviewPageClientProps {
sections?: JdReviewSection[];
onSectionsChange?: (sections: JdReviewSection[]) => void;
}

export default function JdReviewPageClient({
sections,
onSectionsChange,
}: JdReviewPageClientProps) {
const sectionsKey = sections
? JSON.stringify(sections.map(({ id, value }) => [id, value]))
: "mock";

return (
<div className="flex-1 bg-line-neutral-assistive px-6 py-6">
<div className="mx-auto flex w-[1280px] flex-col">
Expand All @@ -23,7 +29,11 @@ export default function JdReviewPageClient({
공고 내용을 확인하고 수정해주세요
</h2>
</div>
<JdReviewMain sections={sections} />
<JdReviewMain
key={sectionsKey}
sections={sections}
onSectionsChange={onSectionsChange}
/>
</div>
</section>
</div>
Expand Down
Loading