E-commerce Vanila JS [11편] 결제단계
결제 구현단계는 크게 '인증 - 배송 입력 - 결제 - 결제확인' 4단계로 이뤄진다.
1 결제단계 :: Shipping
1) CheckoutSteps.js 생성
- 결제 단계마다 UI를 변경하는 컴포넌트 'CheckoutSteps.js'를 components/폴더 아래 생성한다.
const CheckoutSteps = { render: (props) => {
return `
<div class="checkout-steps">
<div class="${props.step1 ? 'active' : '' }"> Signin </div>
<div class="${props.step2 ? 'active' : '' }"> Shipping </div>
<div class="${props.step3 ? 'active' : '' }"> Payment </div>
<div class="${props.step4 ? 'active' : '' }"> Place Order </div>
</div>
`;
},
};
export default CheckoutSteps;
|
[ root\frontend\src\components\CheckoutSteps.js ]
2) utils.js(frontend) 추가
- 로그인 혹은 회원가입 후, 로컬 스토러지에 장바구니 정보가 있다면 계속 결제단계를 진행할 수 있는 기능을 구현한다.
import { getCartItems } from './localStorage'; // 추가 // 중략... export const redirectUser = () => { if(getCartItems().length !== 0){
document.location.hash = '/shipping';
}else{
document.location.hash = '/';
}
};
|
[ root\frontend\src\utils.js ]
3) SigninScreen.js || RegisterScreen.js 수정
- 장바구니에 물품을 담아둔 상태에서 로그인, 회원가입이 진행되었을 경우에는 redirectUser( )를 통해 배송단계 혹은 초기 페이지가 결정되어야 한다.
import { signin } from '../api';
import { getUserInfo, setUserInfo } from '../localStorage';
import { showLoading, hideLoading, showMessage, redirectUser } from '../utils'; // redirectUser 추가
const SigninScreen = {
after_render: () => {
document.getElementById('signin-form')
.addEventListener('submit', async(e) => {
e.preventDefault();
showLoading();
const data = await signin({
email: document.getElementById('email').value,
password: document.getElementById('password').value,
});
hideLoading();
if(data.error){
showMessage(data.error);
}else{
setUserInfo(data);
//삭제 document.location.hash = '/';
redirectUser();
}
})
},
render: () => {
if(getUserInfo().name){
// 삭제 document.location.hash = '/';
redirectUser();
}
|
[ root\frontend\src\screens\SigninScreen.js ]
RegisterScreen.js 역시 수정한다.
import { register } from '../api'; import { getUserInfo, setUserInfo } from '../localStorage';
import { showLoading, hideLoading, showMessage, redirectUser } from '../utils'; // redirectUser 추가
const RegisterScreen = {
after_render: () => {
document.getElementById('register-form')
.addEventListener('submit', async(e) => {
e.preventDefault();
showLoading();
const data = await register({
name: document.getElementById('name').value,
email: document.getElementById('email').value,
password: document.getElementById('password').value,
});
hideLoading();
if(data.error){
showMessage(data.error);
}else{
setUserInfo(data);
// 삭제 document.location.hash = '/';
redirectUser();
}
})
},
render: () => {
if(getUserInfo().name){
// 삭제 document.location.hash = '/';
redirectUser();
}
|
[ root\frontend\src\screens\RegisterScreen.js ]
4) localStorage.js 추가
- 사용자가 입력한 배송폼 정보를 로컬 스토러지에 저장한다.
// 중략... export const getShipping = () => { const shipping = localStorage.getItem('shipping')
? JSON.parse(localStorage.getItem('shipping'))
: {
address: '',
city: '',
postalCode: '',
country: '',
}
return shipping;
}
export const setShipping = ({
address = '',
city = '',
postalCode = '',
country= '',
}) => {
localStorage.setItem(
'shipping',
JSON.stringify({ address, city, postalCode, country })
);
}
|
[ root\frontend\src\localStorage.js ]
5) ShippingScreen.js 생성
- 사용자가 배송관련 정보를 입력하는 폼을 랜더링한다.
import { getUserInfo, getShipping, setShipping } from '../localStorage'; import CheckoutSteps from '../components/CheckoutSteps';
const ShippingScreen = {
after_render: () => {
document.getElementById('shipping-form')
.addEventListener('submit', async(e) => {
e.preventDefault();
setShipping({
address: document.getElementById('address').value,
city: document.getElementById('city').value,
postalCode: document.getElementById('postalCode').value,
country: document.getElementById('country').value,
});
document.location.hash = '/payment';
})
},
render: () =>{
const {name} = getUserInfo();
if(!name){
document.location.hash = '/';
}
const { address, city, postalCode, country } = getShipping();
return `
${CheckoutSteps.render({ step1: true, step2: true })}
<div class="form-container">
<form id="shipping-form">
<ul class="form-items">
<li>
<h1>Shipping</h1>
</li>
<li>
<label for="address">Adress</label>
<input type="text" name="address" id="address" value="${address}" />
</li>
<li>
<label for="city">City</label>
<input type="text" name="city" id="city" value="${city}" />
</li>
<li>
<label for="postalCode">Postal Code</label>
<input type="text" name="postalCode" id="postalCode" value="${postalCode}" />
</li>
<li>
<label for="country">Country</label>
<input type="text" name="country" id="country" value="${country}" />
</li>
<li>
<button type="submit" class="primary">Continue</button>
</li>
</ul>
</form>
</div>
`
}
}
export default ShippingScreen;
|
[ root\frontend\src\screens\ShippingScreen.js ]
6) index.js 추가
- /shipping 라우터를 추가한다.
// 중략...
import RegisterScreen from './screens/RegisterScreen';
import ProfileScreen from './screens/ProfileScreen';
import ShippingScreen from './screens/ShippingScreen'; // 추가
const routes = {
'/' : HomeScreen,
'/product/:id':ProductScreen,
'/cart/:id': CartScreen,
'/cart': CartScreen,
'/signin': SigninScreen,
'/register': RegisterScreen,
'/profile': ProfileScreen,
'/shipping': ShippingScreen, // 추가
}
const router = async() => {
showLoading();
// 중략... |
[ root\frontend\src\index.js ]
7) style.css 추가
- 구매버튼 GUI를 수정한다.
/* Checkout */ .checkout-steps{
display:flex;
justify-content: space-between;
width: 40rem;
margin: 1rem auto;
}
.checkout-steps{
border-top: 0.3rem #c0c0c0 solid;
color: #c0c0c0;
flex: 1 1;
padding-top: 1rem;
}
.checkout-steps > div.active{
color: #f08000;
border-top-color:#f08000;
}
|
[ root\frontend\style.css ]
실행했을 때, 배송폼까지 진행한 단계는 아래와 같다.
|
[ 배송단계 ]
2 결제단계 :: Payment
1) PaymentScreen.js 생성
- 작업의 효율성을 위해서 ShippingScreen.js를 복사한 뒤, 'Shipping'검색어를 'Payment'로 변경한다.
import { getUserInfo, setPayment } from '../localStorage'; import CheckoutSteps from '../components/CheckoutSteps';
const PaymentScreen = {
after_render: () => {
document.getElementById('Payment-form')
.addEventListener('submit', async(e) => {
e.preventDefault();
const paymentMethod = document.querySelector(
'input[name="payment-method"]:checked'
).value;
setPayment({ paymentMethod });
document.location.hash = '/placeorder';
});
},
render: () =>{
const {name} = getUserInfo();
if(!name){
document.location.hash = '/';
}
return `
${CheckoutSteps.render({ step1: true, step2: true, step3: true })}
<div class="form-container">
<form id="Payment-form">
<ul class="form-items">
<li>
<h1>Payment</h1>
</li>
<li>
<div>
<input type="radio"
name="payment-method"
id="paypal"
value="Paypal"
checked
/>
<label for="paypal">PayPal</label>
</div>
</li>
<li>
<div>
<input type="radio"
name="payment-method"
id="stripe"
value="Stripe"
/>
<label for="stripe">Stripe</label>
</div>
</li>
<li>
<button type="submit" class="primary">Continue</button>
</li>
</ul>
</form>
</div>
`
}
}
export default PaymentScreen;
|
[ root\frontend\src\screens\PaymentScreen.js ]
2) localStorage.js 추가
- getPayment, setPayment 함수를 추가한다.
// 중략... export const getPayment = () => {
const payment = localStorage.getItem('payment')
? JSON.parse(localStorage.getItem('payment'))
: {
paymentMethod: 'paypal',
};
return payment;
};
export const setPayment = ({ paymentMethod = 'paypal' }) => {
localStorage.setItem( 'payment', JSON.stringify({ paymentMethod }));
};
|
[ root\frontend\src\localStorage.js ]
3) index.js 수정
- '/payment' 라우터를 추가한다.
// 중략...
import ShippingScreen from './screens/ShippingScreen';
import PaymentScreen from './screens/PaymentScreen'; // 추가
const routes = {
'/' : HomeScreen,
'/product/:id':ProductScreen,
'/cart/:id': CartScreen,
'/cart': CartScreen,
'/signin': SigninScreen,
'/register': RegisterScreen,
'/profile': ProfileScreen,
'/shipping': ShippingScreen,
'/payment': PaymentScreen, // 추가
}
const router = async() => {
showLoading();
// 중략... |
[ root\frontend\src\index.js ]
3 결제단계 :: PlaceOrder
1) PlaceOrderScreen.js 생성
- 결제의 마지막 단계는 합계, 총합, 세금, 기타 가격의 총합을 고객에게 보여준다.
import { getCartItems, getShipping, getPayment, cleanCart } from '../localStorage'; import CheckoutSteps from '../components/CheckoutSteps';
const convertCartToOrder = () =>{
const orderItems = getCartItems();
if(orderItems.length === 0){
document.location.hash = '/cart';
}
const shipping = getShipping();
if(!shipping.address){
document.location.hash = '/shipping';
}
const payment = getPayment();
if(!payment.paymentMethod){
document.location.hash = '/payment';
}
const itemsPrice = orderItems.reduce( (a, c) => a + c.price * c.qty, 0);
const shippingPrice = itemsPrice > 100 ? 0 : 10;
const taxPrice = Math.round(0.15 * itemsPrice * 100) / 100;
const totalPrice = itemsPrice + shippingPrice + taxPrice;
return {
orderItems,
shipping,
payment,
itemsPrice,
shippingPrice,
taxPrice,
totalPrice
}
}
const PlaceOrderScreen = {
after_render: () => {
},
render: () =>{
const {
orderItems,
shipping,
payment,
itemsPrice,
shippingPrice,
taxPrice,
totalPrice
} = convertCartToOrder();
return `
<div>
${CheckoutSteps.render({
step1: true,
step2: true,
step3: true,
step4: true
})}
<div class="order">
<div class="order-info">
<div>
<h2>Shipping</h2>
<div>
${shipping.address}, ${shipping.city}, ${shipping.postalCode},
${shipping.country}
</div>
</div>
<div>
<h2>Payment</h2>
<div>
Payment Method: ${payment.paymentMethod}
</div>
</div>
<div>
<ul class="cart-list-container">
<li>
<h2>Shopping Cart</h2>
<div>Price</div>
</li>
${orderItems.map(item => `
<li>
<div class="cart-image">
<img src="${item.image}" alt="${item.name}">
</div>
<div class="cart-name">
<div>
<a href="/#/product/${item.product}">${item.name} </a>
</div>
<div> Qty: ${item.qty} </div>
</div>
<div class="cart-price"> $${item.price}<div>
</li>
`).join('\n')}
</ul>
</div>
</div>
<div class="order-action">
<ul>
<li>
<h2>Order Summary</h2>
</li>
<li><div>Items</div><div>$${itemsPrice}</div></li>
<li><div>Shipping</div><div>$${shippingPrice}</div></li>
<li><div>Tax</div><div>$${taxPrice}</div></li>
<li class="total"><div>Order Total</div><div>$${totalPrice}</div></li>
<li>
<button id="placeorder-button" class="primary fw">Place Order</button>
</li>
</ul>
</div>
</div>
`;
},
};
export default PlaceOrderScreen;
|
[ root\frontend\src\screens\PlaceOrder.js ]
2) index.js 수정
- '/placeorder' 라우터를 추가한다.
// 중략...
import ShippingScreen from './screens/ShippingScreen';
import PaymentScreen from './screens/PaymentScreen';
import PlaceOrder from './screens/PlaceOrderScreen'; // 추가 const routes = {
'/' : HomeScreen,
'/product/:id':ProductScreen,
'/cart/:id': CartScreen,
'/cart': CartScreen,
'/signin': SigninScreen,
'/register': RegisterScreen,
'/profile': ProfileScreen,
'/shipping': ShippingScreen,
'/payment': PaymentScreen,
'/placeorder': PlaceOrder, // 추가
}
const router = async() => {
showLoading();
// 중략... |
[ root\frontend\src\index.js ]
3) style.css 추가
- 주문합계 css를 추가한다.
/* Order */
.order{
display:flex;
flex-wrap:wrap;
padding: 1rem;
justify-content: space-between;
}
.order h2{
margin: 0;
font-size: 2rem;
padding-bottom: 1rem;
}
.order-info{
flex: 3 1 60rem;
}
.order .cart-list-container{
padding: 0;
}
.order-info > div{
border:0.1rem #c0c0c0 solid;
border-radius: 0.5rem;
background-color:#fcfcfc;
padding: 1rem;
margin: 1rem;
}
.order-info > div:first-child{
margin-top:0;
}
.order-info > div > div{
padding: 1rem;
}
.order-action{
flex: 1 1 20rem;
border:0.1rem #c0c0c0 solid;
border-radius:0.5rem;
background-color:#fcfcfc;
padding: 1rem;
}
.order-action > ul{
padding: 0;
list-style-type:none;
}
.order-action li{
display:flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.order-action .total{
font-size: 2rem;
font-weight: bold;
color: #c04000;
}
|
[ root\frontend\src\localStorage.js ]
4) localStorage.js추가
- cleanCart( ) 추가하기
// 중략... localStorage.setItem('payment', JSON.stringify({ paymentMethod }))
};
export const cleanCart = () => {
localStorage.removeItem('cartItems');
}
|
[ root\frontend\src\localStorage.js ]
5) api.js
- 페이팔 버튼을 클릭했을 때, POST방식으로 페이팔 서버와 데이터를 주고받을 수 있는 라우터가 필요하다. api.js는 프론트와 벡앤드의 가교 역할을 하는만큼 createOrder( )를 생성해서 결제 데이터를 페이팔에 전달해주는 기능을 구현한다.
// 중략... }
export const createOrder = async(order) => {
try{
const { token } = getUserInfo();
const response = await axios({
url: `${apiUrl}/api/orders`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
data: order,
});
if(response.statusText !== 'Created'){
throw new Error(response.data.message);
}
return response.data;
}catch(err){
return { error: err.response ? err.response.data.message : err.message }
}
}
|
[ root\frontend\src\api.js ]
6) orderModel.js
- 서버에 저장할 때 사용하는 사용자 주문정보 스키마를 생성한다.
import mongoose from 'mongoose'; const orderSchema = new mongoose.Schema({
orderItems:[
{
name:{type: String, required: true},
image:{type: String, required: true},
price:{type: Number, required: true},
qty:{type: Number, required: true},
product:{
type: String,
ref: 'Product',
required: true,
},
}
],
user: {
type: String,
ref: 'User',
required: true,
},
shipping:{
address: String,
city: String,
postalCode: String,
country: String,
},
payment: {
paymentMethod: String,
paymentResult: {
orderID: String,
payerID: String,
paymentID: String,
},
},
itemsPrice: Number,
taxPrice: Number,
shippingPrice: Number,
totalPrice: Number,
isPaid: { type: Boolean, required: true, default: false },
paidAt: Date,
isDelivered: { type: Boolean, required: true, default: false },
deliveredAt: Date,
},
{
timestamps: true,
}
);
const Order = mongoose.model('Order', orderSchema);
export default Order;
|
[ root\backend\models\orderModel.js ]
7) orderRouter.js 생성
- 사용자 주문정보가 서버에 저장되도록 orderRouter.js를 생성한다.
import express from 'express'; import expressAsyncHandler from 'express-async-handler';
import { isAuth } from '../utils';
import Order from '../models/orderModel';
const orderRouter = express.Router();
orderRouter.post(
'/',
isAuth,
expressAsyncHandler( async(req, res) => {
const order = new Order({
orderItems: req.body.orderItems,
user: req.user._id,
shipping: req.body.shipping,
payment: req.body.payment,
itemPrice: req.body.itemPrice,
taxPrice: req.body.taxPrice,
shippingPrice: req.body.shippingPrice,
totalPrice: req.body.totalPrice,
});
const createOrder = await order.save();
res.status(201).send({ message: 'New order created', data: createdOrder })
})
);
export default orderRouter;
|
[ root\backend\routers\orderRouter.js ]
8) server.js 추가
- 'api/orders' 서버쪽 라우터를 추가한다.
// 중략...
import userRouter from './routers/userRouter';
import orderRouter from './routers/orderRouter'; // 추가
dbMain().then( () => {
console.log('Connected to mongoDB');
}).catch( err => console.log(err) );
async function dbMain(){
await mongoose
.connect( config.MONGODB_URL, {
useNewUrlParser: true,
useUnifiedTopology: true
})
}
const app = express();
app.use(cors());
app.use(bodyParser.json());
app.use('/api/users', userRouter );
app.use('/api/orders', orderRouter ); // 추가
app.get('/api/products', (req, res) => {
// 중략...
|
[ root\backend\server.js ]
9) data.js 수정
- token값이 24자리에 맞게끔 data.js의 _id를 24자리로 맞춰준다.
export default {
products:[
{
_id: '111111111111111111111111', // 수정
name: 'tree01 3d model',
category: 'Nature',
image: '/images/tree_modeling.jpg',
price: 1.2,
size: '3.1mb',
// 중략...
},
{
|
[ root\backend\data.js ]
4 결제단계 :: Order
1) OrderScreen.js 생성
- 'OrderScreen.js'는 주문 결과창을 랜더링하는 기능을 구현한다.
import { parseRequestUrl } from '../utils'; import { getOrder } from '../api';
const OrderScreen = {
after_render:async() => {},
render:async() => {
const request = parseRequestUrl();
const {
_id,
shipping,
payment,
orderItems,
itemsPrice,
shippingPrice,
taxPrice,
totalPrice,
isDelivered,
deliveredAt,
isPaid,
paidAt,
} = await getOrder(request.id);
return `
<div>
<h1>Order ${_id}</h1>
<div class="order">
<div class="order-info">
<div>
<h2>Shipping</h2>
<div>
${shipping.address}, ${shipping.city}, ${shipping.postalCode},
${shipping.country}
</div>
${
isDelivered
? `<div class="success"> Delivered at ${deliveredAt}</div>`
: `<div class="error"> Not Delivered </div>`
}
</div>
<div>
<h2>Payment</h2>
<div>
Payment Method: ${payment.paymentMethod}
</div>
${
isPaid
? `<div class="success"> Paid at ${paidAt}</div>`
: `<div class="error"> Not Paid </div>`
}
</div>
<div>
<ul class="cart-list-container">
<li>
<h2>Shopping Cart</h2>
<div>Price</div>
</li>
${
orderItems.map(item => `
<li>
<div class="cart-image">
<img src="${item.image}" alt="${item.name}">
</div>
<div class="cart-name">
<div>
<a href="/#/product/${item.product}">${item.name} </a>
</div>
<div> Qty: ${item.qty} </div>
</div>
<div class="cart-price"> $${item.price}<div>
</li>
`).join('\n')}
</ul>
</div>
</div>
<div class="order-action">
<ul>
<li>
<h2>Order Summary</h2>
</li>
<li>
<div>Items</div>
<div>$${itemsPrice}</div>
</li>
<li>
<div>Shipping</div>
<div>$${shippingPrice}</div>
</li>
<li>
<div>Tax</div>
<div>$${taxPrice}</div>
</li>
<li class="total">
<div>Order Total</div>
<div>$${totalPrice}</div>
</li>
<li>
<div class="fw" id="paypal-button">
</div>
</li>
</ul>
</div>
</div>
</div>
`;
},
};
export default OrderScreen;
|
[ root\frontend\src\screens\OrderScreen.js ]
2) orderRouter.js 생성
- orderRouter.js는 주문정보가 서버에 저장되는 POST방식의 라우팅 뿐만 아니라 사용자 ID를 쿼리한 GET방식으로 정보를 받을 수 있어야 한다. 아래 코드를 추가하자.
// 중략...
const orderRouter = express.Router();
// <-- 추가 orderRouter.get('/:id', isAuth, expressAsyncHandler( async(req, res) => {
const order = await Order.findById(req.params.id);
if(order){
res.send(order);
}else{
res.status(400).send({
message: 'Order Not Found'
})
}
}));
// 추가--> orderRouter.post(
'/',
// 중략...
|
[ root\backend\routers\orderRouter.js ]
3) api.js 추가
- OrderScreen.js는 다음과 같이 getOrder( )메서드로 서버에서 사용자id관련 주문정보를 받고 있다.
import { parseRequestUrl } from '../utils'; import { getOrder } from '../api';
const OrderScreen = {
after_render:async() => {},
render:async() => {
const request = parseRequestUrl();
// <-- 서버에서 주문정보를 받는 부분
const {
_id,
shipping,
payment,
orderItems,
itemsPrice,
shippingPrice,
taxPrice,
totalPrice,
isDelivered,
deliveredAt,
isPaid,
paidAt,
} = await getOrder(request.id);
// 서버에서 주문정보를 받는 부분 --> |
[ root\frontend\src\screens\OrderScreen.js ]
위의 주문정보를 받기 위해서 api.js파일에 getOrder( )함수를 추가한다.
export const createOrder = async(order) => {
// 중략...
}
export const getOrder = async(id) => { try{
const { token } = getUserInfo();
const response = await axios({
url: `${apiUrl}/api/orders/${id}`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
if(response.statusText !== 'OK'){
throw new Error(response.data.message);
}
return response.data;
}catch(err){
return {error: err.message}
}
}
|
[ root\frontend\src\api.js ]
4) index.js 추가
- GET방식의 'order/:id'라우터를 추가한다.
// 중략... import PlaceOrderScreen from './screens/PlaceOrderScreen';
import OrderScreen from './screens/OrderScreen'; // 추가
const routes = {
'/' : HomeScreen,
'/product/:id':ProductScreen,
'/order/:id': OrderScreen, // 추가
'/cart/:id': CartScreen,
// 중략...
}
|
[ root\frontend\src\index.js ]
5) Test
- 현재까지 구현한 코드를 실행한다면, 아래와 같이 주문결과를 볼 수 있다.
*파일: