Skip to content

Commit

Permalink
Merge pull request #57 from prgrms-be-devcourse/feature/J-00FE
Browse files Browse the repository at this point in the history
[FEAT] [FE]: J-00FE
  • Loading branch information
sungyeong98 authored Feb 6, 2025
2 parents e317082 + e96faa6 commit 9cd7790
Show file tree
Hide file tree
Showing 15 changed files with 515 additions and 23 deletions.
4 changes: 4 additions & 0 deletions frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const nextConfig = {
'k.kakaocdn.net', // 카카오 프로필 이미지 도메인 추가
],
},
// app 디렉토리 사용 명시
experimental: {
appDir: true
}
}

module.exports = nextConfig
2 changes: 1 addition & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@heroicons/react": "^2.2.0",
"@reduxjs/toolkit": "^2.2.1",
"@tailwindcss/forms": "^0.5.10",
"axios": "^1.6.7",
"axios": "^1.7.9",
"next": "14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/api/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import axios from 'axios';

export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
headers: {
'Content-Type': 'application/json',
},
withCredentials: true
});

// 요청 인터셉터
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);

// 응답 인터셉터
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 인증 에러 처리
localStorage.removeItem('token');
}
return Promise.reject(error);
}
);

export default axiosInstance;
15 changes: 15 additions & 0 deletions frontend/src/api/jobPosting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ApiResponse } from '@/types/common/ApiResponse';
import { JobPostingSearchCondition } from '@/types/job-posting/JobPostingSearchCondition';
import { PageResponse } from '@/types/common/PageResponse';
import { JobPostingPageResponse } from '@/types/job-posting/JobPostingPageResponse';
import axiosInstance from './axios';

export const getJobPostings = async (params: JobPostingSearchCondition): Promise<ApiResponse<PageResponse<JobPostingPageResponse>>> => {
const response = await axiosInstance.get('/api/v1/job-posting', { params });
return response.data;
};

export const getJobPosting = async (id: number): Promise<ApiResponse<JobPostingPageResponse>> => {
const response = await axiosInstance.get(`/api/v1/job-posting/${id}`);
return response.data;
};
22 changes: 22 additions & 0 deletions frontend/src/api/post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ApiResponse } from '@/types/common/ApiResponse';
import { PageResponse } from '@/types/common/PageResponse';
import { PostResponse } from '@/types/post/PostResponse';
import axiosInstance from './axios';

interface GetPostsParams {
categoryId?: number;
keyword?: string;
sort?: 'latest' | 'popular';
page?: number;
size?: number;
}

export const getPosts = async (params: GetPostsParams = {}): Promise<ApiResponse<PageResponse<PostResponse>>> => {
const response = await axiosInstance.get('/api/v1/post', { params });
return response.data;
};

export const getPost = async (id: number): Promise<ApiResponse<PostResponse>> => {
const response = await axiosInstance.get(`/api/v1/post/${id}`);
return response.data;
};
123 changes: 123 additions & 0 deletions frontend/src/app/job-posting/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
'use client';

import { useEffect, useState } from 'react';
import { JobPostingPageResponse } from '@/types/job-posting/JobPostingPageResponse';
import { getJobPosting } from '@/api/jobPosting';
import { BriefcaseIcon, BuildingOfficeIcon, CalendarIcon, AcademicCapIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';

export default function JobPostingDetail({ params }: { params: { id: string } }) {
const [posting, setPosting] = useState<JobPostingPageResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const fetchJobPosting = async () => {
try {
const response = await getJobPosting(parseInt(params.id));
if (response.success) {
setPosting(response.data);
}
} catch (error) {
console.error('Failed to fetch job posting:', error);
} finally {
setIsLoading(false);
}
};

fetchJobPosting();
}, [params.id]);

if (isLoading) {
return (
<div className="max-w-4xl mx-auto p-8 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-6"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8"></div>
<div className="space-y-4">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
</div>
);
}

if (!posting) {
return (
<div className="max-w-4xl mx-auto p-8">
<p className="text-center text-gray-500">채용 공고를 찾을 수 없습니다.</p>
</div>
);
}

