
๐ฏ Zustand๋ฅผ ํตํด ์ ์ญ์ํ๋ฅผ ๊ด๋ฆฌํ๊ณ ๋ก๊ทธ์ธ ํ์ด์ง์ ๋์ ๋ชฉ๋ก ํ์ด์ง๋ฅผ ์ ์ํฉ๋๋ค.
[๋ก๊ทธ์ธ ์ฑ๊ณต]
โ
[ํ ํฐ ํ๋]
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
[์ ์ญ ์ํ] [ํ ํฐ ์ ์ฅ]
isLoggedIn:true localStorage
(Zustand) โ
[http client ์ค์ ]
headers.Authorization์ ํ ํฐ ํฌํจ
๋ก๊ทธ์ธ์ ์ฑ๊ณตํ๋ฉด ํ ํฐ๐์ด ๋ฐํ๋๊ณ , ๋ ๊ณณ์ ์ฐ์ด๊ฒ ๋ฉ๋๋ค.
ํ๋๋, ์ ์ญ ์ํ์ ์ ์ฅํ์ฌ ๋ก๊ทธ์ธ ์ฌ๋ถ๋ฅผ ๊ด๋ฆฌํฉ๋๋ค.
๋๋จธ์ง๋, localStorage์ ์ ์ฅํ์ฌ ์๋ก๊ณ ์นจ์ ํด๋ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์งํ๊ฒ ํฉ๋๋ค.
React ์ํ ๊ด๋ฆฌ๋ฅผ ๊ฐ๋จํ๊ณ ์ง๊ด์ ์ผ๋ก ํด์ฃผ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์
๋๋ค. Redux์ฒ๋ผ ๋ณต์กํ ์ค์ ์์ด ์ ์ญ ์ํ๋ฅผ ๋ง๋ค๊ณ ๊ด๋ฆฌํ ์ ์์ต๋๋ค.

