소스 검색

[회원-로그인] 기본 회원가입 및 로그인 기능 완료, 홈 작업중

DESKTOP-T61HUSC\user 3 일 전
부모
커밋
d9e44a9cb1

+ 3 - 3
app/app.vue

@@ -1,11 +1,11 @@
 <template>
   <UApp>
     <AdminLoadingOverlay />
-    <component :is="DynamicHeader" v-if="!isAdminPage && !isIndexPage" />
+    <component :is="DynamicHeader" v-if="!isAdminPage && isIndexPage" />
     <NuxtLayout>
       <NuxtPage />
     </NuxtLayout>
-    <component :is="DynamicFooter" v-if="!isAdminPage" />
+    <component :is="DynamicFooter" v-if="!isAdminPage && isIndexPage" />
   </UApp>
 </template>
 
@@ -22,7 +22,7 @@
     return route.path.startsWith("/site-manager");
   });
 
-  // index 페이지 체크 (게이트 페이지)
+  // index 페이지 체크 ()
   const isIndexPage = computed(() => {
     return route.path === "/";
   });

+ 0 - 542
app/assets/scss/admin.scss

@@ -46,48 +46,6 @@
   .m--#{$i} {
     margin: #{$i}px !important;
   }
-
-  @media (max-width: 720px) {
-    .pt--#{$i} {
-      padding-top: #{math.div($i, 2)}px !important;
-    }
-
-    .pr--#{$i} {
-      padding-right: #{math.div($i, 2)}px !important;
-    }
-
-    .pb--#{$i} {
-      padding-bottom: #{math.div($i, 2)}px !important;
-    }
-
-    .pl--#{$i} {
-      padding-left: #{math.div($i, 2)}px !important;
-    }
-
-    .p--#{$i} {
-      padding: #{math.div($i, 2)}px !important;
-    }
-
-    .mt--#{$i} {
-      margin-top: #{math.div($i, 2)}px !important;
-    }
-
-    .mr--#{$i} {
-      margin-right: #{math.div($i, 2)}px !important;
-    }
-
-    .mb--#{$i} {
-      margin-bottom: #{math.div($i, 2)}px !important;
-    }
-
-    .ml--#{$i} {
-      margin-left: #{math.div($i, 2)}px !important;
-    }
-
-    .m--#{$i} {
-      margin: #{math.div($i, 2)}px !important;
-    }
-  }
 }
 
 @for $i from 12 through 40 {
@@ -1068,162 +1026,6 @@ header {
 
 }
 
-footer {
-  width: 100%;
-  //--one-footer-color-black: hsla(216, 26%, 1%, 1);
-  --one-footer-color-white: hsla(216, 33%, 99%, 1);
-  --one-footer-neutral-5: hsla(216, 26%, 1%, 0.6);
-  --one-footer-neutral-10: hsla(216, 17%, 26%, 1);
-  --one-footer-neutral-20: hsla(216, 14%, 35%, 1);
-  --one-footer-neutral-70: hsla(216, 33%, 99%, 0.6);
-  --one-footer-side-spacing: 16px;
-  --one-footer-space-xs: var(--spacing-relative-xs);
-  --one-footer-space-s: var(--spacing-relative-sm);
-  --one-footer-space-m: var(--spacing-relative-md);
-  --one-footer-space-l: var(--spacing-relative-lg);
-  --one-footer-space-xl: var(--spacing-relative-xl);
-  --one-footer-space-xxl: var(--spacing-relative-2xl);
-  --one-footer-space-xxxl: var(--spacing-relative-3xl);
-  background: var(--one-footer-color-black);
-  box-sizing: border-box;
-  color: var(--one-footer-color-white);
-
-
-  @media (min-width: 375px) {
-    --one-footer-side-spacing: 28px;
-  }
-
-  @media (min-width: 768px) {
-    --one-footer-side-spacing: 40px;
-  }
-
-  @media (min-width: 1024px) {
-    --one-footer-side-spacing: 60px;
-  }
-
-  .footer--wrap {
-    max-width: 1440px;
-    width: 100%;
-    margin: 0 auto;
-    display: flex;
-    flex-direction: column;
-
-    .footer--site--map {
-      width: 100%;
-      max-width: 1440px;
-      margin: 0 auto;
-      padding: 70px 0px 40px;
-
-      >ul {
-        display: block;
-        list-style: none;
-        row-gap: 40px;
-        padding: 0px;
-        width: 100%;
-
-        @media (min-width: 768px) {
-          display: flex;
-          flex-flow: wrap;
-          width: 100%;
-        }
-        >li {
-          margin-bottom: -1px;
-
-
-          @media (min-width: 768px) {
-            //margin: 0px 24px 40px 0px;            
-            width: calc(33.333%);
-
-
-            &:first-of-type {
-              margin-top: 0px;
-            }
-
-            >h2 {
-              border: none !important;
-            }
-
-            >ul {
-              >h3 {
-                color: #E9E9E9;
-                font-size: 14px;
-                font-style: normal;
-                font-weight: 500;
-                line-height: 100%;
-                /* 14px */
-                text-transform: uppercase;
-                display: flex;
-              }
-
-              >li {
-                padding-left: 0;
-                padding-right: 0;
-                margin-bottom: 25px;
-
-                a {
-                  color: #E9E9E9;
-                  font-size: 14px;
-                  font-style: normal;
-                  font-weight: 500;
-                  line-height: 100%;
-                  /* 14px */
-                  text-transform: uppercase;
-                  display: flex;
-                }
-              }
-            }
-          }
-
-
-          @media (min-width: 1440px) {
-            width: calc(100% / 6);
-          }
-
-          >h2 {
-            color: var(--one-footer-color-white);
-            display: flex;
-            flex-flow: row;
-            -webkit-box-pack: justify;
-            justify-content: space-between;
-            padding: var(--one-footer-space-m) 20px;
-            width: 100%;
-            margin: 0px;
-            color: #FFF;
-            font-size: 18px;
-            font-style: normal;
-            font-weight: 700;
-            line-height: 100%;
-            /* 18px */
-            text-transform: uppercase;
-            text-decoration: none;
-            border-top: 1px solid #4A4A4A;
-            border-bottom: 1px solid #4A4A4A;
-
-
-            @media (min-width: 768px) {
-              padding: 0px;
-              width: auto;
-              margin-bottom: 30px;
-            }
-
-
-            @media (min-width: 1440px) {
-              font-size: 18px;
-              line-height: 28px;
-            }
-
-            @media (min-width: 1920px) {
-              font-size: 20px;
-              line-height: 32px;
-            }
-          }
-
-        }
-      }
-    }
-  }
-}
-
 // ============================================
 // Admin Panel Dark Theme Styles
 // ============================================
@@ -4336,350 +4138,6 @@ footer {
   align-items: center;
 }
 
-
-
-
-
-footer {
-  position: relative;
-  background: #252525;
-
-  .footer--wrap {
-
-    //position: relative;
-    .footer--btn--wrap {
-      display: flex;
-      flex-direction: column;
-      position: fixed;
-      bottom: 120px;
-      //left: calc(100% + 60px);
-      right: 110px;
-      gap: 12px;
-      opacity: 0;
-      z-index: 10;
-      pointer-events: none;
-      transition: all 0.3s;
-
-      &.active {
-        pointer-events: all;
-        opacity: 1;
-      }
-
-      .quick--wrap {
-        position: fixed;
-        right: 49px;
-        border-radius: 20px;
-        background: #F5FAFF;
-        box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.20);
-        width: 192px;
-        overflow: hidden;
-        transition: all 0.3s;
-        border: 1px solid rgba(7, 111, 237, 0.50);
-        opacity: 0;
-        bottom: 330px;
-        pointer-events: none;
-
-        &.active {
-          pointer-events: all;
-          opacity: 1;
-          bottom: 317px;
-        }
-
-        .inner--wrap {
-          padding: 5px 30px;
-          display: flex;
-          flex-direction: column;
-
-          li {
-            text-align: center;
-            display: flex;
-
-            a {
-              width: 100%;
-              color: #333;
-              white-space: nowrap;
-              padding: 12.5px 0;
-              font-size: 13px;
-              text-transform: uppercase;
-              font-weight: 500;
-              transition: all 0.3s;
-              line-height: 1;
-
-              &:hover {
-                color: #076FED;
-                text-decoration-line: underline;
-                text-decoration-style: solid;
-                text-decoration-skip-ink: auto;
-                text-decoration-thickness: auto;
-                text-underline-offset: auto;
-                text-underline-position: from-font;
-                font-weight: 700;
-              }
-            }
-          }
-        }
-      }
-
-      .quick--menu {
-        display: flex;
-        flex-direction: column;
-        background-color: #076fed;
-        border-radius: 100px;
-        box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.20);
-        align-items: center;
-        justify-content: center;
-        width: 70px;
-        height: 100px;
-        gap: 10px;
-        font-size: 12px;
-        font-weight: 700;
-        color: #fff;
-
-        &.active {
-          .ico {
-            background-image: url(/img/ico--quick--close.svg);
-          }
-        }
-
-        .ico {
-          width: 24px;
-          height: 24px;
-          background-image: url(/img/ico--quick.svg);
-        }
-      }
-
-      .scroll--to--top {
-        display: flex;
-        flex-direction: column;
-        box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.20);
-        width: 70px;
-        height: 70px;
-        border-radius: 50%;
-        background-color: #282828;
-        gap: 2px;
-        justify-content: center;
-        font-size: 13px;
-        font-weight: 700;
-        align-items: center;
-
-        .ico {
-          width: 24px;
-          height: 24px;
-          background-image: url(/img/ico--top--arrow.svg);
-        }
-      }
-    }
-
-    .footer--site--map {
-
-      >ul {
-        >li {
-          >ul {
-
-            >h3,
-            >a {
-              position: relative;
-              padding-left: 13px;
-              margin-bottom: 15px;
-              text-transform: capitalize;
-              display: block;
-
-              &:before {
-                content: '';
-                width: 3px;
-                height: 3px;
-                position: absolute;
-                display: block;
-                background: #E9E9E9;
-                top: 50%;
-                left: 0;
-                transform: translateY(-50%);
-              }
-            }
-
-            >li {
-              position: relative;
-              padding-left: 13px;
-
-              &:before {
-                content: '';
-                width: 3px;
-                height: 3px;
-                position: absolute;
-                display: block;
-                background: #E9E9E9;
-                top: 50%;
-                left: 0;
-                transform: translateY(-50%);
-              }
-
-              >a {
-                color: #E9E9E9;
-                font-size: 14px;
-              }
-
-              &.inner--style {
-                margin-bottom: 15px;
-
-                &:last-child {
-                  margin-bottom: 30px;
-                }
-
-                &:before {
-                  display: none;
-                }
-
-                a {
-                  color: #B7B7B7;
-                  font-size: 14px;
-                  font-style: normal;
-                  font-weight: 400;
-                  line-height: 100%;
-                  /* 14px */
-                  text-transform: capitalize;
-                }
-              }
-            }
-          }
-        }
-      }
-    }
-  }
-
-  .footer--info--wrapper {
-    background: #161616;
-
-    .footer--info--wrap {
-      position: relative;
-
-      .sns--wrap {
-        display: flex;
-        justify-content: flex-end;
-        margin-bottom: 12px;
-        gap: 15px;
-        position: absolute;
-        top: 50%;
-        transform: translateY(-50%);
-        right: 0px;
-
-        >a {
-          width: 40px;
-          height: 40px;
-          display: inline-block;
-          border-radius: 50%;
-          display: flex;
-          align-items: center;
-          justify-content: center;
-
-          cursor: pointer;
-          box-sizing: border-box;
-          background: #fff;
-        }
-      }
-
-      .copy--wrap {
-        font-size: 14px;
-        color: #fcfcfdb2;
-        max-width: 1440px;
-        margin: 0 auto;
-        padding: 30px 0px;
-        position: relative;
-
-        .link--list {
-          display: flex;
-          flex-wrap: wrap;
-          gap: 37px;
-
-          >li {
-            position: relative;
-
-            &::after {
-              content: '';
-              height: 15px;
-              width: 1px;
-              background: rgba(255, 255, 255, 0.50);
-              display: block;
-              position: absolute;
-              right: -18px;
-              top: 50%;
-              transform: translateY(-50%);
-            }
-
-            &:last-child {
-              &::after {
-                display: none;
-              }
-            }
-
-            >a {
-              display: inline-block;
-              color: rgb(252, 252, 253);
-              transition: all 0.3s;
-              color: #FFF;
-              font-size: 15px;
-              font-style: normal;
-              font-weight: 700;
-              line-height: 100%;
-              /* 15px */
-              letter-spacing: -0.3px;
-              text-transform: capitalize;
-
-              &:hover {
-                opacity: 0.7;
-              }
-            }
-          }
-        }
-
-        >p {
-          //margin-top: 25px;
-        }
-      }
-    }
-  }
-
-  @media (max-width: 768px) {
-    .footer--wrap {
-      .footer--site--map {
-        >ul {
-          >li {
-            >h2 {
-              cursor: pointer;
-              user-select: none;
-              position: relative;
-              padding-right: 20px;
-
-              &::after {
-                content: '';
-                position: absolute;
-                right: 20px;
-                background-image: url(/img/ico--footer.svg);
-                width: 18px;
-                min-width: 18px;
-                margin-left: 20px;
-                height: 18px;
-                transition: transform 0.3s ease;
-              }
-            }
-
-            >ul {
-              max-height: 0;
-              overflow: hidden;
-              transition: max-height 0.3s ease;
-
-              &.active {
-                max-height: 1000px;
-              }
-            }
-          }
-        }
-      }
-    }
-  }
-}
-
-
-
 .swiper--banner--wrapper {
   width: 100%;
   overflow: hidden;

+ 693 - 1
app/assets/scss/style.scss

@@ -89,7 +89,699 @@ textarea {
     font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
 }
 
+.ico, ::before, ::after{
+    display: inline-block;
+    background-repeat: no-repeat;
+    background-position: center;
+    background-size: 100%;
+}
+
+.btn--border--blue{
+    border-radius: 10px;
+    border: 1px solid #2f6fe0;
+    color: #2f6fe0;
+    font-size: 13px;
+    font-weight: 600;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    text-align: center;
+}
+
+.user--main{
+    .input--wrap{
+        label{
+            color: #8a8f9a;
+            font-size: 13px;
+            margin-bottom: 6px;
+            line-height: 1;
+            display: block;
+            font-weight: 400;
+        }
+        .required{
+            color: #2f6fe0;
+            font-size: 13px;
+            font-weight: 500;
+        }
+        input{
+            height: 52px;
+            border-radius: 12px;
+            line-height: 1;
+            border: 1px solid #e3e6eb;
+            width: 100%;
+            padding: 0 16px;
+            font-size: 14px;
+            font-weight: 400;
+            &::placeholder{
+                color: #b0b5bf;
+            }
+        }
+        &.pw--input--wrap{
+            position: relative;
+            input{
+                padding-right: 48px;
+            }
+            .pw--toggle--btn{
+                display: flex;
+                justify-content: center;
+                align-items: center;
+                position: absolute;
+                right: 0px;
+                width: 48px;
+                height: 52px;
+                bottom: 0;
+            }
+        }
+        .input--inner--wrap{
+            display: flex;
+            gap: 8px;
+            &.gap--4{
+                gap: 4px;
+                align-items: center;
+                font-size: 14px;
+                input{
+                    text-align: center;
+                }
+                span{
+                    color: #b0b5bf;
+                }
+            }
+        }
+        &+.input--info{
+            margin-top: 6px;
+            color: #9aa0ac;
+            font-size: 12px;
+            font-weight: 400;
+        }
+    }
+    button, input[type=radio], input[type=checkbox]{
+        cursor: pointer;
+    }
+
+    .float--btn--wrap{
+        position: fixed;
+        bottom: 32px;
+        padding: 0 25px;
+        left: 0;
+        width: 100%;
+        z-index: 100;
+        a{
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            text-align: center;
+            line-height: 1;
+            background-color: #2f6fe0;
+            border-radius: 12px;
+            color: #fff;
+            height: 52px;
+            font-size: 16px;
+            font-weight: 700;
+            width: 100%;
+            &.disabled{
+                color: #9aa0ac;
+                pointer-events: none;
+                background-color: #e5e7eb;
+            }
+        }
+    }
+    .color--blue{
+        color: #2f6fe0!important;
+    }
+}
+
 .join--container{
-    padding: 60px 25px 80px;
+    padding: 60px 25px 100px;
     min-height: 100vh;
+    .title--wrap{
+        h1{
+            color: #1a1d29;
+            font-size: 24px;
+            font-weight: 800;
+            letter-spacing: -0.3px;
+        }
+        h2{
+            color: #1a1d29;
+            font-size: 22px;
+            font-weight: 800;
+            letter-spacing: -0.3px;
+        }
+        p{
+            color: #8a8f9a;
+            font-size: 14px;
+            font-weight: 400;
+        }
+    }
+    .login--wrap{
+        .auto--login--wrap{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            line-height: 1;
+            .auto--login{
+                label{
+                    display: flex;
+                    align-items: center;
+                    gap: 8px;
+                    color: #6b7280;
+                    font-size: 13px;
+                    line-height: 1;
+                    font-weight: 500;
+                    input[type=checkbox]{
+                        appearance: none;
+                        width: 18px;
+                        height: 18px;
+                        border-radius: 5px;
+                        border: 1px solid #cfd4dc;
+                        background: #fff;
+                        background-image: none;
+                        background-repeat: no-repeat;
+                        background-position: center;
+                        background-size: 12px 12px;
+                        cursor: pointer;
+
+                        &:checked {
+                            border-color: #2f6fe0;
+                            background-color: #2f6fe0;
+                            background-image: url('/img/ico--check.svg');
+                        }
+                    }
+                }
+            }
+            .find--pw--btn{
+                color: #2f6fe0;
+                font-size: 13px;
+                font-weight: 500;
+            }
+        }
+        .login--btn--wrap{
+            display: flex;
+            justify-content: center;
+            button{
+                width: 100%;
+                height: 52px;
+                font-size: 16px;
+                font-weight: 700;
+                color: #fff;
+                border-radius: 12px;
+                background-color: #2f6fe0;
+            }
+        }
+        .social--login--wrap{
+            .social--login--txt{
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                position: relative;
+                &::before{
+                    content: '';
+                    background-color: #e3e6eb;
+                    height: 1px;
+                    width: 100%;
+                    position: absolute;
+                    z-index: -1;
+                    top: 50%;
+                }
+                span{
+                    color: #b0b5bf;
+                    text-align: center;
+                    background-color: #fff;
+                    font-size: 12px;
+                    font-weight: 500;
+                    padding: 0 16px;
+                }
+            }
+            .social--login--list{
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+                gap: 12px;
+                li{
+                    display: flex;
+                    width: 33.3333%;
+                    align-items: center;
+                    justify-content: center;
+                    a{
+                        height: 52px;
+                        background-repeat: no-repeat;
+                        background-position: center;
+                        border-radius: 12px;
+                        font-size: 0;
+                        line-height: 0;
+                        background-size: 24px 24px;
+                        width: 100%;
+                        &.kakao{
+                            background-image: url(/img/ico--kakao.svg);
+                            background-color: #fee500;
+                        }
+                        &.naver{
+                            background-image: url(/img/ico--naver.svg);
+                            background-size: 20px 20px;
+                            background-color: #0f874f;
+                        }
+                        &.apple{
+                            background-image: url(/img/ico--apple.svg);
+                            background-color: #0f1938;
+                        }
+                    }
+                }
+            }
+        }
+        .join--btn--wrap{
+            text-align: center;
+            color: #8a8f9a;
+            font-size: 13px;
+            font-weight: 500;
+            a{
+                color: #2f6fe0;
+                font-weight: 700;
+            }
+        }
+    }
+    .join--step--wrap{
+        .step--txt{
+            color: #8a8f9a;
+            font-size: 12px;
+            font-weight: 600;
+            line-height: 1;
+        }
+        .step--bar{
+            margin-top: 10px;
+            display: flex;
+            gap: 8px;
+            justify-content: space-between;
+            span{
+                width: 33.3333%;
+                height: 4px;
+                background-color: #e3e6eb;
+                border-radius: 2px;
+                transition: backgroundColor 0.3s;
+                &.active{
+                    background-color: #2f6fe0;
+                }
+            }
+        }
+    }
+    .join--wrap{
+        .all--check--wrap{
+            label{
+                display: flex;
+                gap: 12px;
+                background-color: #f4f8ff;
+                padding: 18px 16px;
+                border-radius: 12px;
+                border: 1px solid #bbd2f7;
+                align-items: center;
+                color: #1a1d29;
+                font-size: 15px;
+                font-weight: 700;
+                input[type=checkbox]{
+                    appearance: none;
+                    min-width: 24px;
+                    width: 24px;
+                    height: 24px;
+                    border-radius: 5px;
+                    border: 1px solid #cfd4dc;
+                    background: #fff;
+                    background-image: none;
+                    background-repeat: no-repeat;
+                    background-position: center;
+                    background-size: 16px 16px;
+                    cursor: pointer;
+                    &:checked {
+                        border-color: #2f6fe0;
+                        background-color: #2f6fe0;
+                        background-image: url('/img/ico--check.svg');
+                    }
+                }
+            }
+        }
+        .each--check--wrap{
+            .each--wrap{
+                padding: 12px 0px 12px 16px;
+                display: flex;
+                label{
+                    display: flex;
+                    color: #3d4250;
+                    font-size: 14px;
+                    font-weight: 500;
+                    align-items: center;
+                    gap: 10px;
+                    width: 100%;
+                    input[type=checkbox]{
+                        appearance: none;
+                        width: 22px;
+                        min-width: 22px;
+                        height: 22px;
+                        border-radius: 5px;
+                        border: 1px solid #cfd4dc;
+                        background: #fff;
+                        background-image: none;
+                        background-repeat: no-repeat;
+                        background-position: center;
+                        background-size: 14px 14px;
+                        cursor: pointer;
+
+                        &:checked {
+                            border-color: #2f6fe0;
+                            background-color: #2f6fe0;
+                            background-image: url('/img/ico--check.svg');
+                        }
+                    }
+                    span{
+                        font-weight: 600;
+                        color: #9aa0ac;
+                        font-size: 11px;
+                        margin-left: -4px;
+                    }
+                }
+                button{
+                    width: 30px;
+                    min-width: 30px;
+                    color: #b0b5bf;
+                    font-size: 18px;
+                    margin-left: auto;
+                    font-weight: 400;
+                }
+            }
+        }
+        .join--btn{
+            width: 86px;
+            min-width: 86px;
+        }
+        .join--bubble--wrap{
+            display: flex;
+            flex-wrap: wrap;
+            gap: 8px;
+            .area--bubble{
+                border-radius: 18px;
+                border: 1px solid #d6dae1;
+                background-color: #fff;
+                padding: 10px 20px;
+                color: #4b5563;
+                font-size: 13px;
+                font-weight: 500;
+                &.active{
+                    border: 1px solid #2f6fe0;
+                    background-color: #2f6fe0;
+                    color: #fff;
+                    font-weight: 600;
+                }
+            }
+        }
+    }
+    .join--complete--wrap{
+        text-align: center;
+        .ico{
+            width: 80px;
+            height: 80px;
+            background-color: #19941b;
+            border-radius: 50%;
+            background-image: url(/img/ico--join--complete--check.svg);
+            background-position: center;
+            background-size: 30px 30px;
+        }
+        h2{
+            color: #1a1d29;
+            font-size: 20px;
+            font-weight: 800;
+        }
+        p{
+            color: #8a8f9a;
+            font-size: 14px;
+            font-weight: 400;
+        }
+    }
+    .join--benefit--wrap{
+        border-radius: 14px;
+        background-color: #f4f8ff;
+        border: 1px solid #bbd2f7;
+        padding: 20px 16px;
+        line-height: 1.4;
+        h3{
+            font-size: 13px;
+            font-weight: 700;
+        }
+        p{
+            color: #1a1d29;
+            font-size: 18px;
+            font-weight: 800;
+        }
+        span{
+            display: block;
+            font-size: 12px;
+            color: #8a8f9a;
+            font-weight: 400;
+        }
+    }
+}
+
+.modal--dim{
+    position: fixed;
+    background-color: rgba(15,19,32,0.55);
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100vh;
+    padding: 36px;
+    opacity: 0;
+    transition: all 0.3s;
+    z-index: 1000;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    pointer-events: none;
+    &.active{
+        opacity: 1;
+        pointer-events: all;
+    }
+    .alert--wrap{
+        background-color: #fff;
+        min-width: 288px;
+        max-width: 400px;
+        width: 100%;
+        border-radius: 20px;
+        background: #FFF;
+        box-shadow: 0 12px 32px 0 rgba(15, 23, 41, 0.22);
+        padding: 32px 24px 24px 24px;
+
+        // 기본 상태: 살짝 아래 + 투명
+        opacity: 0;
+        transform: translateY(24px);
+        transition: opacity 0.3s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
+
+        // 활성 상태: 제자리 + 보임 (살짝 튀어오르며 fade-in)
+        &.active{
+            opacity: 1;
+            transform: translateY(0);
+        }
+        &.center{
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            justify-content: center;
+            text-align: center;
+        }
+        .error--ico{
+            width: 56px;
+            height: 56px;
+            border-radius: 28px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            font-size: 30px;
+            font-weight: 800;
+            margin-bottom: 16px;
+
+            // 기본 (error: 빨강)
+            background-color: #fee4e2;
+            color: #e5484d;
+
+            &.error--ico--success{
+                background-color: #d1fae5;
+                color: #059669;
+            }
+            &.error--ico--info{
+                background-color: #dbeafe;
+                color: #2563eb;
+            }
+        }
+        .modal--tit{
+            margin-bottom: 16px;
+            h2{
+                color: #1a1d29;
+                font-size: 17px;
+                font-weight: 700;
+                letter-spacing: -0.3px;
+            }
+        }
+        .modal--cont{
+            p{
+                color: #6b7280;
+                font-size: 14px;
+                font-weight: 400;
+                line-height: 1.5;
+            }
+        }
+        .modal--btn{
+            margin-top: 32px;
+            width: 100%;
+            display: flex;
+            gap: 8px;
+            button{
+                color: #fff;
+                background-color: #2f6fe0;
+                border-radius: 12px;
+                width: 100%;
+                height: 48px;
+                font-size: 15px;
+                font-weight: 700;
+                transition: opacity 0.15s ease;
+
+                &:hover{ opacity: 0.9; }
+
+                &.cancel{
+                    background-color: #f3f4f6;
+                    color: #6b7280;
+                }
+            }
+        }
+    }
+}
+
+.user--header{
+    height: 60px;
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    background-color: #fff;
+    z-index: 100;
+    padding: 0 20px;
+    .header--wrap{
+        display: flex;
+        align-items: center;
+        height: 100%;
+        .header--logo{
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            .logo{
+                width: 30px;
+                height: 30px;
+                border-radius: 9px;
+                background-image: url(/img/ico--logo.svg);
+            }
+            h1{
+                color: #1a1d29;
+                font-size: 18px;
+                font-weight: 800;
+                letter-spacing: -0.18px;
+            }
+        }
+    }
+}
+
+.home--wrap{
+    background-color: #F6F7F9;
+    padding-top: 60px;
+    padding-bottom: 76px;
+    min-height: calc(100vh);
+    
+    .home--tab--wrap{
+        padding: 0 8px;
+        background-color: #fff;
+        border-bottom: 1px solid #eceef1;
+        display: flex;
+        overflow-x: auto;
+        button{
+            white-space: nowrap;
+            height: 50px;
+            padding: 0 12px;
+            color: #8a8f9a;
+            font-weight: 500;
+            font-size: 15px;
+            span{
+                position: relative;
+            }
+            &.active{
+                color: #1a1d29;
+                font-weight: 700;
+                span{
+                    &::before{
+                        content: '';
+                        position: absolute;
+                        background-color:#fc801c;
+                        display: inline-block;
+                        bottom: -11px;
+                        width: 100%;
+                        height: 2px;
+                        border-radius: 2px;
+                    }
+                }
+            }
+        }
+    }
+
+    .home--container{
+        padding: 20px 20px 25px 20px;
+    }
+}
+
+footer{
+    position: fixed;
+    bottom: 0;
+    left: 0;
+    width: 100%;
+    border-top: 1px solid #eceef1;
+    background-color: #fff;
+    .footer--wrap{
+        display: flex;
+        .ico{
+            width: 24px;
+            height: 24px;
+            &.ico1{
+                background-image: url(/img/ico--footer1.svg);
+            }
+            &.ico2{
+                background-image: url(/img/ico--footer2.svg);
+            }
+            &.ico3{
+                background-image: url(/img/ico--footer3.svg);
+            }
+            &.ico4{
+                background-image: url(/img/ico--footer4.svg);
+            }
+        }
+        a{
+            width: 25%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            flex-direction: column;
+            height: 76px;
+            font-size: 11px;
+            font-weight: 500;
+            gap: 5px;
+            color: #9aa0ac;
+            &.active{
+                font-weight: 700;
+                color: #FC801C;
+                .ico{
+                    &.ico1{
+                        background-image: url(/img/ico--footer1--on.svg);
+                    }
+                    &.ico2{
+                        background-image: url(/img/ico--footer2--on.svg);
+                    }
+                    &.ico3{
+                        background-image: url(/img/ico--footer3--on.svg);
+                    }
+                    &.ico4{
+                        background-image: url(/img/ico--footer4--on.svg);
+                    }
+                }
+            }
+        }
+    }
 }

+ 84 - 0
app/components/AppAlertModal.vue

@@ -0,0 +1,84 @@
+<template>
+  <ClientOnly>
+    <Teleport to="body">
+      <div
+        class="modal--dim"
+        :class="{ active: modelValue }"
+        @click.self="handleBackdrop"
+      >
+        <div class="alert--wrap" :class="[{ center, active: modelValue }]">
+          <div
+            v-if="iconType"
+            :class="['error--ico', `error--ico--${iconType}`]"
+          >{{ iconText }}</div>
+
+          <div v-if="title" class="modal--tit">
+            <h2>{{ title }}</h2>
+          </div>
+
+          <div v-if="message" class="modal--cont">
+            <p v-html="formattedMessage"></p>
+          </div>
+
+          <div class="modal--btn">
+            <button
+              v-if="type === 'confirm'"
+              type="button"
+              class="cancel"
+              @click="handleCancel"
+            >{{ cancelText }}</button>
+            <button type="button" @click="handleConfirm">{{ confirmText }}</button>
+          </div>
+        </div>
+      </div>
+    </Teleport>
+  </ClientOnly>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  modelValue: { type: Boolean, default: false },
+  // 'alert'(확인만) | 'confirm'(취소/확인)
+  type: { type: String, default: 'alert' },
+  // 'error' | 'success' | 'info' | null
+  iconType: { type: String, default: null },
+  title: { type: String, default: '' },
+  message: { type: String, default: '' },
+  confirmText: { type: String, default: '확인' },
+  cancelText: { type: String, default: '취소' },
+  center: { type: Boolean, default: true },
+  // 백드롭 클릭으로 닫기 허용 여부
+  closeOnBackdrop: { type: Boolean, default: false },
+})
+
+const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
+
+const iconText = computed(() => {
+  if (props.iconType === 'error') return '!'
+  if (props.iconType === 'success') return '✓'
+  if (props.iconType === 'info') return 'i'
+  return ''
+})
+
+const formattedMessage = computed(() =>
+  (props.message || '').replace(/\n/g, '<br />')
+)
+
+const close = () => emit('update:modelValue', false)
+
+const handleConfirm = () => {
+  emit('confirm')
+  close()
+}
+
+const handleCancel = () => {
+  emit('cancel')
+  close()
+}
+
+const handleBackdrop = () => {
+  if (props.closeOnBackdrop) close()
+}
+</script>

