소스 검색

feat: cart context, account informations via session

cart-context
ntasicc 3 년 전
부모
커밋
e28d6e405c

+ 52
- 8
components/cards/cart-card/CartCard.jsx 파일 보기

@@ -1,7 +1,15 @@
import { Box, Button, ButtonGroup, Paper, Typography } from '@mui/material';
import Image from 'next/image';
import PropType from 'prop-types';
import { useState } from 'react';

const CartCard = ({ product, initialQuantity, remove, updateQuantity }) => {
const [quantity, setQuantity] = useState(initialQuantity);

// useEffect(() => {
// updateQuantity(product?.customID, quantity);
// }, [quantity]);

const CartCard = () => {
return (
<Paper
sx={{
@@ -40,7 +48,7 @@ const CartCard = () => {
fontSize: 20,
}}
>
Begin Mug in White
{product?.name}
</Typography>
</Box>
<Box
@@ -48,6 +56,7 @@ const CartCard = () => {
display: 'flex',
flexDirection: 'column',
width: '20%',
justifyContent: 'center',
alignItems: 'center',
}}
>
@@ -79,7 +88,12 @@ const CartCard = () => {
fontSize: 17,
width: 25,
}}
onClick={() => {}}
onClick={() => {
if (quantity > 0) {
updateQuantity(product?.customID, quantity - 1);
setQuantity((prevState) => prevState - 1);
}
}}
>
-
</Button>
@@ -90,7 +104,7 @@ const CartCard = () => {
width: 25,
}}
>
1
{quantity}
</Button>
<Button
sx={{
@@ -98,7 +112,10 @@ const CartCard = () => {
fontSize: 17,
width: 25,
}}
onClick={() => {}}
onClick={() => {
updateQuantity(product?.customID, quantity + 1);
setQuantity((prevState) => prevState + 1);
}}
>
+
</Button>
@@ -116,26 +133,53 @@ const CartCard = () => {
startIcon={
<Image src="/images/x.svg" alt="remove" width={15} height={15} />
}
onClick={() => remove(product.customID)}
>
Remove
</Button>
</Box>
<Box
sx={{ ml: 3, display: 'flex', flexDirection: 'column', width: '20%' }}
sx={{
ml: 3,
display: 'flex',
flexDirection: 'column',
width: '20%',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Typography
sx={{
width: '100%',
textAlign: 'center',
height: 25,
fontSize: 20,
fontSize: 18,
}}
>
Total: $20
Price: ${product?.price}
</Typography>
</Box>
</Paper>
);
};

CartCard.propTypes = {
product: PropType.shape({
category: PropType.string,
name: PropType.string,
image: PropType.string,
description: PropType.string,
place: PropType.string,
people: PropType.string,
process: PropType.string,
pairing: PropType.string,
available: PropType.Boolean,
isFeatured: PropType.Boolean,
price: PropType.number,
customID: PropType.string,
}),
initialQuantity: PropType.number,
remove: PropType.func,
updateQuantity: PropType.func,
};
export default CartCard;

+ 35
- 4
components/cart-content/CartContent.jsx 파일 보기

@@ -1,9 +1,40 @@
import { Breadcrumbs, Divider, Grid, Typography } from '@mui/material';
import { Box } from '@mui/system';
import { useStore, useStoreUpdate } from '../../store/cart-context';
import CartCard from '../cards/cart-card/CartCard';
import OrderSummaryCard from '../cards/order-summary-card/OrderSummaryCard';

const CartContent = () => {
const { cartStorage, totalPrice } = useStore();
const { removeCartValue, updateItemQuantity } = useStoreUpdate();

const mapProductsToDom = () => {
if (cartStorage?.length) {
return cartStorage.map((element, i) => (
<CartCard
key={i}
product={element?.product}
initialQuantity={element?.quantity}
remove={removeCartValue}
updateQuantity={updateItemQuantity}
></CartCard>
));
} else {
return (
<Typography
sx={{
pl: 12,
mt: 6,
height: '100%',
textAlign: 'center',
fontSize: 45,
}}
>
Your cart is currently empty
</Typography>
);
}
};
return (
<Grid container spacing={2} sx={{ py: 10, height: '100%', width: '100%' }}>
<Grid item xs={12}>
@@ -28,13 +59,13 @@ const CartContent = () => {
</Breadcrumbs>
</Grid>
<Grid item xs={8}>
<CartCard></CartCard>
<CartCard></CartCard>
<CartCard></CartCard>
{mapProductsToDom()}
</Grid>
<Grid item xs={4}>
<Box sx={{ width: '80%', mt: 2 }}>
<OrderSummaryCard data={{ totalPrice: 60 }}></OrderSummaryCard>
<OrderSummaryCard
data={{ totalPrice: totalPrice }}
></OrderSummaryCard>
</Box>
</Grid>
</Grid>

+ 62
- 2
components/forms/register/RegisterForm.jsx 파일 보기

@@ -14,7 +14,7 @@ import Link from 'next/link';
import { useState } from 'react';

import { FORGOT_PASSWORD_PAGE, LOGIN_PAGE } from '../../../constants/pages';
import { createUser } from '../../../requests/accountRequests';
import { createUser } from '../../../requests/accounts/accountRequests';
import { registerSchema } from '../../../schemas/registerSchema';
import ErrorMessageComponent from '../../mui/ErrorMessageComponent';

@@ -39,7 +39,12 @@ const RegisterForm = () => {
values.fullName,
values.username,
values.email,
values.password
values.password,
values.address,
values.address2,
values.city,
values.country,
values.postcode
);
console.log(result);
} catch (error) {
@@ -54,6 +59,11 @@ const RegisterForm = () => {
email: '',
password: '',
confirmPassword: '',
address: '',
address2: '',
city: '',
country: '',
postcode: '',
},
validationSchema: registerSchema,
onSubmit: submitHandler,
@@ -158,6 +168,56 @@ const RegisterForm = () => {
),
}}
/>
<TextField
name="address"
label="Address"
margin="normal"
value={formik.values.address}
onChange={formik.handleChange}
error={formik.touched.address && Boolean(formik.errors.address)}
helperText={formik.touched.address && formik.errors.address}
fullWidth
/>
<TextField
name="address"
label="Address2"
margin="normal"
value={formik.values.address2}
onChange={formik.handleChange}
error={formik.touched.address2 && Boolean(formik.errors.address2)}
helperText={formik.touched.address2 && formik.errors.address2}
fullWidth
/>
<TextField
name="city"
label="City"
margin="normal"
value={formik.values.city}
onChange={formik.handleChange}
error={formik.touched.city && Boolean(formik.errors.city)}
helperText={formik.touched.city && formik.errors.city}
fullWidth
/>
<TextField
name="country"
label="Country"
margin="normal"
value={formik.values.country}
onChange={formik.handleChange}
error={formik.touched.country && Boolean(formik.errors.country)}
helperText={formik.touched.country && formik.errors.country}
fullWidth
/>
<TextField
name="postcode"
label="Postal Code"
margin="normal"
value={formik.values.postcode}
onChange={formik.handleChange}
error={formik.touched.postcode && Boolean(formik.errors.postcode)}
helperText={formik.touched.postcode && formik.errors.postcode}
fullWidth
/>
<Button
type="submit"
variant="contained"

+ 13
- 24
components/forms/shipping-details/ShippingDetailsForm.jsx 파일 보기

@@ -1,5 +1,6 @@
import { Box, Button, Paper, TextField } from '@mui/material';
import { useFormik } from 'formik';
import { useSession } from 'next-auth/react';
import PropType from 'prop-types';
import { useState } from 'react';
import { shippingDetailsSchema } from '../../../schemas/shippingDetailsSchema';
@@ -7,20 +8,19 @@ import ErrorMessageComponent from '../../mui/ErrorMessageComponent';

const ShippingDetailsForm = ({ backBtn = false }) => {
const [error] = useState({ hasError: false, errorMessage: '' });
const { data: session } = useSession();
const submitHandler = async (values) => {
console.log(values);
};

const formik = useFormik({
initialValues: {
fullName: '',
email: '',
address: '',
address2: '',
city: '',
country: '',
poostalCode: '',
fullName: session.user.fullName,
address: session.user.address,
address2: session.user.address2,
city: session.user.city,
country: session.user.country,
postcode: session.user.postcode,
},
validationSchema: shippingDetailsSchema,
onSubmit: submitHandler,
@@ -46,17 +46,6 @@ const ShippingDetailsForm = ({ backBtn = false }) => {
onSubmit={formik.handleSubmit}
sx={{ position: 'relative', mt: 1, p: 1 }}
>
<TextField
name="email"
label="Email"
margin="normal"
value={formik.values.email}
onChange={formik.handleChange}
error={formik.touched.email && Boolean(formik.errors.email)}
helperText={formik.touched.email && formik.errors.email}
autoFocus
fullWidth
/>
<TextField
name="fullName"
label="Name"
@@ -110,13 +99,13 @@ const ShippingDetailsForm = ({ backBtn = false }) => {
sx={{ mr: 1.5 }}
/>
<TextField
name="city"
label="City"
name="postcode"
label="Postal Code"
margin="normal"
value={formik.values.city}
value={formik.values.postcode}
onChange={formik.handleChange}
error={formik.touched.city && Boolean(formik.errors.city)}
helperText={formik.touched.city && formik.errors.city}
error={formik.touched.postcode && Boolean(formik.errors.postcode)}
helperText={formik.touched.postcode && formik.errors.postcode}
fullWidth
/>
</Box>

+ 28
- 2
components/layout/navbar/Navbar.jsx 파일 보기

@@ -10,10 +10,11 @@ import {
PRODUCTS_PAGE,
PROFILE_PAGE,
} from '../../../constants/pages';
import { useStore } from '../../../store/cart-context';

const Navbar = () => {
const router = useRouter();
const { totalQuantity } = useStore();
return (
<AppBar
position="absolute"
@@ -150,7 +151,32 @@ const Navbar = () => {
}}
>
<Link key="home" href={CART_PAGE}>
<Image src="/images/cart.svg" alt="cart" width={24} height={24} />
<Box>
<Box
sx={{
color: 'white',
zIndex: 3,
width: 20,
height: 20,
borderRadius: 20,
textAlign: 'center',
px: 0.5,
ml: 2.2,
mt: -1,
fontSize: 16,
position: 'absolute',
backgroundColor: 'primary.main',
}}
>
{totalQuantity}
</Box>
<Image
src="/images/cart.svg"
alt="cart"
width={24}
height={24}
/>
</Box>
</Link>
,
</Box>

+ 24
- 2
components/products/featured-product/FeaturedProduct.jsx 파일 보기

@@ -1,10 +1,20 @@
import { Box } from '@mui/system';
import PropType from 'prop-types';
import { useStore, useStoreUpdate } from '../../../store/cart-context';
import ProductImage from './ProductImage';
import ProductInfo from './ProductInfo';

const FeaturedProduct = ({ product, bColor, image, side }) => {
const data = { name: product.name, description: product.description };
const { addCartValue } = useStoreUpdate();
const { cartStorage } = useStore();
const addProductToCart = (quantity) => addCartValue(product, quantity);
const inCart = cartStorage?.some(
(item) => item.product.customID === product.customID
)
? true
: false;

return (
<Box
sx={{
@@ -18,10 +28,22 @@ const FeaturedProduct = ({ product, bColor, image, side }) => {
{side === 'left' ? (
<ProductImage image={image}></ProductImage>
) : (
<ProductInfo bColor={bColor} side={side} data={data}></ProductInfo>
<ProductInfo
bColor={bColor}
side={side}
data={data}
addProductToCart={addProductToCart}
inCart={inCart}
></ProductInfo>
)}
{side === 'left' ? (
<ProductInfo bColor={bColor} side={side} data={data}></ProductInfo>
<ProductInfo
bColor={bColor}
side={side}
data={data}
addProductToCart={addProductToCart}
inCart={inCart}
></ProductInfo>
) : (
<ProductImage image={image}></ProductImage>
)}

+ 16
- 5
components/products/featured-product/ProductInfo.jsx 파일 보기

@@ -2,8 +2,11 @@ import { Button, ButtonGroup, Typography } from '@mui/material';
import { Box } from '@mui/system';
import Image from 'next/image';
import PropType from 'prop-types';
import { useState } from 'react';

const ProductInfo = ({ data, bColor, side, addProductToCart, inCart }) => {
const [quantity, setQuantity] = useState(1);

const ProductInfo = ({ data, bColor, side }) => {
return (
<Box
sx={{
@@ -64,7 +67,9 @@ const ProductInfo = ({ data, bColor, side }) => {
fontSize: 20,
width: 50,
}}
onClick={() => {}}
onClick={() => {
setQuantity((prevState) => prevState - 1);
}}
>
-
</Button>
@@ -75,7 +80,7 @@ const ProductInfo = ({ data, bColor, side }) => {
width: 50,
}}
>
1
{quantity}
</Button>
<Button
sx={{
@@ -83,7 +88,9 @@ const ProductInfo = ({ data, bColor, side }) => {
fontSize: 20,
width: 50,
}}
onClick={() => {}}
onClick={() => {
setQuantity((prevState) => prevState + 1);
}}
>
+
</Button>
@@ -95,8 +102,10 @@ const ProductInfo = ({ data, bColor, side }) => {
width: 150,
color: 'white',
}}
disabled={inCart}
onClick={() => addProductToCart(quantity)}
>
Add to cart
{inCart ? 'In Cart' : 'Add to cart'}
</Button>
</Box>
</Box>
@@ -110,5 +119,7 @@ ProductInfo.propTypes = {
}),
bColor: PropType.string,
side: PropType.string,
addProductToCart: PropType.func,
inCart: PropType.Boolean | PropType.undefined,
};
export default ProductInfo;

+ 24
- 0
hooks/useCalculateTotal.js 파일 보기

@@ -0,0 +1,24 @@
import { useState } from 'react';
import { getStorage } from '../utils/helpers/storage';

const useCalculateTotal = () => {
const CART_KEY = 'cart-products';

const [total, setTotal] = useState(() => {
const cart = getStorage(CART_KEY);

if (cart && cart.length) {
return cart
.map((entry) => entry?.product.price * entry?.quantity)
.reduce((accum, curValue) => accum + curValue);
} else {
return 0;
}
});

return {
total,
};
};

export default useCalculateTotal;

+ 11
- 15
models/user.js 파일 보기

@@ -58,26 +58,13 @@ const UserSchema = new mongoose.Schema({
required: [true, 'Please provide an address.'],
trim: true,
},
phone: {
address2: {
type: String,
unique: [true, 'Phone number must be unique.'],
required: [true, 'Please provide a phone number.'],
trim: true,
lowercase: true,
validate(value) {
if (!validator.isMobilePhone(value)) {
throw new Error('Not a valid phone number');
}
},
},
postcode: {
type: String,
required: [true, 'Please provide a postal code.'],
validate(value) {
if (!validator.isPostalCode(value)) {
throw new Error('Not a valid postal code');
}
},
},
});

@@ -100,7 +87,16 @@ UserSchema.statics.findByCredentials = async (username, password) => {
throw new Error('Unable to login');
}

return user;
const userData = {
fullName: user.fullName,
email: user.email,
address: user.address,
address2: user.address2,
city: user.city,
country: user.country,
postcode: user.postcode,
};
return userData;
};

UserSchema.pre('save', async function (next) {

+ 15
- 12
pages/_app.js 파일 보기

@@ -10,6 +10,7 @@ import Head from 'next/head';
import { useState } from 'react';
import Layout from '../components/layout/base-layout/Layout';
import CircularIndeterminate from '../components/loader/route-loader/CircularIndeterminate';
import StorageProvider from '../store/cart-context';
import '../styles/globals.css';
import theme from '../styles/muiTheme';

@@ -21,18 +22,20 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }) {
<Hydrate state={pageProps.dehydratedState}>
<SessionProvider session={session}>
<ThemeProvider theme={theme}>
<Layout>
<Head>
<title>Coffee Shop</title>
<meta name="description" content="NextJS template" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
</Head>
<CircularIndeterminate />
<Component {...pageProps} />
</Layout>
<StorageProvider>
<Layout>
<Head>
<title>Coffee Shop</title>
<meta name="description" content="NextJS template" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
</Head>
<CircularIndeterminate />
<Component {...pageProps} />
</Layout>
</StorageProvider>
</ThemeProvider>
</SessionProvider>
</Hydrate>

+ 10
- 2
pages/api/auth/[...nextauth].js 파일 보기

@@ -7,16 +7,24 @@ export default NextAuth({
session: {
jwt: true,
},
callbacks: {
async jwt({ token, user }) {
return { ...token, ...user };
},
async session({ session, user, token }) {
return token;
},
},
providers: [
Credentials({
async authorize(credentials) {
await dbConnect();

const user = await User.findByCredentials(
const userData = await User.findByCredentials(
credentials.username,
credentials.password
);
return { name: user.fullName };
return { user: userData };
},
}),
],

+ 1
- 0
pages/api/auth/signup.js 파일 보기

@@ -9,6 +9,7 @@ async function handler(req, res) {
switch (method) {
case 'POST': {
try {
console.log(req.body);
const user = await User.create(req.body);
res
.status(201)

+ 30
- 16
pages/profile/index.js 파일 보기

@@ -1,27 +1,41 @@
import { useSession } from 'next-auth/react';
import { Button } from '@mui/material';
import { getSession, signOut, useSession } from 'next-auth/react';
import ProfileContent from '../../components/profile-content/ProfileContent';
import { LOGIN_PAGE } from '../../constants/pages';

const ProfilePage = () => {
const { data: session } = useSession();

return <ProfileContent></ProfileContent>;
console.log(session);

function logoutHandler() {
signOut();
}
return (
<>
<ProfileContent></ProfileContent>
<Button color="inherit" onClick={logoutHandler}>
Logout
</Button>
</>
);
};

// export async function getServerSideProps(context) {
// const session = await getSession({ req: context.req });
export async function getServerSideProps(context) {
const session = await getSession({ req: context.req });

// if (!session) {
// return {
// redirect: {
// destination: LOGIN_PAGE,
// permanent: false,
// },
// };
// }
if (!session) {
return {
redirect: {
destination: LOGIN_PAGE,
permanent: false,
},
};
}

// return {
// props: { session },
// };
// }
return {
props: { session },
};
}

export default ProfilePage;

+ 22
- 2
requests/accounts/accountRequests.js 파일 보기

@@ -1,9 +1,29 @@
import apiEndpoints from '../apiEndpoints';

export const createUser = async (fullName, username, email, password) => {
export const createUser = async (
fullName,
username,
email,
password,
address,
address2,
city,
country,
postcode
) => {
const response = await fetch(apiEndpoints.account.createUser, {
method: 'POST',
body: JSON.stringify({ fullName, username, email, password }),
body: JSON.stringify({
fullName,
username,
email,
password,
address,
address2,
city,
country,
postcode,
}),
headers: {
'Content-Type': 'application/json',
},

+ 12
- 7
schemas/registerSchema.js 파일 보기

@@ -1,12 +1,17 @@
import * as Yup from "yup";
import * as Yup from 'yup';

export const registerSchema = Yup.object().shape({
fullName: Yup.string().required("Full name is required"),
username: Yup.string().required("Username is required"),
email: Yup.string().email().required("Email is required"),
password: Yup.string().required("Password is required"),
fullName: Yup.string().required('Full name is required'),
username: Yup.string().required('Username is required'),
email: Yup.string().email().required('Email is required'),
password: Yup.string().required('Password is required'),
confirmPassword: Yup.string().oneOf(
[Yup.ref("password"), null],
"Passwords must match"
[Yup.ref('password'), null],
'Passwords must match'
),
address: Yup.string().required('Address is required'),
address2: Yup.string(),
city: Yup.string().required('City is required'),
country: Yup.string().required('Country is required'),
postcode: Yup.string().required('Postal code is required'),
});

+ 1
- 2
schemas/shippingDetailsSchema.js 파일 보기

@@ -2,10 +2,9 @@ import * as Yup from 'yup';

export const registerSchema = Yup.object().shape({
fullName: Yup.string().required('Full name is required'),
email: Yup.string().email().required('Email is required'),
address: Yup.string().required('Address is required'),
address2: Yup.string(),
city: Yup.string().required('City is required'),
country: Yup.string().required('Country name is required'),
postalCode: Yup.string().required('Postal code name is required'),
postcode: Yup.string().required('Postal code name is required'),
});

+ 168
- 0
store/cart-context.js 파일 보기

@@ -0,0 +1,168 @@
import { createContext, useContext, useState } from 'react';
import { getStorage, setStorage } from '../utils/helpers/storage';

const StorageContext = createContext({
cartStorage: [],
totalPrice: 0,
totalQuantity: 0,
});
const StorageDispatchContext = createContext({
addCartValue: (product, quantity) => {},
clearCart: () => {},
removeCartValue: (productId) => {},
setCartStorage: (cart) => {},
updateItemQuantity: (productId, quantity) => {},
});

export const useStore = () => {
return useContext(StorageContext);
};
export const useStoreUpdate = () => {
return useContext(StorageDispatchContext);
};

const useStorage = () => {
const CART_KEY = 'cart-products';
const [cartStorage, setCartStorage] = useState(getStorage(CART_KEY));
const [totalPrice, setTotalPrice] = useState(() => {
const cart = getStorage(CART_KEY);

if (cart && cart.length) {
return cart
.map((entry) => entry?.product.price * entry?.quantity)
.reduce((accum, curValue) => accum + curValue);
} else {
return 0;
}
});

const [totalQuantity, setTotalQuantity] = useState(() => {
const cart = getStorage(CART_KEY);

if (cart && cart.length) {
return cart.length;
} else {
return 0;
}
});

const addCartValue = (product, quantity) => {
const items = getStorage(CART_KEY);

if (!items) {
setStorage(CART_KEY, [{ product, quantity }]);
} else {
const isItemDuplicate = items.some(
(item) => item.product.customID === product.customID
);

if (!isItemDuplicate) {
items.push({ product, quantity });
setTotalQuantity((prevState) => prevState + 1);
setStorage(CART_KEY, items);
} else {
return;
}
}

const newTotalPrice = items
.map((entry) => entry?.product.price * entry?.quantity)
.reduce((accum, curValue) => accum + curValue);

setTotalPrice(newTotalPrice);

setCartStorage(items);
};

const updateItemQuantity = (productId, quantity) => {
if (quantity < 0) return;
const items = getStorage(CART_KEY);
let updatedItems = items;

if (items) {
updatedItems = items.map((entry) => {
if (entry?.product.customID === productId) {
console.log('true');
entry.quantity = quantity;
}
return entry;
});

setStorage(CART_KEY, updatedItems);
}

const newTotalPrice = updatedItems
.map((entry) => entry?.product.price * entry?.quantity)
.reduce((accum, curValue) => accum + curValue);

setTotalPrice(newTotalPrice);
setCartStorage(updatedItems);
};

const clearCart = () => {
setStorage(CART_KEY, []);
setCartStorage([]);
};

const removeCartValue = (productId) => {
const items = getStorage(CART_KEY);

const newStorage = items?.filter(
(item) => item.product.customID !== productId
);

if (newStorage.length === 0) {
setTotalPrice(0);
} else {
const newTotalPrice = newStorage
.map((entry) => entry?.product.price * entry?.quantity)
.reduce((accum, curValue) => accum + curValue);
setTotalPrice(newTotalPrice);
}
setTotalQuantity((prevState) => prevState - 1);
setStorage(CART_KEY, newStorage);
setCartStorage(newStorage);
};

return {
addCartValue,
cartStorage,
totalPrice,
totalQuantity,
clearCart,
removeCartValue,
setCartStorage,
updateItemQuantity,
};
};

const StorageProvider = ({ children }) => {
const {
cartStorage,
totalPrice,
totalQuantity,
addCartValue,
clearCart,
setCartStorage,
removeCartValue,
updateItemQuantity,
} = useStorage();

return (
<StorageContext.Provider value={{ cartStorage, totalPrice, totalQuantity }}>
<StorageDispatchContext.Provider
value={{
addCartValue,
clearCart,
removeCartValue,
setCartStorage,
updateItemQuantity,
}}
>
{children}
</StorageDispatchContext.Provider>
</StorageContext.Provider>
);
};

export default StorageProvider;

+ 20
- 0
utils/helpers/storage.js 파일 보기

@@ -0,0 +1,20 @@
export const setStorage = (key, value) => {
window.localStorage.setItem(key, JSON.stringify(value));
};

export const getStorage = (key) => {
if (typeof window === 'undefined') {
return null;
}

const storedItems = window.localStorage.getItem(key);

return storedItems ? JSON.parse(storedItems) : [];
};

export const removeStorage = (key) => {
if (typeof window === 'undefined') {
return null;
}
window.localStorage.removeItem(key);
};

Loading…
취소
저장