[클론 코딩] Nuxt3 웹 사이트 만들기(NuxtLink를 이용한 페이지간 이동 및 Quasar UI 구현) - 3편

2023. 7. 6. 16:29초기 과업/FrontEnd

작성자알 수 없는 사용자

728x90
반응형

 

안녕하세요. 기깔나는 사람들에서 프론트엔드를 맡고있는 해리입니다.

이번편에서는 페이지 생성 및 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>

 

Step1

 

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>

 

Step2

 

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>

 

Step3

 

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. 짐 옮기는 유형 선택

Step5

 

Step 6. 거주지 층수 유형 선택

Step6

 

Step 7. 목적지 주소 선택

Step7

 

Step 8. 도작치에 짐 옮기는 유형 선택

Step8

 

Step 9. 목적 거주지 층수 유형 선택

Step9

 

Step 10. 필요한 트럭 대 수 선택

 

Step 11. 같이 작업 여부 선택 

Step11

 

 

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>

 

Step12

 

견적서 등록시 필요한 컴포넌트들과 공통으로 사용되는 컴포넌트를 분리하여 재사용할 수 있도록 구현해보았습니다!


이번편에서는 UI 와 관련된 작업을 위주로 설명드렸습니다. 다음편에서는 Firestore와 Nuxt server API 를 연동하여 견적서를 등록해보겠습니다!

끝까지 읽어주셔서 감사합니다! 🙂

 

 

 

 

728x90
반응형