+ 6 - 0
app/components/footer.vue

@@ -1,5 +1,11 @@
 <template>
   <footer>
+    <div class="footer--wrap">
+      <NuxtLink to="/" class="active"><i class="ico ico1"></i>홈</NuxtLink>
+      <NuxtLink to="/"><i class="ico ico2"></i>탐색</NuxtLink>
+      <NuxtLink to="/"><i class="ico ico3"></i>내 참여</NuxtLink>
+      <NuxtLink to="/"><i class="ico ico4"></i>MY</NuxtLink>
+    </div>
   </footer>
 </template>
 

+ 4 - 2
app/components/header.vue

@@ -1,6 +1,8 @@
 <template>
-  <header>
-
+  <header class="user--header">
+    <div class="header--wrap">
+      <NuxtLink to="/" class="header--logo"><i class="logo"></i><h1>파이럿존</h1></NuxtLink>
+    </div>
   </header>
 </template>
 

+ 10 - 13
app/pages/index.vue

@@ -1,17 +1,14 @@
 <template>
-  <main>
-    <div class="gate--wrap">
-      <div class="gate--container">
-        <NuxtLink to="/ford" class="gate ford">
-          <i class="logo gate--1">포드</i>
-          <h1>정통 아메리칸 라인업</h1>
-          <button>포드 바로가기<i class="ico"></i></button>
-        </NuxtLink>
-        <NuxtLink to="/lincoln" class="gate lincoln">
-          <i class="logo">링컨</i>
-          <h1>평온함, 여유 그리고 링컨</h1>
-          <button>링컨 바로가기<i class="ico"></i></button>
-        </NuxtLink>
+  <main class="user--main">
+    <div class="home--wrap">
+      <div class="home--tab--wrap">
+        <button class="active"><span>전체</span></button>
+        <button><span>챌린지</span></button>
+        <button class=""><span>퀘스트</span></button>
+        <button><span>참여중</span></button>
+      </div>
+      <div class="home--container">
+        <div class=""></div>
       </div>
     </div>
   </main>