return (
<div className="max-w-4xl mx-auto p-8">
<Link
href="/"
className="inline-flex items-center text-blue-600 hover:text-blue-800 mb-6"
>
← 목록으로 돌아가기
</Link>

<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold mb-4">{posting.subject}</h1>

<div className="flex items-center text-gray-600 mb-6">
<BuildingOfficeIcon className="h-5 w-5 mr-2" />
<a
href={posting.companyLink}
target="_blank"
className="text-blue-600 hover:text-blue-800"
>
{posting.companyName}
</a>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="flex items-center">
<BriefcaseIcon className="h-5 w-5 text-gray-500 mr-2" />
<span>
경력: {posting.experienceLevel.name}
{posting.experienceLevel.min > 0 && ` (${posting.experienceLevel.min}년 이상)`}
</span>
</div>

<div className="flex items-center">
<AcademicCapIcon className="h-5 w-5 text-gray-500 mr-2" />
<span>학력: {posting.requireEducate.name}</span>
</div>

<div className="flex items-center">
<CalendarIcon className="h-5 w-5 text-gray-500 mr-2" />
<span>마감일: {new Date(posting.closeDate).toLocaleDateString()}</span>
</div>

<div className="flex items-center">
<span className="font-semibold text-blue-600">{posting.salary.name}</span>
</div>
</div>

<div className="mb-8">
<h2 className="text-lg font-semibold mb-3">필요 기술</h2>
<div className="flex flex-wrap gap-2">
{posting.jobSkillList.map((skill) => (
<span
key={skill.jobSkillId}
className="px-3 py-1 bg-blue-50 text-blue-700 rounded-full text-sm"
>
{skill.name}
</span>
))}
</div>
</div>

<a
href={posting.url}
target="_blank"
className="inline-block w-full text-center bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 transition-colors duration-200"
>
지원하기
</a>
</div>
</div>
);
}
11 changes: 8 additions & 3 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import RootLayoutClient from "@/components/RootLayoutClient";
import Providers from "@/components/Providers";

const inter = Inter({ subsets: ["latin"] });

Expand All @@ -12,13 +13,17 @@ export const metadata: Metadata = {

export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html lang="ko">
<body className={inter.className}>
<RootLayoutClient>{children}</RootLayoutClient>
<Providers>
<RootLayoutClient>
{children}
</RootLayoutClient>
</Providers>
</body>
</html>
);
Expand Down
109 changes: 91 additions & 18 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,99 @@
import Image from "next/image";
'use client';

import { useEffect, useState } from 'react';
import { JobPostingPageResponse } from '@/types/job-posting/JobPostingPageResponse';
import { getJobPostings } from '@/api/jobPosting';
import { BriefcaseIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import JobPostingCard from '@/components/job/JobPostingCard';

export default function Home() {
const [jobPostings, setJobPostings] = useState<JobPostingPageResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');

useEffect(() => {
const fetchData = async () => {
try {
const response = await getJobPostings({
pageNum: 0,
pageSize: 100
});

console.log('Jobs Response:', response);

if (response.success) {
setJobPostings(response.data.content);
}
} catch (error) {
console.error('Failed to fetch job postings:', error);
} finally {
setIsLoading(false);
}
};

fetchData();
}, []);

return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
{/* 게시글 영역 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* 게시글 자리 표시자 */}
{[1, 2, 3, 4, 5, 6].map((item) => (
<div
key={item}
className="bg-white p-6 rounded-lg shadow-lg hover:shadow-xl transition-shadow duration-200 min-h-[200px] flex flex-col"
>
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="mt-auto pt-4 flex justify-between items-center">
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<main>
{/* 히어로 섹션 */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">
IT 채용 정보를 한눈에
</h1>
<p className="text-xl text-blue-100 mb-8">
최신 개발자 채용 공고를 찾아보세요
</p>

{/* 검색바 */}
<div className="max-w-2xl mx-auto">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="기술 스택, 회사명으로 검색"
className="w-full pl-10 pr-4 py-3 rounded-lg text-gray-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
))}
</div>
</div>

<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* 채용 공고 섹션 */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2 mb-6">
<BriefcaseIcon className="h-6 w-6 text-blue-600" />
채용 공고
</h2>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{isLoading ? (
[...Array(6)].map((_, index) => (
<div
key={`job-skeleton-${index}`}
className="bg-white p-6 rounded-lg shadow-lg animate-pulse"
>
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
</div>
))
) : (
jobPostings.map((posting) => (
<div key={posting.id} className="bg-white rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-1">
<JobPostingCard posting={posting} />
</div>
))
)}
</div>
</div>
</div>
</div>
</main>
);
}
Loading

0 comments on commit 9cd7790

Please sign in to comment.