Skip to content

Commit 177d39c

Browse files
authored
Merge pull request #75 from Leets-Official/71-feat/구매-페이지-제작
[Feat] 구매 페이지 제작
2 parents adacff9 + 624331e commit 177d39c

16 files changed

Lines changed: 654 additions & 11 deletions

File tree

src/app/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export default [
55
route('signup', 'routes/signup.tsx'),
66
layout('layout/MainLayout.tsx', [
77
index('routes/(main)/_index.tsx'),
8+
route('buy', 'routes/(main)/buy.tsx'),
9+
route('buy/:id', 'routes/(main)/buy.detail.tsx'),
810
route('sell', 'routes/(main)/sell.tsx'),
911
route('sell/confirm', 'routes/(main)/sell.confirm.tsx'),
1012
route('mypage', 'routes/(main)/mypage.tsx'),
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { BuyDetailPage } from '@pages/buy';
2+
3+
export default BuyDetailPage;

src/app/routes/(main)/buy.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { BuyPage } from '@pages/buy';
2+
3+
export default BuyPage;

src/pages/buy/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { BuyPage, BuyDetailPage } from './ui';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { BATTERY_OPTIONS, PRODUCT_CONDITION_OPTIONS, SCRATCH_OPTIONS, SCREEN_OPTIONS } from '@pages/sell/model/options';
2+
import type { BuyItem } from '@shared/types/buy';
3+
4+
export const buildDetailInfo = (item: BuyItem) => {
5+
const conditionLabel =
6+
PRODUCT_CONDITION_OPTIONS.find((option) => option.value === item.condition)?.label.split('-')[0] ?? '';
7+
const scratchLabel = SCRATCH_OPTIONS.find((option) => option.value === item.scratch)?.label ?? '';
8+
const crackLabel = SCREEN_OPTIONS.find((option) => option.value === item.screenCrack)?.label ?? '';
9+
const batteryLabel = BATTERY_OPTIONS.find((option) => option.value === item.battery)?.label ?? '';
10+
11+
return [conditionLabel, scratchLabel, crackLabel, batteryLabel].join(' · ');
12+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { BUY_ITEMS } from '@shared/mocks/data/buy';
2+
3+
// NOTE: API 연동 후 mocks → 서버 데이터로 교체 예정
4+
export const getBuyItems = () => BUY_ITEMS;
5+
6+
// NOTE: API 연동 후 단건 조회 API로 교체 예정
7+
export const getBuyItemById = (id?: string) => {
8+
if (!id) {
9+
return undefined;
10+
}
11+
return BUY_ITEMS.find((entry) => entry.id === id);
12+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { PRICE_RANGES } from '@shared/mocks/data/buy';
2+
import type { BuyItem } from '@shared/types/buy';
3+
4+
type FilterParams = {
5+
items: BuyItem[];
6+
query: string;
7+
selectedManufacturers: string[];
8+
selectedModels: string[];
9+
selectedPrices: string[];
10+
availableOnly: boolean;
11+
};
12+
13+
export const filterBuyItems = ({
14+
items,
15+
query,
16+
selectedManufacturers,
17+
selectedModels,
18+
selectedPrices,
19+
availableOnly,
20+
}: FilterParams) => {
21+
const trimmedQuery = query.trim();
22+
23+
return items.filter((item) => {
24+
const matchesManufacturer = selectedManufacturers.length === 0 || selectedManufacturers.includes(item.brand);
25+
const matchesModel = selectedModels.length === 0 || selectedModels.includes(item.model);
26+
const matchesPrice =
27+
selectedPrices.length === 0 ||
28+
selectedPrices.some((priceId) => {
29+
const range = PRICE_RANGES.find((price) => price.id === priceId);
30+
if (!range) {
31+
return false;
32+
}
33+
return item.priceValue >= range.min && item.priceValue < range.max;
34+
});
35+
const matchesAvailability = !availableOnly || item.available;
36+
const matchesQuery = trimmedQuery.length === 0 || item.title.includes(trimmedQuery);
37+
38+
return matchesManufacturer && matchesModel && matchesPrice && matchesAvailability && matchesQuery;
39+
});
40+
};

src/pages/buy/ui/BuyDetailPage.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { ROUTES } from '@shared/constants';
2+
import { Button } from '@shared/ui/Button';
3+
import { Card } from '@shared/ui/Card';
4+
import { FavoriteButton } from '@shared/ui/FavoriteButton';
5+
import { Profile } from '@shared/ui/Profile';
6+
import { useMemo } from 'react';
7+
import { Link, useNavigate, useParams } from 'react-router';
8+
import { buildDetailInfo } from '../model/buildDetailInfo';
9+
import { getBuyItemById, getBuyItems } from '../model/buyRepository';
10+
11+
export default function BuyDetailPage() {
12+
const navigate = useNavigate();
13+
const { id } = useParams();
14+
15+
const item = useMemo(() => getBuyItemById(id), [id]);
16+
17+
const detailInfo = useMemo(() => (item ? buildDetailInfo(item) : ''), [item]);
18+
19+
const similarItems = useMemo(() => {
20+
if (!item) {
21+
return [];
22+
}
23+
// NOTE: API 연동 시 추천/유사 상품 서버 로직으로 대체
24+
return getBuyItems()
25+
.filter((entry) => entry.id !== item.id && entry.model === item.model)
26+
.slice(0, 4);
27+
}, [item]);
28+
29+
if (!item) {
30+
return (
31+
<main className="min-h-screen bg-white pb-20">
32+
<div className="mx-auto w-full max-w-[1200px] px-4 pt-10">
33+
<section className="bg-white px-6 py-16 text-center text-gray-600">
34+
상품을 찾을 수 없어요.
35+
<div className="mt-6 flex justify-center">
36+
<Button size="auto" onClick={() => navigate(ROUTES.BUY, { viewTransition: true })}>
37+
구매 목록으로 돌아가기
38+
</Button>
39+
</div>
40+
</section>
41+
</div>
42+
</main>
43+
);
44+
}
45+
46+
return (
47+
<main className="min-h-screen bg-white pb-20">
48+
<div className="mx-auto w-full max-w-[1200px] px-4 pt-10">
49+
<section className="bg-white pb-12">
50+
<div className="grid gap-6 lg:grid-cols-[1fr_590px]">
51+
<div className="h-[568px] overflow-hidden rounded-(--radius-l) bg-gray-50">
52+
<img src={item.image} alt={item.title} className="h-full w-full object-cover" />
53+
</div>
54+
<div className="flex flex-col gap-4">
55+
<div>
56+
<h1 className="typo-title-2 text-gray-900">{item.title}</h1>
57+
<p className="mt-2 text-[20px] font-semibold text-gray-900">{item.priceLabel}</p>
58+
</div>
59+
60+
<div className="border-y border-gray-100 py-4 text-[14px] text-gray-600">
61+
<div className="flex flex-col gap-2">
62+
<div className="flex gap-4">
63+
<span className="w-16 text-gray-500">제조사</span>
64+
<span className="text-gray-800">{item.specs.manufacturer}</span>
65+
</div>
66+
<div className="flex gap-4">
67+
<span className="w-16 text-gray-500">모델명</span>
68+
<span className="text-gray-800">{item.specs.model}</span>
69+
</div>
70+
<div className="flex gap-4">
71+
<span className="w-16 text-gray-500">색상</span>
72+
<span className="text-gray-800">{item.specs.color}</span>
73+
</div>
74+
<div className="flex gap-4">
75+
<span className="w-16 text-gray-500">저장</span>
76+
<span className="text-gray-800">{item.specs.storage}</span>
77+
</div>
78+
<div className="flex gap-4">
79+
<span className="w-16 text-gray-500">배터리</span>
80+
<span className="text-gray-800">{item.specs.battery}</span>
81+
</div>
82+
</div>
83+
<p className="mt-3 text-[12px] text-gray-400">{detailInfo}</p>
84+
</div>
85+
86+
<div className="text-[14px] text-gray-700">
87+
<ul className="space-y-1 text-gray-700">
88+
{item.description.map((line) => (
89+
<li key={line}>{line}</li>
90+
))}
91+
</ul>
92+
</div>
93+
94+
<div className="mt-4 flex items-center gap-3">
95+
<Button size="full">판매자와 연락하기</Button>
96+
<FavoriteButton ariaLabel="찜하기" />
97+
</div>
98+
</div>
99+
</div>
100+
101+
<div className="mt-6 flex items-center gap-3">
102+
<Profile size="sm" image={item.seller.profileImage} alt={`${item.seller.nickname} 프로필`} />
103+
<span className="typo-body-2 text-gray-900">{item.seller.nickname}</span>
104+
</div>
105+
106+
<section className="mt-[157px]">
107+
<h2 className="typo-title-3 text-gray-900">위 제품과 비슷한 상품</h2>
108+
<div className="mt-6 grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-4 lg:gap-[22px]">
109+
{similarItems.map((entry) => (
110+
<Link key={entry.id} to={`${ROUTES.BUY}/${entry.id}`} className="block focus-visible:outline-none">
111+
<Card
112+
image={entry.image}
113+
title={entry.title}
114+
price={entry.priceLabel}
115+
date={entry.dateLabel}
116+
className="h-[399px] w-[282px]"
117+
/>
118+
</Link>
119+
))}
120+
</div>
121+
</section>
122+
</section>
123+
</div>
124+
</main>
125+
);
126+
}

0 commit comments

Comments
 (0)