+ 103 - 0
app/pages/login/agree.vue

@@ -0,0 +1,103 @@
+<template>
+  <main class="user--main">
+    <div class="join--container">
+      <div class="join--step--wrap">
+        <div class="step--txt">
+          <span class="color--blue">1 / 3</span> 약관 동<span class="color--blue">의</span>
+        </div>
+        <div class="step--bar">
+          <span class="active"></span>
+          <span></span>
+          <span></span>
+        </div>
+      </div>
+      <div class="join--step1 mt--22">
+        <div class="title--wrap">
+          <h2>약관에 동의해주세요</h2>
+        </div>
+        <div class="join--wrap mt--26">
+          <div class="all--check--wrap">
+            <label>
+              <input type="checkbox" v-model="allAgree">
+              약관 전체 동의
+            </label>
+          </div>
+          <div class="each--check--wrap mt--20">
+            <div class="each--wrap">
+              <label>
+                <input type="checkbox" v-model="termsAgree">
+                이용약관 <span class="color--blue">(필수)</span>
+              </label>
+              <button type="button">›</button>
+            </div>
+            <div class="each--wrap">
+              <label>
+                <input type="checkbox" v-model="privacyAgree">
+                개인정보 수집 및 이용 안내 <span class="color--blue">(필수)</span>
+              </label>
+              <button type="button">›</button>
+            </div>
+            <div class="each--wrap">
+              <label>
+                <input type="checkbox" v-model="marketingAgree">
+                이벤트 및 SMS 등 수신 <span>(선택)</span>
+              </label>
+              <button type="button">›</button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="float--btn--wrap">
+      <a
+        href="#"
+        :class="{ disabled: !canProceed }"
+        @click.prevent="goNext"
+      >다음</a>
+    </div>
+
+    <AppAlertModal
+      v-model="showErrorModal"
+      icon-type="error"
+      title="약관 동의 필요"
+      message="필수 약관에 동의해주세요."
+    />
+  </main>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import AppAlertModal from '~/components/AppAlertModal.vue'
+
+const router = useRouter()
+
+const termsAgree = ref(false)      // 이용약관 (필수)
+const privacyAgree = ref(false)    // 개인정보 (필수)
+const marketingAgree = ref(false)  // 이벤트/SMS (선택)
+const showErrorModal = ref(false)
+
+// 전체 동의 — 양방향 동기화
+const allAgree = computed({
+  get: () => termsAgree.value && privacyAgree.value && marketingAgree.value,
+  set: (val) => {
+    termsAgree.value = val
+    privacyAgree.value = val
+    marketingAgree.value = val
+  }
+})
+
+// 필수 2개 체크되면 다음 활성화
+const canProceed = computed(() => termsAgree.value && privacyAgree.value)
+
+const goNext = () => {
+  if (!canProceed.value) {
+    showErrorModal.value = true
+    return
+  }
+  // 마케팅 동의 여부를 sessionStorage로 전달
+  sessionStorage.setItem('signup_marketing', marketingAgree.value ? 'Y' : 'N')
+  router.push('/login/join')
+}
+</script>

+ 169 - 0
app/pages/login/index.vue

@@ -0,0 +1,169 @@
+<template>
+  <main>
+    <div class="join--container">
+      <div class="title--wrap">
+        <h1>로그인</h1>
+        <p class="mt--9">파이럿존 이용을 위해 로그인해 주세요</p>
+      </div>
+      <div class="login--wrap mt--25">
+        <form @submit.prevent="handleLogin">
+          <div class="input--wrap">
+              <label for="login--id">아이디</label>
+              <input
+                id="login--id"
+                v-model="loginId"
+                type="text"
+                placeholder="아이디를 입력해 주세요"
+                autocomplete="username"
+              >
+          </div>
+          <div class="input--wrap mt--18 pw--input--wrap">
+              <label for="login--pw">비밀번호</label>
+              <input
+                id="login--pw"
+                v-model="loginPw"
+                :type="showPw ? 'text' : 'password'"
+                placeholder="비밀번호를 입력해 주세요"
+                autocomplete="current-password"
+              >
+              <button
+                type="button"
+                class="pw--toggle--btn"
+                :aria-label="showPw ? '비밀번호 숨기기' : '비밀번호 표시'"
+                @click="showPw = !showPw"
+              >
+                <img v-if="showPw" src="/img/ico--pw.svg" alt="비밀번호 표시" />
+                <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
+              </button>
+          </div>
+          <div class="auto--login--wrap mt--18">
+            <div class="auto--login">
+              <label>
+                <input type="checkbox" v-model="autoLogin">
+                자동 로그인
+              </label>
+            </div>
+            <NuxtLink to="/find" class="find--pw--btn">아이디ㆍ비밀번호 찾기</NuxtLink>
+          </div>
+          <div class="login--btn--wrap mt--30">
+            <button type="submit" :disabled="loggingIn">
+              {{ loggingIn ? '로그인 중...' : '로그인' }}
+            </button>
+          </div>
+        </form>
+        <div class="social--login--wrap">
+          <div class="social--login--txt mt--28"><span>또는</span></div>
+          <ul class="social--login--list mt--26">
+            <li><NuxtLink to="#" class="kakao">카카오톡</NuxtLink></li>
+            <li><NuxtLink to="#" class="naver">네이버</NuxtLink></li>
+            <li><NuxtLink to="#" class="apple">애플</NuxtLink></li>
+          </ul>
+        </div>
+        <div class="join--btn--wrap mt--36">
+          아직 회원이 아니신가요? <NuxtLink to="/login/agree">회원가입</NuxtLink>
+        </div>
+      </div>
+    </div>
+
+    <AppAlertModal
+      v-model="modal.show"
+      :icon-type="modal.iconType"
+      :title="modal.title"
+      :message="modal.message"
+    />
+  </main>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import AppAlertModal from '~/components/AppAlertModal.vue'
+
+const router = useRouter()
+const { post } = useApi()
+
+const loginId = ref('')
+const loginPw = ref('')
+const showPw = ref(false)
+const autoLogin = ref(false)
+const loggingIn = ref(false)
+
+// 공통 알림 모달
+const modal = reactive({
+  show: false,
+  iconType: 'error',
+  title: '',
+  message: '',
+})
+const showAlert = (title, message, iconType = 'error') => {
+  modal.title = title
+  modal.message = message
+  modal.iconType = iconType
+  modal.show = true
+}
+
+// 로그인 처리
+const handleLogin = async () => {
+  if (loggingIn.value) return
+
+  if (!loginId.value.trim() || !loginPw.value) {
+    showAlert('입력 확인', '아이디와 비밀번호를 모두 입력해 주세요.')
+    return
+  }
+
+  loggingIn.value = true
+  try {
+    const { data, error } = await post('/users/login', {
+      username: loginId.value.trim(),
+      password: loginPw.value,
+      auto_login: autoLogin.value,
+    })
+
+    if (error || !data?.success) {
+      showAlert(
+        '로그인에 실패했습니다',
+        error?.message || data?.message || '아이디 또는 비밀번호가 일치하지 않습니다.\n다시 한번 확인해 주세요.'
+      )
+      return
+    }
+
+    // 토큰 + user 정보 저장
+    const { token, expires_at, user } = data.data
+    const storage = autoLogin.value ? localStorage : sessionStorage
+
+    storage.setItem('user_token', token)
+    storage.setItem('user_token_expires', expires_at)
+    storage.setItem('user', JSON.stringify(user))
+    storage.setItem('auto_login', autoLogin.value ? 'Y' : 'N')
+
+    // 메인으로 이동
+    router.push('/')
+  } catch (e) {
+    console.error('[Login] error:', e)
+    showAlert('오류', '서버 오류가 발생했습니다.')
+  } finally {
+    loggingIn.value = false
+  }
+}
+
+// 페이지 진입 시 — 이미 로그인되어 있으면 메인으로
+onMounted(() => {
+  const token = localStorage.getItem('user_token') || sessionStorage.getItem('user_token')
+  const expires = localStorage.getItem('user_token_expires') || sessionStorage.getItem('user_token_expires')
+
+  if (token && expires) {
+    if (new Date(expires.replace(' ', 'T')) > new Date()) {
+      // 유효한 토큰 → 메인으로
+      router.replace('/')
+    } else {
+      // 만료된 토큰 정리
+      localStorage.removeItem('user_token')
+      localStorage.removeItem('user_token_expires')
+      localStorage.removeItem('user')
+      sessionStorage.removeItem('user_token')
+      sessionStorage.removeItem('user_token_expires')
+      sessionStorage.removeItem('user')
+    }
+  }
+})
+</script>

+ 323 - 0
app/pages/login/join.vue