npm install zustand
import { create } from 'zustand';
interface StoreState {
isLoggedIn: boolean;
storeLogin: (token: string) => void;
storeLogout: () => void;
}
const getToken = () => {
const token = localStorage.getItem('token');
return token;
};
const setToken = (token: string) => {
localStorage.setItem('token', token);
};
const removeToken = () => {
localStorage.removeItem('token');
};
export const useAuthStore = create<StoreState>((set) => ({
isLoggedIn: getToken() ? true : false,
storeLogin: (token: string) => {
set({ isLoggedIn: true });
setToken(token);
},
storeLogout: () => {
set({ isLoggedIn: false });
removeToken();
},
}));
useAuthStore : ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์ญ์ผ๋ก ๊ด๋ฆฌํ๋ ์ปค์คํ
ํ
์
๋๋ค.
create((set) => ({...}) : store(์ํ ์ ์ฅ์)๋ฅผ ๋ง๋๋ ํจ์์ด๋ฉฐ, set์ ํตํด ์ํ๋ฅผ ๋ณ๊ฒฝํฉ๋๋ค.
isLoggedIn : ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์ฅํฉ๋๋ค. (true or false)
storeLogin : ํ ํฐ์ ์ ์ฅํ๊ณ , ๋ก๊ทธ์ธ ์ํ๋ก ๋ณ๊ฒฝํฉ๋๋ค.
storeLogout : ํ ํฐ์ ์ญ์ ํ๊ณ , ๋ก๊ทธ์์ ์ํ๋ก ๋ณ๊ฒฝํฉ๋๋ค.
<Books />
โโโ <BookFilter /> โ ์นดํ
๊ณ ๋ฆฌ ํํฐ
โโโ <BooksViewSwitcher /> โ ์ฑ
๋ทฐ ํํฐ (๊ทธ๋ฆฌ๋, ๋ฆฌ์คํธ)
โโโ <BookList /> โ ๋์ ๋ชฉ๋ก
โ โโโ <BookItem /> โ ๊ฐ๋ณ ๋์ ์์ดํ
1
โ โโโ <BookItem /> โ ๊ฐ๋ณ ๋์ ์์ดํ
2
โ โโโ <BookItem /> โ ๊ฐ๋ณ ๋์ ์์ดํ
3
โ โ
โโโ <BookEmpty /> โ ์ฑ
์ด ์์ ๋ ๋ณด์ฌ์ฃผ๋ UI (์กฐ๊ฑด๋ถ ๋ ๋๋ง)
โโโ <Pagination /> โ ํ์ด์ง๋ค์ด์
์ปจํธ๋กค๋ฌ (๋ค์/์ด์ ํ์ด์ง ์ด๋)
โโโโโโโโโโโโโโโโโโโโโโโ ๊ฐ ํ๋ ๋ณ๊ฒฝ โ QS ์์ฒญ
โ <BooksFilter /> โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโ โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ /books โ
โ ?category_id=0 & news=true & view=grid โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ useBooks โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ 1. QS ๋ณ๊ฒฝ ๊ฐ์ง โ โ
โ โ 2. fetch ํธ์ถ โ โ
โ โ 3. books ์ํ ๊ฐฑ์ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ <Books /> โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ ํ๋ฉด ์ํ ๊ฐฑ์ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import styled from 'styled-components';
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
export default function BooksFilter() {
const { category } = useCategory();
const [searchParams, setSearchParams] = useSearchParams();
const handleCategory = (id: number | null) => {
const newSearchParams = new URLSearchParams(searchParams);
if (id === null) {
newSearchParams.delete('category_id');
} else {
newSearchParams.set('category_id', id.toString());
}
setSearchParams(newSearchParams);
};
const handleNews = () => {
const newSearchParams = new URLSearchParams(searchParams);
if (newSearchParams.get('news')) {
newSearchParams.delete('news');
} else {
newSearchParams.set('news', 'true');
}
setSearchParams(newSearchParams);
};
return (
<StyledBooksFilter>
<div className='category'>
{category.map((item) => (
<Button
size='medium'
scheme={item.isActive ? 'primary' : 'normal'}
key={item.id}
onClick={() => handleCategory(item.id)}
>
{item.name}
</Button>
))}
</div>
<div className='new'>
<Button
size='medium'
scheme={searchParams.get('news') ? 'primary' : 'normal'}
onClick={handleNews}
>
์ ๊ฐ
</Button>
</div>
</StyledBooksFilter>
);
}
const StyledBooksFilter = styled.div`
display: flex;
gap: 24px;
.category {
display: flex;
gap: 8px;
}
`;
handleCategory : ์นดํ
๊ณ ๋ฆฌ ๋ฒํผ์ ๋๋ฅด๋ฉด, ํ์ฌ URL์ ๊ฒ์ ํ๋ผ๋ฏธํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ก์ด URLSearchParams ๊ฐ์ฒด๋ฅผ ์์ฑํฉ๋๋ค..
id๊ฐ null์ด๋ฉด category_id๋ฅผ ์ ๊ฑฐํฉ๋๋ค.
๊ทธ๋ ์ง ์์ผ๋ฉด id๋ฅผ ๋ฌธ์์ด๋ก ๋ฐ๊ฟ์ category_id์ ์ค์ ํฉ๋๋ค.
=> ์ต์ข
์ ์ผ๋ก ๋ณ๊ฒฝ๋ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์(setSearchParams)์ํต๋๋ค.
handleNews : ์ ๊ฐ ๋ฒํผ์ ๋๋ฅด๋ฉด, ํ์ฌ URL์ ๊ฒ์ ํ๋ผ๋ฏธํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ก์ด URLSearchParams ๊ฐ์ฒด๋ฅผ ์์ฑํฉ๋๋ค.
news ํ๋ผ๋ฏธํฐ๊ฐ ์์ผ๋ฉด ์ ๊ฑฐํ๊ณ , ์์ผ๋ฉด true๋ก ์ค์ ํฉ๋๋ค.=> ์ต์ข
์ ์ผ๋ก ๋ณ๊ฒฝ๋ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์(setSearchParams)์ํต๋๋ค.
return : category ๋ฐฐ์ด์ ๋๋ฉด์ ๊ฐ๊ฐ์ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ๋ฒํผ์ผ๋ก ๋ ๋๋งํฉ๋๋ค.
ํด๋น ์นดํ
๊ณ ๋ฆฌ๊ฐ isActive์ผ ๊ฒฝ์ฐ primary ์คํ์ผ์ ์ ์ฉํฉ๋๋ค.
๋ฒํผ ํด๋ฆญ ์ ํด๋น ์นดํ
๊ณ ๋ฆฌ id๋ก ํํฐ๋งํฉ๋๋ค.
import styled from 'styled-components';
import Button from '../common/Button';
import { FaList, FaTh } from 'react-icons/fa';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';
import { useEffect } from 'react';
const viewOptions = [
{
value: 'list',
icon: <FaList />,
},
{
value: 'grid',
icon: <FaTh />,
},
];
export type ViewMode = 'grid' | 'list';
export default function BooksViewSwitcher() {
const [searchParams, setSearchParams] = useSearchParams();
const handleSwitch = (value: string) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set(QUERYSTRING.VIEW, value);
setSearchParams(newSearchParams);
};
useEffect(() => {
if (!searchParams.get(QUERYSTRING.VIEW)) {
handleSwitch('grid');
}
}, []);
return (
<StyledBooksViewSwitcher>
{viewOptions.map((option) => (
<Button
key={option.value}
size='medium'
scheme={
searchParams.get(QUERYSTRING.VIEW) === option.value
? 'primary'
: 'normal'
}
onClick={() => handleSwitch(option.value)}
>
{option.icon}
</Button>
))}
</StyledBooksViewSwitcher>
);
}
const StyledBooksViewSwitcher = styled.div`
display: flex;
gap: 8px;
svg {
fill: #fff;
}
`;
viewOptions : ๋ฆฌ์คํธํ ๋ณด๊ธฐ์ ๊ทธ๋ฆฌ๋ํ ๋ณด๊ธฐ์ ๋ํ ์ต์
๋ฐฐ์ด์
๋๋ค.
handleSwitch : ์์ด์ฝ์ ํด๋ฆญํ์ ๋ ํธ์ถ๋์ด URL์ view ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ฅผ ๊ฐฑ์ ํฉ๋๋ค.
ํ์ฌ URL์ ๊ฒ์ ํ๋ผ๋ฏธํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ก์ด URLSearchParams ๊ฐ์ฒด๋ฅผ ์์ฑํฉ๋๋ค.
ํด๋น ๋ณด๊ธฐ ๋ฐฉ์(grid , list)์ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ์ ์ถ๊ฐํฉ๋๋ค.
=> ์ต์ข
์ ์ผ๋ก ๋ณ๊ฒฝ๋ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์(setSearchParams)์ํต๋๋ค.
useEffect : ์ฒ์ ํ์ด์ง๊ฐ ๋ ๋ฉ๋ ๋ ์ฟผ๋ฆฌ์คํธ๋ง์ view๊ฐ ์๋ค๋ฉด, grid๋ฅผ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ์ค์ ํฉ๋๋ค.
import styled from 'styled-components';
import BookItem from './BookItem';
import { Book } from '../../models/book.model';
import { useLocation } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { QUERYSTRING } from '../../constants/querystring';
import { ViewMode } from './BooksViewSwitcher';
interface Props {
books: Book[];
}
export default function BooksList({ books }: Props) {
const [view, setView] = useState<ViewMode>('grid');
const location = useLocation();
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get(QUERYSTRING.VIEW)) {
setView(params.get(QUERYSTRING.VIEW) as ViewMode);
}
}, [location.search]);
return (
<StyledBooksList view={view}>
{books.map((item) => (
<BookItem key={item.id} book={item} view={view} />
))}
</StyledBooksList>
);
}
interface BooksListStyleProps {
view: ViewMode;
}
const StyledBooksList = styled.div<BooksListStyleProps>`
display: grid;
grid-template-columns: ${({ view }) =>
view === 'grid' ? 'repeat(4, 1fr)' : 'repeat(1, 1fr)'};
gap: 24px;
`;
useState<ViewMode>('grid') : ๊ธฐ๋ณธ๊ฐ์ grid๋ก ํด์ ๋ทฐ ๋ชจ๋ ์ํ๋ฅผ ์ ์ธํฉ๋๋ค.
useLocation() : react-router-dom์ ํ
์ผ๋ก ํ์ฌ ํ์ด์ง์ URL์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ต๋๋ค.
๐
useLocation()๋ ์์๋ณด๊ธฐURL :
http://localhost:3000/books?view=grid
hash: URL์ ํด์(#) ๋ถ๋ถkey: URL ์ํ์ ๊ณ ์ ๊ฐ (location์ ์ํ ๋ณ๊ฒฝ์ ์ถ์ )pathname: ํ์ฌ ๊ฒฝ๋กsearch: URL์ ์ฟผ๋ฆฌ ๋ฌธ์์ดstate: ์ํ๊ฐ (์ฌ๊ธฐ์๋ ์ค์ ๋์ง ์์)
๐ค
useSearchParams()์useLocation()์ ์ฐจ์ด๋ ๋ญ๊น?โช๏ธ
useSearchParamsโsearchParams.get('key')/setSearchParams(...)
โ ์ฟผ๋ฆฌ์คํธ๋ง์ ์ง์ ์กฐ์ํ๊ณ ์ถ์ ๋โช๏ธ
useLocationโlocation.pathname,location.search,location.hash
โ ํ์ฌ URL ์ ๋ณด๋ฅผ ์ฝ๊ธฐ๋ง ํ๊ณ ์ถ์ ๋
useEffect : location.search(์ฟผ๋ฆฌ์คํธ๋ง)์ด ๋ฐ๋ ๋๋ง๋ค ์คํ๋๋ ํจ์๋ก URL์์ view ์ฟผ๋ฆฌ๊ฐ์ด ์๋์ง ํ์ธํ๊ณ , ์์ผ๋ฉด ๊ทธ ๊ฐ์ ํ์ฌ ์ํ๋ก ์ค์ ํฉ๋๋ค.
return : books ๋ฐฐ์ด์ ์ํํ๋ฉฐ BookItem์ ํ๋์ฉ ์ถ๋ ฅํ๊ณ , ๊ฐ ์ฑ
์ ๋ณด(book)์ ํ์ฌ ๋ทฐ(view)๋ฅผ ์ ๋ฌํฉ๋๋ค.
import styled from 'styled-components';
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import { formatNumber } from '../../utils/format';
import { FaHeart } from 'react-icons/fa';
import { Theme } from '../../style/theme';
import { ViewMode } from './BooksViewSwitcher';
interface Props {
book: Book;
view?: ViewMode;
}
export default function BookItem({ book, view }: Props) {
return (
<StyledBookItem view={view}>
<div className='img'>
<img src={getImgSrc(book.img)} alt={book.title} />
</div>
<div className='content'>
<h2 className='title'>{book.title}</h2>
<p className='summary'>{book.summary}</p>
<p className='author'>{book.author}</p>
<p className='price'>{formatNumber(book.price)}์</p>
<div className='likes'>
<FaHeart />
<span>{book.likes}</span>
</div>
</div>
</StyledBookItem>
);
}
const StyledBookItem = styled.div<Pick<Props, 'view'>>`
display: flex;
flex-direction: ${({ view }) => (view === 'grid' ? 'column' : 'row')};
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
.img {
border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
overflow: hidden;
width: ${({ view }) => (view === 'grid' ? 'auto' : '160px')};
img {
max-width: 100%;
}
}
.content {
padding: 16px;
position: relative;
flex: ${({ view }) => (view === 'grid' ? 0 : 1)};
.title {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 12px 0;
}
.summary {
font-size: 0.875rem;
color: ${({ theme }) => (theme as Theme).color.background};
margin: 0 0 4px 0;
}
.author {
font-size: 0.875rem;
color: ${({ theme }) => (theme as Theme).color.background};
margin: 0 0 4px 0;
}
.price {
font-size: 1rem;
color: ${({ theme }) => (theme as Theme).color.background};
margin: 0 0 4px 0;
font-weight: 700;
}
.likes {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.875rem;
color: ${({ theme }) => (theme as Theme).color.primary};
margin: 0 0 4px 0;
font-weight: 700;
border: 1px solid ${({ theme }) => (theme as Theme).color.border};
border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
padding: 4px 12px;
position: absolute;
bottom: 16px;
right: 16px;
svg {
color: ${({ theme }) => (theme as Theme).color.primary};
}
}
}
`;
Props : book์ ์ฑ
์ ๋ฐ์ดํฐ๋ฅผ ๋ด๊ณ ์์ผ๋ฉฐ, view๋ ์ ํ์ ํ๋กํผํฐ๋ก ๋ณด๊ธฐ ๋ชจ๋๋ฅผ ๋ํ๋
๋๋ค.
book ๊ฐ์ฒด์ ์ฌ๋ฌ ์์ฑ๊ฐ์ ๊ธฐ๋ฐ์ผ๋ก ์ฑ
์ ์ ๋ณด๋ฅผ ํ๋ฉด์ ํ์ํฉ๋๋ค.
import { FaSmileWink } from 'react-icons/fa';
import styled from 'styled-components';
import Title from '../common/Title';
import { Link } from 'react-router-dom';
export default function BooksEmpty() {
return (
<StyledBooksEmpty>
<div className='icon'>
<FaSmileWink />
</div>
<Title size='large' color='secondary'>
๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค.
</Title>
<p>
<Link to='/books'>์ ์ฒด ๊ฒ์ ๊ฒฐ๊ณผ๋ก ์ด๋</Link>
</p>
</StyledBooksEmpty>
);
}
const StyledBooksEmpty = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
padding: 120px 0;
.icon {
svg {
font-size: 4rem;
fill: #ccc;
}
}
`;
Link : '/books' ํ์ด์ง๋ก ์ด๋ํ๋ ๋งํฌ๋ฅผ ํ์ํฉ๋๋ค.import styled from 'styled-components';
import { Pagination as IPagination } from '../../models/pagination.model';
import { LIMIT } from '../../constants/pagination';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';
interface Props {
pagination: IPagination;
}
export default function Pagination({ pagination }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const { totalCount, currentPage } = pagination;
const pages: number = Math.ceil(totalCount / LIMIT);
const handleClickPage = (page: number) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set(QUERYSTRING.PAGE, page.toString());
setSearchParams(newSearchParams);
};
return (
<StyledPagination>
{pages > 0 && (
<ol>
{Array(pages)
.fill(0)
.map((_, index) => (
<li>
<Button
key={index}
size='small'
scheme={index + 1 === currentPage ? 'primary' : 'normal'}
onClick={() => handleClickPage(index + 1)}
>
{index + 1}
</Button>
</li>
))}
</ol>
)}
</StyledPagination>
);
}
const StyledPagination = styled.div`
display: flex;
justify-content: start;
align-items: center;
padding: 24px 0;
ol {
list-style: none;
display: flex;
gap: 8px;
padding: 0;
margin: 0;
}
`;
Props : IPagination ํ์
์ผ๋ก ์ง์ ํ์ฌ ๋ณ์๊ฐ ํท๊ฐ๋ฆฌ์ง ์๊ฒ ํฉ๋๋ค.
handleClickPage : ํ์ด์ง ๋ฒํธ๋ฅผ ํด๋ฆญํ์ ๋ ํธ์ถ๋์ด URL์ page ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ฅผ ๊ฐฑ์ ํฉ๋๋ค.
ํ์ฌ URL์ ๊ฒ์ ํ๋ผ๋ฏธํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ก์ด URLSearchParams ๊ฐ์ฒด๋ฅผ ์์ฑํฉ๋๋ค.
ํ์ด์ง ๋ฒํธ๋ฅผ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ์ ์ถ๊ฐํฉ๋๋ค.
=> ์ต์ข
์ ์ผ๋ก ๋ณ๊ฒฝ๋ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์(setSearchParams)์ํต๋๋ค.


์ ์ญ ์ํ๋ฅผ ๊ด๋ฆฌํ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊ฐ ๋ง์ง๋ง, zustand๊ฐ ์ต์์น ์๋ค๋ ๊ฒ ๋ง๊ณ ๋ ์ฌ์ฉํ๊ธฐ์ ํจ์ฌ ํธํ ๊ฒ ๊ฐ๋ค. ๋์ ํ์ด์ง๋ฅผ ๋ง๋ค๋ฉด์ ์ปดํฌ๋ํธ๋ก ๋๋๊ณ , ํ๋ํ๋์ฉ ์ฐ๊ฒฐ์ํค๋ฉด์ ์๊ฐํ๋ ๊ฒ ์์ง์ ํ๋ค๊ณ , ํ์ ์ง์ ํ๋ ๊ฒ์ ๋ ์ต์ํด์ ธ์ผ ํ ๊ฒ ๊ฐ๋ค.