Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#3] 도서 목록/검색 화면 개발 #4

Merged
merged 13 commits into from
Jan 21, 2025
Merged
16 changes: 14 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
/* config options here */
images: {
dangerouslyAllowSVG: true,
remotePatterns: [
{
protocol: 'https',
hostname: 'placehold.co',
},
{
protocol: 'https',
hostname: 'image.aladin.co.kr',
},
],
},
};

export default nextConfig;
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
]
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ky": "^1.7.4",
"lucide-react": "^0.471.0",
"next": "15.1.4",
"react": "^19.0.0",
Expand Down
43 changes: 43 additions & 0 deletions pnpm-lock.yaml

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

Empty file removed src/entities/.gitkeep
Empty file.
3 changes: 3 additions & 0 deletions src/entities/book/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as BookCard } from './ui/BookCard';
export { default as BookList } from './ui/BookList';
export type { Book } from './model/book';
8 changes: 8 additions & 0 deletions src/entities/book/model/book.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface Book {
id: number;
title: string;
author: string;
publisher: string;
publishDate: string;
coverImage: string;
}
33 changes: 33 additions & 0 deletions src/entities/book/ui/BookCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Image from 'next/image';
import { Card, CardContent } from '@/shared/ui/card';
import { Book } from '../model/book';

interface BookCardProps {
book: Book;
}

export default function BookCard({ book }: BookCardProps) {
Copy link

Choose a reason for hiding this comment

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

이 컴포넌트는 pure 하기 때문에 과감하게 memo 한 결과를 export 해도 됨

const { coverImage, title, author, publisher, publishDate } = book;

return (
<Card className="overflow-hidden">
<div className="flex flex-row sm:flex-col">
<div className="w-1/3 sm:w-full">
<Image
src={coverImage}
alt={`${title} 표지`}
width={200}
height={300}
className="size-full object-contain sm:h-64 sm:object-cover"
/>
</div>
<CardContent className="w-2/3 p-4 sm:w-full">
<h3 className="mb-1 text-sm font-bold sm:mb-2 sm:text-lg">{title}</h3>
<p className="mb-1 text-xs text-gray-600 sm:text-sm">{author}</p>
<p className="mb-1 text-xs text-gray-600 sm:text-sm">{publisher}</p>
<p className="text-xs text-gray-600 sm:text-sm">{publishDate}</p>
</CardContent>
</div>
</Card>
);
}
19 changes: 19 additions & 0 deletions src/entities/book/ui/BookList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Book } from '../model/book';
import BookCard from './BookCard';

interface BookListProps {
books: Book[];
}

export default function BookList({ books }: BookListProps) {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{books.map((book) => (
<BookCard
key={book.id}
book={book}
/>
))}
</div>
);
}
Empty file removed src/features/.gitkeep
Empty file.
2 changes: 2 additions & 0 deletions src/features/book-filter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as BookFilter } from './ui/BookFilter';
export type { FilterType } from './model/filter-type';
1 change: 1 addition & 0 deletions src/features/book-filter/model/filter-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type FilterType = 'new' | 'bestseller';
32 changes: 32 additions & 0 deletions src/features/book-filter/ui/BookFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Sparkles, TrendingUp } from 'lucide-react';
import { FilterType } from '../model/filter-type';

interface BookFilterProps {
currentFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
}

export default function BookFilter({ currentFilter, onFilterChange }: BookFilterProps) {
return (
<div className="flex space-x-8 border-b">
<button
className={`flex flex-col items-center pb-2 ${
currentFilter === 'new' ? 'border-b-2 border-primary text-primary' : 'text-gray-500'
}`}
onClick={() => onFilterChange('new')}
>
<Sparkles className="mb-1 size-6" />
<span className="text-xs">신간</span>
</button>
<button
className={`flex flex-col items-center pb-2 ${
currentFilter === 'bestseller' ? 'border-b-2 border-primary text-primary' : 'text-gray-500'
}`}
onClick={() => onFilterChange('bestseller')}
>
<TrendingUp className="mb-1 size-6" />
<span className="text-xs">베스트셀러</span>
</button>
</div>
);
}
1 change: 1 addition & 0 deletions src/features/book-search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as BookSearch } from './ui/BookSearch';
47 changes: 47 additions & 0 deletions src/features/book-search/ui/BookSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';

interface BookSearchProps {
initialSearchTerm?: string;
}

export default function BookSearch({ initialSearchTerm = '' }: BookSearchProps) {
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
Copy link

Choose a reason for hiding this comment

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

debounce 사용 가능성도 염두에 두면 좋을 것 같아요


const router = useRouter();

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
router.push(`/?q=${searchTerm}`);
};

return (
<form
className="mb-4 flex gap-2"
onSubmit={handleSubmit}
>
<Input
aria-label="도서 검색"
className="w-full"
type="search"
placeholder="제목, 저자로 도서 검색.."
value={searchTerm}
onChange={handleChange}
/>
<Button
aria-label="검색하기"
type="submit"
>
검색
</Button>
</form>
);
}
23 changes: 23 additions & 0 deletions src/pages/home/api/dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface BookDTO {
title: string;
link: string;
author: string;
pubDate: string;
description: string;
isbn: string;
isbn13: string;
itemId: number;
priceSales: number;
priceStandard: number;
mallType: string;
stockStatus: string;
mileage: number;
cover: string;
categoryId: number;
categoryName: string;
publisher: string;
salesPoint: number;
adult: boolean;
fixedPrice: boolean;
customerReviewRank: number;
}
54 changes: 54 additions & 0 deletions src/pages/home/api/fetch-books.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { aladinApi } from '@/shared/api/aladin-client';
import { BookDTO } from './dto';
import { adaptBookDTO } from './mapper';

interface Response {
version: string;
title: string;
link: string;
pubDate: string;
totalResults: number;
startIndex: number;
itemsPerPage: number;
query: string;
searchCategoryId: number;
searchCategoryName: string;
item: BookDTO[];
}

interface FetchBooksParams {
q?: string;
}

export async function fetchBooks({ q }: FetchBooksParams) {
const response = q ? fetchSearchBooks(q) : fetchNewBooks();
const data = await response.json<Response>();
return data.item.map(adaptBookDTO);
}

const baseSearchParams = {
MaxResults: '10',
start: '1',
SearchTarget: 'Book',
Cover: 'Big',
output: 'js',
Version: '20131101',
};
Comment on lines +29 to +36
Copy link

Choose a reason for hiding this comment

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

Suggested change
const baseSearchParams = {
MaxResults: '10',
start: '1',
SearchTarget: 'Book',
Cover: 'Big',
output: 'js',
Version: '20131101',
};
const baseSearchParams = {
MaxResults: '10',
start: '1',
SearchTarget: 'Book',
Cover: 'Big',
output: 'js',
Version: '20131101',
} as const;


function fetchNewBooks() {
const searchParams = new URLSearchParams({
...baseSearchParams,
QueryType: 'ItemNewAll',
});

return aladinApi.get('ItemList.aspx', { searchParams });
}

function fetchSearchBooks(q: string) {
const searchParams = new URLSearchParams({
...baseSearchParams,
Query: q,
});

return aladinApi.get('ItemSearch.aspx', { searchParams });
}
13 changes: 13 additions & 0 deletions src/pages/home/api/mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Book } from '@/entities/book';
import { BookDTO } from './dto';

export function adaptBookDTO(dto: BookDTO): Book {
return {
id: dto.itemId,
title: dto.title,
author: dto.author,
publisher: dto.publisher,
publishDate: dto.pubDate,
coverImage: dto.cover,
};
}
23 changes: 23 additions & 0 deletions src/pages/home/ui/BookControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import { useState } from 'react';
import { BookFilter, FilterType } from '@/features/book-filter';
import { BookSearch } from '@/features/book-search';

interface BookControlsProps {
initialSearchTerm?: string;
}

export default function BookControls({ initialSearchTerm }: BookControlsProps) {
const [filter, setFilter] = useState<FilterType>('new');

return (
<div className="mb-8">
<BookSearch initialSearchTerm={initialSearchTerm} />
<BookFilter
currentFilter={filter}
onFilterChange={setFilter}
/>
</div>
);
}
Loading
Loading