@@ -0,0 +1,323 @@
+<template>
+  <main class="user--main">
+    <div class="join--container">
+      <div class="join--step--wrap">
+        <div class="step--txt">
+          <span class="color--blue">2 / 3</span> 정보 입<span class="color--blue">력</span>
+        </div>
+        <div class="step--bar">
+          <span class="active"></span>
+          <span class="active"></span>
+          <span></span>
+        </div>
+      </div>
+      <div class="join--step2 mt--22">
+        <div class="title--wrap">
+          <h2>정보 입력</h2>
+          <p class="mt--8">파이럿존 이용을 위해 회원가입을 해주세요</p>
+        </div>
+        <div class="join--wrap mt--18">
+
+          <!-- 아이디 -->
+          <div class="input--wrap">
+            <label for="join--id">아이디 <span class="required">*</span></label>
+            <div class="input--inner--wrap">
+              <input
+                id="join--id"
+                v-model="username"
+                type="text"
+                placeholder="아이디 입력"
+                maxlength="20"
+                @input="usernameChecked = false"
+              >
+              <button
+                type="button"
+                class="btn--border--blue join--btn"
+                :disabled="checkingUsername"
+                @click="checkUsername"
+              >{{ checkingUsername ? '확인중...' : '중복확인' }}</button>
+            </div>
+          </div>
+          <p class="input--info" :class="{ 'color--blue': usernameChecked }">
+            {{ usernameChecked ? '✓ 사용 가능한 아이디입니다.' : '영문/숫자, 6자 이상 20자 이하' }}
+          </p>
+
+          <!-- 비밀번호 -->
+          <div class="input--wrap mt--10 pw--input--wrap">
+            <label for="join--pw">비밀번호 <span class="required">*</span></label>
+            <input
+              id="join--pw"
+              v-model="password"
+              :type="showPw ? 'text' : 'password'"
+              placeholder="비밀번호 입력"
+              maxlength="30"
+            >
+            <button
+              type="button"
+              class="pw--toggle--btn"
+              :aria-label="showPw ? '비밀번호 숨기기' : '비밀번호 표시'"
+              @click="showPw = !showPw"
+            >
+              <img v-if="showPw" src="/img/ico--pw.svg" alt="비밀번호 표시" />
+              <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
+            </button>
+          </div>
+          <p class="input--info" :style="passwordInvalid ? 'color:#e5484d;' : ''">
+            영문(소문자)+숫자+특수문자 조합 8자 이상
+          </p>
+
+          <!-- 비밀번호 확인 -->
+          <div class="input--wrap mt--10 pw--input--wrap">
+            <label for="join--pw2">비밀번호 확인 <span class="required">*</span></label>
+            <input
+              id="join--pw2"
+              v-model="password2"
+              :type="showPw2 ? 'text' : 'password'"
+              placeholder="비밀번호 재입력"
+              maxlength="30"
+            >
+            <button
+              type="button"
+              class="pw--toggle--btn"
+              :aria-label="showPw2 ? '비밀번호 숨기기' : '비밀번호 표시'"
+              @click="showPw2 = !showPw2"
+            >
+              <img v-if="showPw2" src="/img/ico--pw.svg" alt="비밀번호 표시" />
+              <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
+            </button>
+          </div>
+          <p v-if="password2 && password !== password2" class="input--info" style="color:#e5484d;">
+            비밀번호가 일치하지 않습니다.
+          </p>
+
+          <!-- 이름 -->
+          <div class="input--wrap mt--18">
+            <label for="join--name">이름 <span class="required">*</span></label>
+            <input id="join--name" v-model="name" type="text" placeholder="이름 입력" maxlength="50">
+          </div>
+
+          <!-- 핸드폰 -->
+          <div class="input--wrap mt--18">
+            <label for="join--phone1">핸드폰 <span class="required">*</span></label>
+            <div class="input--inner--wrap gap--4">
+              <input id="join--phone1" v-model="phoneFront" type="text" maxlength="3">
+              <span>-</span>
+              <input v-model="phoneMiddle" type="text" maxlength="4" inputmode="numeric">
+              <span>-</span>
+              <input v-model="phoneLast" type="text" maxlength="4" inputmode="numeric">
+            </div>
+          </div>
+
+          <!-- 닉네임 -->
+          <div class="input--wrap mt--18">
+            <label for="join--nickname">닉네임 <span class="required">*</span></label>
+            <div class="input--inner--wrap">
+              <input
+                id="join--nickname"
+                v-model="nickname"
+                type="text"
+                placeholder="닉네임 입력"
+                maxlength="20"
+                @input="nicknameChecked = false"
+              >
+              <button
+                type="button"
+                class="btn--border--blue join--btn"
+                :disabled="checkingNickname"
+                @click="checkNickname"
+              >{{ checkingNickname ? '확인중...' : '중복확인' }}</button>
+            </div>
+          </div>
+          <p v-if="nicknameChecked" class="input--info color--blue">✓ 사용 가능한 닉네임입니다.</p>
+
+          <!-- 낚시 선호지역 -->
+          <div class="input--wrap mt--18">
+            <label>낚시 선호지역 <span class="required">*</span> (중복 선택)</label>
+            <div class="join--bubble--wrap mt--16">
+              <button
+                v-for="area in allAreas"
+                :key="area"
+                type="button"
+                class="area--bubble"
+                :class="{ active: preferAreas.includes(area) }"
+                @click="toggleArea(area)"
+              >{{ area }}</button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="float--btn--wrap">
+      <a
+        href="#"
+        :class="{ disabled: !canSubmit || submitting }"
+        @click.prevent="handleSubmit"
+      >{{ submitting ? '가입 중...' : '회원가입 완료' }}</a>
+    </div>
+
+    <AppAlertModal
+      v-model="modal.show"
+      :icon-type="modal.iconType"
+      :title="modal.title"
+      :message="modal.message"
+    />
+  </main>
+</template>
+
+<script setup>
+import { ref, computed, reactive } from 'vue'
+import { useRouter } from 'vue-router'
+import AppAlertModal from '~/components/AppAlertModal.vue'
+
+const router = useRouter()
+const { post } = useApi()
+
+// 폼 데이터
+const username = ref('')
+const password = ref('')
+const password2 = ref('')
+const name = ref('')
+const phoneFront = ref('010')
+const phoneMiddle = ref('')
+const phoneLast = ref('')
+const nickname = ref('')
+const preferAreas = ref([])
+
+// UI 상태
+const showPw = ref(false)
+const showPw2 = ref(false)
+const checkingUsername = ref(false)
+const checkingNickname = ref(false)
+const usernameChecked = ref(false)
+const nicknameChecked = ref(false)
+const submitting = ref(false)
+
+// 알림 모달 (공통)
+const modal = reactive({
+  show: false,
+  iconType: 'error',
+  title: '',
+  message: '',
+})
+const showAlert = (title, message, iconType = 'error') => {
+  modal.title = title
+  modal.message = message
+  modal.iconType = iconType
+  modal.show = true
+}
+
+// 선호지역
+const allAreas = ['남해', '동해', '민물', '서해', '제주']
+const toggleArea = (area) => {
+  const idx = preferAreas.value.indexOf(area)
+  if (idx === -1) preferAreas.value.push(area)
+  else preferAreas.value.splice(idx, 1)
+}
+
+// 정규식
+const USERNAME_RE = /^[a-zA-Z0-9]{6,20}$/
+const PASSWORD_RE = /^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>/?]).{8,}$/
+
+// 비밀번호 조건 미충족 (입력은 됐는데 조건 안 맞음)
+const passwordInvalid = computed(() =>
+  password.value !== '' && !PASSWORD_RE.test(password.value)
+)
+
+// 회원가입 가능 여부 (버튼 활성화)
+const canSubmit = computed(() =>
+  usernameChecked.value &&
+  nicknameChecked.value &&
+  USERNAME_RE.test(username.value) &&
+  PASSWORD_RE.test(password.value) &&
+  password.value === password2.value &&
+  name.value.trim() !== '' &&
+  /^\d{3,4}$/.test(phoneMiddle.value) &&
+  /^\d{4}$/.test(phoneLast.value) &&
+  preferAreas.value.length > 0
+)
+
+// 아이디 중복확인
+const checkUsername = async () => {
+  if (!USERNAME_RE.test(username.value)) {
+    showAlert('아이디 형식 오류', '영문/숫자 6자 이상 20자 이하로 입력해주세요.')
+    return
+  }
+  checkingUsername.value = true
+  try {
+    const { data, error } = await post('/users/check-username', { username: username.value })
+    if (error || !data?.success) {
+      showAlert('중복확인 실패', error?.message || data?.message || '확인에 실패했습니다.')
+      usernameChecked.value = false
+      return
+    }
+    usernameChecked.value = true
+    showAlert('확인 완료', '사용 가능한 아이디입니다.', 'success')
+  } catch (e) {
+    console.error(e)
+    showAlert('오류', '서버 오류가 발생했습니다.')
+  } finally {
+    checkingUsername.value = false
+  }
+}
+
+// 닉네임 중복확인
+const checkNickname = async () => {
+  if (nickname.value.trim().length < 2 || nickname.value.trim().length > 20) {
+    showAlert('닉네임 형식 오류', '닉네임은 2자 이상 20자 이하로 입력해주세요.')
+    return
+  }
+  checkingNickname.value = true
+  try {
+    const { data, error } = await post('/users/check-nickname', { nickname: nickname.value })
+    if (error || !data?.success) {
+      showAlert('중복확인 실패', error?.message || data?.message || '확인에 실패했습니다.')
+      nicknameChecked.value = false
+      return
+    }
+    nicknameChecked.value = true
+    showAlert('확인 완료', '사용 가능한 닉네임입니다.', 'success')
+  } catch (e) {
+    console.error(e)
+    showAlert('오류', '서버 오류가 발생했습니다.')
+  } finally {
+    checkingNickname.value = false
+  }
+}
+
+// 회원가입 제출
+const handleSubmit = async () => {
+  if (!canSubmit.value || submitting.value) return
+
+  submitting.value = true
+  try {
+    const marketingAgree = sessionStorage.getItem('signup_marketing') || 'N'
+
+    const payload = {
+      username: username.value,
+      password: password.value,
+      name: name.value.trim(),
+      phone: `${phoneFront.value}-${phoneMiddle.value}-${phoneLast.value}`,
+      nickname: nickname.value.trim(),
+      prefer_area: preferAreas.value.join(','),
+      marketing_agree_YN: marketingAgree,
+    }
+
+    const { data, error } = await post('/users/signup', payload)
+    if (error || !data?.success) {
+      showAlert('회원가입 실패', error?.message || data?.message || '가입에 실패했습니다.')
+      return
+    }
+
+    // 성공 — sessionStorage 정리 + 완료 페이지 이동
+    sessionStorage.removeItem('signup_marketing')
+    sessionStorage.setItem('signup_done_nickname', data.data?.nickname || nickname.value)
+    router.push('/login/joinComplete')
+  } catch (e) {
+    console.error(e)
+    showAlert('오류', '서버 오류가 발생했습니다.')
+  } finally {
+    submitting.value = false
+  }
+}
+</script>

+ 51 - 0
app/pages/login/joinComplete.vue

@@ -0,0 +1,51 @@
+<template>
+  <main class="user--main">
+    <div class="join--container">
+      <div class="join--step--wrap">
+        <div class="step--txt">
+          <span class="color--blue">3 / 3</span> 완<span class="color--blue">료</span>
+        </div>
+        <div class="step--bar">
+          <span class="active"></span>
+          <span class="active"></span>
+          <span class="active"></span>
+        </div>
+      </div>
+      <div class="join--step3 mt--54">
+        <div class="join--complete--wrap">
+          <i class="ico"></i>
+          <h2 class="mt--28">
+            회원가입이 완료되었습니다.
+          </h2>
+          <p class="mt--14">파이럿존에 오신 것을 환영합니다! <br />첫 챌린지에 도전해보세요.</p>
+        </div>
+        <div class="join--benefit--wrap mt--32">
+          <!-- TODO : 관리자 페이지에서 가입 보너스 설정 -->
+          <h3 class="color--blue">🎁 신규 가입 혜택</h3>
+          <p class="mt--8">포인트 <strong class="color--blue">1,000P</strong> 지급</p>
+          <span class="mt--9">마이 > 인벤토리에서 확인</span>
+        </div>
+      </div>
+    </div>
+
+    <div class="float--btn--wrap">
+      <NuxtLink to="/login">확인</NuxtLink>
+    </div>
+  </main>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+
+const nickname = ref('')
+
+onMounted(() => {
+  // 가입 직후 전달된 닉네임 표시
+  nickname.value = sessionStorage.getItem('signup_done_nickname') || ''
+})
+
+onBeforeUnmount(() => {
+  // 화면 떠날 때 sessionStorage 정리
+  sessionStorage.removeItem('signup_done_nickname')
+})
+</script>

+ 746 - 0
app/pages/site-manager/quest/create.vue

