[클론 코딩] Nuxt3 웹 사이트 만들기(NuxtLink를 이용한 페이지간 이동 및 Quasar UI 구현) - 3편
안녕하세요. 기깔나는 사람들에서 프론트엔드를 맡고있는 해리입니다.
이번편에서는 페이지 생성 및 NuxtLink 컴포넌트를 이용한 페이지 이동과 나머지 화면 UI 구현해보겠습니다!
[클론 코딩] Nuxt3 웹 사이트 만들기(프로젝트 생성 및 초기 구성) - 1편
[클론 코딩] Nuxt3 웹 사이트 만들기(Quasar를 이용한 UI 구현) - 2편
NuxtLik 이용하여 페이지간 연결하기
2편에서 작성한 메뉴 정보에 path 값을 추가해줍니다.
import { GlobalMenu } from "~/types/menu";
export const MENUS: GlobalMenu[] = [
{
key: "global-menu-1",
name: "이사·이사청소",
children: [
{ key: "global-menu-1-1", name: "이사", path: "/moving" },
{ key: "global-menu-1-1", name: "입주/이사청소", path: "/deepclean" },
{ key: "global-menu-1-1", name: "인터넷 가입", path: "/internet" },
],
},
{ key: "global-menu-2", name: "파트너 지원", path: "/partner", children: [] },
];
위에 입력한 path 값들을 pages 경로에 .vue 파일을 생성해줍니다.
이렇게 해주면 nuxt에서 pages 경로에 생성된 .vue 파일을 기반으로 라우팅을 제공합니다!
마지막으로 GlobalNavigation 코드를 수정해줍니다.
q-btn 을 nuxt-link 로 변경하여 메뉴에 path 값이 있는 경우에만 이동되도록 설정해주고 하위 메뉴인 q-item에도 path 값을 넣어줍니다.
<template>
<div class="nav">
<nuxt-link
v-for="menu in MENUS"
:key="menu.key"
class="nav-item"
:to="menu.path"
>
<div class="text-subtitle1">{{ menu.name }}</div>
<q-menu v-if="menu.children.length > 0">
<q-list style="min-width: 100px">
<q-item
v-for="children in menu.children"
:key="children.key"
clickable
v-close-popup
:to="children.path"
>
<q-item-section>{{ children.name }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</nuxt-link>
</div>
</template>
<script lang="ts">
import { MENUS } from "~/const/menu";
export default defineComponent({
setup() {
return { MENUS };
},
});
</script>
<style lang="scss">
.nav {
flex: 1;
display: flex;
justify-content: flex-end;
height: 100%;
align-items: center;
}
.nav-item {
padding: 0 20px;
color: #000;
border-radius: 0;
text-decoration: none;
cursor: pointer;
}
.nav-item::before {
box-shadow: none;
}
.nav-item:hover::before {
border-bottom: solid $primary 3px;
}
.nav-item:hover {
color: $primary;
}
.text-subtitle1 {
font-weight: 500 !important;
}
.q-focus-helper {
background-color: #fff !important;
}
</style>
"메인" 화면 구현하기
메인의 이미지슬라이더는 여러 화면에서 공통으로 사용하여 공통으로 분리하였습니다.
Quasar의 Qcarosel 컴포넌트를 사용하여 구현해주었습니다!
ImageSlide.vue
<!-- 이미지 슬라이더 -->
<template>
<q-carousel v-model="slide" animated infinite autoplay>
<q-carousel-slide
v-for="image in images"
:key="image.name"
:img-src="image.src"
:name="image.name"
/>
</q-carousel>
</template>
<script lang="ts">
import { PropType } from "nuxt/dist/app/compat/capi";
import { SlideImage } from "~/types/slide";
export default {
props: {
images: {
type: Array as PropType<SlideImage[]>,
default() {
return [];
},
},
},
setup() {
const slide = ref<number>(1);
return { slide };
},
};
</script>
<style lang="scss" scoped>
.q-carousel {
min-height: 720px;
position: absolute;
top: 0;
left: 0;
width: 100%;
}
</style>
Moving.vue
<template>
<section class="slide-section">
<div class="elementor-widget-container">
<h2 class="text-w-lg">미소<br />생활의 모든 것</h2>
</div>
<div class="elementor-widget-container">
<h6>모두의 일상이 행복하도록</h6>
<h6>미소가 함께 할게요</h6>
</div>
<div class="elementor-widget-container q-mt-xl">
<nuxt-link to="/services" class="btn-service">
<h6 class="btn-text">미소 예약하기</h6>
</nuxt-link>
</div>
<ImageSlide :images="images" />
</section>
</template>
<script lang="ts">
import ImageSlide from "~/components/common/ImageSlide.vue";
import { useTestStore } from "~/store/test";
import { SlideImage } from "~/types/slide";
export default {
setup() {
const testStore = useTestStore();
const images: SlideImage[] = [
{ name: 1, src: "/assets/images/main-01.jpeg" },
{ name: 2, src: "/assets/images/main-02.jpeg" },
{ name: 3, src: "/assets/images/main-03.jpeg" },
];
return { title: testStore.title, images };
},
components: { ImageSlide },
};
</script>
<style lang="scss" scoped>
.slide-section {
max-width: 1080px;
min-height: 720px;
margin: 0 auto;
padding: 160px 0px 0px 0px;
}
.elementor-widget-container {
color: #fff;
z-index: 999;
position: relative;
}
.btn-service {
background-color: $primary;
display: block;
width: 28%;
padding: 16px 0px 16px 0px;
text-decoration: none;
}
.btn-text {
text-align: center;
color: #fff;
}
</style>
"소형이사 견적 요청" 화면 구현하기
소형이사 견적서는 총 13단계로 이뤄집니다. 그 중 핸드폰 번호 인증 부분은 제외하여 총 12단계의 필드로 컴포넌트를 분리하려고합니다. 한 컴포넌트에서 담당하는 기능이 많이지면 가독성이 떨어지고 추후 유지보수에 영향이 가기때문입니다!
견적서 등록을 위한 12단계는 크게 선택/날짜/주소 유형으로 나뉘어져 비슷한 단계는 설명을 생략하도록 하겠습니다.
💡 컴포넌트는 각 단계별로 분리되어있고, 중복되는 작은 단위의 컴포넌트는 공통 컴포넌트로 분리되어있습니다.
Step 1. 이사 종류 선택
RadioGroup.vue
Radio 를 구성할 배열인 items와 선택된 Radio 버튼을 표시해주기위해 선택된 값인 selected 값을 Props로 받았습니다. 마지막으로 wrap 는 표현해줄 Radio 가 많은 경우 두줄처리를 위해 true 인 경우 처리해줍니다.
<template>
<div class="radio-group">
<button
v-for="item in items"
:key="item.value"
:class="['radio-button', wrap && 'wrap']"
@click="$emit('onSelect', item.value)"
>
<RadioSvg :selected="selected === item.value" />
<div class="option-label">{{ item.label }}</div>
</button>
</div>
</template>
<script lang="ts">
import { PropType } from "nuxt/dist/app/compat/capi";
import RadioSvg from "./svg/RadioSvg.vue";
import { RadioItem } from "~/types/radio";
export default {
props: {
items: {
type: Array as PropType<RadioItem[]>,
default() {
return [];
},
},
selected: {
type: String,
default: "",
},
wrap: {
type: Boolean,
default: false,
},
},
emits: ["onSelect"],
components: { RadioSvg },
};
</script>
<style lang="scss" scoped>
.radio-group {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
}
.radio-button {
padding: 18px;
width: 100%;
display: flex;
-webkit-box-align: center;
align-items: center;
outline: none;
border: none;
background: none;
font-size: 17px;
line-height: 14px;
text-align: left;
}
.radio-button.wrap {
width: 50% !important;
}
</style>
위의 RadioGroup 컴포넌트를 이용해 1단계인 이사 종류 선택 컴포넌트를 구현했습니다.
<!-- [이사] 무료 견적서 폼 Step 1 (이사 종류) -->
<template>
<div class="reveal-from-bottom row">
<div class="col-sm-6">
<div class="text-subtitle1 text-w-lg q-pt-md">
원하시는 이사 종류를 선택해주세요.
</div>
</div>
<div class="col-sm-6">
<div style="position: relative">
<RadioGroup
:items="items"
:selected="type"
@onSelect="(value:string) => $emit('change', value)"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from "nuxt/dist/app/compat/capi";
import RadioGroup from "~/components/common/RadioGroup.vue";
import { RadioItem } from "~/types/radio";
import { SmallMovingType } from "~/types/smallmoving";
export default {
props: {
type: {
type: String as PropType<SmallMovingType>,
default: "",
},
},
setup() {
const items: RadioItem[] = [
{ label: "포장 이사", value: "a" },
{ label: "반포장 이사", value: "b" },
{ label: "직접 포장 (일반 이사)", value: "c" },
{ label: "차량만 (용달 이사)", value: "d" },
];
return { items };
},
emits: ["change"],
components: { RadioGroup },
};
</script>
Step 2. 이사 날짜 선택
Quasar QDate 컴포넌트를 이용해 달력을 구현하고 Props 로 date 값을 받고 setup 함수에서 양방향 바인딩을 위해 ref 로 다시 할당해주었습니다. v-model 값이 변경되면 즉시 부모 컴포넌트로 변경 이벤트를 날리기위해 watch 를 사용하였고, 마찬가지로 props가 변경되는 것을 감시하여 변경되면 v-model 값도 같이 변경해주었습니다!
<!-- [이사] 무료 견적서 폼 Step 1 (이사 예정일) -->
<template>
<div class="reveal-from-bottom row">
<div class="col-sm-6">
<div class="text-subtitle1 text-w-lg q-pt-md">
이사 예정일은 언제이신가요?
</div>
</div>
<div class="col-sm-6">
<div style="position: relative">
<div class="css-1sooxwi">
<q-date v-model="dateModel" minimal />
</div>
</div>
</div>
<hr class="continuousTo-date" />
</div>
</template>
<script lang="ts">
import { SetupContext } from "nuxt/dist/app/compat/capi";
interface SetupProps {
date: string;
}
export default {
props: {
date: {
date: String,
default: "",
},
},
emits: ["change"],
setup(props: SetupProps, { emit }: SetupContext) {
const dateModel = ref<string>(props.date);
// props 값이 변경되는 것을 감시한다. open 값이 변경되면 값을 갱신해줌.
watch(props, () => {
dateModel.value = props.date;
});
watch(dateModel, () => {
emit("change", dateModel.value);
});
return { dateModel };
},
};
</script>
Step 3. 이사 시간 선택
Step1과 마찬가지로 선택 유형이기 때문에 RadioGroup 컴포넌트를 이용해 처리해주었습니다.
Step3 이후로는 선택유형은 모두 비슷하기 때문에 코드는 생략하도록 하겠습니다!
<!-- [이사] 무료 견적서 폼 Step 1 (이사 시간) -->
<template>
<div class="reveal-from-bottom row">
<div class="col-sm-6">
<div class="text-subtitle1 text-w-lg q-pt-md">
시간은 언제가 좋으세요?
</div>
</div>
<div class="col-sm-6">
<div>
<div style="position: relative">
<RadioGroup
:items="items"
:selected="time"
@onSelect="(value:string) => $emit('change', value)"
:wrap="true"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from "nuxt/dist/app/compat/capi";
import RadioGroup from "~/components/common/RadioGroup.vue";
import { RadioItem } from "~/types/radio";
import { MoveTime } from "~/types/smallmoving";
export default {
props: {
time: {
type: String as PropType<MoveTime>,
default: "",
},
},
setup() {
const items: RadioItem[] = [
{ label: "오전 7시", value: "7" },
{ label: "오전 8시", value: "8" },
{ label: "오전 9시", value: "9" },
{ label: "오후 1시", value: "13" },
{ label: "오후 2시", value: "14" },
{ label: "오후 3시", value: "15" },
];
return { items };
},
emits: ["change"],
components: { RadioGroup },
};
</script>
Step 4. 현재 주소 선택
입력된 키워드를 주소 검색 API 사용하여 아래에 목록으로 제공해줍니다.
주소 검색 API 는 카카오 API 를 사용하려고 합니다!
카카오 API를 사용하기 위해선 애플리케이션을 등록하여 생성된 앱 키로 인증 처리를 해줘야합니다.
https://developers.kakao.com/docs/latest/ko/local/dev-guide
Kakao Developers
카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.
developers.kakao.com
kako 앱 키 발급
Kakao Developers 사이트에 로그인한 후 애플리케이션을 추가해줍니다.
생성된 애플리케이션을 들어가면 앱 키와 ID 가 발급된 것을 확인하실 수 있습니다.
이제 프로젝트로 돌아가 kakao API 를 이용하여 주소를 검색 해보겠습니다!
아래는 검색 시 호출되는 kakao API 입니다.
kakao API를 호출하여 검색한 키워드와 일치하는 정보를 반환받습니다.
documents 는 키워드와 일치하는 주소 목록, meta는 documents 관련 정보 (총 검색결과 수, 페이징 정보 등) 를 얻을 수 있습니다.
{
"documents": [
{
"address": {
"address_name": "서울 구로구 구로동",
"b_code": "1153010200",
"h_code": "",
"main_address_no": "",
"mountain_yn": "N",
"region_1depth_name": "서울",
"region_2depth_name": "구로구",
"region_3depth_h_name": "",
"region_3depth_name": "구로동",
"sub_address_no": "",
"x": "126.882642231121",
"y": "37.4957707378732"
},
"address_name": "서울 구로구 구로동",
"address_type": "REGION",
"road_address": null,
"x": "126.882642231121",
"y": "37.4957707378732"
},
...
],
"meta": {
"is_end": true,
"pageable_count": 6,
"total_count": 6
}
}
주소 검색 모달
q-input 에서 검색어를 입력한 후 엔터 키를 누르게되면 search 함수가 호출되어 주소 검색 API 를 통해 키워드에 일치하는 주소목록을 반환받습니다. 반환받은 값을 addressList 넣습니다. 그러면 search-result 가 활성화되고 검색된 결과를 목록으로 보여줍니다.
<!-- 주소 검색 모달 -->
<template>
<q-dialog v-model="searchAddressModal" @hide="$emit('dismiss')">
<div class="modal-content bg-white">
<div class="modal-header">
<div class="text-subtitle1 text-w-lg">주소 등록</div>
</div>
<div class="modal-body">
<div class="modal-body-container">
<q-input
filled
v-model="inputVal"
name="address"
class="query"
placeholder="주소를 검색해주세요"
@keydown.enter.prevent="search"
/>
<!-- 검색 예시 -->
<div class="search-example" v-if="addressList.length === 0">
<b>검색 예시</b>
<div><span class="light">지역 + 지번:</span> 역삼동 725-24</div>
<div>
<span class="light">도로명 + 건물번호:</span> 논현로86길 32
</div>
<div><span class="light">지역 + 아파트명:</span> 청담 래미안</div>
</div>
<!-- 검색 결과 -->
<div class="search-result" v-else>
<button
v-for="address in addressList"
:key="address.address_name"
class="address"
>
<div class="primary">{{ address.address_name }}</div>
<div class="secondary">
{{ address.road_address.region_1depth_name }}
{{ address.road_address.region_2depth_name }}
{{ address.road_address.region_3depth_name }}
</div>
</button>
</div>
</div>
</div>
</div>
</q-dialog>
</template>
<script lang="ts">
import { KAKAO_API_URL } from "~/const/api";
import { KakaoApiResponse, KakaoDocument } from "~/types/api";
interface SetupProps {
open: boolean;
value: string;
}
export default {
props: {
open: {
type: Boolean,
default: false,
},
value: {
type: String,
default: "",
},
},
emits: ["dismiss"],
setup(props: SetupProps) {
const config = useRuntimeConfig();
const searchAddressModal = ref<boolean>(props.open);
const inputVal = ref<string>(props.value);
const addressList = ref<KakaoDocument[]>([]);
// props 값이 변경되는 것을 감시한다. open 값이 변경되면 값을 갱신해줌.
watch(props, () => {
searchAddressModal.value = props.open;
});
// 주소 키워드 검색
const search = async () => {
const data = await fetch(
"https://dapi.kakao.com/v2/local/search/address.json?query=구로동",
{
headers: {
Authorization: `KakaoAK c8e771705242b05d49538cf1fff7d5e0`,
},
}
).then((res) => res.json());
addressList.value = data.documents;
};
return { searchAddressModal, inputVal, addressList, search };
},
};
</script>
Step 5. 짐 옮기는 유형 선택
Step 6. 거주지 층수 유형 선택
Step 7. 목적지 주소 선택
Step 8. 도작치에 짐 옮기는 유형 선택
Step 9. 목적 거주지 층수 유형 선택
Step 10. 필요한 트럭 대 수 선택
Step 11. 같이 작업 여부 선택
Step 12. 전달 사항 입력
Input 으로 전달 받은 값은 SQL Injection 공격 방어를 위해 특정한 문자를 제거하는 필터함수를 적용해주었습니다.
[참고] https://kciter.so/posts/basic-web-hacking/#sql-injection
string.ts
/**
* @description SQL Injection 공격을 방어하기 위해 특정 문자열 필터링 함수
* @param str 변경할 문자열
*/
export const regexInputReplace = (str: string) => {
let newStr = str;
newStr = newStr.replace(/\n/g, "");
newStr = newStr.replace(/\t/g, "");
newStr = newStr.replace(/[|]/g, "");
newStr = newStr.replace(/#/g, "");
newStr = newStr.replace(/--/g, "");
newStr = newStr.replace(/[$]/g, "");
return newStr;
};
Step12.vue
<!-- [이사] 무료 견적서 폼 Step 1 (이사 종류) -->
<template>
<div class="reveal-from-bottom row">
<div class="col-sm-6">
<div class="text-subtitle1 text-w-lg q-pt-md">
파트너에게 전달할 내용을 적어주세요.
</div>
</div>
<div class="col-sm-6">
<div>
<div style="position: relative">
<div class="residence">
<input
v-model="inputVal"
placeholder="추가 전달사항 입력"
type="text"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { SetupContext } from "nuxt/dist/app/compat/capi";
interface SetupProps {
cn: string;
}
export default {
props: {
cn: {
type: String,
default: "",
},
},
setup(props: SetupProps, { emit }: SetupContext) {
const inputVal = ref<string>(props.cn);
/**
* @description props 를 감시한 후 변경이 감지되었을 떄 콜백함수를 실행한다,
*/
watch(props, () => {
inputVal.value = props.cn;
});
/**
* @description inputVal 를 감시한 후 변경이 감지되었을 떄 콜백함수를 실행한다,
*/
watch(inputVal, () => {
// SQL Injection 공격 방어
inputVal.value = regexInputReplace(inputVal.value);
emit("change", inputVal.value);
});
return { inputVal };
},
emits: ["change"],
};
</script>
<style lang="scss" scoped>
.residence {
display: flex;
flex-direction: row;
margin-top: 8px;
}
.residence input {
flex: 1 1 0%;
user-select: auto !important;
background: #f3f4f8;
outline: none;
border: none;
padding: 16px 12px;
font-size: 16px;
border-radius: 2px;
min-width: 2em;
}
</style>
견적서 등록시 필요한 컴포넌트들과 공통으로 사용되는 컴포넌트를 분리하여 재사용할 수 있도록 구현해보았습니다!
이번편에서는 UI 와 관련된 작업을 위주로 설명드렸습니다. 다음편에서는 Firestore와 Nuxt server API 를 연동하여 견적서를 등록해보겠습니다!
끝까지 읽어주셔서 감사합니다! 🙂