Skip to content

Commit 52e470b

Browse files
committed
fix: adds job position
1 parent 907a9fc commit 52e470b

File tree

3 files changed

+224
-2
lines changed

3 files changed

+224
-2
lines changed

src/components/post/JobPositions.tsx

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { useQuery } from '@apollo/react-hooks';
2+
import React, { useEffect, useRef, useState } from 'react';
3+
import { JOB_POSITIONS, JobPosition } from '../../lib/graphql/ad';
4+
import styled from 'styled-components';
5+
import VelogResponsive from '../velog/VelogResponsive';
6+
import Typography from '../common/Typography';
7+
import { themedPalette } from '../../lib/styles/themes';
8+
import { ellipsis } from '../../lib/styles/utils';
9+
import media from '../../lib/styles/media';
10+
import gtag from '../../lib/gtag';
11+
12+
type Props = {
13+
category: 'frontend' | 'backend' | 'mobile' | 'python' | 'node' | 'ai' | null;
14+
};
15+
16+
function JobPositions({ category }: Props) {
17+
const [isObserved, setIsObserved] = useState(false);
18+
const { data } = useQuery<{ jobPositions: JobPosition[] }>(JOB_POSITIONS, {
19+
variables: {
20+
category: category ?? undefined,
21+
},
22+
skip: !isObserved,
23+
});
24+
25+
const ref = useRef<HTMLDivElement>(null);
26+
const initializedRef = useRef(false);
27+
28+
useEffect(() => {
29+
const observer = new IntersectionObserver(
30+
(entries) => {
31+
entries.forEach((entry) => {
32+
if (entry.isIntersecting && !initializedRef.current) {
33+
setIsObserved(true);
34+
}
35+
});
36+
},
37+
{
38+
rootMargin: '300px',
39+
threshold: 0,
40+
},
41+
);
42+
if (!ref.current) return;
43+
observer.observe(ref.current);
44+
return () => {
45+
observer.disconnect();
46+
};
47+
}, []);
48+
49+
const onClick = () => {
50+
gtag('event', 'job_position_click');
51+
};
52+
53+
useEffect(() => {
54+
if (!isObserved) {
55+
return;
56+
}
57+
gtag('event', 'job_position_view');
58+
}, [isObserved]);
59+
60+
if (!data?.jobPositions)
61+
return (
62+
<Block>
63+
<div ref={ref}></div>
64+
</Block>
65+
);
66+
67+
return (
68+
<Block>
69+
<div ref={ref}></div>
70+
<Typography>
71+
<h4>관련 채용 정보</h4>
72+
<Container>
73+
{data.jobPositions.map((jobPosition) => (
74+
<Card key={jobPosition.id} onClick={onClick}>
75+
<a href={jobPosition.url}>
76+
<Thumbnail src={jobPosition.thumbnail} />
77+
</a>
78+
<Company>
79+
<a href={jobPosition.url}>
80+
<img src={jobPosition.companyLogo} />
81+
</a>
82+
<div>
83+
<a href={jobPosition.url}>{jobPosition.companyName}</a>
84+
</div>
85+
</Company>
86+
<JobTitle href={jobPosition.url}>{jobPosition.name}</JobTitle>
87+
</Card>
88+
))}
89+
</Container>
90+
</Typography>
91+
</Block>
92+
);
93+
}
94+
95+
const Block = styled(VelogResponsive)`
96+
${media.small} {
97+
h4 {
98+
padding-left: 1rem;
99+
padding-right: 1rem;
100+
}
101+
}
102+
`;
103+
const Container = styled.div`
104+
display: flex;
105+
gap: 1rem;
106+
a {
107+
display: block;
108+
color: inherit;
109+
&:hover {
110+
text-decoration: none;
111+
color: inherit;
112+
}
113+
}
114+
${media.small} {
115+
padding-left: 0.5rem;
116+
padding-right: 0.5rem;
117+
gap: 0.5rem;
118+
overflow-x: auto;
119+
overflow-y: hidden;
120+
padding-bottom: 1rem;
121+
}
122+
`;
123+
124+
const Card = styled.div`
125+
width: 25%;
126+
${media.small} {
127+
flex-shrink: 0;
128+
width: 27vw;
129+
}
130+
`;
131+
132+
const Thumbnail = styled.img`
133+
width: 100%;
134+
aspect-ratio: 400 / 292;
135+
object-fit: cover;
136+
border-radius: 4px;
137+
`;
138+
139+
const Company = styled.div`
140+
display: flex;
141+
gap: 0.5rem;
142+
img {
143+
display: block;
144+
width: 16px;
145+
height: 16px;
146+
}
147+
font-size: 10px;
148+
align-items: center;
149+
color: ${themedPalette.text2};
150+
${ellipsis};
151+
margin-bottom: 0.5rem;
152+
`;
153+
154+
const JobTitle = styled.a`
155+
font-size: 12px;
156+
font-weight: 600;
157+
line-height: 1.25;
158+
`;
159+
160+
export default JobPositions;

