๐ CVXV [Live Demo]
CVXV๋ ๊ฐ์์ ์จ๋ผ์ธ ์คํ ์ด์ ๋๋ค. '๋ณต์ฌ ๋ถ์ฌ๋ฃ๊ธฐํ๋ฏ ์ธ์ ์ด๋์๋, ์์ ๋กญ๊ฒ'๋ผ๋ ์์ด๋์ด๋ฅผ ๋ฐํ์ผ๋ก 'Copy Cut Paste'๋ผ๋ ๋ธ๋๋๋ฅผ ์ฐฝ์กฐํ์ต๋๋ค. ์ฌ์ฉ์๋ ๋ก๊ทธ์ธ ํ ์ํ์ ์ดํด๋ณด๊ณ ์ฅ๋ฐ๊ตฌ๋์ ์ถ๊ฐํ๋ ๋ฑ ์ผํ์ ์ฝ๊ฒ ์ฆ๊ธธ ์ ์์ต๋๋ค. Firebase Google Auth๋ฅผ ํตํ ๋ก๊ทธ์ธ ๊ธฐ๋ฅ๊ณผ Realtime Database๋ฅผ ์ด์ฉํ ๋ฐ์ดํฐ ๋๊ธฐํ ๊ธฐ๋ฅ์ ๊ตฌํํ์ต๋๋ค. ๋ํ React Query๋ฅผ ์ฌ์ฉํด ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๊ณ ์บ์ฑํ์ฌ ๊ด๋ฆฌํ์ต๋๋ค. ๋๋ถ์ด ๋ฐ์ํ ์น ๋์์ธ๊ณผ Adobe Photoshop์ ์ฌ์ฉํ ์ํ ๋์์ธ ์์ , ๊ทธ๋ฆฌ๊ณ ํผ๋ธ๋ฆฌ์ฑ ์์ ์ ์งํํ์ต๋๋ค.
- 2023-06-15 ~ 2023-07-02
- ๊ฐ์ธ ํ๋ก์ ํธ (1์ธ)
- Home - ๋ฉ์ธ ํ์ด์ง(
/
) - Shop - ์ ์ฒด ์ํ ๋ชฉ๋ก ํ์ด์ง(
/shop
) - Shop - ์นดํ
๊ณ ๋ฆฌ๋ณ ์ํ ๋ชฉ๋ก ํ์ด์ง(
/shop/:category
) - ProductDetail - ์ํ ์์ธ ํ์ด์ง(
/products/:id
) - NewProduct - ์ํ ๋ฑ๋ก ํ์ด์ง(
/products/new
) - MyBags - ์ฅ๋ฐ๊ตฌ๋ ํ์ด์ง(
/bags
) - NotFound - 404 ํ์ด์ง
const router = createBrowserRouter([
{
path: '/',
element: <App />,
errorElement: <NotFound />,
children: [
{
index: true,
path: '/',
element: <Home />,
},
{
path: '/products/:id',
element: <ProductDetail />,
},
{
path: '/shop',
element: <Shop />,
},
{
path: '/shop/:category',
element: <Shop />,
},
{
path: '/products/new',
element: (
<ProtectedRoute requireAdmin>
<NewProduct />
</ProtectedRoute>
),
},
{
path: '/bags',
element: (
<ProtectedRoute>
<MyBags />
</ProtectedRoute>
),
},
],
},
]);
- ๋ก๊ทธ์ธ ๋ฐ ๋ก๊ทธ์์
- ์ํ ๋ฐ์ดํฐ ๋ถ๋ฌ์ค๊ธฐ
- ์ด๋๋ฏผ ๊ณ์ ์ผ ๊ฒฝ์ฐ ์ํ ๋ฑ๋ก
- ์ํ ์นดํ ๊ณ ๋ฆฌ๋ณ ํํฐ๋ง ๋ฐ ์ํ ์ ๋ ฌ
- ์ํ ์์ธ๋ณด๊ธฐ
- ์ฅ๋ฐ๊ตฌ๋ ๊ตฌํ
- ์ฅ๋ฐ๊ตฌ๋์ ์ํ ์ถ๊ฐ
- ์ํ ์๋ ๋ณ๊ฒฝ
- ์ฅ๋ฐ๊ตฌ๋์์ ์ํ ์ญ์
- ์์๋ฆฌ์คํธ ๊ตฌํ
์ฌ์ฉ์ ๊ด๋ฆฌ๋ Firebase์ Authentication์์ ์ ๊ณตํ๋ ๊ธฐ๋ฅ์ ํตํด ์ด๋ฃจ์ด์ง๋๋ค.
import {
getAuth,
signInWithPopup,
GoogleAuthProvider,
signOut,
onAuthStateChanged,
} from 'firebase/auth';
const auth = getAuth();
const provider = new GoogleAuthProvider();
const database = getDatabase(app);
๋ก๊ทธ์ธ ๋ฐ ๋ก๊ทธ์์์ ๊ตฌํํ๊ธฐ ์ํด signInWithPopup()
๋ฐ signOut()
ํจ์๋ฅผ ํธ์ถํฉ๋๋ค.
// ๋ก๊ทธ์ธ
export function login() {
signInWithPopup(auth, provider).catch(console.error);
}
// ๋ก๊ทธ์์
export function logout() {
signOut(auth).catch(console.error);
}
์๋ก๊ณ ์นจ์ ์ฌ์ฉ์์ ๋ก๊ทธ์ธ ์ํ๊ฐ ์ด๊ธฐํ๋์ง ์๋๋ก onAuthStateChanged()
ํจ์๋ฅผ ํธ์ถํ์ฌ ๋ก๊ทธ์ธ ์ํ๋ฅผ ๊ด์ฐฐํฉ๋๋ค.
// ๋ก๊ทธ์ธ ์ํ ํ์ธ
export function onUserStateChange(callback) {
onAuthStateChanged(auth, async (user) => {
const updatedUser = user ? await adminUser(user) : null;
callback(updatedUser);
});
}
์ดํ ์ฌ์ฉ์๊ฐ ์ด๋๋ฏผ ๊ณ์ ์ธ์ง ํ๋จํฉ๋๋ค. ์ด๋ฅผ ์ํด Firebase์ Realtime Database์ ์ ์ฅ๋ ์ฌ์ฉ์์ admins ๋ฐฐ์ด์ get
๋ฉ์๋๋ฅผ ํธ์ถํ์ฌ ๊ฐ์ ธ์ต๋๋ค. ๊ทธ๋ฆฌ๊ณ ์ฌ์ฉ์์ uid๋ฅผ ํฌํจํ๊ณ ์๋์ง ํ์ธํฉ๋๋ค.
// ์ด๋๋ฏผ ๊ถํ ํ์ธ
async function adminUser(user) {
return get(ref(database, 'admins')) //
.then((snapshot) => {
if (snapshot.exists()) {
const admins = snapshot.val();
const isAdmin = admins.includes(user.uid);
return { ...user, isAdmin };
}
return user;
});
}
๋ก๊ทธ์ธ ์ํ์ ๊ฐ์ด ์ฑ ์ ๋ฐ์ ์ผ๋ก ๋์ผํ ๋ฐ์ดํฐ์ ์ ๊ทผํ๋ ค๋ฉด ์ปจํ
์คํธ๋ฅผ ์ฌ์ฉํฉ๋๋ค. onUserStateChange()
ํจ์๋ฅผ ํธ์ถํ๊ณ ์ฝ๋ฐฑ ํจ์(setUser)๋ฅผ ์ ๋ฌํฉ๋๋ค.
import { login, logout, onUserStateChange } from '../api/firebase';
const AuthContext = createContext();
export function AuthContextProvider({ children }) {
const [user, setUser] = useState();
// ๋ก๊ทธ์ธ ์ํ ์
๋ฐ์ดํธ
useEffect(() => {
onUserStateChange((user) => {
setUser(user);
});
}, []);
return (
<AuthContext.Provider
value={{ user, uid: user && user.uid, login, logout }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuthContext() {
return useContext(AuthContext);
}
Shop ํ์ด์ง์์๋ ์นดํ
๊ณ ๋ฆฌ์ ํด๋นํ๋ ์ํ ๋ชฉ๋ก์ ๋ณผ ์ ์์ต๋๋ค. getProducts
ํจ์๋ก Firebase Realtime Database์์ 'products' ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ต๋๋ค. ๋ฐ์ดํฐ๊ฐ ์๋ค๋ฉด Object.values
๋ฉ์๋๋ก ๊ฐ์ฒด์ ๊ฐ๋ง ๊ฐ์ ธ์ต๋๋ค.
import { ref, get } from 'firebase/database';
// ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ
export async function getProducts() {
return get(ref(database, 'products')) //
.then((snapshot) => {
if (snapshot.exists()) {
return Object.values(snapshot.val());
}
return [];
});
}
React Query๋ฅผ ์ฌ์ฉํ์ฌ ์ํ ๋ฐ์ดํฐ๋ฅผ ์บ์ํ๊ณ ์
๋ฐ์ดํธํ๋ useProducts
์ปค์คํ
ํ
์ ๋ง๋ญ๋๋ค. ์ด๋ ๊ฒ ํ๋ฉด ์ํ๊ณผ ๊ด๋ จ๋ ์์ฒญ์ ํ ๊ณณ์์ ํจ์จ์ ์ผ๋ก ๊ด๋ฆฌํ ์ ์์ต๋๋ค.
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { getProducts as fetchProducts, addNewProduct } from '../api/firebase';
export default function useProducts() {
const queryClient = useQueryClient();
// ์ฟผ๋ฆฌ ์์ฑ
const productsQuery = useQuery(['products'], fetchProducts, {
staleTime: 1000 * 60,
});
// addProduct...
return { productsQuery, addProduct };
}
useProducts
์ปค์คํ
ํ
์ ์ฌ์ฉํด products ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ต๋๋ค. useFilterAndSort
์ปค์คํ
ํ
์ products์ option, category๋ฅผ ์ ๋ฌํ์ฌ ํํฐ๋ง ๋ ๋ฐ์ดํฐ(sortedData)๋ฅผ ๋ฐ์์ต๋๋ค. map
๋ฉ์๋๋ก ๋ฐฐ์ด์ ์ํํ๋ฉฐ <ul>
์์ ๋ด์ ๋์ ์ผ๋ก ๋ ๋๋ง ํฉ๋๋ค.
export default function Shop() {
const { category } = useParams();
const [option, setOption] = useState();
const {
productsQuery: { isLoading, error, data: products },
} = useProducts();
const { sortedData } = useFilterAndSort(products, option, category);
return (
<div className={styles.layout}>
<Sort products={sortedData} onChange={setOption} />
<ul className={styles.items}>
{sortedData &&
sortedData.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</ul>
</div>
);
}
์ด๋๋ฏผ ๊ณ์ ์ '/products/new' ํ์ด์ง์์ ์๋ก์ด ์ํ์ ๋ฑ๋กํ ์ ์์ต๋๋ค. addNewProduct()
ํจ์๋ product์ image URL์ ์ ๋ฌ๋ฐ์ ref
๋ฉ์๋๋ก ๊ฒฝ๋ก๋ฅผ ์ค์ ํ๊ณ set
๋ฉ์๋๋ฅผ ํธ์ถํ์ฌ Firebase Realtime Database์ ๋ฐ์ดํฐ๋ฅผ ์์ฑํฉ๋๋ค.
import { ref, set } from 'firebase/database';
// ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ํ ๋ฑ๋ก
export async function addNewProduct(product, image) {
const id = uuid();
return set(ref(database, `products/${id}`), {
...product,
id,
price: parseInt(product.price),
image,
tags: product.tags.split(','),
});
}
useProducts ์ปค์คํ
ํ
์ addProduct
ํจ์๋ useMutation ํ
์ ์ฌ์ฉํด product์ url ๋งค๊ฐ๋ณ์๋ก ์ ์ํ์ ์ถ๊ฐํ๋ ๋น๋๊ธฐ ํจ์์
๋๋ค. ์ํ ๋ฑ๋ก์ด ์ฑ๊ณตํ๋ฉด invalidateQueries
๋ฅผ ์ฌ์ฉํด ์ฟผ๋ฆฌ๋ฅผ ๋ฌดํจํํ๊ณ ์ต์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ต๋๋ค.
import { getProducts as fetchProducts, addNewProduct } from '../api/firebase';
export default function useProducts() {
const queryClient = useQueryClient();
// productsQuery...
const addProduct = useMutation(
({ product, url }) => addNewProduct(product, url),
{
onSuccess: () => queryClient.invalidateQueries(['products']),
}
);
return { productsQuery, addProduct };
}
NewProduct ์ปดํฌ๋ํธ์์ ์ฌ์ฉ์๊ฐ ์์์ ์ ์ถํฉ๋๋ค. uploadImage()
ํจ์์ ์ฌ์ฉ์๊ฐ ์
๋ก๋ํ file์ ์ ๋ฌํ์ฌ ํด๋ผ์ฐ๋๋๋ฆฌ URL์ ํ๋ํ๊ณ , addProduct.mutate
๋ฅผ ํธ์ถํ์ฌ ์ํ ๋ฑ๋ก ์์ฒญ์ ๋ณด๋
๋๋ค.
import { uploadImage } from '../api/uploader';
export default function NewProduct() {
// ...
const { addProduct } = useProducts();
const hanleSubmit = (e) => {
e.preventDefault();
setIsUploading(true);
uploadImage(file) //
.then((url) => {
addProduct.mutate(
{ product, url },
{
onSuccess: () => {
setSuccess('The ITEM HAS BEEN ADDED.');
setTimeout(() => {
setSuccess(null);
}, 1500);
},
}
);
}) //
.finally(() => {
fileRef.current.value = '';
setProduct({});
setIsUploading(false);
});
};
// ...
}
์ฌ์ฉ์๋ ์ฌ์ด๋ ๋ฐ์์ ์นดํ ๊ณ ๋ฆฌ๋ฅผ ํด๋ฆญํ์ฌ ํํฐ๋ง๋ ์ํ ๋ชฉ๋ก์ ์ฐพ์ ์ ์์ต๋๋ค. ์ํ ๋ชฉ๋ก์ ์ ์ํ ์, ์ด๋ฆ ์, ๋ฎ์ ๊ฐ๊ฒฉ ์, ๋์ ๊ฐ๊ฒฉ ์์ผ๋ก ์ ๋ ฌ์ด ๊ฐ๋ฅํฉ๋๋ค.
useFilterAndSort
์ปค์คํ
ํ
์ ๋ง๋ค์ด ์นดํ
๊ณ ๋ฆฌ๋ณ๋ก ์ํ ๋ฐ์ดํฐ๋ฅผ ํํฐ๋งํ๊ณ , ํํฐ๋ง๋ ์ํ๋ค์ ์ต์
์ ๋ฐ๋ผ ์ ๋ ฌํฉ๋๋ค.
filter
๋ฉ์๋๋ฅผ ์ฌ์ฉํด category์ ํด๋นํ๋ products๋ง ํํฐ๋งํฉ๋๋ค. useMemo
ํ
์ ์ฌ์ฉํด products์ category ๊ฐ์ด ๋ณ๊ฒฝ๋ ๋๋ง filteredData๋ฅผ ๋ค์ ๊ณ์ฐํฉ๋๋ค.
export default function useFilterAndSort(products, option, category) {
const filteredData = useMemo(() => {
return products && category
? products.filter(
(product) => product.category.toLowerCase() === category
)
: products || [];
}, [products, category]);
// ...
}
switch
๋ฌธ๊ณผ sort
๋ฉ์๋๋ฅผ ์ฌ์ฉํด option ๊ฐ์ ๋ฐ๋ผ ๊ฐ๊ฐ ๋์์ ์ํํ๊ณ ์ ๋ ฌ๋ ๋ฐฐ์ด์ ๋ฐํํฉ๋๋ค.
// ...
const sortedData = useMemo(() => {
const sortedArray = [...filteredData];
switch (option) {
case 'new':
sortedArray.sort((a, b) => (a.id > b.id ? 1 : -1));
break;
case 'alphabetical':
sortedArray.sort((a, b) =>
a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1
);
break;
case 'low price':
sortedArray.sort((a, b) => a.price - b.price);
break;
case 'high price':
sortedArray.sort((a, b) => b.price - a.price);
break;
default:
return filteredData;
}
return sortedArray;
}, [filteredData, option]);
return {
sortedData,
};
์ํ ๋ชฉ๋ก ํ์ด์ง์์ ๊ฐ๋ณ ์ํ์ ํด๋ฆญํ๋ฉด React Router์ useNavigate
ํ
์ ์ฌ์ฉํด ์ํ ์์ธ ํ์ด์ง(/products/${id}
)๋ก ์ด๋ํฉ๋๋ค. ์ด๋ state ๊ฐ์ฒด์ product๋ฅผ ์ ๋ฌํฉ๋๋ค.
export default function ProductCard({
product,
product: { id, image, title, price },
}) {
// ...
const navigate = useNavigate();
// handleAdd...
// handleRemove...
return (
<li className={styles.item}>
<div className={styles.image}>
<img
src={image}
alt={title}
onClick={() => {
navigate(`/products/${id}`, { state: { product } });
}}
/>
</div>
</li>
);
}
ProductDetail ์ปดํฌ๋ํธ๋ Firebase Realtime Database์ ์ ์ฅ๋ ์ํ์ ์ธ๋ถ ์ ๋ณด๋ฅผ ํ์ํฉ๋๋ค. useLocation
ํ
์ ์ฌ์ฉํด ์ ๋ฌ๋ฐ์ state ๊ฐ์ฒด์์ product ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ ๋ ๋๋งํฉ๋๋ค.
export default function ProductDetail() {
// ...
const {
state: {
product: { id, image, title, description, price, tags, category },
},
} = useLocation();
// handleAdd...
// addOrUpdateBagItem.mutate...
// return...
}
์ฅ๋ฐ๊ตฌ๋๋ฅผ ๊ตฌํํ๊ธฐ ์ํด Firebase Realtime Database์์ userId์ ํด๋นํ๋ bags ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ต๋๋ค. get
๋ฉ์๋์ ref
๋ฉ์๋๋ฅผ ์ฌ์ฉํด ๊ฒฝ๋ก๋ฅผ ๋ช
์ํ๊ณ , Object.values
๋ฅผ ์ฌ์ฉํ์ฌ ํด๋น ๊ฐ๋ง์ ์ฝ์ด์ต๋๋ค.
import { ref, get } from 'firebase/database';
// ์ฅ๋ฐ๊ตฌ๋ ๋ถ๋ฌ์ค๊ธฐ
export async function getBag(userId) {
return get(ref(database, `bags/${userId}`)) //
.then((snapshot) => {
const items = snapshot.val() || {};
return Object.values(items);
});
}
์ฅ๋ฐ๊ตฌ๋์ ๊ด๋ จ๋ ์ฟผ๋ฆฌ ๋ก์ง์ ํ ๊ณณ์์ ๊ด๋ฆฌํ๊ธฐ ์ํด useBag
์ด๋ผ๋ ์ปค์คํ
ํ
์ ๋ง๋ญ๋๋ค. ์ด๋ ๊ฒ ํ๋ฉด ์ปดํฌ๋ํธ๋ฅผ ๊ฐ๊ฒฐํ๊ฒ ์ ์งํ๊ณ , ์บ์ฑ๋ ๋ฐ์ดํฐ๋ฅผ ํจ์จ์ ์ผ๋ก ์ฒ๋ฆฌํ ์ ์์ต๋๋ค.
์ฌ์ฉ์ ๋ณ๋ก ์บ์๊ฐ ์ด๋ฃจ์ด์ง๋๋ก ์ฟผ๋ฆฌ ํค๋ก uid๋ฅผ ์ฌ์ฉํฉ๋๋ค. ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธํ์ง ์์ ๊ฒฝ์ฐ (uid๊ฐ falsy์ผ ๋), ์ฟผ๋ฆฌ๊ฐ ์ํ๋์ง ์๋๋ก enabled๋ฅผ ์ค์ ํฉ๋๋ค. mutation์ด ์ฑ๊ณต์ ์ผ๋ก ์๋ฃ๋๋ฉด invalidateQueries๋ฅผ ํตํด ์ฟผ๋ฆฌ๋ฅผ ๋ค์ ๋ก๋ํฉ๋๋ค.
import { useAuthContext } from '../context/AuthContext';
import { addOrUpdateToBag, getBag, removeFromBag } from '../api/firebase';
export default function useBag() {
const { uid } = useAuthContext();
const queryClient = useQueryClient();
// ์ฟผ๋ฆฌ ์์ฑ
const bagQuery = useQuery(['bags', uid || ''], () => getBag(uid), {
enabled: !!uid,
});
// ์ฅ๋ฐ๊ตฌ๋์ ์ํ์ ์ถ๊ฐํ๊ฑฐ๋ ์
๋ฐ์ดํธ
const addOrUpdateBagItem = useMutation(
(product) => addOrUpdateToBag(uid, product),
{
onSuccess: () => queryClient.invalidateQueries(['bags', uid]),
}
);
// ์ฅ๋ฐ๊ตฌ๋์์ ํน์ ์ํ ์ญ์
const removeBagItem = useMutation(
(productId) => removeFromBag(uid, productId),
{
onSuccess: () => {
queryClient.invalidateQueries(['bags', uid]);
},
}
);
return { bagQuery, addOrUpdateBagItem, removeBagItem };
}
์ฌ์ฉ์๊ฐ ์ํ์ ์ฅ๋ฐ๊ตฌ๋์ ์ถ๊ฐํ๋ฉด Firebase Realtime Database์ ์ฆ๊ฐ์ ์ผ๋ก ๋ฐ์๋ฉ๋๋ค. addOrUpdateToBag
ํจ์์์ ref
๋ฉ์๋๋ก ๊ฒฝ๋ก๋ฅผ ๋ช
์ํ๊ณ , set
๋ฉ์๋๋ก ๋ฐ์ดํฐ๋ฅผ ์ ์ฅํ๊ฑฐ๋ ์
๋ฐ์ดํธ ํฉ๋๋ค.
import { ref, set } from 'firebase/database';
// ์ฅ๋ฐ๊ตฌ๋์ ์ํ ์ถ๊ฐ ๋๋ ์
๋ฐ์ดํธ
export async function addOrUpdateToBag(userId, product) {
return set(ref(database, `bags/${userId}/${product.id}`), product);
}
์ฌ์ฉ์๋ ์ถ๊ฐํ ์ํ์ +
๋๋ -
์์ด์ฝ์ ํด๋ฆญํ์ฌ ์๋์ ๋ณ๊ฒฝํ ์ ์์ต๋๋ค. BagItem
์ปดํฌ๋ํธ๋ MyBag ์ปดํฌ๋ํธ๋ก๋ถํฐ product๋ฅผ props๋ก ๋ฐ์์ ๊ฐ๋ณ ์ํ์ ํ์ํฉ๋๋ค. ์๋์ ๋ณ๊ฒฝํ๋ฉด ๋ฐ์ดํฐ๊ฐ ๋ณ๊ฒฝ๋ ๊ฒ์ด๋ฏ๋ก ์บ์๋ฅผ ์
๋ฐ์ดํธํฉ๋๋ค.
export default function BagItem({
product,
product: { id, title, price, quantity, image },
}) {
const { addOrUpdateBagItem, removeBagItem } = useBag();
const navigate = useNavigate();
// handleRemove...
// ์๋ ๊ฐ์
const handleMinus = () => {
if (quantity < 2) return;
addOrUpdateBagItem.mutate({ ...product, quantity: quantity - 1 });
};
// ์๋ ์ฆ๊ฐ
const handlePlus = () => {
addOrUpdateBagItem.mutate({ ...product, quantity: quantity + 1 });
};
// ...
}
์ฌ์ฉ์๋ X
๋ฒํผ์ ๋๋ฌ ํน์ ์ํ์ ์ฅ๋ฐ๊ตฌ๋์์ ์ญ์ ํ ์ ์์ต๋๋ค.
export default function BagItem({
product,
product: { id, title, price, quantity, image },
}) {
const { addOrUpdateBagItem, removeBagItem } = useBag();
const handleRemove = () => {
removeBagItem.mutate(id);
};
// handleMinus...
// handlePlus...
// ...
}
Firebase Realtime Database์์ ref
๋ฉ์๋๋ก ๊ฒฝ๋ก๋ฅผ ์ฐพ์ remove
๋ฉ์๋๋ก productId์ ํด๋นํ๋ ์ํ ๋ฐ์ดํฐ๋ฅผ ์ญ์ ํฉ๋๋ค.
import { ref, remove } from 'firebase/database';
// productId์ ์ผ์นํ๋ ์ํ ์ญ์
export async function removeFromBag(userId, productId) {
return remove(ref(database, `bags/${userId}/${productId}`));
}
์ฌ์ฉ์๊ฐ ProductCard
์ ๋ถ๋งํฌ ์์ด์ฝ์ ํด๋ฆญํ์ฌ ์์๋ฆฌ์คํธ์ ์ํ์ ์ถ๊ฐ ๋๋ ์ ๊ฑฐํ ์ ์์ต๋๋ค. ์ด ๊ธฐ๋ฅ์ ์ฅ๋ฐ๊ตฌ๋์ ์ ์ฌํ ๋ก์ง์ ์ฌ์ฉํฉ๋๋ค. ๋ฐ์ดํฐ ๋ณ๊ฒฝ ์ฌํญ์ Firebase Realtime Database์ ์ค์๊ฐ์ผ๋ก ๋ฐ์๋ฉ๋๋ค.
export default function ProductCard({
product,
product: { id, image, title, price },
}) {
const { user, login } = useAuthContext();
const {
wishQuery: { isLoading: isLoadingWish, data: wishProducts },
addOrUpdateWishItem,
removeWishItem,
} = useWish();
const [wishId, setWishId] = useState([]);
const [success, setSuccess] = useState();
const navigate = useNavigate();
const hasId = wishId && wishId.includes(id);
// ์์๋ฆฌ์คํธ์ ์ํ ์ถ๊ฐ
const handleAdd = () => {
if (!user) {
login();
return;
}
addOrUpdateWishItem.mutate(product, {
onSuccess: () => {
setSuccess('SAVING CHANGES');
setTimeout(() => setSuccess(null), 1500);
},
});
};
// ์์๋ฆฌ์คํธ์์ ์ํ ์ญ์
const handleRemove = () => {
removeWishItem.mutate(id, {
onSuccess: () => {
setSuccess('SAVING CHANGES');
setTimeout(() => setSuccess(null), 2000);
},
});
};
//...
CVXV ์น ์ฌ์ดํธ๋ ํค๋ณด๋ ์ฌ์ฉ ๋ณด์ฅ์ ์ํด ํค๋ณด๋์ ํญ(Tab) ํค๋ก ์น์ ํ์ํ ์ ์๋๋ก ํฉ๋๋ค. ์ฌ์ฉ์๋ ํญ ํค๋ฅผ ๋๋ฌ ์ ์ฒด ์ํ ๋๋ ๋ณธ๋ฌธ์ผ๋ก ๊ฑด๋๋ฐ๊ธฐ ํ ์ ์์ต๋๋ค.
Navbar์ ํ๋ฒ๊ฑฐ ์์ด์ฝ์ ํด๋ฆญํ๋ฉด Sidebar๊ฐ ๋ํ๋๊ณ , x
๋๋ ๋ฐํํ๋ฉด์ ํด๋ฆญํ๋ฉด ์ฌ์ด๋๋ฐ๊ฐ ๋ซํ๋๋ค. Sidebar
์ปดํฌ๋ํธ์ ์ฌ์ด๋ ๋ฐ์ ์ด๋ฆผ ์ํ(isOpen
)์ ๋ซํ ํจ์(onClose
)๋ฅผ props๋ก ์ ๋ฌํฉ๋๋ค.
export default function Navbar() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const location = useLocation();
// ์ฌ์ด๋๋ฐ ์ด๊ธฐ
const handleSidebarOpen = () => {
setIsSidebarOpen(true);
};
// ์ฌ์ด๋๋ฐ ๋ซ๊ธฐ
const handleSidebarClose = () => {
setIsSidebarOpen(false);
};
// ํ์ด์ง ์ด๋ ์ ์ฌ์ด๋๋ฐ ๋ซ๊ธฐ
useEffect(() => {
setIsSidebarOpen(false);
}, [location]);
return (
<>
<Sidebar isOpen={isSidebarOpen} onClose={handleSidebarClose} />
<nav className={styles.nav}></nav>
</>
);
}
Sidebar
์ปดํฌ๋ํธ๋ ์ฌ์ฉ์๊ฐ ํ์ด์ง ์ด๋ํ ์ ์๋ ๋งํฌ ๋ชฉ๋ก์ ๋ํ๋
๋๋ค. ์ฌ์ด๋๋ฐ์ ์ด๋ฆผ/๋ซํ ์ํ์ธ isOpen
๊ณผ ์ฌ์ด๋๋ฐ๋ฅผ ๋ซ๋ ํจ์์ธ onClose
ํจ์๋ฅผ props๋ก ๋ฐ์ต๋๋ค.
export default function Sidebar({ isOpen, onClose }) {
const { user } = useAuthContext();
// isOpen์ ์ํ์ ๋ฐ๋ผ ํด๋์ค๋ฅผ ๋ฌ๋ฆฌ ์ ์ฉ
const sidebarClasses = `${styles.container} ${
isOpen ? styles.open : styles.close
}`;
return (
<>
{isOpen && <Overlay onClose={onClose} />}
<aside className={sidebarClasses}>
<Icon onClick={onClose} option='close'>
<VscChromeClose />
</Icon>
<ul className={styles.links}>
{user && user.isAdmin && (
<li className={styles.link}>
<Link to='/products/new'>Register Product</Link>
</li>
)}
{link.map((value, index) => (
<li key={index} className={styles.link}>
<Link to={value.link}>{value.title}</Link>
</li>
))}
</ul>
</aside>
</>
);
}
aside
์์์ fixed postion์ ์ ์ฉํ์ฌ ํ๋ฉด ์์์ ๋ํ๋๋๋ก ํฉ๋๋ค. open/close
ํด๋์ค๋ฅผ ํตํด container๋ฅผ X๋ฐฉํฅ์ผ๋ก ์ด๋์ํต๋๋ค.
.container {
width: 30%;
height: 100%;
position: fixed;
top: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--black-500);
z-index: 999;
}
.open {
transform: translateX(0);
transition: transform 300ms ease-in;
}
.close {
transform: translateX(-100%);
transition: transform 300ms ease-in;
}
.overlay {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 998;
}
CVXV ์น ์ฌ์ดํธ๋ ๋ฐ์ํ์ผ๋ก ๋์์ธ๋์ด ๋ค์ํ ๋๋ฐ์ด์ค์ ์ต์ ํ๋ ๋ ์ด์์์ ๋ณผ ์ ์์ต๋๋ค. ์ด๋ก์จ ์ฌ์ฉ์๊ฐ ์น ์ฌ์ดํธ๋ฅผ ๋์ฑ ํธ๋ฆฌํ๊ฒ ์ด์ฉํ ์ ์๋๋ก ํฉ๋๋ค.
category๋ useParams๋ฅผ ์ฌ์ฉํ์ฌ URL ๊ฒฝ๋ก(/shop/fashion
)์์ ๋์ ํ๋ผ๋ฏธํฐ(fashion
)๋ฅผ ์ถ์ถํ ๊ฒ์
๋๋ค. ์ฌ์ฉ์๊ฐ ์กด์ฌํ์ง ์๋ category๋ฅผ ํฌํจํ ๊ฒฝ๋ก๋ก ์ง์
ํ๋ ๊ฒ์ ๋ฐฉ์งํ๊ณ , ์ ํจํ ์นดํ
๊ณ ๋ฆฌ์ ๋ํ ์ํ์ ๋ณด์ฌ์ฃผ๊ณ ์ ํ์ต๋๋ค.
์ฌ์ฉ์๊ฐ ์กด์ฌํ์ง ์๋ category๋ฅผ ๊ฐ๋ฆฌํค๋ ๊ฒฝ๋ก, ์๋ฅผ ๋ค์ด /shop/apple
๋ก ์ด๋ํ ์ ์์์ต๋๋ค. ์ด๋ก ์ธํด ๋น ํ๋ฉด์ด๋ ์ค๋ฅ ํ์ด์ง๊ฐ ๋ ๋๋ง ๋์์ต๋๋ค.
Array.some
๋ฉ์๋๋ ๋ฐฐ์ด ์์ ์์๊ฐ ์ฃผ์ด์ง ์กฐ๊ฑด์ ๋ง์กฑํ๋์ง ๊ฒ์ฌํ์ฌ boolean์ ๋ฐํํฉ๋๋ค. ์ด๋ฅผ ์ด์ฉํด product.category์ category ํ๋ผ๋ฏธํฐ๊ฐ ์ผ์นํ์ง ์์ผ๋ฉด useNavigate
ํ
์ ์ฌ์ฉํ์ฌ Home('/')์ผ๋ก ๋ฆฌ๋ค์ด๋ ์
ํฉ๋๋ค.
export default function useFilterAndSort(products, option, category) {
const navigate = useNavigate();
if (
products &&
category &&
!products.some((product) => product.category.toLowerCase() === category)
) {
navigate('/');
}
}
์ด๋ ๊ฒ ํจ์ผ๋ก์จ, ์ฌ์ฉ์๊ฐ ์ ํจํ ์นดํ ๊ณ ๋ฆฌ๊ฐ ์๋ URL๋ก ์ด๋ํ๋ ๊ฒ์ ๋ฐฉ์งํ๊ณ , ์ผ์นํ์ง ์๋ ์นดํ ๊ณ ๋ฆฌ์ ๋ํ ์ํ์ ํ์ํ์ง ์๋๋ก ๋ง๋ค์์ต๋๋ค.
์ฌ์ฉ์๊ฐ ์์๋ฆฌ์คํธ์ ์ถ๊ฐํ ์ํ์ ๋ถ๋งํฌ ์ํ๋ฅผ ๊ธฐ์ตํ๊ณ ์ ์งํ๋๋ก ๋ง๋ค๊ณ ์ ํ์ต๋๋ค.
ํ์ด์ง ์๋ก ๊ณ ์นจ ๋๋ ์ด๋ ์ ๋ถ๋งํฌ ์ํ๊ฐ ์ด๊ธฐํ๋์์ต๋๋ค. ์ด๋ก ์ธํด ์ฌ์ฉ์๋ ์์๋ฆฌ์คํธ์ ์ถ๊ฐํ ์ํ์ ์๋ณํ๋ ๋ฐ ์ด๋ ค์์ด ์์์ต๋๋ค.
wishId
๋ฐฐ์ด์ ๋ง๋ค์ด ์์๋ฆฌ์คํธ์ ์ถ๊ฐํ ์ํ์ id๋ฅผ ์ ์ฅํ๊ณ ๊ด๋ฆฌํฉ๋๋ค. useEffect ํ
์ ์ฌ์ฉํด wishProducts
๋ฐฐ์ด์ด ๋ณ๊ฒฝ๋ ๋๋ง๋ค ํด๋น ๋ฐฐ์ด์์ ์ํ id๋ฅผ ์ถ์ถํ์ฌ wishId ๋ฐฐ์ด์ ์
๋ฐ์ดํธํฉ๋๋ค. ์ํ ์นด๋๋ฅผ ๋ ๋๋งํ ๋, Array.includes
๋ฉ์๋ ๊ฐ ์ํ์ id๊ฐ wishId ๋ฐฐ์ด์ ํฌํจ๋์ด ์๋์ง ํ์ธํฉ๋๋ค. ํฌํจ๋์ด ์๋ค๋ฉด ๋ถ๋งํฌ๋ฅผ ์ฑ์ฐ๊ณ , ๊ทธ๋ ์ง ์์ผ๋ฉด ๋น ๋ถ๋งํฌ๋ฅผ ๋ ๋๋งํฉ๋๋ค.
const [wishId, setWishId] = useState([]);
const hasId = wishId && wishId.includes(id);
useEffect(() => {
const wishIds = wishProducts && wishProducts.map((item) => item.id);
setWishId(wishIds);
}, [wishProducts]);
return (
<Icon onClick={hasId ? handleRemove : handleAdd} option='mark'>
{hasId ? <BsBookmarkFill /> : <BsBookmark />}
</Icon>
);
์ด๋ ๊ฒ ํ์ฌ ์ํ์ ๋ถ๋งํฌ ์ํ๋ฅผ ๊ธฐ์ตํ๊ณ ์ ์งํ์ฌ ์ฌ์ฉ์ ๊ฒฝํ์ ํฅ์์ํฌ ์ ์์์ต๋๋ค.