@@ -0,0 +1,746 @@
+<template>
+  <div class="admin--page-content">
+    <div class="admin--form">
+      <form @submit.prevent="handleSubmit">
+
+        <!-- ============================
+             퀘스트 기본 정보
+        ============================ -->
+        <div class="admin--quest--tab--wrap">
+          <div class="tab--wrap" :class="{ 'is-right': questType === 'challenge' }">
+            <div class="quest--tab__indicator"></div>
+            <button
+              type="button"
+              class="quest--tab"
+              :class="{ 'is-active': questType === 'solo' }"
+              @click="questType = 'solo'"
+            >단독진행</button>
+            <button
+              type="button"
+              class="quest--tab"
+              :class="{ 'is-active': questType === 'challenge' }"
+              @click="questType = 'challenge'"
+            >챌린지 연동</button>
+          </div>
+          <p class="" v-if="questType === 'challenge'">
+            <span class="color--yellow">🔗</span> 진행중 챌린지의 마지막 단계 진출자만 모아 진행 - 일반 신청자 모집 안 함
+          </p>
+          <p v-else>
+            <span>🎯</span> 퀘스트만 단독 진행 - 모든 사용자 신청ㆍ참여 가능
+          </p>
+        </div>
+        <table class="admin--form--table">
+          <colgroup>
+            <col style="width: 140px;">
+            <col>
+          </colgroup>
+          <tbody>
+            <tr v-if="questType == 'challenge'">
+              <th>연동 챌린지 <span class="admin--required">*</span></th>
+              <td>
+                <div class="input--wrap">
+                  <select class="admin--form-select" required>
+                    <option value="">선택하세요</option>
+                    <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
+                  </select>
+                </div>
+                <p class="yellow--desc mt--4">진행중 챌린지만 노출되며, 선택한 챌린지의 마지막 단계(최종 라운드) 진출자 명단이 자동 참여 대상이 됩니다.</p>
+              </td>
+            </tr>
+            <tr v-if="questType == 'challenge'">
+              <th>참여 대상</th>
+              <td>
+                <p class="green--desc">
+                  마지막 단계 진출자 00명    
+                </p>
+              </td>
+            </tr>
+            <tr>
+              <th><div>퀘스트명 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.name" type="text" class="w--full admin--form-input" placeholder="예: 동해안 왕대구 선상낚시대회" required />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>기간 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
+                  <span class="admin--date-separator">-</span>
+                  <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>지역 설정 <span class="admin--required">*</span></div></th>
+              <td>
+                <p>낚시분야·지역(장소)·제휴 구분을 행 단위로 지정하고, 각 행의 적용 단계(1~5)와 아이템을 설정합니다. [+추가]로 여러 조합을 등록할 수 있습니다.</p>
+                <div class="admin--inner--table--wrap">
+                  <table class="admin--quest--table">
+                    <colgroup>
+                      <col>
+                    </colgroup>
+                    <thead>
+                      <tr>
+                        <th>낚시분야</th>
+                        <th>지역(장소)</th>
+                        <th>제휴 구분</th>
+                        <th>장소 구분</th>
+                        <th>선상ㆍ낚시터</th>
+                        <th>적용 단계</th>
+                        <th>아이템</th>
+                        <th>관리</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr>
+                        <td>
+                          <div class="input--wrap">
+                            <select name="" id="" class="admin--form-select">
+                              <option value="">전체 분야</option>
+                            </select>
+                          </div>
+                        </td>
+                        <td>
+                          <div class="input--wrap">
+                            <select name="" id="" class="admin--form-select">
+                              <option value="">전체 지역</option>
+                            </select>
+                          </div>
+                        </td>
+                        <td>
+                          <div class="input--wrap">
+                            <select name="" id="" class="admin--form-select">
+                              <option value="">전체</option>
+                              <option value="Y">제휴</option>
+                              <option value="N">비제휴</option>
+                            </select>
+                          </div>
+                        </td>
+                        <td>
+                          <div class="input--wrap">
+                            <select name="" id="" class="admin--form-select">
+                              <option value="">전체 장소</option>
+                              <option value="onboard">선상</option>
+                              <option value="fishing">낚시터</option>
+                            </select>
+                          </div>
+                        </td>
+                        <td>
+                          <div></div>
+                        </td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+                <p>예) 바다낚시터·전체·비제휴 → 1·2단계 아이템 지정  /  낚시어선·제주도·제휴 → 3단계 지정</p>
+              </td>
+            </tr>
+            <tr>
+              <th><div>참가비 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    v-model="formData.fee"
+                    type="number"
+                    min="0"
+                    class="admin--form-input w--200"
+                    :placeholder="isFree ? '0 (무료)' : '예: 10000'"
+                    :disabled="isFree"
+                    required
+                  />
+                  <span>원</span>
+                  <label class="admin--checkbox-label">
+                    <input type="checkbox" v-model="isFree" @change="onFreeChange" /> 무료
+                  </label>
+                </div>
+              </td>
+            </tr>
+            <tr v-if="questType !== 'challenge'">
+              <th><div>최대 참가자 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.max_participants" type="number" min="100" max="999999" class="admin--form-input w--120" placeholder="100 ~ 999999" required />
+                  <span>명</span>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>상세내용</div></th>
+              <td>
+                <ClientOnly>
+                  <SunEditor
+                    v-model="formData.description"
+                    height="400px"
+                    placeholder="퀘스트 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)"
+                  />
+                </ClientOnly>
+              </td>
+            </tr>
+            <tr>
+              <th><div>타이틀 이미지</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    ref="imageInput"
+                    type="file"
+                    accept="image/*"
+                    class="admin--form-file-hidden"
+                    @change="onImageChange"
+                  />
+                  <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
+                    이미지 선택
+                  </button>
+                  <span v-if="image" class="ml--16">{{ image.file.name }}</span>
+                </div>
+                <p class="mt--10">권장 1200x800, JPG/PNG/GIF/WebP, 5MB 이하 (선택 사항)</p>
+                <div v-if="image" class="onboard--photo-grid mt--10">
+                  <div class="onboard--photo-item">
+                    <img :src="image.preview" alt="미리보기" />
+                    <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
+                  </div>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>상태 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <label class="admin--radio-label">
+                    <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
+                  </label>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        <!-- 버튼 영역 -->
+        <div class="admin--form-actions">
+          <button type="button" class="admin--btn" @click="goToList">
+            ← 목록으로
+          </button>
+          <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
+            {{ isSaving ? "저장 중..." : "저장" }}
+          </button>
+        </div>
+
+        <!-- 성공/에러 메시지 -->
+        <div v-if="successMessage" class="admin--alert admin--alert-success">
+          {{ successMessage }}
+        </div>
+        <div v-if="errorMessage" class="admin--alert admin--alert-error">
+          {{ errorMessage }}
+        </div>
+      </form>
+    </div>
+
+    <!-- ============================
+         아이템 선택 모달
+    ============================ -->
+    <ClientOnly>
+      <Teleport to="body">
+        <div
+          v-if="itemModal.isOpen"
+          class="admin--modal-overlay admin--alert-overlay"
+          @click.self="closeItemModal"
+        >
+          <div class="admin--modal admin--form-modal admin--item-modal" @click.stop>
+            <div class="admin--modal-header">
+              <h4>아이템 선택</h4>
+              <button type="button" class="admin--modal-close" @click="closeItemModal">✕</button>
+            </div>
+            <div class="admin--modal-body">
+              <div class="admin--item-modal__search mb--16">
+                <input
+                  v-model="itemModal.searchKeyword"
+                  type="text"
+                  class="admin--form-input w--full"
+                  placeholder="🔍 아이템명 검색"
+                />
+              </div>
+
+              <ul v-if="filteredItems().length > 0" class="admin--item-modal__grid">
+                <li
+                  v-for="it in filteredItems()"
+                  :key="it.id"
+                  class="admin--item-modal__card"
+                  :class="{ 'is-selected': itemModal.tempSelected.includes(it.id) }"
+                >
+                  <label>
+                    <input
+                      type="checkbox"
+                      :checked="itemModal.tempSelected.includes(it.id)"
+                      @change="toggleItemInModal(it.id)"
+                    />
+                    <div class="admin--item-modal__thumb">
+                      <img
+                        v-if="it.file_path"
+                        :src="getImageUrl(it.file_path)"
+                        :alt="it.name"
+                      />
+                      <div v-else class="admin--item-modal__no-img">🎁</div>
+                    </div>
+                    <div class="admin--item-modal__name">{{ it.name }}</div>
+                    <div class="admin--item-modal__meta">
+                      <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
+                      <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
+                    </div>
+                  </label>
+                </li>
+              </ul>
+
+              <div v-else class="admin--item-modal__empty">
+                {{ itemModal.searchKeyword ? '검색 결과가 없습니다.' : '등록된 아이템이 없습니다.' }}
+              </div>
+            </div>
+            <div class="admin--modal-footer">
+              <span class="admin--item-modal__count mr--auto">{{ itemModal.tempSelected.length }}개 선택</span>
+              <button type="button" class="admin--btn" @click="closeItemModal">취소</button>
+              <button type="button" class="admin--btn admin--btn-primary-fill ml--8" @click="applyItemModal">적용</button>
+            </div>
+          </div>
+        </div>
+      </Teleport>
+    </ClientOnly>
+  </div>
+</template>
+
+<script setup>
+  import { ref, onMounted, onBeforeUnmount } from "vue";
+  import { useRouter } from "vue-router";
+  import DatePicker from "~/components/admin/DatePicker.vue";
+  import SunEditor from "~/components/admin/SunEditor.vue";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const { get, post, upload } = useApi();
+  const { getImageUrl } = useImage();
+
+  const isSaving = ref(false);
+  const successMessage = ref("");
+  const errorMessage = ref("");
+  const questType = ref("solo"); // 'solo' | 'challenge'
+
+  // ============================
+  // 옵션 데이터
+  // ============================
+  const fieldOptions = ref([]);
+  const areaOptions = ref([]);
+  const placesAll = ref([]);   // 검색용 전체 장소 (선상 + 낚시터, _placeType 필드로 구분)
+  const itemsAll = ref([]);    // 아이템 모달용 전체 아이템
+
+  // ============================
+  // 퀘스트 기본 정보
+  // ============================
+  const formData = ref({
+    name: "",
+    fee: "",
+    max_participants: "",
+    status_YN: "Y",
+    description: "",
+  });
+  const startDate = ref("");
+  const endDate = ref("");
+  const isFree = ref(false);
+
+  // 무료 체크박스 토글
+  const onFreeChange = () => {
+    if (isFree.value) {
+      formData.value.fee = "0";
+    } else {
+      formData.value.fee = "";
+    }
+  };
+
+  // ============================
+  // 이미지 업로드
+  // ============================
+  const imageInput = ref(null);
+  const image = ref(null);
+  const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
+
+  const triggerImageInput = () => imageInput.value?.click();
+
+  const onImageChange = (e) => {
+    const file = (e.target.files || [])[0];
+    e.target.value = "";
+    if (!file) return;
+    if (!file.type.startsWith("image/")) {
+      errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
+      return;
+    }
+    if (file.size > MAX_IMAGE_SIZE) {
+      errorMessage.value = "이미지가 5MB를 초과합니다.";
+      return;
+    }
+    if (image.value) URL.revokeObjectURL(image.value.preview);
+    image.value = { file, preview: URL.createObjectURL(file) };
+  };
+
+  const removeImage = () => {
+    if (image.value) {
+      URL.revokeObjectURL(image.value.preview);
+      image.value = null;
+    }
+  };
+
+  // ============================
+  // 라운드/장소 동적 배열
+  // ============================
+  let _keySeq = 0;
+  const nextKey = () => ++_keySeq;
+
+  function createPlace() {
+    return {
+      _key: nextKey(),
+      field_id: "",
+      area_id: "",
+      partnership_YN: "",
+      onboards: [],       // 적용된 장소 키 배열 (예: 'onboard-1', 'fishing-3')
+      items: [],          // [{ item_id, name, qty }] — Phase 2
+      // UI 상태
+      dropdownOpen: false,
+      searchKeyword: "",
+      tempSelected: [],   // 드롭다운 내 임시 체크 (장소 키 배열)
+    };
+  }
+  function createRound(no) {
+    return {
+      _key: nextKey(),
+      round_no: no,
+      place_mode: "all",
+      qualified: "",
+      items: [],          // [{ item_id, name, qty }] — Phase 2
+      places: [],
+    };
+  }
+
+  const rounds = ref([createRound(1), createRound(2)]);
+
+  function renumberRounds() {
+    rounds.value.forEach((r, i) => { r.round_no = i + 1; });
+  }
+
+  function addRound() {
+    if (rounds.value.length >= 5) return;
+    rounds.value.push(createRound(rounds.value.length + 1));
+  }
+  function removeRound(idx) {
+    if (rounds.value.length <= 2) return;
+    rounds.value.splice(idx, 1);
+    renumberRounds();
+  }
+  function changePlaceMode(round, mode) {
+    round.place_mode = mode;
+    // specific 으로 전환했는데 장소가 없으면 자동으로 장소 1 생성
+    if (mode === "specific" && round.places.length === 0) {
+      round.places.push(createPlace());
+    }
+  }
+  function addPlace(round) {
+    round.places.push(createPlace());
+  }
+  function removePlace(round, idx) {
+    round.places.splice(idx, 1);
+    // specific 모드인데 장소가 0개면 자동으로 1개 다시 추가 (또는 all 모드로 되돌리기 — 여기선 하나 자동 추가)
+    if (round.places.length === 0) {
+      round.places.push(createPlace());
+    }
+  }
+
+  // ============================
+  // 장소(선상+낚시터) 검색 드롭다운
+  // ============================
+  // 장소 키 헬퍼: 'onboard-1', 'fishing-2' 형태로 고유 식별
+  const placeKey = (p) => `${p._placeType}-${p.id}`;
+  const placeByKey = (k) => placesAll.value.find((p) => placeKey(p) === k);
+  const placeNameByKey = (k) => placeByKey(k)?.name || "?";
+  const placeTypeByKey = (k) => (k && k.startsWith("fishing-")) ? "fishing" : "onboard";
+
+  function closeAllDropdowns() {
+    rounds.value.forEach((r) =>
+      r.places.forEach((p) => { p.dropdownOpen = false; })
+    );
+  }
+
+  function openDropdown(place) {
+    closeAllDropdowns();
+    place.tempSelected = [...place.onboards];
+    place.dropdownOpen = true;
+  }
+
+  function filteredPlaces(place) {
+    return placesAll.value.filter((p) => {
+      if (place.field_id && String(p.field_id) !== String(place.field_id)) return false;
+      if (place.area_id && String(p.area_id) !== String(place.area_id)) return false;
+      if (place.partnership_YN && p.partnership_YN !== place.partnership_YN) return false;
+      if (place.searchKeyword) {
+        const kw = place.searchKeyword.toLowerCase();
+        if (!String(p.name || "").toLowerCase().includes(kw)) return false;
+      }
+      return true;
+    });
+  }
+
+  function togglePlaceInTemp(place, key) {
+    const idx = place.tempSelected.indexOf(key);
+    if (idx === -1) place.tempSelected.push(key);
+    else place.tempSelected.splice(idx, 1);
+  }
+
+  function isAllFilteredSelected(place) {
+    const filtered = filteredPlaces(place);
+    if (filtered.length === 0) return false;
+    return filtered.every((p) => place.tempSelected.includes(placeKey(p)));
+  }
+
+  function toggleAll(place) {
+    const filtered = filteredPlaces(place);
+    const filteredKeys = filtered.map(placeKey);
+    if (isAllFilteredSelected(place)) {
+      const set = new Set(filteredKeys);
+      place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
+    } else {
+      const merged = new Set([...place.tempSelected, ...filteredKeys]);
+      place.tempSelected = [...merged];
+    }
+  }
+
+  // 지역별 그룹화: [{area, items: [...]}, ...]
+  function groupedFilteredPlaces(place) {
+    const filtered = filteredPlaces(place);
+    const map = new Map();
+    filtered.forEach((p) => {
+      const area = p.area_name || "미분류";
+      if (!map.has(area)) map.set(area, []);
+      map.get(area).push(p);
+    });
+    return Array.from(map.entries()).map(([area, items]) => ({ area, items }));
+  }
+
+  function isAllInGroupSelected(place, items) {
+    if (!items || items.length === 0) return false;
+    return items.every((p) => place.tempSelected.includes(placeKey(p)));
+  }
+
+  function toggleAllInGroup(place, items) {
+    const keys = items.map(placeKey);
+    if (isAllInGroupSelected(place, items)) {
+      const set = new Set(keys);
+      place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
+    } else {
+      const merged = new Set([...place.tempSelected, ...keys]);
+      place.tempSelected = [...merged];
+    }
+  }
+
+  function applyDropdown(place) {
+    place.onboards = [...place.tempSelected];
+    place.dropdownOpen = false;
+  }
+
+  function removePlaceChip(place, key) {
+    place.onboards = place.onboards.filter((k) => k !== key);
+  }
+
+  // 외부 클릭 시 모든 드롭다운 닫기
+  function handleDocumentClick() {
+    closeAllDropdowns();
+  }
+
+  // ============================
+  // 아이템 선택 모달
+  // ============================
+  const itemModal = ref({
+    isOpen: false,
+    target: null,        // round 또는 place 객체 (둘 다 .items 배열 가짐)
+    tempSelected: [],    // 임시 선택된 item id 배열
+    searchKeyword: "",
+  });
+
+  function openItemModal(target) {
+    itemModal.value.target = target;
+    itemModal.value.tempSelected = target.items.map((i) => i.item_id);
+    itemModal.value.searchKeyword = "";
+    itemModal.value.isOpen = true;
+  }
+
+  function closeItemModal() {
+    itemModal.value.isOpen = false;
+    itemModal.value.target = null;
+    itemModal.value.tempSelected = [];
+    itemModal.value.searchKeyword = "";
+  }
+
+  function toggleItemInModal(itemId) {
+    const idx = itemModal.value.tempSelected.indexOf(itemId);
+    if (idx === -1) itemModal.value.tempSelected.push(itemId);
+    else itemModal.value.tempSelected.splice(idx, 1);
+  }
+
+  function filteredItems() {
+    if (!itemModal.value.searchKeyword) return itemsAll.value;
+    const kw = itemModal.value.searchKeyword.toLowerCase();
+    return itemsAll.value.filter((i) =>
+      String(i.name || "").toLowerCase().includes(kw)
+    );
+  }
+
+  function applyItemModal() {
+    const target = itemModal.value.target;
+    if (!target) return;
+    target.items = itemModal.value.tempSelected.map((id) => {
+      const it = itemsAll.value.find((x) => x.id === id);
+      return {
+        item_id: id,
+        name: it?.name || "?",
+        type: it?.type || "",
+        point: it?.point ?? null,
+      };
+    });
+    closeItemModal();
+  }
+
+  // ============================
+  // 데이터 로드
+  // ============================
+  async function loadOptions() {
+    try {
+      const [fieldRes, areaRes, onboardRes, fishingRes, itemRes] = await Promise.all([
+        get("/field/list", { params: { per_page: 1000 } }),
+        get("/area/list", { params: { per_page: 1000 } }),
+        get("/onboard/list", { params: { per_page: 1000 } }),
+        get("/fishing/list", { params: { per_page: 1000 } }),
+        get("/item/list", { params: { per_page: 1000, status: "Y" } }),
+      ]);
+      if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
+      if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
+
+      // 선상 + 낚시터 통합 (_placeType으로 구분)
+      const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
+        .map((o) => ({ ...o, _placeType: "onboard" }));
+      const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
+        .map((f) => ({ ...f, _placeType: "fishing" }));
+      placesAll.value = [...onboards, ...fishings];
+
+      if (itemRes.data?.success) itemsAll.value = itemRes.data.data.items || [];
+    } catch (e) {
+      console.error("Load options error:", e);
+    }
+  }
+
+  // ============================
+  // 폼 제출
+  // ============================
+  async function handleSubmit() {
+    errorMessage.value = "";
+    successMessage.value = "";
+
+    // 프론트 1차 검증
+    if (!formData.value.name.trim()) return (errorMessage.value = "퀘스트명을 입력하세요.");
+    if (!formData.value.fee.toString().trim()) return (errorMessage.value = "참가비를 입력하세요.");
+    if (!startDate.value) return (errorMessage.value = "시작일을 선택하세요.");
+    if (!endDate.value) return (errorMessage.value = "종료일을 선택하세요.");
+    if (!formData.value.max_participants) return (errorMessage.value = "최대 참가자를 입력하세요.");
+
+    for (let i = 0; i < rounds.value.length; i++) {
+      const r = rounds.value[i];
+      if (!r.qualified) {
+        return (errorMessage.value = `라운드 ${i + 1}의 진출자 수를 입력하세요.`);
+      }
+      if (r.place_mode === "specific") {
+        if (r.places.length === 0) {
+          return (errorMessage.value = `라운드 ${i + 1}에 장소를 1개 이상 추가하세요.`);
+        }
+        for (let j = 0; j < r.places.length; j++) {
+          if (r.places[j].onboards.length === 0) {
+            return (errorMessage.value = `라운드 ${i + 1} 장소 ${j + 1}에 선상을 1개 이상 선택하세요.`);
+          }
+        }
+      }
+    }
+
+    isSaving.value = true;
+    try {
+      const payload = {
+        name: formData.value.name,
+        fee: formData.value.fee,
+        start_date: startDate.value,
+        end_date: endDate.value,
+        max_participants: Number(formData.value.max_participants),
+        description: formData.value.description,
+        status_YN: formData.value.status_YN,
+        rounds: rounds.value.map((r) => ({
+          round_no: r.round_no,
+          place_mode: r.place_mode,
+          qualified: Number(r.qualified),
+          items: r.place_mode === "all"
+            ? r.items.map((it) => ({ item_id: it.item_id }))
+            : [],
+          places: r.place_mode === "specific"
+            ? r.places.map((p) => ({
+                // 'onboard-1', 'fishing-3' → [{type:'onboard', id:1}, {type:'fishing', id:3}, ...]
+                onboards: p.onboards.map((key) => {
+                  const i = key.indexOf("-");
+                  return { type: key.substring(0, i), id: Number(key.substring(i + 1)) };
+                }),
+                items: p.items.map((it) => ({ item_id: it.item_id })),
+              }))
+            : [],
+        })),
+      };
+
+      const { data, error } = await post("/challenge", payload);
+
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
+        return;
+      }
+
+      const newId = data.data?.id;
+
+      // 이미지가 있으면 업로드 (퀘스트 id 받은 뒤)
+      if (newId && image.value) {
+        const fd = new FormData();
+        fd.append("image", image.value.file);
+        const { data: imgRes, error: imgErr } = await upload(`/challenge/${newId}/image`, fd);
+
+        if (imgErr || !imgRes?.success) {
+          errorMessage.value = "퀘스트는 등록됐지만 이미지 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
+          setTimeout(() => router.push("/site-manager/quest/list"), 1500);
+          return;
+        }
+      }
+
+      successMessage.value = data.message || "퀘스트가 등록되었습니다.";
+      setTimeout(() => {
+        router.push("/site-manager/quest/list");
+      }, 1000);
+    } catch (e) {
+      errorMessage.value = "서버 오류가 발생했습니다.";
+      console.error("Quest save error:", e);
+    } finally {
+      isSaving.value = false;
+    }
+  }
+
+  const goToList = () => router.push("/site-manager/quest/list");
+
+  onMounted(() => {
+    loadOptions();
+    document.addEventListener("click", handleDocumentClick);
+  });
+
+  onBeforeUnmount(() => {
+    document.removeEventListener("click", handleDocumentClick);
+  });
+</script>

