Skip to content

Commit 326a28e

Browse files
authored
Merge pull request #28 from Leets-Official/feat/#25/today-recommendation-ui
feat: 오늘의 추천 페이지 UI 구현
2 parents b7ecb3a + 864422a commit 326a28e

38 files changed

Lines changed: 1385 additions & 26 deletions

src/components/common/Button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const buttonVariants = cva(
1010
solid:
1111
'bg-primary-400 text-gray-900 hover:bg-primary-500 active:bg-primary-600 disabled:bg-gray-200 disabled:text-gray-600',
1212
outline:
13-
'border border-primary-400 bg-white text-gray-900 hover:bg-gray-50 active:bg-gray-100 disabled:border-gray-200 disabled:text-gray-300',
13+
'border border-primary-400 bg-white text-gray-900 hover:bg-gray-50 active:bg-gray-100 disabled:border-gray-200 disabled:text-gray-600',
1414
},
1515
},
1616
defaultVariants: {

src/components/common/ResultIcon.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ const resultIconVariants = cva(
1111
{
1212
variants: {
1313
variant: {
14-
success: 'border-primary-400 text-primary-400',
15-
warning: 'border-warning-400 text-warning-400',
16-
danger: 'border-danger-400 text-danger-400',
17-
loading: 'border-info-400 text-info-400',
18-
quiz: 'border-[#5917b8] text-[#5917b8]',
14+
success: 'border-primary-400 text-gray-900',
15+
warning: 'border-warning-400 text-gray-900',
16+
danger: 'border-danger-400 text-gray-900',
17+
loading: 'border-info-400 text-gray-900',
18+
quiz: 'border-[#5917b8] text-gray-900',
1919
},
2020
},
2121
defaultVariants: {

src/components/common/Tag.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,39 @@ import HashIcon from '@/assets/icons/icon-hash.svg?react';
44
import PlusIcon from '@/assets/icons/icon-plus.svg?react';
55
import { cn } from '@/utils/cn';
66

7-
type TagVariant = 'removable' | 'add' | 'hash';
7+
type TagVariant = 'removable' | 'add' | 'hash' | 'plain';
88

99
type TagProps = ComponentPropsWithRef<'button'> & {
1010
variant?: TagVariant;
1111
label: string;
1212
};
1313

14+
const TAG_CLASS_NAME =
15+
'inline-flex h-10 items-center justify-center gap-0.5 rounded-full border border-gray-200 bg-white px-3 text-base leading-6 font-normal text-gray-900';
16+
1417
export default function Tag({
1518
className,
1619
variant = 'removable',
1720
label,
1821
type = 'button',
1922
...props
2023
}: TagProps) {
21-
return (
22-
<button
23-
type={type}
24-
className={cn(
25-
'inline-flex h-10 items-center justify-center gap-0.5 rounded-full border border-gray-200 bg-white px-3 text-base leading-6 font-normal text-gray-900',
26-
className,
27-
)}
28-
{...props}
29-
>
24+
const content = (
25+
<>
3026
{variant === 'add' && <PlusIcon className="size-4" />}
3127
{variant === 'hash' && <HashIcon className="size-4" />}
3228
{label}
3329
{variant === 'removable' && <CloseIcon className="size-4" />}
30+
</>
31+
);
32+
33+
if (variant === 'plain') {
34+
return <span className={cn(TAG_CLASS_NAME, className)}>{content}</span>;
35+
}
36+
37+
return (
38+
<button type={type} className={cn(TAG_CLASS_NAME, className)} {...props}>
39+
{content}
3440
</button>
3541
);
3642
}

src/components/layout/AppShell.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { type ReactNode } from 'react';
2+
import { useNavigate } from 'react-router';
3+
import Footer from '@/components/layout/Footer';
4+
import Header from '@/components/layout/Header';
5+
import { cn } from '@/utils/cn';
6+
7+
const NAV_TABS = [
8+
{ key: 'today', label: '오늘의 추천', path: '/today' },
9+
{ key: 'explore', label: '탐색', path: '/explore' },
10+
{ key: 'saved', label: '저장 목록', path: '/today/revisit' },
11+
] as const;
12+
13+
type NavTabKey = (typeof NAV_TABS)[number]['key'];
14+
15+
interface AppShellProps {
16+
variant?: 'home' | 'guest';
17+
activeTab?: NavTabKey;
18+
children: ReactNode;
19+
className?: string;
20+
}
21+
22+
export default function AppShell({
23+
variant = 'home',
24+
activeTab = 'today',
25+
children,
26+
className,
27+
}: AppShellProps) {
28+
const navigate = useNavigate();
29+
const activeIndex = NAV_TABS.findIndex((tab) => tab.key === activeTab);
30+
31+
return (
32+
<div className="flex min-h-screen flex-col bg-gray-50">
33+
{variant === 'home' ? (
34+
<Header
35+
variant="tab"
36+
tabs={NAV_TABS.map(({ label }) => ({ label }))}
37+
activeIndex={activeIndex}
38+
onTabChange={(index) => navigate(NAV_TABS[index].path)}
39+
/>
40+
) : (
41+
<Header />
42+
)}
43+
<main
44+
className={cn('flex flex-1 flex-col items-center justify-center px-3 py-10', className)}
45+
>
46+
{children}
47+
</main>
48+
<Footer />
49+
</div>
50+
);
51+
}

src/components/layout/Header.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,20 +99,22 @@ export default function Header(props: HeaderProps) {
9999
if (props.variant === 'tab') {
100100
return (
101101
<HeaderShell className={className}>
102-
<HeaderLogo>Job.is</HeaderLogo>
103-
<HeaderTabNav
104-
tabs={props.tabs}
105-
activeIndex={props.activeIndex}
106-
onTabChange={props.onTabChange}
107-
/>
102+
<div className="flex items-center gap-10">
103+
<HeaderLogo>Job.is</HeaderLogo>
104+
<HeaderTabNav
105+
tabs={props.tabs}
106+
activeIndex={props.activeIndex}
107+
onTabChange={props.onTabChange}
108+
/>
109+
</div>
108110
{props.profileImageUrl ? (
109111
<img
110112
src={props.profileImageUrl}
111113
alt=""
112-
className="size-9 shrink-0 rounded-full object-cover"
114+
className="mr-2 size-9 shrink-0 rounded-full object-cover"
113115
/>
114116
) : (
115-
<span className="size-9 shrink-0 rounded-full bg-gray-100" />
117+
<span className="mr-2 size-9 shrink-0 rounded-full bg-gray-100" />
116118
)}
117119
</HeaderShell>
118120
);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import ArrowRightIcon from '@/assets/icons/icon-arrow-right.svg?react';
2+
import { Button } from '@/components/common';
3+
4+
interface JobDetailApplyPanelProps {
5+
onApply?: () => void;
6+
onIntendToApply?: () => void;
7+
onSave?: () => void;
8+
}
9+
10+
export default function JobDetailApplyPanel({
11+
onApply,
12+
onIntendToApply,
13+
onSave,
14+
}: JobDetailApplyPanelProps) {
15+
const secondaryActions = [
16+
{ label: '지원 의향', onClick: onIntendToApply },
17+
{ label: '저장', onClick: onSave },
18+
{ label: '관심없음', onClick: undefined },
19+
];
20+
21+
return (
22+
<div className="flex w-full flex-col gap-2">
23+
<Button className="w-full" onClick={onApply} disabled={!onApply}>
24+
지원하기
25+
<ArrowRightIcon className="size-5" />
26+
</Button>
27+
<div className="flex w-full gap-2">
28+
{secondaryActions.map(({ label, onClick }) => (
29+
<Button
30+
key={label}
31+
variant="outline"
32+
className="flex-1 px-2"
33+
onClick={onClick}
34+
disabled={!onClick}
35+
>
36+
{label}
37+
</Button>
38+
))}
39+
</div>
40+
</div>
41+
);
42+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { JobDetailGlanceItem } from '@/features/jobs/types/jobDetail';
2+
3+
interface JobDetailAtAGlanceProps {
4+
items: JobDetailGlanceItem[];
5+
}
6+
7+
export default function JobDetailAtAGlance({ items }: JobDetailAtAGlanceProps) {
8+
return (
9+
<div className="flex w-full flex-col gap-2">
10+
<p className="text-label-small font-semibold tracking-wide text-text-tertiary uppercase">
11+
At a Glance
12+
</p>
13+
<div className="grid grid-cols-3 gap-2">
14+
{items.map((item) => (
15+
<div
16+
key={item.label}
17+
className="flex flex-col gap-1 rounded-sm border border-gray-200 bg-gray-50 p-4"
18+
>
19+
<span className="text-body-small font-medium text-text-tertiary">{item.label}</span>
20+
<span className="text-body-large font-bold text-text-primary">{item.value}</span>
21+
</div>
22+
))}
23+
</div>
24+
</div>
25+
);
26+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { JobDetailContentSection } from '@/features/jobs/types/jobDetail';
2+
3+
interface JobDetailContentProps {
4+
sections: JobDetailContentSection[];
5+
techStack: string[];
6+
}
7+
8+
export default function JobDetailContent({ sections, techStack }: JobDetailContentProps) {
9+
return (
10+
<div className="flex w-full flex-col gap-3">
11+
<p className="text-heading-xxsmall font-bold text-text-primary">상세 내용</p>
12+
<div className="flex w-full flex-col gap-4 rounded-sm border border-gray-200 bg-white p-4">
13+
{sections.map((section) => (
14+
<div key={section.heading} className="flex flex-col gap-1">
15+
<p className="text-body-small font-bold text-text-primary">{section.heading}</p>
16+
<ul className="flex flex-col gap-0.5 pl-4">
17+
{section.items.map((item, index) => (
18+
<li
19+
key={`${section.heading}-${index}`}
20+
className="list-disc text-body-small font-medium text-text-secondary"
21+
>
22+
{item}
23+
</li>
24+
))}
25+
</ul>
26+
</div>
27+
))}
28+
<div className="flex flex-col gap-1">
29+
<p className="text-body-small font-bold text-text-primary">기술 스택</p>
30+
<div className="flex flex-wrap gap-2">
31+
{techStack.map((tech) => (
32+
<span
33+
key={tech}
34+
className="inline-flex h-8 items-center rounded-full border border-gray-200 bg-white px-3 text-body-small font-medium text-text-secondary"
35+
>
36+
{tech}
37+
</span>
38+
))}
39+
</div>
40+
</div>
41+
</div>
42+
</div>
43+
);
44+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
interface JobDetailEditorNoteProps {
2+
note: string;
3+
}
4+
5+
export default function JobDetailEditorNote({ note }: JobDetailEditorNoteProps) {
6+
return (
7+
<div className="flex w-full flex-col gap-2">
8+
<p className="text-heading-xxsmall font-bold text-text-primary">Editor&apos;s Note</p>
9+
<div className="w-full rounded-sm bg-gray-50 p-4">
10+
<p className="whitespace-pre-line text-body-small font-medium text-text-secondary">
11+
{note}
12+
</p>
13+
</div>
14+
</div>
15+
);
16+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Badge } from '@/components/common';
2+
import type {
3+
JobDetailFitCriterionItem,
4+
JobDetailFitStatus,
5+
} from '@/features/jobs/types/jobDetail';
6+
import { cn } from '@/utils/cn';
7+
8+
const STATUS_STYLE: Record<
9+
JobDetailFitStatus,
10+
{ icon: string; label: string; badgeColor: 'ok' | 'est' | 'warn'; boxClassName: string }
11+
> = {
12+
met: { icon: '✓', label: '충족', badgeColor: 'ok', boxClassName: 'bg-primary-50' },
13+
estimated: { icon: '~', label: '추정', badgeColor: 'est', boxClassName: 'bg-info-50' },
14+
caution: { icon: '!', label: '주의', badgeColor: 'warn', boxClassName: 'bg-warning-50' },
15+
};
16+
17+
interface JobDetailFitCriteriaProps {
18+
items: JobDetailFitCriterionItem[];
19+
}
20+
21+
export default function JobDetailFitCriteria({ items }: JobDetailFitCriteriaProps) {
22+
return (
23+
<div className="flex w-full flex-col gap-2">
24+
<p className="text-heading-xxsmall font-bold text-text-primary">당신의 기준으로 따져봤어요</p>
25+
<div className="flex w-full flex-col gap-2">
26+
{items.map((item, index) => {
27+
const status = STATUS_STYLE[item.status];
28+
29+
return (
30+
<div
31+
key={`${item.title}-${index}`}
32+
className={cn('flex items-start gap-2 rounded-sm p-4', status.boxClassName)}
33+
>
34+
<Badge type="solid" color={status.badgeColor} className="mt-0.5 shrink-0">
35+
{status.icon} {status.label}
36+
</Badge>
37+
<p className="text-body-small font-medium text-text-primary">
38+
<span className="font-bold">{item.title}</span>{item.description}
39+
</p>
40+
</div>
41+
);
42+
})}
43+
</div>
44+
</div>
45+
);
46+
}

0 commit comments

Comments
 (0)