Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type ExperienceType = ExperienceTypeCode;
export interface ExperienceUpsertBody {
title: string;

type: ExperienceType | null;
type: string | null;

startAt: string | null;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { EXPERIENCE_TYPE } from "@/shared/config/experience";
import {
EXPERIENCE_TYPE,
type ExperienceTypeCode,
} from "@/shared/config/experience";
import { parseYMD } from "@/shared/lib/format-date";
import { ModalBasic, Tooltip } from "@/shared/ui";
import { Button } from "@/shared/ui/button/button";
Expand Down Expand Up @@ -40,7 +43,10 @@ const ExperienceViewer = () => {
);
}

const typeLabel = current.type ? EXPERIENCE_TYPE[current.type] : "미지정";
const typeLabel =
current.type && current.type in EXPERIENCE_TYPE
? EXPERIENCE_TYPE[current.type as ExperienceTypeCode]
: "미지정";

return (
<main className={s.page}>
Expand Down
5 changes: 2 additions & 3 deletions src/features/experience/api/use-experience-list.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import { api } from "@/shared/api/axios-instance";
import { experienceQueryKey } from "@/shared/api/config/query-key";

import type { ExperienceList } from "../type/experience";
import type { ExperienceTypeCode } from "@/shared/config";

export const getExperienceList = async ({
type,
page,
}: {
type?: ExperienceTypeCode | undefined;
type?: string;
page: number;
}) => {
const response = await api.experiences.getSummaryExperienceList({
Expand All @@ -24,7 +23,7 @@ export const useGetExperienceList = ({
type,
page,
}: {
type: ExperienceTypeCode | null;
type?: string | null;
page: number;
}) => {
return useQuery({
Expand Down
33 changes: 21 additions & 12 deletions src/pages/experience/experience-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSearchParams, useNavigate } from "react-router-dom";

import { ROUTES } from "@/app/routes/paths";
import { useGetExperienceList } from "@/features/experience/api/use-experience-list.query";
Expand All @@ -9,24 +9,33 @@ import { ExperienceFilter } from "@/widgets";
import * as styles from "./experience-page.css";
import { ExperienceListContainer } from "./ui/experience-list-container";

import type { ExperienceTypeCode } from "@/shared/config/experience";

const ExperiencePage = () => {
const [filter, setFilter] = useState<ExperienceTypeCode | null>(null);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();

const currentPage = Number(searchParams.get("page")) || 1;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

page 쿼리 파라미터 정규화가 없어 잘못된 페이지 요청이 가능합니다.

현재 계산식은 -1, 1.5 같은 값도 그대로 통과합니다. 페이지는 1 이상의 정수로 제한해 방어적으로 처리하는 게 안전합니다.

🔧 수정 제안
-  const currentPage = Number(searchParams.get("page")) || 1;
+  const parsedPage = Number(searchParams.get("page"));
+  const currentPage =
+    Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const currentPage = Number(searchParams.get("page")) || 1;
const parsedPage = Number(searchParams.get("page"));
const currentPage =
Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/experience/experience-page.tsx` at line 16, The page query isn't
being normalized so values like "-1" or "1.5" slip through; read the raw value
from searchParams.get("page"), parse it with parseInt(raw, 10) (or
Math.floor(Number(raw))), validate that the parsed value is an integer >= 1, and
if it's NaN or < 1 fall back to 1; replace the current currentPage assignment
(which uses Number(searchParams.get("page")) || 1) with this defensive
parsing/validation logic so currentPage is always a positive integer.

const type = searchParams.get("type") || "";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

type를 빈 문자열로 강제하면 “필터 미선택” 상태가 깨집니다.

Line 17에서 null""로 바꿔버려서, Line 22의 훅으로 type=""이 전달됩니다. 이러면 필터가 없는 상태여도 API/URL에 빈 값(type=)이 남을 수 있습니다. null을 유지하고, 값이 없을 때는 쿼리 키를 삭제하는 방식이 안전합니다.

🔧 수정 제안
-  const type = searchParams.get("type") || "";
+  const type = searchParams.get("type");

   const { data } = useGetExperienceList({
     type,
     page: currentPage,
   });

   const handleFilterChange = (value: string) => {
     setIsExpTouched(true);
-    setSearchParams({
-      type: value,
-      page: "1",
-    });
+    setSearchParams((prev) => {
+      const next = new URLSearchParams(prev);
+      if (value) next.set("type", value);
+      else next.delete("type");
+      next.set("page", "1");
+      return next;
+    });
   };

   const handlePageChange = (page: number) => {
-    setSearchParams({
-      type,
-      page: String(page),
-    });
+    setSearchParams((prev) => {
+      const next = new URLSearchParams(prev);
+      if (type) next.set("type", type);
+      else next.delete("type");
+      next.set("page", String(page));
+      return next;
+    });
   };

Also applies to: 22-23, 28-31, 35-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/experience/experience-page.tsx` at line 17, The code forces
searchParams.get("type") to an empty string which breaks the "no-filter
selected" state; revert to letting const type = searchParams.get("type") return
null when absent and update any places that read or set this param (the var name
type and uses of searchParams.get) to treat null as "unset" — when updating the
URL/query (e.g., via your setSearchParams or related hooks referenced around
lines 22-23, 28-31, 35-37) remove the key entirely instead of writing type="";
ensure any consumer hooks accept null/undefined as the unset state rather than
an empty string.


const [isExpTouched, setIsExpTouched] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const navigate = useNavigate();

const { data } = useGetExperienceList({
type: filter,
type,
page: currentPage,
});

const handleFilterChange = (value: ExperienceTypeCode | null) => {
const handleFilterChange = (value: string) => {
setIsExpTouched(true);
setFilter(value);
setCurrentPage(1);
setSearchParams({
type: value,
page: "1",
});
};

const handlePageChange = (page: number) => {
setSearchParams({
type,
page: String(page),
});
};

return (
Expand All @@ -53,15 +62,15 @@ const ExperiencePage = () => {
</button>

<ExperienceFilter
value={filter}
value={type}
onChange={handleFilterChange}
isTouched={isExpTouched}
hasTotal={true}
/>
</div>
</section>

<ExperienceListContainer data={data} onPageChange={setCurrentPage} />
<ExperienceListContainer data={data} onPageChange={handlePageChange} />
</div>
);
};
Expand Down
91 changes: 58 additions & 33 deletions src/pages/home/search-section/search-section.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";

import { useGetCompanies } from "@/features/home";
import { ScaleFilter, IndustryFilter } from "@/features/home/ui";
Expand All @@ -11,42 +12,66 @@ import * as styles from "./search-section.css";

import type { IndustryCode, ScaleCode } from "@/shared/config";

interface CompanySearchParamsType {
keyword?: string;
industry?: IndustryCode;
scale?: ScaleCode;
sort?: string;
page?: number;
isRecruited?: boolean;
}

const SearchSection = () => {
const [params, setParams] = useState<CompanySearchParamsType>({
page: 1,
isRecruited: true,
});
const [searchParams, setSearchParams] = useSearchParams();

const keyword = searchParams.get("keyword") || "";
const industry = (searchParams.get("industry") as IndustryCode) || undefined;
const scale = (searchParams.get("scale") as ScaleCode) || undefined;
const page = Number(searchParams.get("page")) || 1;
const isRecruited = searchParams.get("isRecruited") !== "false";

const params = {
keyword,
industry,
scale,
page,
isRecruited,
};

const { data, isLoading, isPlaceholderData } = useGetCompanies(params);
const content = data?.content || [];
const hasResult = content.length > 0;
const [searchValue, setSearchValue] = useState("");
const currentPage = params.page ?? 1;

const [searchValue, setSearchValue] = useState(keyword);

const [isScaleTouched, setIsScaleTouched] = useState(false);
const [isIndustryTouched, setIsIndustryTouched] = useState(false);

const updateParams = (patch: Partial<CompanySearchParamsType>) => {
setParams((prev) => ({
...prev,
...patch,
}));
const updateSearchParams = (
patch: Record<string, string | number | boolean | undefined>
) => {
const newParams = new URLSearchParams(searchParams);

Object.entries(patch).forEach(([key, value]) => {
if (value === undefined || value === "") {
newParams.delete(key);
} else {
newParams.set(key, String(value));
}
});

if (!patch.page) {
newParams.set("page", "1");
}

setSearchParams(newParams);
};

const handlePageChange = (newPage: number) => {
if (isPlaceholderData) return;
updateParams({ page: newPage });
updateSearchParams({ page: newPage });
};

const handleSearch = (newKeyword: string) => {
updateSearchParams({ keyword: newKeyword });
};

// 검색값 유지
useEffect(() => {
setSearchValue(keyword);
}, [keyword]);

return (
<>
<section
Expand All @@ -68,7 +93,7 @@ const SearchSection = () => {
placeholder="지원하고 싶은 기업을 검색해보세요"
value={searchValue}
onChange={setSearchValue}
onSearch={(keyword) => updateParams({ keyword, page: 1 })}
onSearch={handleSearch}
/>
</div>
</div>
Expand All @@ -78,20 +103,20 @@ const SearchSection = () => {
<div className={styles.container}>
<div className={styles.filterWrapper}>
<IndustryFilter
value={params.industry ?? null}
value={industry ?? null}
isTouched={isIndustryTouched}
onChange={(industry) => {
onChange={(newIndustry) => {
setIsIndustryTouched(true);
updateParams({ industry, page: 1 });
updateSearchParams({ industry: newIndustry });
}}
/>

<ScaleFilter
value={params.scale}
value={scale}
isTouched={isScaleTouched}
onChange={(scale) => {
onChange={(newScale) => {
setIsScaleTouched(true);
updateParams({ scale, page: 1 });
updateSearchParams({ scale: newScale });
}}
/>

Expand All @@ -100,9 +125,9 @@ const SearchSection = () => {
</p>

<Toggle
checked={params.isRecruited ?? true}
onCheckedChange={(isRecruited) =>
updateParams({ isRecruited, page: 1 })
checked={isRecruited}
onCheckedChange={(checked) =>
updateSearchParams({ isRecruited: checked })
}
/>
</div>
Expand All @@ -122,7 +147,7 @@ const SearchSection = () => {
))}
</div>
<Pagination
currentPage={currentPage}
currentPage={page}
totalPage={data?.totalPage ?? 1}
onPageChange={handlePageChange}
/>
Expand Down
45 changes: 26 additions & 19 deletions src/pages/matching-list/matching-list-page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";

import { useGetAiReportList } from "@/features/matching-list/api/use-get-matching-list";
import { ICON_MATCH, ERROR } from "@/shared/assets/images";
Expand All @@ -7,39 +8,45 @@ import { Search } from "@/shared/ui";
import { ListSection } from "./list-section/list-section";
import * as styles from "./matching-list-page.css";

interface MatchingListParams {
keyword?: string;
page: number;
}
const MatchingListPage = () => {
const [params, setParams] = useState<MatchingListParams>({
keyword: "",
page: 1,
});
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Number(searchParams.get("page")) || 1;
const keyword = searchParams.get("keyword") || "";

const { data, isLoading } = useGetAiReportList(params);
const { content = [], currentPage = 1, totalPage = 1 } = data ?? {};
const { data, isLoading } = useGetAiReportList({
page: currentPage,
keyword,
});
const { content = [], totalPage = 1 } = data ?? {};

const showEmptyState = !isLoading && content.length === 0;
const showList = content.length > 0;

const [searchValue, setSearchValue] = useState("");
const [searchValue, setSearchValue] = useState(keyword);

const handleSearch = (keyword: string) => {
setParams({ keyword, page: 1 });
const handleSearch = (newKeyword: string) => {
setSearchParams({
keyword: newKeyword,
page: "1",
});
};

const handlePageChange = (page: number) => {
setParams((prev) => ({
...prev,
page,
}));
setSearchParams({
keyword,
page: String(page),
});
};

const handleSearchChange = (keyword: string) => {
setSearchValue(keyword);
};

// 검색값 유지
useEffect(() => {
setSearchValue(keyword);
}, [keyword]);

return (
<main className={styles.container}>
{/* header 섹션 */}
Expand Down Expand Up @@ -84,7 +91,7 @@ const MatchingListPage = () => {
/>
<p className={styles.emptyTitle}>"검색 결과가 없습니다"</p>
<p className={styles.emptyDescription}>
{params.keyword
{keyword
? "다른 검색어로 다시 시도해보세요."
: "경험 등록하기 버튼을 눌러 경험을 등록해보세요."}
</p>
Expand Down
4 changes: 2 additions & 2 deletions src/shared/api/generate/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export interface ExperienceRequestDto {
*/
title: string;
/** @example "INTERNSHIP" */
type: "INTERNSHIP" | "PROJECT" | "EDUCATION" | "ETC";
type: string;
/**
* @format date
* @example "2025-12-23"
Expand Down Expand Up @@ -812,7 +812,7 @@ export class Api<
*/
getSummaryExperienceList: (
query?: {
type?: "INTERNSHIP" | "PROJECT" | "EDUCATION" | "ETC";
type?: string;
/**
* @format int32
* @default 1
Expand Down
Loading
Loading