+ 312 - 0
app/pages/site-manager/quest/list.vue

@@ -0,0 +1,312 @@
+<template>
+  <div class="admin--field-list">
+    <!-- 상단 검색/액션 영역 -->
+    <div class="admin--search-box type2">
+      <div class="admin--search--inner--box">
+        <div class="admin--search-form">
+          <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
+            <option value="">전체</option>
+            <option value="recruiting">모집중</option>
+            <option value="running">진행중</option>
+            <option value="ended">종료</option>
+          </select>
+          <input
+            v-model="searchQuery"
+            type="text"
+            placeholder="퀘스트명으로 검색"
+            @keyup.enter="onSearch"
+            class="admin--form-input admin--search-input"
+          />
+          <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
+          <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
+        </div>
+        <div class="admin--search-actions">
+          <button class="admin--btn-add" @click="goToCreate">+ 새 퀘스트 등록</button>
+        </div>
+      </div>
+      <div class="admin--search--inner--box">
+        <div class="admin--search-form">
+          <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
+          <span class="admin--date-separator">-</span>
+          <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
+          <div class="admin--quick-range">
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('today')">오늘</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('7d')">7일</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('15d')">15일</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('1m')">1개월</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('3m')">3개월</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('1y')">1년</button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <p class="admin--table--count">전체 {{ counts.all }}ㆍ모집중 {{ counts.recruiting }}ㆍ진행중 {{ counts.running }}ㆍ종료 {{ counts.ended }}</p>
+    <div class="admin--table-wrapper">
+      <table class="admin--table fishing--table">
+        <thead>
+          <tr>
+            <th style="width: 40px;">번호</th>
+            <th>퀘스트명</th>
+            <th style="width: 100px;">상태</th>
+            <th style="width: 100px;">라운드</th>
+            <th style="width: 100px;">모집/전체</th>
+            <th style="">기간</th>
+            <th style="width: 120px;">관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="7" class="admin--table-loading">데이터를 불러오는 중...</td>
+          </tr>
+          <tr v-else-if="!spots || spots.length === 0">
+            <td colspan="7" class="admin--table-empty">등록된 퀘스트가 없습니다.</td>
+          </tr>
+          <tr
+            v-else
+            v-for="(item, index) in spots"
+            :key="item.id"
+            class="admin--table-row-clickable"
+            @click="goToDetail(item.id)"
+          >
+            <td class="date">{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td class="admin--table-title">{{ item.name }}</td>
+            <td>
+              <span :class="['admin--badge', getStatusBadgeClass(item.derived_status)]">
+                {{ getStatusLabel(item.derived_status) }}
+              </span>
+            </td>
+            <td>R{{ item.current_round || 1 }}/{{ item.total_rounds }}</td>
+            <td>{{ item.max_participants }}</td>
+            <td class="date">{{ formatDate(item.start_date) }} ~ {{ formatDate(item.end_date) }}</td>
+            <td>
+              <div class="admin--table-actions">
+                <button class="admin--btn-small admin--btn-blue" @click.stop="goToEdit(item.id)">
+                  수정
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button
+        v-if="totalPages > 2"
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(1)"
+        title="처음"
+      >
+        ◀◀
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+        title="이전"
+      >
+        ◀
+      </button>
+      <button
+        v-for="page in visiblePages"
+        :key="page"
+        class="admin--pagination-btn"
+        :class="{ 'is-active': page === currentPage }"
+        @click="changePage(page)"
+      >
+        {{ page }}
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(currentPage + 1)"
+        title="다음"
+      >
+        ▶
+      </button>
+      <button
+        v-if="totalPages > 2"
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(totalPages)"
+        title="끝"
+      >
+        ▶▶
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted } from "vue";
+  import { useRouter } from "vue-router";
+  import DatePicker from "~/components/admin/DatePicker.vue";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const { get } = useApi();
+
+  const isLoading = ref(false);
+  const spots = ref([]);
+  const currentPage = ref(1);
+  const perPage = ref(10);
+  const totalCount = ref(0);
+  const totalPages = ref(0);
+  const counts = ref({ all: 0, recruiting: 0, running: 0, ended: 0 });
+
+  const searchQuery = ref("");
+  const filterStatus = ref("");      // '', recruiting, running, ended
+  const startDate = ref("");         // YYYY-MM-DD
+  const endDate = ref("");           // YYYY-MM-DD
+
+  // YYYY-MM-DD 포맷터
+  const toYMD = (d) => {
+    const y = d.getFullYear();
+    const m = String(d.getMonth() + 1).padStart(2, "0");
+    const day = String(d.getDate()).padStart(2, "0");
+    return `${y}-${m}-${day}`;
+  };
+
+  // 빠른 기간 선택 (오늘 기준)
+  const setRange = (kind) => {
+    const today = new Date();
+    const end = toYMD(today);
+    const startDt = new Date();
+    switch (kind) {
+      case "today":
+        break;
+      case "7d":
+        startDt.setDate(startDt.getDate() - 7);
+        break;
+      case "15d":
+        startDt.setDate(startDt.getDate() - 15);
+        break;
+      case "1m":
+        startDt.setMonth(startDt.getMonth() - 1);
+        break;
+      case "3m":
+        startDt.setMonth(startDt.getMonth() - 3);
+        break;
+      case "1y":
+        startDt.setFullYear(startDt.getFullYear() - 1);
+        break;
+    }
+    startDate.value = toYMD(startDt);
+    endDate.value = end;
+    onSearch();
+  };
+
+  // 보이는 페이지 번호 계산
+  const visiblePages = computed(() => {
+    const pages = [];
+    const maxVisible = 5;
+    let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
+    let end = Math.min(totalPages.value, start + maxVisible - 1);
+
+    if (end - start < maxVisible - 1) {
+      start = Math.max(1, end - maxVisible + 1);
+    }
+    for (let i = start; i <= end; i++) {
+      pages.push(i);
+    }
+    return pages;
+  });
+
+  // 데이터 로드
+  const loadSpots = async () => {
+    isLoading.value = true;
+
+    const params = {
+      page: currentPage.value,
+      per_page: perPage.value,
+    };
+    if (searchQuery.value) params.search = searchQuery.value;
+    if (filterStatus.value) params.status = filterStatus.value;
+    if (startDate.value) params.start_date = startDate.value;
+    if (endDate.value) params.end_date = endDate.value;
+
+    const { data, error } = await get("/quest/list", { params });
+
+    if (error) {
+      console.error("[QuestList] 목록 로드 실패:", error);
+      spots.value = [];
+      totalCount.value = 0;
+      totalPages.value = 0;
+      counts.value = { all: 0, recruiting: 0, running: 0, ended: 0 };
+    } else if (data?.success && data?.data) {
+      spots.value = data.data.items || [];
+      totalCount.value = data.data.total || 0;
+      totalPages.value = data.data.total_pages || 0;
+      counts.value = data.data.counts || { all: 0, recruiting: 0, running: 0, ended: 0 };
+    }
+
+    isLoading.value = false;
+  };
+
+  // 검색
+  const onSearch = () => {
+    currentPage.value = 1;
+    loadSpots();
+  };
+
+  // 검색 초기화
+  const resetSearch = () => {
+    searchQuery.value = "";
+    filterStatus.value = "";
+    startDate.value = "";
+    endDate.value = "";
+    currentPage.value = 1;
+    loadSpots();
+  };
+
+  // 페이지 변경
+  const changePage = (page) => {
+    if (page < 1 || page > totalPages.value) return;
+    currentPage.value = page;
+    loadSpots();
+    window.scrollTo({ top: 0, behavior: "smooth" });
+  };
+
+  // 이동
+  const goToCreate = () => router.push("/site-manager/quest/create");
+  const goToDetail = (id) => router.push(`/site-manager/quest/detail/${id}`);
+  const goToEdit = (id) => router.push(`/site-manager/quest/edit/${id}`);
+
+  // 상태 라벨 / 뱃지 클래스 (derived_status: hidden/recruiting/running/ended)
+  const getStatusLabel = (status) =>
+    status === "hidden" ? "미사용"
+    : status === "recruiting" ? "모집중"
+    : status === "running" ? "진행중"
+    : status === "ended" ? "종료"
+    : "-";
+  const getStatusBadgeClass = (status) =>
+    status === "hidden" ? "admin--badge-hidden"
+    : status === "recruiting" ? "admin--badge-recruiting"
+    : status === "running" ? "admin--badge-running"
+    : status === "ended" ? "admin--badge-ended"
+    : "";
+
+  // 날짜 포맷
+  const formatDate = (dateString) => {
+    if (!dateString) return "-";
+    const date = new Date(dateString.replace(" ", "T"));
+    if (isNaN(date.getTime())) return dateString;
+    return date.toLocaleDateString("ko-KR", {
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+    });
+  };
+
+  onMounted(() => {
+    loadSpots();
+  });
+</script>