src/containers/post/PostViewer.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import gtag from '../../lib/gtag';
4646
import FollowButton from '../../components/common/FollowButton';
4747
import { BANNER_ADS } from '../../lib/graphql/ad';
4848
import PostBanner from '../../components/post/PostBanner';
49+
import JobPositions from '../../components/post/JobPositions';
4950

5051
const UserProfileWrapper = styled(VelogResponsive)`
5152
margin-top: 16rem;
@@ -75,6 +76,7 @@ const PostViewer: React.FC<PostViewerProps> = ({
7576
}) => {
7677
const setShowFooter = useSetShowFooter();
7778
const [showRecommends, setShowRecommends] = useState(false);
79+
7880
useEffect(() => {
7981
window.scrollTo(0, 0);
8082
}, [username, urlSlug]);
@@ -251,6 +253,40 @@ const PostViewer: React.FC<PostViewerProps> = ({
251253
}
252254
}, [customAd, shouldShowBanner, shouldShowFooterBanner]);
253255

256+
const category = useMemo(() => {
257+
const frontendKeywords = ['프런트엔드', '리액트', 'vue', 'react', 'next'];
258+
const backendKeywords = ['백엔드', '서버', '데이터베이스', 'db'];
259+
const aiKeywords = ['인공지능', '머신러닝', '딥러닝', 'ai'];
260+
const mobileKeywords = [
261+
'모바일',
262+
'안드로이드',
263+
'ios',
264+
'react native',
265+
'플러터',
266+
'flutter',
267+
];
268+
const pythonKeywords = ['파이썬', 'python'];
269+
const nodeKeywords = ['노드', 'node', 'express', 'koa', 'nest'];
270+
271+
if (!data?.post) return null;
272+
const { post } = data;
273+
const merged = post.title
274+
.concat(post.tags.join(','))
275+
.concat(post.body)
276+
.toLowerCase();
277+
if (frontendKeywords.some((keyword) => merged.includes(keyword)))
278+
return 'frontend';
279+
if (backendKeywords.some((keyword) => merged.includes(keyword)))
280+
return 'backend';
281+
if (aiKeywords.some((keyword) => merged.includes(keyword))) return 'ai';
282+
if (mobileKeywords.some((keyword) => merged.includes(keyword)))
283+
return 'mobile';
284+
if (pythonKeywords.some((keyword) => merged.includes(keyword)))
285+
return 'python';
286+
if (nodeKeywords.some((keyword) => merged.includes(keyword))) return 'node';
287+
return null;
288+
}, [data]);
289+
254290
const onRemove = async () => {
255291
if (!data || !data.post) return;
256292
setIsRemoveLoading(true);
@@ -486,10 +522,10 @@ const PostViewer: React.FC<PostViewerProps> = ({
486522
/>
487523
</UserProfileWrapper>
488524
<LinkedPostList linkedPosts={post.linked_posts} />
489-
{shouldShowBanner && isContentLongEnough ? (
525+
{shouldShowBanner && isContentLongEnough && customAd ? (
490526
<PostBanner customAd={customAd} />
491527
) : null}
492-
{shouldShowFooterBanner ? (
528+
{shouldShowFooterBanner && customAd ? (
493529
<PostBanner isDisplayAd={true} customAd={customAd} />
494530
) : null}
495531
<PostComments
@@ -498,6 +534,10 @@ const PostViewer: React.FC<PostViewerProps> = ({
498534
postId={post.id}
499535
ownPost={post.user.id === userId}
500536
/>
537+
{(shouldShowBanner || shouldShowFooterBanner) && !customAd ? (
538+
<JobPositions category={category} />
539+
) : null}
540+
501541
{showRecommends ? (
502542
<RelatedPost postId={post.id} showAds={post?.user.id !== userId} />
503543
) : null}

src/lib/graphql/ad.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ export type Ad = {
88
url: string;
99
};
1010

11+
export type JobPosition = {
12+
id: string;
13+
name: string;
14+
companyName: string;
15+
companyLogo: string;
16+
thumbnail: string;
17+
url: string;
18+
};
19+
1120
export const BANNER_ADS = gql`
1221
query BannerAds($writerUsername: String!) {
1322
bannerAds(writer_username: $writerUsername) {
@@ -19,3 +28,16 @@ export const BANNER_ADS = gql`
1928
}
2029
}
2130
`;
31+
32+
export const JOB_POSITIONS = gql`
33+
query JobPositions($category: String) {
34+
jobPositions(category: $category) {
35+
id
36+
name
37+
companyName
38+
companyLogo
39+
thumbnail
40+
url
41+
}
42+
}
43+
`;

0 commit comments

Comments
 (0)