+ 7 - 0
backend/app/Config/Routes.php

@@ -101,6 +101,13 @@ $routes->post('api/challenge/(:num)/image', 'Api\ChallengeController::uploadImag
 $routes->delete('api/challenge/(:num)/image', 'Api\ChallengeController::deleteImage/$1');
 $routes->delete('api/challenge/(:num)', 'Api\ChallengeController::delete/$1');
 
+// Users (회원)
+$routes->post('api/users/login', 'Api\UsersController::login');
+$routes->post('api/users/logout', 'Api\UsersController::logout');
+$routes->post('api/users/check-username', 'Api\UsersController::checkUsername');
+$routes->post('api/users/check-nickname', 'Api\UsersController::checkNickname');
+$routes->post('api/users/signup', 'Api\UsersController::signup');
+
 // File Upload
 $routes->post('api/upload/file', 'Api\UploadController::uploadFile');
 $routes->post('api/upload/image', 'Api\UploadController::uploadImage');

+ 281 - 0
backend/app/Controllers/Api/UsersController.php

@@ -0,0 +1,281 @@
+<?php
+
+namespace App\Controllers\Api;
+
+use CodeIgniter\HTTP\ResponseInterface;
+
+class UsersController extends BaseApiController
+{
+    protected $format = 'json';
+    protected $table = 'users';
+
+    private const ALLOWED_AREAS = ['남해', '동해', '민물', '서해', '제주'];
+
+    /**
+     * 회원 로그인
+     * POST /api/users/login
+     */
+    public function login()
+    {
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload)) $payload = [];
+
+            $username = trim((string) ($payload['username'] ?? ''));
+            $password = (string) ($payload['password'] ?? '');
+            $autoLogin = !empty($payload['auto_login']);
+
+            if ($username === '' || $password === '') {
+                return $this->respondError('아이디와 비밀번호를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $db = $this->getDB();
+            $user = $db->table($this->table)
+                ->where('username', $username)
+                ->where('deleted_YN', 'N')
+                ->get()
+                ->getRow();
+
+            if (!$user) {
+                return $this->respondError('아이디 또는 비밀번호가 일치하지 않습니다.', ResponseInterface::HTTP_UNAUTHORIZED);
+            }
+
+            // 계정 상태 확인
+            if ($user->status === 'inactive') {
+                return $this->respondError('비활성화된 계정입니다.', ResponseInterface::HTTP_FORBIDDEN);
+            }
+            if ($user->status === 'suspended') {
+                return $this->respondError('정지된 계정입니다. 고객센터에 문의하세요.', ResponseInterface::HTTP_FORBIDDEN);
+            }
+
+            // 비밀번호 검증
+            if (!password_verify($password, $user->password)) {
+                return $this->respondError('아이디 또는 비밀번호가 일치하지 않습니다.', ResponseInterface::HTTP_UNAUTHORIZED);
+            }
+
+            // last_login_at 업데이트
+            $now = date('Y-m-d H:i:s');
+            $db->table($this->table)->where('id', $user->id)->update([
+                'last_login_at' => $now,
+            ]);
+
+            // 토큰 발급 + 저장 (자동로그인: 30일 / 일반: 24시간)
+            $token = bin2hex(random_bytes(32));
+            $expiresAt = $autoLogin
+                ? date('Y-m-d H:i:s', strtotime('+30 days'))
+                : date('Y-m-d H:i:s', strtotime('+24 hours'));
+
+            $db->table('user_tokens')->insert([
+                'user_id'    => $user->id,
+                'token'      => $token,
+                'expires_at' => $expiresAt,
+                'created_at' => $now,
+            ]);
+
+            return $this->respondSuccess([
+                'token'      => $token,
+                'expires_at' => $expiresAt,
+                'user' => [
+                    'id'           => (int) $user->id,
+                    'username'     => $user->username,
+                    'nickname'     => $user->nickname,
+                    'name'         => $user->name,
+                    'phone'        => $user->phone,
+                    'prefer_area'  => $user->prefer_area,
+                    'profile_image'=> $user->profile_image,
+                ],
+            ], '로그인 성공');
+        } catch (\Exception $e) {
+            log_message('error', 'login error: ' . $e->getMessage());
+            return $this->respondError('로그인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 회원 로그아웃
+     * POST /api/users/logout
+     */
+    public function logout()
+    {
+        try {
+            $authHeader = $this->getAuthHeader();
+            if (!empty($authHeader)) {
+                $token = str_replace('Bearer ', '', $authHeader);
+                if (!empty($token)) {
+                    $this->getDB()->table('user_tokens')->where('token', $token)->delete();
+                }
+            }
+            return $this->respondSuccess(null, '로그아웃 성공');
+        } catch (\Exception $e) {
+            log_message('error', 'logout error: ' . $e->getMessage());
+            return $this->respondError('로그아웃 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 아이디 중복 확인
+     * POST /api/users/check-username
+     */
+    public function checkUsername()
+    {
+        try {
+            $payload = $this->request->getJSON(true);
+            $username = trim((string) ($payload['username'] ?? ''));
+
+            if ($username === '') {
+                return $this->respondError('아이디를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!preg_match('/^[a-zA-Z0-9]{6,20}$/', $username)) {
+                return $this->respondError('영문/숫자 6~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $exists = $this->getDB()->table($this->table)
+                ->where('username', $username)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+
+            if ($exists > 0) {
+                return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            return $this->respondSuccess(null, '사용 가능한 아이디입니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'checkUsername error: ' . $e->getMessage());
+            return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 닉네임 중복 확인
+     * POST /api/users/check-nickname
+     */
+    public function checkNickname()
+    {
+        try {
+            $payload = $this->request->getJSON(true);
+            $nickname = trim((string) ($payload['nickname'] ?? ''));
+
+            if ($nickname === '') {
+                return $this->respondError('닉네임을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
+                return $this->respondError('닉네임은 2~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $exists = $this->getDB()->table($this->table)
+                ->where('nickname', $nickname)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+
+            if ($exists > 0) {
+                return $this->respondError('이미 사용 중인 닉네임입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            return $this->respondSuccess(null, '사용 가능한 닉네임입니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'checkNickname error: ' . $e->getMessage());
+            return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 회원가입
+     * POST /api/users/signup
+     */
+    public function signup()
+    {
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload)) $payload = [];
+
+            $username       = trim((string) ($payload['username'] ?? ''));
+            $password       = (string) ($payload['password'] ?? '');
+            $name           = trim((string) ($payload['name'] ?? ''));
+            $phone          = trim((string) ($payload['phone'] ?? ''));
+            $nickname       = trim((string) ($payload['nickname'] ?? ''));
+            $preferArea     = trim((string) ($payload['prefer_area'] ?? ''));
+            $marketingAgree = (($payload['marketing_agree_YN'] ?? 'N') === 'Y') ? 'Y' : 'N';
+
+            // 검증
+            if (!preg_match('/^[a-zA-Z0-9]{6,20}$/', $username)) {
+                return $this->respondError('아이디는 영문/숫자 6~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!preg_match('/^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]).{8,}$/', $password)) {
+                return $this->respondError('비밀번호는 영문 소문자+숫자+특수문자 조합 8자 이상으로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if ($name === '' || mb_strlen($name) > 50) {
+                return $this->respondError('이름을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            // 핸드폰 정규화: 숫자만 추출 후 0XX-XXXX-XXXX 형식으로
+            // 010 / 011 / 016 / 017 / 018 / 019 모두 허용 (10~11자리)
+            $phoneDigits = preg_replace('/[^0-9]/', '', $phone);
+            if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
+                return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            // 11자리: 3-4-4 / 10자리: 3-3-4
+            if (strlen($phoneDigits) === 11) {
+                $phoneFormatted = substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 4) . '-' . substr($phoneDigits, 7);
+            } else {
+                $phoneFormatted = substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6);
+            }
+
+            if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
+                return $this->respondError('닉네임은 2~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            // 선호지역 검증
+            $preferAreaNormalized = null;
+            if ($preferArea !== '') {
+                $areas = array_filter(array_map('trim', explode(',', $preferArea)));
+                foreach ($areas as $a) {
+                    if (!in_array($a, self::ALLOWED_AREAS, true)) {
+                        return $this->respondError('잘못된 선호지역입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+                    }
+                }
+                $preferAreaNormalized = implode(',', array_values(array_unique($areas)));
+            }
+
+            $db = $this->getDB();
+
+            // 중복 체크
+            $userExists = $db->table($this->table)
+                ->where('username', $username)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            if ($userExists > 0) {
+                return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            $nickExists = $db->table($this->table)
+                ->where('nickname', $nickname)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            if ($nickExists > 0) {
+                return $this->respondError('이미 사용 중인 닉네임입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            // INSERT
+            $db->table($this->table)->insert([
+                'username'           => $username,
+                'password'           => password_hash($password, PASSWORD_DEFAULT),
+                'nickname'           => $nickname,
+                'name'               => $name,
+                'phone'              => $phoneFormatted,
+                'prefer_area'        => $preferAreaNormalized,
+                'signup_type'        => 'local',
+                'marketing_agree_YN' => $marketingAgree,
+                'status'             => 'active',
+                'deleted_YN'         => 'N',
+                'created_at'         => date('Y-m-d H:i:s'),
+            ]);
+            $newId = $db->insertID();
+
+            return $this->respondSuccess(
+                ['id' => $newId, 'username' => $username, 'nickname' => $nickname],
+                '회원가입이 완료되었습니다.',
+                ResponseInterface::HTTP_CREATED
+            );
+        } catch (\Exception $e) {
+            log_message('error', 'signup error: ' . $e->getMessage());
+            return $this->respondError('회원가입 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+}

+ 337 - 63
db.vuerd.json

@@ -2,11 +2,11 @@
   "$schema": "https://raw.githubusercontent.com/dineug/erd-editor/main/json-schema/schema.json",
   "version": "3.0.0",
   "settings": {
-    "width": 3000,
-    "height": 3000,
-    "scrollTop": -1663.5063,
-    "scrollLeft": -669,
-    "zoomLevel": 1,
+    "width": 5000,
+    "height": 5000,
+    "scrollTop": -2201,
+    "scrollLeft": -100,
+    "zoomLevel": 0.91,
     "show": 431,
     "database": 4,
     "databaseName": "piratezone",
@@ -48,7 +48,8 @@
       "YEZp3T6qdZJFAUK3rT0d8",
       "mq_Abm-qvzZj2SGtCoZS-",
       "uErSlvV8r5f5ifu5jqD3m",
-      "ENjuT56buJGJXbrxHT5r8"
+      "ENjuT56buJGJXbrxHT5r8",
+      "FHU6rWTcKRzHe-i18i0YU"
     ],
     "relationshipIds": [
       "02Rf0D1riQbaw0LqkaD6r",
@@ -458,7 +459,7 @@
           "color": ""
         },
         "meta": {
-          "updateAt": 1780900874715,
+          "updateAt": 1782369451769,
           "createAt": 1780897281420
         }
       },
@@ -835,6 +836,55 @@
           "updateAt": 1782180008475,
           "createAt": 1781850563485
         }
+      },
+      "FHU6rWTcKRzHe-i18i0YU": {
+        "id": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "users",
+        "comment": "",
+        "columnIds": [
+          "UOAGlUGHEvlsEWw7gid2c",
+          "6sj-kTY7eIgLJKj364eRM",
+          "8wN_TBXUlE8f_K8HqhrvC",
+          "yciLWOz6SqAdeckOBJMjq",
+          "sEQ1o5h47EPMWdpPTF0ua",
+          "_4XDB35D7ClusPZ6Pgyug",
+          "Ro4wG6oyS68sfNfroAQDh",
+          "2gEyv0W2iXVBMAh793573",
+          "V3OTQe2inQqNAuNZqAgge",
+          "erCg_jdaO9-vBDiC52ZMp",
+          "MI271JjjG3Y-CbxRcNZqA",
+          "9--BWOujD9XbZYOoMUBva",
+          "JiM-eHg6S_HpC173gzbPB",
+          "tedo4N_RhDPMJRCKhVSe7"
+        ],
+        "seqColumnIds": [
+          "UOAGlUGHEvlsEWw7gid2c",
+          "6sj-kTY7eIgLJKj364eRM",
+          "8wN_TBXUlE8f_K8HqhrvC",
+          "yciLWOz6SqAdeckOBJMjq",
+          "sEQ1o5h47EPMWdpPTF0ua",
+          "_4XDB35D7ClusPZ6Pgyug",
+          "Ro4wG6oyS68sfNfroAQDh",
+          "2gEyv0W2iXVBMAh793573",
+          "V3OTQe2inQqNAuNZqAgge",
+          "erCg_jdaO9-vBDiC52ZMp",
+          "MI271JjjG3Y-CbxRcNZqA",
+          "9--BWOujD9XbZYOoMUBva",
+          "JiM-eHg6S_HpC173gzbPB",
+          "tedo4N_RhDPMJRCKhVSe7"
+        ],
+        "ui": {
+          "x": 75.8242,
+          "y": 2649.5926,
+          "zIndex": 1342,
+          "widthName": 60,
+          "widthComment": 60,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1782370117133,
+          "createAt": 1782369459548
+        }
       }
     },
     "tableColumnEntities": {
@@ -4817,6 +4867,286 @@
           "updateAt": 1782180005436,
           "createAt": 1782179998093
         }
+      },
+      "UOAGlUGHEvlsEWw7gid2c": {
+        "id": "UOAGlUGHEvlsEWw7gid2c",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369593610,
+          "createAt": 1782369587535
+        }
+      },
+      "8wN_TBXUlE8f_K8HqhrvC": {
+        "id": "8wN_TBXUlE8f_K8HqhrvC",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "password",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369612322,
+          "createAt": 1782369599647
+        }
+      },
+      "yciLWOz6SqAdeckOBJMjq": {
+        "id": "yciLWOz6SqAdeckOBJMjq",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "nickname",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369638437,
+          "createAt": 1782369626587
+        }
+      },
+      "sEQ1o5h47EPMWdpPTF0ua": {
+        "id": "sEQ1o5h47EPMWdpPTF0ua",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369649259,
+          "createAt": 1782369641497
+        }
+      },
+      "_4XDB35D7ClusPZ6Pgyug": {
+        "id": "_4XDB35D7ClusPZ6Pgyug",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "phone",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369664184,
+          "createAt": 1782369650444
+        }
+      },
+      "Ro4wG6oyS68sfNfroAQDh": {
+        "id": "Ro4wG6oyS68sfNfroAQDh",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "join_type",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "local",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369684294,
+          "createAt": 1782369671497
+        }
+      },
+      "2gEyv0W2iXVBMAh793573": {
+        "id": "2gEyv0W2iXVBMAh793573",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "marketing_agree_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "N",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 111,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369736777,
+          "createAt": 1782369720217
+        }
+      },
+      "erCg_jdaO9-vBDiC52ZMp": {
+        "id": "erCg_jdaO9-vBDiC52ZMp",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "status",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "active",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369740510,
+          "createAt": 1782369726066
+        }
+      },
+      "MI271JjjG3Y-CbxRcNZqA": {
+        "id": "MI271JjjG3Y-CbxRcNZqA",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "last_login_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369756194,
+          "createAt": 1782369748859
+        }
+      },
+      "9--BWOujD9XbZYOoMUBva": {
+        "id": "9--BWOujD9XbZYOoMUBva",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "N",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369771679,
+          "createAt": 1782369760739
+        }
+      },
+      "JiM-eHg6S_HpC173gzbPB": {
+        "id": "JiM-eHg6S_HpC173gzbPB",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369787026,
+          "createAt": 1782369775710
+        }
+      },
+      "tedo4N_RhDPMJRCKhVSe7": {
+        "id": "tedo4N_RhDPMJRCKhVSe7",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "updated_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 62,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369799261,
+          "createAt": 1782369792569
+        }
+      },
+      "V3OTQe2inQqNAuNZqAgge": {
+        "id": "V3OTQe2inQqNAuNZqAgge",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "prefer_area",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 61,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782369858861,
+          "createAt": 1782369824264
+        }
+      },
+      "6sj-kTY7eIgLJKj364eRM": {
+        "id": "6sj-kTY7eIgLJKj364eRM",
+        "tableId": "FHU6rWTcKRzHe-i18i0YU",
+        "name": "username",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782370115230,
+          "createAt": 1782370101663
+        }
       }
     },
     "relationshipEntities": {
@@ -5128,34 +5458,6 @@
           "createAt": 1781848938068
         }
       },
-      "cQM9kvVeDuTfV5icj_iVA": {
-        "id": "cQM9kvVeDuTfV5icj_iVA",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "mq_Abm-qvzZj2SGtCoZS-",
-          "columnIds": [
-            "bx5Tr0cBZAxO-YRoqzpMJ"
-          ],
-          "x": 2111.5319,
-          "y": 1991.5118,
-          "direction": 2
-        },
-        "end": {
-          "tableId": "dvwjXJtxKUI-09IaRj7WY",
-          "columnIds": [
-            "ssFG1sGE0EqF92GWWtj8s"
-          ],
-          "x": 2217.7891,
-          "y": 2065.4683,
-          "direction": 4
-        },
-        "meta": {
-          "updateAt": 1781850220744,
-          "createAt": 1781850220744
-        }
-      },
       "sbZTLI8vBOt-LGecshoWr": {
         "id": "sbZTLI8vBOt-LGecshoWr",
         "identification": false,
@@ -5240,34 +5542,6 @@
           "createAt": 1781850351428
         }
       },
-      "zBM5KzWf9oVhzq45fh0ka": {
-        "id": "zBM5KzWf9oVhzq45fh0ka",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "M6yRZXwLzg6Ly6UOQfoJ7",
-          "columnIds": [
-            "lMowl58nEv6pMZDKYAI9U"
-          ],
-          "x": 1995.5,
-          "y": 1846.8317,
-          "direction": 8
-        },
-        "end": {
-          "tableId": "ENjuT56buJGJXbrxHT5r8",
-          "columnIds": [
-            "-HdDr1oGCrMrGBkvRQAGz"
-          ],
-          "x": 1974.7923,
-          "y": 2190.2021,
-          "direction": 1
-        },
-        "meta": {
-          "updateAt": 1781850628971,
-          "createAt": 1781850628971
-        }
-      },
       "-J3Jmq-B-YJYcBO7CQ6-B": {
         "id": "-J3Jmq-B-YJYcBO7CQ6-B",
         "identification": false,

+ 3 - 0
public/img/ico--apple.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.05 12.04C17.02 9.19001 19.38 7.83001 19.48 7.76001C18.16 5.82001 16.09 5.56001 15.36 5.53001C13.61 5.35001 11.94 6.56001 11.05 6.56001C10.17 6.56001 8.8 5.55001 7.35 5.58001C5.44 5.61001 3.69 6.69001 2.7 8.40001C0.720003 11.84 2.19 16.93 4.12 19.72C5.06 21.08 6.19 22.61 7.67 22.56C9.09 22.5 9.63 21.64 11.35 21.64C13.06 21.64 13.55 22.56 15.05 22.53C16.58 22.5 17.55 21.14 18.49 19.77C19.57 18.19 20.02 16.66 20.04 16.58C20.01 16.56 17.06 15.43 17.02 12.03L17.05 12.04ZM14.28 3.78001C15.06 2.83001 15.59 1.51001 15.45 0.200012C14.32 0.250012 12.96 0.950012 12.15 1.90001C11.43 2.74001 10.79 4.08001 10.96 5.37001C12.22 5.47001 13.5 4.73001 14.28 3.78001Z" fill="white"/>
+</svg>

+ 3 - 0
public/img/ico--check.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.5 8.5L6.5 11.5L12.5 5" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
public/img/ico--footer1--on.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 9L12 2L21 9V20C21 20.5304 20.7893 21.0391 20.4142 21.4142C20.0391 21.7893 19.5304 22 19 22H5C4.46957 22 3.96086 21.7893 3.58579 21.4142C3.21071 21.0391 3 20.5304 3 20V9Z" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 22V12H15V22" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
public/img/ico--footer1.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 9L12 2L21 9V20C21 20.5304 20.7893 21.0391 20.4142 21.4142C20.0391 21.7893 19.5304 22 19 22H5C4.46957 22 3.96086 21.7893 3.58579 21.4142C3.21071 21.0391 3 20.5304 3 20V9Z" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 22V12H15V22" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
public/img/ico--footer2--on.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M16.24 7.76001L14.12 14.12L7.76001 16.24L9.88001 9.88001L16.24 7.76001Z" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
public/img/ico--footer2.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M16.24 7.75977L14.12 14.1198L7.75999 16.2398L9.87999 9.87977L16.24 7.75977Z" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
public/img/ico--footer3--on.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M15 2H9C8.44772 2 8 2.44772 8 3V5C8 5.55228 8.44772 6 9 6H15C15.5523 6 16 5.55228 16 5V3C16 2.44772 15.5523 2 15 2Z" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M16 4H18C18.5304 4 19.0391 4.21071 19.4142 4.58579C19.7893 4.96086 20 5.46957 20 6V20C20 20.5304 19.7893 21.0391 19.4142 21.4142C19.0391 21.7893 18.5304 22 18 22H6C5.46957 22 4.96086 21.7893 4.58579 21.4142C4.21071 21.0391 4 20.5304 4 20V6C4 5.46957 4.21071 4.96086 4.58579 4.58579C4.96086 4.21071 5.46957 4 6 4H8" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 14L11 16L15 12" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
public/img/ico--footer3.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M15 2H9C8.44772 2 8 2.44772 8 3V5C8 5.55228 8.44772 6 9 6H15C15.5523 6 16 5.55228 16 5V3C16 2.44772 15.5523 2 15 2Z" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M16 4H18C18.5304 4 19.0391 4.21071 19.4142 4.58579C19.7893 4.96086 20 5.46957 20 6V20C20 20.5304 19.7893 21.0391 19.4142 21.4142C19.0391 21.7893 18.5304 22 18 22H6C5.46957 22 4.96086 21.7893 4.58579 21.4142C4.21071 21.0391 4 20.5304 4 20V6C4 5.46957 4.21071 4.96086 4.58579 4.58579C4.96086 4.21071 5.46957 4 6 4H8" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 14L11 16L15 12" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
public/img/ico--footer4--on.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 13C14.7614 13 17 10.7614 17 8C17 5.23858 14.7614 3 12 3C9.23858 3 7 5.23858 7 8C7 10.7614 9.23858 13 12 13Z" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M20 21C20 18.8783 19.1571 16.8434 17.6569 15.3431C16.1566 13.8429 14.1217 13 12 13C9.87827 13 7.84344 13.8429 6.34315 15.3431C4.84285 16.8434 4 18.8783 4 21" stroke="#FC801C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
public/img/ico--footer4.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 13C14.7614 13 17 10.7614 17 8C17 5.23858 14.7614 3 12 3C9.23858 3 7 5.23858 7 8C7 10.7614 9.23858 13 12 13Z" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M20 21C20 18.8783 19.1571 16.8434 17.6569 15.3431C16.1566 13.8429 14.1217 13 12 13C9.87827 13 7.84344 13.8429 6.34315 15.3431C4.84285 16.8434 4 18.8783 4 21" stroke="#9AA0AC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
public/img/ico--join--complete--check.svg

@@ -0,0 +1,3 @@
+<svg width="29" height="22" viewBox="0 0 29 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 11.6797L3.82812 7.85156L10.2734 14.1797L24.4922 0L28.3203 3.82812L10.2734 21.8359L0 11.6797Z" fill="white"/>
+</svg>

+ 3 - 0
public/img/ico--kakao.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 3C6.5 3 2 6.6 2 11C2 13.8 3.9 16.3 6.7 17.7C6.5 18.4 6 20.4 5.9 20.8C5.9 21 6 21.2 6.3 21C6.6 20.8 9.3 19 10.5 18.1C11 18.2 11.5 18.2 12 18.2C17.5 18.2 22 14.6 22 10.2C22 5.8 17.5 3 12 3Z" fill="#3A1D1D"/>
+</svg>

+ 4 - 0
public/img/ico--logo.svg

@@ -0,0 +1,4 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="30" height="30" rx="9" fill="#FC801C"/>
+<path d="M15.5 8.83325V14.8541C15.5 15.3444 15.3546 15.8237 15.0822 16.2314C14.8098 16.6391 14.4226 16.9569 13.9696 17.1445C13.5166 17.3322 13.0181 17.3813 12.5372 17.2856C12.0563 17.19 11.6145 16.9538 11.2678 16.6071C10.9211 16.2604 10.685 15.8187 10.5893 15.3377C10.4936 14.8568 10.5427 14.3584 10.7304 13.9053C10.918 13.4523 11.2358 13.0651 11.6435 12.7927C12.0512 12.5203 12.5305 12.3749 13.0208 12.3749" stroke="white" stroke-width="1.41667" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 10 - 0
public/img/ico--naver.svg

@@ -0,0 +1,10 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_1139_27)">
+<path d="M16.273 12.845L7.376 0H0V24H7.726V11.156L16.624 24H24V0H16.273V12.845Z" fill="white"/>
+</g>
+<defs>
+<clipPath id="clip0_1139_27">
+<rect width="24" height="24" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 15 - 0
public/img/ico--pw--off.svg

@@ -0,0 +1,15 @@
+<svg width="20" height="12" viewBox="0 0 20 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_350_159)">
+<path d="M19.3463 2.90003C19.3479 4.23887 18.4418 5.56339 16.7375 6.58878C15.0462 7.60626 12.6669 8.25782 10.0032 8.26108C7.33954 8.26434 4.95865 7.6186 3.26491 6.60527C1.55816 5.58406 0.64874 4.26176 0.647101 2.92292" stroke="#B0B5BF" stroke-width="1.3" stroke-linecap="round"/>
+<path d="M3.50001 7.59998L2.70001 9.39998" stroke="#B0B5BF" stroke-width="1.3" stroke-linecap="round"/>
+<path d="M6.80002 8.09998L6.40002 9.99998" stroke="#B0B5BF" stroke-width="1.3" stroke-linecap="round"/>
+<path d="M10 8.29999V10.2" stroke="#B0B5BF" stroke-width="1.3" stroke-linecap="round"/>
+<path d="M13.2 8.09998L13.6 9.99998" stroke="#B0B5BF" stroke-width="1.3" stroke-linecap="round"/>
+<path d="M16.5 7.59998L17.3 9.39998" stroke="#B0B5BF" stroke-width="1.3" stroke-linecap="round"/>
+</g>
+<defs>
+<clipPath id="clip0_350_159">
+<rect width="20" height="12" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 4 - 0
public/img/ico--pw.svg

@@ -0,0 +1,4 @@
+<svg width="20" height="12" viewBox="0 0 20 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 0.650391C12.6637 0.650391 15.0438 1.29904 16.7363 2.31445C18.4418 3.33775 19.3496 4.66116 19.3496 6C19.3496 7.33884 18.4418 8.66225 16.7363 9.68555C15.0438 10.701 12.6637 11.3496 10 11.3496C7.33632 11.3496 4.95621 10.701 3.26367 9.68555C1.55817 8.66225 0.650391 7.33884 0.650391 6C0.650391 4.66116 1.55817 3.33775 3.26367 2.31445C4.95621 1.29904 7.33632 0.650391 10 0.650391Z" stroke="#B0B5BF" stroke-width="1.3"/>
+<circle cx="10" cy="6" r="3" fill="#B0B5BF"/>
+</svg>