Compare commits

...

8 Commits

Author SHA1 Message Date
2661ec376b 251212_register 2025-12-12 03:45:52 +09:00
c9f19e28ba 251212_login 2025-12-12 03:38:56 +09:00
manojuno
1eba2a0a49 251211_Header_Main_Update 2025-12-11 07:22:11 +09:00
e6d5f0436d 251209 2025-12-09 05:43:50 +09:00
0f8f0c2f51 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-12-09 03:17:53 +09:00
f7b2ba7f9c 2501209 2025-12-09 03:16:51 +09:00
c76c49d42f Merge branch 'main' of https://git.manos.kr/movemama/bobu 2025-07-15 11:24:25 +09:00
8fd4066d42 250715 2025-07-15 11:23:20 +09:00
46 changed files with 12374 additions and 2128 deletions

1
.gitignore vendored
View File

@@ -67,3 +67,4 @@ node_modules/
# dataconnect generated files # dataconnect generated files
.dataconnect .dataconnect
sendgrid.env

View File

@@ -1,10 +1,33 @@
<template> <template>
<section class="relative py-8 px-4 bg-transparent"> <section class="relative py-8 px-4 bg-transparent">
<Carousel v-bind="config" class="mx-auto max-w-screen-xl"> <!-- 🔹 CTA Overlay -->
<div
class="absolute z-20 bottom-20 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 pointer-events-auto"
>
<!-- Red button -->
<NuxtLink
to="/shop"
class="rounded-full bg-red-500 text-white px-8 py-2.5 text-sm sm:text-base font-semibold shadow-lg hover:bg-red-600 transition"
>
지금 예약하기
</NuxtLink>
<!-- NPay -->
<NuxtLink to="/shop">
<img
:src="SOCIAL_IMAGES.npay"
alt="NPay"
class="h-6 sm:h-7 object-contain drop-shadow rounded-full"
/>
</NuxtLink>
</div>
<!-- 🔹 Carousel Section -->
<Carousel v-bind="config" class="mx-auto max-w-screen-xl relative">
<Slide <Slide
v-for="(imgSrc, idx) in images" v-for="(imgSrc, idx) in images"
:key="idx" :key="idx"
class="relative h-64 sm:h-80 md:h-96 lg:h-[500px] overflow-hidden rounded-lg shadow-lg" class="relative h-80 sm:h-96 md:h-[420px] lg:h-[800px] overflow-hidden rounded-lg shadow-lg"
> >
<img <img
:src="imgSrc" :src="imgSrc"
@@ -13,7 +36,6 @@
/> />
</Slide> </Slide>
<!-- Add navigation arrows and pagination indicators -->
<template #addons> <template #addons>
<Navigation /> <Navigation />
<Pagination /> <Pagination />
@@ -21,54 +43,26 @@
</Carousel> </Carousel>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { Carousel, Slide, Navigation, Pagination } from 'vue3-carousel'; import { Carousel, Slide, Navigation, Pagination } from 'vue3-carousel';
import 'vue3-carousel/dist/carousel.css'; import 'vue3-carousel/dist/carousel.css';
import { MAIN_IMAGES } from '@/data/assets';
// Build a simple array of URLs from your MAIN_IMAGES object import { MAIN_IMAGES, SOCIAL_IMAGES } from '@/data/assets';
const images = computed<string[]>(() => Object.values(MAIN_IMAGES)); const images = computed<string[]>(() => Object.values(MAIN_IMAGES));
// Carousel configuration
const config = { const config = {
height: 500, // fixed height (px); adjust as needed (500px here) height: 500,
itemsToShow: 1, // always show one slide at a time itemsToShow: 1,
gap: 0, // no horizontal gap between slides wrapAround: true,
wrapAround: true, // loop infinitely autoplay: 4000,
mouseWheel: false, // disable mouse-wheel navigation
autoplay: 4000, // change slide every 4 seconds
pauseAutoplayOnHover: true, pauseAutoplayOnHover: true,
breakpoints: { breakpoints: {
1280: { itemsToShow: 1, height: 500 }, // ≥2xl 1280: { height: 500 },
1024: { itemsToShow: 1, height: 450 }, // ≥xl 1024: { height: 450 },
768: { itemsToShow: 1, height: 400 }, // ≥md 768: { height: 400 },
0: { itemsToShow: 1, height: 300 }, // mobile 0: { height: 300 },
}, },
}; };
</script> </script>
<style scoped>
/* Customize arrow & pagination colors via CSS variables */
.carousel {
--vc-nav-background: rgba(255, 255, 255, 0.8);
--vc-nav-border-radius: 100%;
--vc-nav-icon-size: 1.25rem;
--vc-pgn-background-color: rgba(255, 255, 255, 0.5);
--vc-pgn-active-color: rgba(255, 255, 255, 1);
}
/* Optional: subtle shadow behind navigation buttons */
.carousel__navigation-button {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
/* Ensure all images are rounded and cover the slide area */
img {
border-radius: 8px;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@@ -1,54 +1,120 @@
<template> <template>
<header class="relative isolate z-10 bg-white dark:bg-gray-900 shadow-sm"> <header class="relative isolate z-10 bg-white dark:bg-gray-900 shadow-sm">
<nav <nav
class="mx-auto flex max-w-7xl min-h-20 lg:min-h-32 items-end justify-between p-6 lg:px-8" class="mx-auto max-w-7xl min-h-20 lg:min-h-32 p-6 lg:px-8 grid grid-cols-3 items-center"
aria-label="Global Navigation" aria-label="Global Navigation"
> >
<!-- MobileSidebar Open Button --> <!-- Left: MobileSidebar Button -->
<div class="flex justify-start"> <div class="flex justify-start">
<!-- <button <button
type="button" type="button"
class="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700 dark:text-white" class="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700 dark:text-white"
@click="mobileMenuOpen = true" @click="mobileMenuOpen = true"
aria-label="Open main menu" aria-label="Open main menu"
> >
<Bars3Icon class="size-6" aria-hidden="true" /> <Bars3Icon class="size-6" aria-hidden="true" />
</button> --> </button>
</div> </div>
<!-- Desktop Navigation --> <!-- Center: Logo -->
<!-- Logo --> <div class="flex justify-center">
<div class="flex">
<NuxtLink to="/" class="-m-1.5 p-1.5" aria-label="Bobu Home"> <NuxtLink to="/" class="-m-1.5 p-1.5" aria-label="Bobu Home">
<span class="sr-only">Bobu</span> <span class="sr-only">Bobu</span>
<img <img
class="h-14 w-auto block dark:hidden transition-all duration-300" class="h-10 w-auto block dark:hidden transition-all duration-300"
:src="LOGOS.Red" :src="LOGOS.Red"
alt="Bobu Logo Light" alt="Bobu Logo Light"
/> />
<img <img
class="h-14 w-auto hidden dark:block transition-all duration-300" class="h-10 w-auto hidden dark:block transition-all duration-300"
:src="LOGOS.White" :src="LOGOS.White"
alt="Bobu Logo Dark" alt="Bobu Logo Dark"
/> />
</NuxtLink> </NuxtLink>
</div> </div>
<!-- <PopoverGroup class="hidden lg:flex lg:gap-x-12">
<NuxtLink
v-for="item in navItems"
:key="item.name"
:to="item.href"
class="text-sm font-semibold leading-6 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
>
{{ item.name }}
</NuxtLink>
</PopoverGroup> -->
<!-- Desktop HeaderActions --> <!-- Right: Buttons -->
<div class="flex justify-end"> <div class="flex justify-end items-center">
<!-- <HeaderActions /> --> <!-- Mobile: Icon Buttons -->
<div
class="flex items-center gap-x-4 lg:hidden text-gray-700 dark:text-white"
>
<!-- If NOT logged in Login Icon -->
<NuxtLink
v-if="!userStore.userLoggedIn"
to="/login"
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
<font-awesome-icon
:icon="['fas', 'circle-user']"
class="text-2xl"
/>
</NuxtLink>
<!-- If logged in My Page Icon -->
<NuxtLink
v-else
to="/mypage"
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
<font-awesome-icon
:icon="['fas', 'circle-user']"
class="text-2xl"
/>
</NuxtLink>
<!-- Cart Icon -->
<NuxtLink
to="/shop"
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
<font-awesome-icon
:icon="['fas', 'shopping-cart']"
class="text-xl"
/>
</NuxtLink>
</div>
<!-- 🖥 Desktop: Text Buttons -->
<div class="hidden lg:flex items-center gap-x-3">
<!-- 로그인 / 회원가입 (red border) -->
<NuxtLink
v-if="!userStore.userLoggedIn"
to="/login"
class="rounded-full border border-red-500 text-red-500 bg-white px-3 py-1.5 text-xs font-semibold hover:bg-red-50 dark:hover:bg-red-900/20 transition"
>
로그인 / 회원가입
</NuxtLink>
<!-- 마이페이지 (red border) -->
<NuxtLink
v-else
to="/mypage"
class="rounded-full border border-red-500 text-red-500 bg-white px-3 py-1.5 text-xs font-semibold hover:bg-red-50 dark:hover:bg-blue-900/20 transition"
>
마이페이지
</NuxtLink>
<!-- 장바구니 (amber border) -->
<NuxtLink
to="/shop"
class="rounded-full border border-amber-500 text-amber-500 bg-white px-3 py-1.5 text-xs font-semibold hover:bg-amber-50 dark:hover:bg-amber-900/20 transition"
>
장바구니
</NuxtLink>
<!-- 로그아웃 (gray border, can be red if you prefer) -->
<button
v-if="userStore.userLoggedIn"
@click="userStore.signOut()"
class="rounded-full border border-gray-400 text-gray-600 bg-white px-3 py-1.5 text-xs font-semibold hover:bg-gray-100 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-800 transition"
>
로그아웃
</button>
</div>
</div> </div>
</nav> </nav>
<!-- MobileSidebar --> <!-- MobileSidebar -->
<Dialog <Dialog
@@ -117,6 +183,19 @@
</div> </div>
<div class="py-6 space-y-2"> <div class="py-6 space-y-2">
<!-- Logout -->
<button
v-if="userStore.userLoggedIn"
@click="
() => {
userStore.signOut();
mobileMenuOpen = false;
}
"
class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-500 dark:text-gray-300 hover:text-gray-50 dark:hover:text-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
로그아웃
</button>
<!-- Not logged in: show 로그인/회원가입 --> <!-- Not logged in: show 로그인/회원가입 -->
<NuxtLink <NuxtLink
v-if="!userStore.userLoggedIn" v-if="!userStore.userLoggedIn"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,20 +8,25 @@
<p <p
class="text-center text-2xl font-extrabold text-indigo-500 dark:text-indigo-400" class="text-center text-2xl font-extrabold text-indigo-500 dark:text-indigo-400"
> >
와디즈 펀딩 참여자 발송 페이지 가벼운 채비로 떠나는 정선 백패킹 포레스트
</p> </p>
<figure class="mt-10"> <figure class="mt-10">
<blockquote <blockquote
class="text-center text-lg font-normal text-gray-900 dark:text-gray-100" class="text-center text-lg font-normal text-gray-900 dark:text-gray-100"
> >
<p>안녕하세요, 메이커 보부입니다!</p> <p>안녕하세요. 주식회사 보부입니다.</p>
<p>보부의 여정에 마음을 더해주셔서 감사합니다.</p> <p>정선 백패킹 포레스트 예약 시스템입니다.</p>
<p>여러분의 따뜻한 응원 덕분에,</p> <br />
<p>정선의 자연속 1 2일의 쉼을 준비할 있었습니다.</p> <p>
<p>자연에 기대어, 잠시 천천히 머물러보는 시간을 선물해드릴게요.</p> 원활한 운영을 위해 아래 '참여자 정보 입력하기' 클릭 문항을
<p class="mt-4 font-semibold"> 작성해주시기 바랍니다.
보부와 함께할 하루, 이제 예약으로 이어집니다."
</p> </p>
<p>
정성스러운 답변은 나은 경험을 준비하는 도움이 됩니다.
</p>
<br />
<p>감사합니다.</p>
</blockquote> </blockquote>
</figure> </figure>
</div> </div>

View File

@@ -0,0 +1,44 @@
<template>
<section class="bg-gray-50 dark:bg-gray-900 py-16 sm:py-20">
<div class="mx-auto max-w-4xl px-4">
<!-- Title -->
<div class="text-center">
<h2
class="text-sm font-semibold tracking-[0.2em] text-gray-500 dark:text-gray-400 uppercase"
>
BOBU Nomad Social Club
</h2>
<p
class="mt-4 text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 leading-snug"
>
자연 , 자유롭지만 차분한 공간.
</p>
<p
class="mt-2 text-base sm:text-lg font-medium text-gray-600 dark:text-gray-300 leading-relaxed"
>
정선의 유일한 공유 오피스에서 일과 쉼의 균형을 찾아보세요.
</p>
</div>
<!-- Body text -->
<div
class="mt-8 text-center sm:text-left text-gray-700 dark:text-gray-300 leading-relaxed"
>
<p class="whitespace-pre-line">
멀리 가지 않아도 곁에 있는 자연. 낮에는 머물고, 저녁엔 떠나고, 밤에는
자연 속에서 쉬어가는 일과 쉼의 순환. 보부는 사이의 연결을
제공합니다.
</p>
</div>
</div>
</section>
</template>
<script setup lang="ts">
// Presentational only no script logic needed for now
</script>
<style scoped>
/* Tailwind handles most styling; no extra CSS for now */
</style>

View File

@@ -1,76 +1,84 @@
<template> <template>
<section class="bg-white dark:bg-gray-900 py-16 sm:py-24"> <section class="bg-gray-50 dark:bg-gray-900 py-16 sm:py-24">
<div class="mx-auto max-w-3xl px-4 text-center"> <div class="mx-auto max-w-3xl px-4 text-center">
<!-- Title --> <!-- Title -->
<h2 <h2
class="text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl" class="text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl"
> >
노마드소셜클럽 보부<br /> 노마드소셜클럽 보부<br />
<span class="text-xl font-medium text-gray-600 dark:text-gray-300" <span class="text-xl font-medium text-gray-600 dark:text-gray-300">
>Nomad Social Club BOBU</span Nomad Social Club BOBU
> </span>
</h2> </h2>
</div> </div>
<!-- Image Grid --> <!-- Image Grid -->
<div <div
class="mt-12 mx-auto grid max-w-4xl grid-cols-2 gap-6 px-4 sm:grid-cols-4" class="mt-10 mx-auto grid max-w-3xl grid-cols-2 sm:grid-cols-3 gap-4 px-4"
>
<div
v-for="(imgSrc, idx) in images"
:key="idx"
class="flex justify-center"
> >
<img <img
v-for="(imgSrc, idx) in images"
:key="idx"
:src="imgSrc" :src="imgSrc"
alt="About image" alt="About image"
class="h-32 w-32 rounded-full object-cover shadow-md" class="aspect-square w-full rounded-xl object-cover shadow-md dark:shadow-none dark:bg-gray-800"
/> />
</div> </div>
</div>
<!-- Description -->
<div
class="mt-12 mx-auto max-w-2xl px-4 text-center text-gray-700 dark:text-gray-300"
>
<!-- Description Text --> <!-- Description Text -->
<div <div
class="mt-12 mx-auto max-w-2xl px-4 text-center text-gray-700 dark:text-gray-300" class="mt-12 mx-auto max-w-2xl px-4 text-center text-gray-700 dark:text-gray-300"
> >
<p class="whitespace-pre-line leading-relaxed"> <!-- Lead / Highlight -->
강원도 정선군 정선읍 봉양리, 작은 마을에 자리한 워케이션 오피스입니다. <p
다양한 방식으로 일하는 창작자들을 위해 공유 오피스, 로컬 콘텐츠, class="text-lg sm:text-xl font-semibold leading-relaxed text-gray-900 dark:text-gray-100"
투어(워크숍) 등을 운영하며 일과 일상, 여행이 자연스럽게 이어지는 유연한 >
삶의 방식을 제안합니다. 자연 , 차분하게 몰입하는 공간.<br />
정선의 유일한 공유 오피스, WORK & STOP.
</p>
<!-- Body paragraphs -->
<div class="mt-6 space-y-4 text-sm sm:text-base leading-relaxed">
<p>
일과 쉼이 자연스럽게 이어지는 공간을 만들었습니다. 틀에 갇힌
사무실이 아닌, 자유롭지만 차분한 분위기 속에서 몸과 마음이
편안해지고 집중이 깊어집니다.
</p>
<p>
성과를 위한 몰입의 순간에도 쉼의 가치를 잃지 않도록. 여유와 놀이가
공존하는 공간에서 감정과 에너지를 부드럽게 환기하고, 나를 위한
충전의 시간을 마련하세요.
</p>
<p>
정선의 맑은 공기와 자연이 함께하는 이곳, 보부에서 오늘보다 단단한
내일을 준비하는 당신을 기다립니다. 정선까지 당신에게, 쉬어갈
호흡을 선물하세요.
</p> </p>
</div> </div>
</div>
</div>
<!-- Link Buttons with Logos --> <!-- Link Buttons -->
<div class="mt-8 flex justify-center space-x-6 px-4"> <div class="mt-10 flex justify-center px-4">
<!-- 네이버플레이스 --> <!-- 네이버 예약 (Place or Reservation) -->
<a <a
:href="socialLinks.naver" :href="socialLinks.naver"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="flex items-center space-x-2 px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition" class="flex items-center space-x-2 px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition"
> >
<img <img
:src="socialImages.naver" :src="socialImages.naver"
alt="네이버플레이스 로고" alt="네이버 예약 로고"
class="h-6 w-6 object-contain" class="h-6 w-6 object-contain"
/> />
<span>네이버플레이스</span> <span>공유 오피스 예약하기</span>
</a>
<!-- 인스타그램 -->
<a
:href="socialLinks.instagram"
target="_blank"
rel="noopener noreferrer"
class="flex items-center space-x-2 px-4 py-2 bg-pink-500 text-white font-semibold rounded-lg hover:bg-pink-600 transition"
>
<img
:src="socialImages.instagram"
alt="인스타그램 로고"
class="h-6 w-6 object-contain"
/>
<span>인스타그램</span>
</a> </a>
</div> </div>
</section> </section>
@@ -81,14 +89,11 @@ import { computed } from 'vue';
import { MAIN2_IMAGES, SOCIAL_IMAGES } from '@/data/assets'; import { MAIN2_IMAGES, SOCIAL_IMAGES } from '@/data/assets';
import { SOCIAL_LINKS } from '@/data/config'; import { SOCIAL_LINKS } from '@/data/config';
// 1) MAIN2_IMAGES → 배열 const images = [MAIN2_IMAGES.main1, MAIN2_IMAGES.main2, MAIN2_IMAGES.main3];
const images = computed<string[]>(() => Object.values(MAIN2_IMAGES));
// 2) SOCIAL_IMAGES (로고)와 SOCIAL_LINKS (URL) 노출
const socialImages = SOCIAL_IMAGES; const socialImages = SOCIAL_IMAGES;
const socialLinks = SOCIAL_LINKS; const socialLinks = SOCIAL_LINKS;
</script> </script>
<style scoped> <style scoped>
/* Tailwind 유틸리티만으로 충분하므로 추가 CSS 불필요 */ /* Tailwind only */
</style> </style>

View File

@@ -1,97 +1,77 @@
<template> <template>
<section class="bg-white dark:bg-gray-900 py-24 sm:py-32"> <section
<div class="mx-auto max-w-7xl px-6 lg:px-8"> class="bg-white pt-20 pb-16 sm:pt-24 sm:pb-24 xl:pb-32 dark:bg-gray-900"
<div
class="mx-auto grid max-w-2xl grid-cols-1 items-start gap-x-8 gap-y-16 sm:gap-y-24 lg:mx-0 lg:max-w-none lg:grid-cols-2"
> >
<!-- Quote + Image Card -->
<div class="lg:pr-4">
<div <div
class="relative overflow-hidden rounded-3xl bg-gray-900 px-6 pt-64 pb-9 shadow-2xl sm:px-12 lg:max-w-lg lg:px-8 lg:pb-8 xl:px-10 xl:pb-10" class="pb-16 sm:pb-20 xl:pb-0 dark:bg-gray-800/60 dark:outline dark:outline-white/5"
>
<div
class="mx-auto flex max-w-7xl flex-col items-center gap-x-8 gap-y-10 px-6 sm:gap-y-8 lg:px-8 xl:flex-row xl:items-stretch"
>
<!-- Left: Image -->
<div class="-mt-8 w-full max-w-2xl xl:-mb-8 xl:w-96 xl:flex-none">
<div
class="relative h-56 sm:h-72 md:h-80 lg:h-96 xl:h-full aspect-auto after:absolute after:inset-0 after:rounded-2xl after:inset-ring after:inset-ring-black/5 dark:after:inset-ring-white/15 md:-mx-8 xl:mx-0"
> >
<img <img
class="absolute inset-0 w-full h-full object-cover brightness-105 saturate-100" class="absolute inset-0 size-full rounded-2xl bg-gray-200 object-cover shadow-2xl dark:bg-gray-700 dark:shadow-none"
src="/assets/img/shoot.jpg" :src="MAIN2_IMAGES.main3"
alt="Bobu team visual" alt="BOBU Nomad Social Club"
/> />
<!-- dark overlay lighten in light mode --> </div>
<div class="absolute inset-0 bg-black/40 dark:bg-black/60" /> </div>
<figure class="relative isolate"> <!-- Right: Text block -->
<blockquote class="mt-6 text-xl font-semibold text-white"> <div
class="w-full max-w-2xl xl:max-w-none xl:flex-auto xl:px-16 xl:py-20"
>
<figure class="relative isolate pt-8 sm:pt-12">
<!-- Decorative quote SVG -->
<svg
viewBox="0 0 162 128"
fill="none"
aria-hidden="true"
class="absolute top-0 left-0 -z-10 h-24 stroke-gray-300 dark:stroke-white/15"
>
<path
id="about-bobu-quote-shape"
d="M65.5697 118.507L65.8918 118.89C68.9503 116.314 71.367 113.253 73.1386 109.71C74.9162 106.155 75.8027 102.28 75.8027 98.0919C75.8027 94.237 75.16 90.6155 73.8708 87.2314C72.5851 83.8565 70.8137 80.9533 68.553 78.5292C66.4529 76.1079 63.9476 74.2482 61.0407 72.9536C58.2795 71.4949 55.276 70.767 52.0386 70.767C48.9935 70.767 46.4686 71.1668 44.4872 71.9924L44.4799 71.9955L44.4726 71.9988C42.7101 72.7999 41.1035 73.6831 39.6544 74.6492C38.2407 75.5916 36.8279 76.455 35.4159 77.2394L35.4047 77.2457L35.3938 77.2525C34.2318 77.9787 32.6713 78.3634 30.6736 78.3634C29.0405 78.3634 27.5131 77.2868 26.1274 74.8257C24.7483 72.2185 24.0519 69.2166 24.0519 65.8071C24.0519 60.0311 25.3782 54.4081 28.0373 48.9335C30.703 43.4454 34.3114 38.345 38.8667 33.6325C43.5812 28.761 49.0045 24.5159 55.1389 20.8979C60.1667 18.0071 65.4966 15.6179 71.1291 13.7305C73.8626 12.8145 75.8027 10.2968 75.8027 7.38572C75.8027 3.6497 72.6341 0.62247 68.8814 1.1527C61.1635 2.2432 53.7398 4.41426 46.6119 7.66522C37.5369 11.6459 29.5729 17.0612 22.7236 23.9105C16.0322 30.6019 10.618 38.4859 6.47981 47.558L6.47976 47.558L6.47682 47.5647C2.4901 56.6544 0.5 66.6148 0.5 77.4391C0.5 84.2996 1.61702 90.7679 3.85425 96.8404L3.8558 96.8445C6.08991 102.749 9.12394 108.02 12.959 112.654L12.959 112.654L12.9646 112.661C16.8027 117.138 21.2829 120.739 26.4034 123.459L26.4033 123.459L26.4144 123.465C31.5505 126.033 37.0873 127.316 43.0178 127.316C47.5035 127.316 51.6783 126.595 55.5376 125.148L55.5376 125.148L55.5477 125.144C59.5516 123.542 63.0052 121.456 65.9019 118.881L65.5697 118.507Z"
/>
<use href="#about-bobu-quote-shape" x="86" />
</svg>
<!-- Top: 3 키워드 -->
<blockquote
class="text-lg/8 sm:text-xl/8 font-semibold text-gray-900 dark:text-gray-100"
>
<p> <p>
{{ $t('about.quote') }} 노마드 (생활인구)<br />
소셜 (관계 맺음)<br />
클럽 (공간)
</p> </p>
</blockquote> </blockquote>
<figcaption class="mt-6 text-sm text-gray-300"> <!-- Middle: 문단 -->
<strong class="font-semibold text-white">CEO,</strong>
{{ $t('about.ceo') }}
</figcaption>
</figure>
</div>
</div>
<!-- Text Block -->
<div>
<div class="text-base text-gray-700 dark:text-gray-300 lg:max-w-lg">
<p <p
class="text-base font-semibold text-indigo-600 dark:text-indigo-400" class="mt-6 text-base/7 sm:text-lg/8 text-gray-800 dark:text-gray-200"
> >
{{ $t('about.sectionLabel') }} 세가지 주요 키워드로 다양한 사람들이 안전하고 즐거운 관계를 맺을
있도록, 노마드소셜클럽 보부(BOBU) 지역 문화를 기반으로
활동하는 로컬 브랜드입니다.
</p> </p>
<h1 <!-- Bottom: 번째 문단 -->
class="mt-2 text-4xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-5xl" <p
class="mt-4 text-base/7 sm:text-lg/8 text-gray-800 dark:text-gray-200"
> >
{{ $t('about.sectionTitle') }} 정선을 찾는 생활인구와 노마드 워커들을 위한 (WORK) (STOP)
</h1> 공간을 운영하며 로컬을 경험하고 즐길 있는 콘텐츠를 제작합니다.
</p>
<div class="max-w-xl"> </figure>
<p class="mt-6">{{ $t('about.mission') }}</p>
<p class="mt-8">{{ $t('about.vision') }}</p>
<p class="mt-8">{{ $t('about.portfolio') }}</p>
</div>
</div>
<!-- Stats -->
<dl
class="mt-10 grid grid-cols-2 gap-8 border-t border-gray-900/10 dark:border-gray-100/10 pt-10 sm:grid-cols-4"
>
<div v-for="(stat, statIdx) in stats" :key="statIdx">
<dt
class="text-sm font-semibold text-gray-600 dark:text-gray-300"
>
{{ stat.label }}
</dt>
<dd
class="mt-2 text-3xl font-bold tracking-tight text-gray-900 dark:text-white"
>
{{ stat.value }}
</dd>
</div>
</dl>
<!-- CTA -->
<div class="mt-10 flex">
<NuxtLink
to="/projects"
class="text-base font-semibold text-indigo-600 dark:text-indigo-400 hover:underline"
>
{{ $t('about.cta') }} <span aria-hidden="true">&rarr;</span>
</NuxtLink>
</div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const stats = [ import { MAIN2_IMAGES } from '@/data/assets'; // Presentational only no logic needed
{ label: 'Founded', value: '2018' },
{ label: 'Projects Delivered', value: '120+' },
{ label: 'Technologies', value: 'FILM / XR / 3D' },
{ label: 'Countries', value: '3' },
];
</script> </script>
<style scoped> <style scoped>
/* Tailwind utilities handle all dark/light styling */ /* Tailwind handles visuals */
</style> </style>

View File

@@ -1,94 +1,107 @@
<template> <template>
<section class="bg-white dark:bg-gray-900 py-24 sm:py-32"> <section class="bg-white dark:bg-gray-900 py-16 sm:py-24">
<div class="mx-auto max-w-7xl px-6 lg:px-8"> <div class="mx-auto max-w-4xl px-4">
<div class="mx-auto max-w-2xl text-center"> <!-- Heading -->
<h2 <header class="text-center">
class="text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl"
>
지난 프로그램 또는 후기
</h2>
<p class="mt-2 text-lg/8 text-gray-600 dark:text-gray-400">
Learn how to grow your business with our expert advice.
</p>
</div>
<div
class="mx-auto mt-16 grid max-w-2xl auto-rows-fr grid-cols-1 gap-8 sm:mt-20 lg:mx-0 lg:max-w-none lg:grid-cols-3"
>
<article
v-for="post in posts"
:key="post.id"
class="relative flex flex-col justify-end overflow-hidden rounded-2xl"
>
<!-- Background image -->
<img
:src="post.imageUrl"
alt=""
class="absolute inset-0 w-full h-full object-cover z-0"
/>
<!-- 20% black overlay in light, 50% in dark -->
<div class="absolute inset-0 bg-black/20 dark:bg-black/50 z-10"></div>
<!-- Gradient on top of overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent z-20"
></div>
<!-- Content -->
<div class="relative z-30 px-8 pb-8 pt-48 sm:pt-32 lg:pt-48">
<div
class="flex flex-wrap items-center gap-y-1 overflow-hidden text-sm/6 text-gray-300"
>
<time :datetime="post.datetime" class="mr-8">{{
post.date
}}</time>
<div class="-ml-4 flex items-center gap-x-4">
<svg
viewBox="0 0 2 2"
class="-ml-0.5 h-2 w-2 flex-none fill-white/50"
>
<circle cx="1" cy="1" r="1" />
</svg>
</div>
</div>
<h3 class="mt-3 text-lg/6 font-semibold text-white">
<a :href="post.href" class="relative block">
<span class="absolute inset-0" />
{{ post.title }}
</a>
</h3>
<p <p
class="mt-2 text-sm text-gray-200 dark:text-gray-300 line-clamp-2" class="text-sm font-semibold tracking-[0.15em] text-gray-500 dark:text-gray-400 uppercase"
> >
{{ post.description }} 일을 마치고, 자연으로 떠나는 시간
</p>
<h2
class="mt-3 text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100"
>
백패킹 장비 대여
</h2>
</header>
<!-- 🌄 Image Grid (placed *after* content) -->
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-4">
<img
v-for="(img, i) in imageList"
:key="i"
:src="img"
class="aspect-square w-full rounded-xl object-cover shadow-md dark:shadow-none dark:bg-gray-800"
alt="Backpacking image"
/>
</div>
<!-- Body text -->
<div
class="mt-8 space-y-4 text-sm sm:text-base leading-relaxed text-gray-700 dark:text-gray-300"
>
<p>
보부에서는 정선의 자연을 온전히 느낄 있도록 백패킹 장비 대여
서비스를 함께 운영하고 있습니다.
</p>
<p>
가벼운 마음으로 떠날 있게 텐트부터 침낭, 랜턴까지 필요한 장비를
준비해드려요.
</p>
<p>
일의 여운을 자연이 부드럽게 감싸주며, 오늘의 나를 위한 쉼은 보부에서
그렇게 완성됩니다.
</p> </p>
</div> </div>
</article>
<!-- Gear list -->
<div class="mt-10 rounded-2xl bg-gray-50 px-5 py-6 dark:bg-gray-800/70">
<p
class="text-xs font-semibold tracking-wide text-gray-600 dark:text-gray-300"
>
기본 대여 장비
</p>
<ul
class="mt-3 flex flex-wrap gap-2 text-xs sm:text-sm text-gray-700 dark:text-gray-200"
>
<li class="pill">배낭</li>
<li class="pill">텐트</li>
<li class="pill">자충매트</li>
<li class="pill">침낭</li>
<li class="pill">의자</li>
<li class="pill">테이블</li>
<li class="pill">D팩</li>
<li class="pill">랜턴</li>
</ul>
</div>
<!-- CTA -->
<div class="mt-10 flex flex-col items-center gap-4 text-center">
<p class="text-sm sm:text-base text-gray-700 dark:text-gray-300">
<strong
>오롯이 나에게 집중할 있는 시간, 정선의 자연을
경험하세요.</strong
>
</p>
<NuxtLink
to="/backpacking"
class="flex items-center space-x-2 px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition"
>
<img
:src="SOCIAL_IMAGES.naver"
alt="백패킹 예약 아이콘"
class="h-6 w-6 object-contain"
/>
<span>백패킹 예약하기</span>
</NuxtLink>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup lang="ts">
const posts = [ import { MAIN2_IMAGES, SOCIAL_IMAGES } from '@/data/assets';
{
id: 1, const imageList = [
title: '와디즈', MAIN2_IMAGES.main1,
href: '#', MAIN2_IMAGES.main2,
description: MAIN2_IMAGES.main3,
'Illo sint voluptas. Error voluptates culpa eligendi. Hic vel totam vitae illo. Non aliquid explicabo necessitatibus unde. Sed exercitationem placeat consectetur nulla deserunt vel. Iusto corrupti dicta.', MAIN2_IMAGES.main4,
imageUrl:
'https://images.unsplash.com/photo-1496128858413-b36217c2ce36?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3603&q=80',
date: 'Mar 16, 2020',
datetime: '2020-03-16',
author: {
name: 'Michael Foster',
imageUrl:
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
},
]; ];
</script> </script>
<style scoped> <style scoped>
/* .pill {
Ensure you have the Tailwind line-clamp plugin enabled for “line-clamp-2”. @apply rounded-full bg-white px-3 py-1 shadow-sm dark:bg-gray-900/60;
*/ }
</style> </style>

View File

@@ -0,0 +1,92 @@
<template>
<section class="bg-white dark:bg-gray-900 py-24 sm:py-32">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="mx-auto max-w-2xl text-center">
<h2
class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl"
>
지난 프로그램 또는 후기
</h2>
<p class="mt-2 text-lg/8 text-gray-600 dark:text-gray-400"></p>
</div>
<div
class="mx-auto mt-16 grid max-w-2xl auto-rows-fr grid-cols-1 gap-8 sm:mt-20 lg:mx-0 lg:max-w-none lg:grid-cols-3"
>
<article
v-for="post in posts"
:key="post.id"
class="relative flex flex-col justify-end overflow-hidden rounded-2xl"
>
<!-- Background image -->
<img
:src="post.imageUrl"
alt=""
class="absolute inset-0 w-full h-full object-cover z-0"
/>
<!-- 20% black overlay in light, 50% in dark -->
<div class="absolute inset-0 bg-black/20 dark:bg-black/50 z-10"></div>
<!-- Gradient on top of overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent z-20"
></div>
<!-- Content -->
<div class="relative z-30 px-8 pb-8 pt-48 sm:pt-32 lg:pt-48">
<div
class="flex flex-wrap items-center gap-y-1 overflow-hidden text-sm/6 text-gray-300"
>
<time :datetime="post.datetime" class="mr-8">{{
post.date
}}</time>
<div class="-ml-4 flex items-center gap-x-4">
<svg
viewBox="0 0 2 2"
class="-ml-0.5 h-2 w-2 flex-none fill-white/50"
>
<circle cx="1" cy="1" r="1" />
</svg>
</div>
</div>
<h3 class="mt-3 text-lg/6 font-semibold text-white">
<a :href="post.href" class="relative block">
<span class="absolute inset-0" />
{{ post.title }}
</a>
</h3>
<p
class="mt-2 text-sm text-gray-200 dark:text-gray-300 line-clamp-2"
>
{{ post.description }}
</p>
</div>
</article>
</div>
</div>
</section>
</template>
<script setup>
const posts = [
{
id: 1,
title: '와디즈',
href: '#',
description:
'Illo sint voluptas. Error voluptates culpa eligendi. Hic vel totam vitae illo. Non aliquid explicabo necessitatibus unde. Sed exercitationem placeat consectetur nulla deserunt vel. Iusto corrupti dicta.',
imageUrl:
'https://images.unsplash.com/photo-1496128858413-b36217c2ce36?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3603&q=80',
date: 'Mar 16, 2020',
datetime: '2020-03-16',
author: {
name: 'Michael Foster',
imageUrl:
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
},
];
</script>
<style scoped>
/*
Ensure you have the Tailwind line-clamp plugin enabled for “line-clamp-2”.
*/
</style>

View File

@@ -0,0 +1,67 @@
<template>
<section class="relative px-6 py-28 sm:px-10 sm:py-36 lg:px-16 bg-gray-900">
<!-- Background image -->
<div class="absolute inset-0 overflow-hidden">
<img
:src="bgImage"
alt="백패킹 커뮤니티 프로그램"
class="size-full object-cover"
/>
</div>
<!-- Dark overlay -->
<div aria-hidden="true" class="absolute inset-0 bg-black/50"></div>
<!-- Content -->
<div
class="relative mx-auto flex max-w-3xl flex-col items-center text-center"
>
<!-- Small heading -->
<p
class="text-sm font-semibold tracking-[0.15em] text-gray-200 uppercase"
>
숨과 사이, 서로의 배경이 되어 함께하는 느슨한 연결
</p>
<!-- Main heading -->
<h2 class="mt-4 text-2xl font-bold tracking-tight text-white sm:text-3xl">
백패킹 커뮤니티 프로그램
</h2>
<!-- Body text -->
<p class="mt-5 text-base sm:text-lg text-gray-50 leading-relaxed">
보부에서는 혼자 떠나는 쉼도 아름답지만,<br />
때로는 조용히 함께 머무르는 순간이 깊은 울림을 준다고 생각합니다.<br />
<br />
그래서 우리는 함께 걷고, 함께 쉬는 백패킹 커뮤니티 프로그램을
운영합니다.<br />
마음이 잠시 쉬어갈 자리를 찾는 사람들에게,<br />
자연 속에서 각자의 속도로 머무르며 서로의 온도를 확인하는 시간입니다.
</p>
<!-- Closing line -->
<p class="mt-10 text-base sm:text-lg text-gray-100">
정선의 밤공기를 마시며, 조용한 동행을 함께하세요.
</p>
<!-- CTA -->
<div class="mt-12">
<NuxtLink
to="/backpacking-community"
class="block w-full rounded-md bg-white px-8 py-3 text-base font-semibold text-gray-900 hover:bg-gray-100 sm:w-auto"
>
백패킹 커뮤니티 프로그램 참여하기
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { MAIN2_IMAGES } from '@/data/assets';
// Use any image you like here (main1~main4)
const bgImage = MAIN2_IMAGES.main4;
</script>
<style scoped>
/* Tailwind handles most of it */
</style>

View File

@@ -0,0 +1,52 @@
<template>
<section class="bg-white dark:bg-gray-900 py-20 sm:py-28">
<div class="mx-auto max-w-4xl px-6 text-center">
<!-- Small intro text -->
<p
class="text-sm font-semibold tracking-[0.15em] text-gray-500 dark:text-gray-400 uppercase"
>
일과 사이, 일에 온도를 맞추고 쉼에 숨을 싣는 시간
</p>
<!-- Main Heading -->
<h2
class="mt-3 text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100"
>
워케이션 프로그램
</h2>
<!-- Body Paragraph -->
<div
class="mt-6 text-base sm:text-lg leading-relaxed text-gray-700 dark:text-gray-300 space-y-4"
>
<p>자연 속에서 집중의 리듬을 회복하고 쉼의 온기를 되찾는 시간.</p>
<p>
도시의 속도에서 잠시 벗어나 나만의 속도로 일하고, 쉬고, 머무르세요.
낮에는 책상 위의 나로, 밤에는 자연 속의 나로.<br
class="hidden sm:block"
/>
하루 안에서 일과 쉼이 조용히 순환합니다.
</p>
<p>조용하지만 분명한 변화를 느껴보세요.</p>
</div>
<!-- CTA Button -->
<div class="mt-10">
<NuxtLink
to="/workation"
class="inline-flex items-center space-x-2 px-6 py-3 bg-green-700 text-white font-semibold rounded-lg hover:bg-green-600 transition shadow-md dark:shadow-none"
>
<span>워케이션 프로그램 문의하기</span>
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup lang="ts">
// No images needed
</script>
<style scoped>
/* Tailwind handles visuals */
</style>

View File

@@ -0,0 +1,721 @@
<template>
<div
class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-12"
>
<div
class="w-full max-w-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-sm px-6 py-8 sm:px-8"
>
<!-- Title -->
<h1 class="text-center text-2xl font-bold text-gray-900 dark:text-white">
회원가입
</h1>
<!-- STEP 1: 약관 동의 -->
<section v-if="!termsProceed" class="mt-8 space-y-6">
<!-- 이용약관 -->
<div
class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 max-h-60 overflow-y-auto p-4"
>
<app-termsof-use />
</div>
<!-- 이용약관 동의 -->
<div class="space-y-3 text-sm text-gray-800 dark:text-gray-200">
<p>
<strong class="font-semibold text-red-500">[필수]</strong>
&nbsp;위의 이용약관에 동의하십니까?
</p>
<div class="flex items-center gap-6">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="radio"
v-model="agreedToTerms1"
value="true"
class="h-4 w-4 text-black focus:ring-black border-gray-300"
/>
<span></span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="radio"
v-model="agreedToTerms1"
value="false"
class="h-4 w-4 text-black focus:ring-black border-gray-300"
/>
<span>아니오</span>
</label>
</div>
</div>
<!-- 개인정보 수집/이용 안내 -->
<div
class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900"
>
<table
class="min-w-full text-xs sm:text-sm text-gray-800 dark:text-gray-200"
>
<thead class="bg-gray-100 dark:bg-gray-800/70">
<tr class="text-left">
<th class="px-3 py-2">구분</th>
<th class="px-3 py-2">수집/이용 항목</th>
<th class="px-3 py-2">수집/이용 목적</th>
<th class="px-3 py-2">보유기간</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="px-3 py-2">필수</td>
<td class="px-3 py-2">
성명, 아이디, 비밀번호, 휴대폰번호, 이메일, 회원유형
</td>
<td class="px-3 py-2" rowspan="2">
회원관리 서비스 제공 (공유 오피스·백패킹 )
</td>
<td class="px-3 py-2" rowspan="2">
탈퇴 즉시 파기, 관련 법령에 따른 보관 (최대 5)
</td>
</tr>
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="px-3 py-2">선택</td>
<td class="px-3 py-2">회원 프로필 사진</td>
</tr>
</tbody>
</table>
</div>
<div
class="space-y-3 text-xs sm:text-sm text-gray-700 dark:text-gray-300"
>
<p>
위의 개인정보 수집·이용에 대한 동의를 거부할 권리가 있습니다. 다만,
동의하지 않을 경우 일부 서비스 이용에 제한이 있을 있습니다.
</p>
<p>
<strong class="font-semibold text-red-500">[필수]</strong>
&nbsp;위와 같이 개인정보를 수집·이용하는 동의하십니까?
</p>
<div class="flex items-center gap-6">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="radio"
v-model="agreedToTerms2"
value="true"
class="h-4 w-4 text-black focus:ring-black border-gray-300"
/>
<span></span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="radio"
v-model="agreedToTerms2"
value="false"
class="h-4 w-4 text-black focus:ring-black border-gray-300"
/>
<span>아니오</span>
</label>
</div>
</div>
<!-- 다음 버튼 -->
<button
type="button"
@click="termsProceed = true"
:disabled="!(agreedToTerms1 === 'true' && agreedToTerms2 === 'true')"
class="mt-4 flex w-full justify-center rounded-md px-4 py-3 text-sm font-semibold text-white shadow-sm transition"
:class="{
'bg-gray-400 cursor-not-allowed': !(
agreedToTerms1 === 'true' && agreedToTerms2 === 'true'
),
'bg-black hover:bg-gray-800':
agreedToTerms1 === 'true' && agreedToTerms2 === 'true',
}"
>
다음
</button>
</section>
<!-- STEP 2: 회원정보 입력 -->
<section v-else class="mt-8">
<!-- Alert -->
<div
v-if="reg_show_alert"
:class="[
'mb-4 text-center text-sm font-semibold text-white rounded-md px-3 py-2',
reg_alert_variant,
]"
>
{{ reg_alert_msg }}
</div>
<vee-form
:validation-schema="schema"
:initial-values="userData"
@submit="handleSubmit"
class="space-y-4"
>
<!-- 이메일 -->
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-sm font-medium text-gray-900 dark:text-white">
이메일
</label>
<button
type="button"
@click="checkEmail"
:disabled="!abletoCheck || emailChecked"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium border transition"
:class="{
'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed':
!abletoCheck && !emailChecked,
'bg-yellow-100 text-yellow-700 border-yellow-400':
abletoCheck && !emailChecked,
'bg-green-100 text-green-700 border-green-500': emailChecked,
}"
>
<div v-if="emailState.checking">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
</div>
<svg
v-else
class="h-1.5 w-1.5"
:class="{
'fill-gray-300': !abletoCheck && !emailChecked,
'fill-yellow-500': abletoCheck && !emailChecked,
'fill-green-500': emailChecked,
}"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx="3" cy="3" r="3" />
</svg>
<span>중복 확인</span>
</button>
</div>
<vee-field
v-model="emailInput"
type="email"
name="email"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
placeholder="example@normadbobu.com"
/>
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
{{ emailMessage }}
</p>
<ErrorMessage class="mt-1 text-xs text-red-600" name="email" />
</div>
<!-- 이름 -->
<div>
<label
class="mb-1.5 block text-sm font-medium text-gray-900 dark:text-white"
>
이름
</label>
<vee-field
type="text"
name="name"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
placeholder="이름을 입력하세요"
/>
<ErrorMessage class="mt-1 text-xs text-red-600" name="name" />
</div>
<!-- 비밀번호 -->
<div>
<label
class="mb-1.5 block text-sm font-medium text-gray-900 dark:text-white"
>
비밀번호
</label>
<vee-field
name="password"
:bails="false"
v-slot="{ field, errors }"
>
<input
v-bind="field"
type="password"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
placeholder="비밀번호를 입력하세요"
/>
<div
v-for="error in errors"
:key="error"
class="mt-1 text-xs text-red-600"
>
{{ error }}
</div>
</vee-field>
</div>
<!-- 비밀번호 확인 -->
<div>
<label
class="mb-1.5 block text-sm font-medium text-gray-900 dark:text-white"
>
비밀번호 확인
</label>
<vee-field
type="password"
name="confirm_password"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
placeholder="비밀번호를 다시 입력하세요"
/>
<ErrorMessage
class="mt-1 text-xs text-red-600"
name="confirm_password"
/>
</div>
<!-- 회원 유형 -->
<div>
<label
class="mb-1.5 block text-sm font-medium text-gray-900 dark:text-white"
>
회원 유형
</label>
<vee-field
as="select"
name="membership"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-black"
>
<option value="일반회원">일반회원</option>
<option value="노마드 워커">노마드 워커</option>
<option value="로컬 주민">로컬 주민</option>
<option value="기업/단체">기업/단체</option>
</vee-field>
<ErrorMessage class="mt-1 text-xs text-red-600" name="membership" />
</div>
<!-- reCAPTCHA -->
<div ref="recaptchaWrapperRef" class="mt-2">
<div id="recaptcha-container"></div>
</div>
<!-- 휴대폰 번호 -->
<div>
<label
class="flex justify-between mb-1.5 text-sm font-medium text-gray-900 dark:text-white"
>
<span>휴대폰 번호</span>
<button
v-if="startPhoneRegister"
type="button"
@click="resetPhonenumber"
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
변경하기
</button>
</label>
<input
type="tel"
v-model="rawPhoneNumber"
placeholder="010-1234-5678"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
:disabled="startPhoneRegister"
:class="{ 'bg-gray-100 dark:bg-gray-800': startPhoneRegister }"
/>
</div>
<!-- 인증번호 입력 -->
<div v-if="startPhoneRegister">
<label
class="flex justify-between mb-1.5 text-sm font-medium text-gray-900 dark:text-white"
>
<span>인증번호</span>
<button
v-if="canResend"
type="button"
@click="resendSms"
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
다시 전송하기
</button>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
</div>
</label>
<input
v-model="smsCode"
placeholder="인증번호를 입력하세요"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
/>
</div>
<!-- 버튼 영역 -->
<!-- 1) 이메일 미확인 -->
<button
v-if="!emailChecked"
type="button"
disabled
class="mt-6 flex w-full justify-center rounded-md bg-gray-400 px-4 py-3 text-sm font-semibold text-white shadow-sm cursor-not-allowed"
>
이메일 중복 확인을 완료해주세요
</button>
<!-- 2) 전화번호 인증 시작 버튼 -->
<button
v-if="!startPhoneRegister && emailChecked"
type="button"
@click="startPhoneNumberVerification"
:disabled="!isValidPhoneNumber"
class="mt-6 flex w-full justify-center rounded-md px-4 py-3 text-sm font-semibold text-white shadow-sm transition"
:class="{
'bg-gray-400 cursor-not-allowed': !isValidPhoneNumber,
'bg-black hover:bg-gray-800': isValidPhoneNumber,
}"
>
<span v-if="!isValidPhoneNumber">휴대폰 번호를 확인해주세요</span>
<span v-else>인증번호 전송하기</span>
</button>
<!-- 3) 최종 가입 버튼 -->
<button
v-if="startPhoneRegister"
type="submit"
:disabled="reg_in_submission || !emailChecked"
class="mt-6 flex w-full justify-center rounded-md px-4 py-3 text-sm font-semibold text-white shadow-sm transition"
:class="{
'bg-gray-500 cursor-not-allowed':
reg_in_submission || !emailChecked,
'bg-black hover:bg-gray-800': !reg_in_submission && emailChecked,
}"
>
<div v-if="onSmsfunction">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
</div>
<span v-else>가입하기</span>
</button>
</vee-form>
<!-- Loading overlay -->
<app-loading-overlay
:isLoading="reg_in_submission"
:loadingMessage="loadingMessage"
/>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, reactive, toRefs, computed } from 'vue';
import { useRouter } from 'vue-router';
import type { ConfirmationResult } from 'firebase/auth';
import useUserStore from '@/stores/user';
import { checkEmailDuplicate } from '@/utils/firebaseUtils';
import AppTermsofUse from '~/components/TermsofUse.vue';
import AppLoadingOverlay from '~/components/LoadingOverlay.vue';
import { useNuxtApp } from '#app';
import { signInWithPhoneNumber } from 'firebase/auth';
const { $firebase, $createRecaptchaVerifier } = useNuxtApp();
const firebaseAuth = $firebase.auth;
const createRecaptchaVerifier =
$firebase.createRecaptchaVerifier ?? $createRecaptchaVerifier;
// ② If you did NOT touch the plugin, just use:
/// const createRecaptchaVerifier = $createRecaptchaVerifier;
const router = useRouter();
const userStore = useUserStore();
const emits = defineEmits(['register-success']);
/* ---------- STEP control ---------- */
const termsProceed = ref(false);
const agreedToTerms1 = ref<'true' | 'false' | ''>('');
const agreedToTerms2 = ref<'true' | 'false' | ''>('');
/* ---------- VeeValidate schema ---------- */
const schema = {
name: 'required|min:2|max:10',
email: 'required|min:2|max:100|email',
password: 'required|min:9|max:100|excluded:password',
confirm_password: 'passwords_mismatch:@password',
membership: 'required|membership_excluded',
};
interface RegisterValues {
email: string;
password: string;
confirm_password: string;
name: string;
membership: string;
isActive: boolean;
profile_img: string;
created: any;
uid: string;
phone: string;
}
const userData = ref({
membership: '일반회원',
});
/* ---------- Email duplication check ---------- */
const loadingMessage = 'Uploading! 잠시만 기다려주세요...';
const emailState = reactive({
emailInput: '',
abletoCheck: false,
emailChecked: false,
emailMessage: '',
checking: false,
});
const { emailInput, abletoCheck, emailChecked, emailMessage } =
toRefs(emailState);
const checkEmail = async () => {
emailState.checking = true;
if (!emailState.abletoCheck) {
emailState.checking = false;
return;
}
const result = await checkEmailDuplicate(emailState.emailInput);
switch (result.status) {
case 'exists':
emailState.emailMessage = '이미 존재하는 이메일입니다.';
emailState.checking = false;
break;
case 'available':
emailState.emailMessage = '가입 가능한 이메일입니다.';
emailState.emailChecked = true;
emailState.checking = false;
break;
case 'error':
default:
emailState.abletoCheck = false;
emailState.emailMessage = result.message;
emailState.checking = false;
break;
}
};
watch(
() => emailState.emailInput,
(newValue) => {
emailState.abletoCheck = false;
emailState.emailChecked = false;
emailState.emailMessage = '';
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
emailState.abletoCheck = emailRegex.test(newValue);
}
);
/* ---------- Phone verification ---------- */
const rawPhoneNumber = ref('');
const phoneNumber = ref('');
const smsCode = ref('');
const startPhoneRegister = ref(false);
const phoneVerified = ref(false);
const onSmsfunction = ref(false);
const canResend = ref(true);
const cooldownTime = ref(5);
const recaptchaWrapperRef = ref<HTMLElement | null>(null);
const isValidPhoneNumber = computed(() => {
if (typeof rawPhoneNumber.value !== 'string') return false;
const cleaned = rawPhoneNumber.value.replace(/-/g, '');
return (
(cleaned.startsWith('010') || cleaned.startsWith('070')) &&
cleaned.length === 11
);
});
const resetPhonenumber = () => {
rawPhoneNumber.value = '';
startPhoneRegister.value = false;
recaptchaVerifier = null;
confirmationResult = null;
};
const processedPhoneNumber = computed(() => {
if (typeof rawPhoneNumber.value !== 'string') return '';
let cleaned = rawPhoneNumber.value.replace(/-/g, '');
if (cleaned.startsWith('0')) {
cleaned = '+82' + cleaned.substring(1);
}
return cleaned;
});
let recaptchaVerifier: any | null = null;
let confirmationResult: ConfirmationResult | null = null;
const startPhoneNumberVerification = async () => {
try {
phoneNumber.value = processedPhoneNumber.value;
} catch (error) {
console.error(error);
}
try {
startPhoneRegister.value = true;
if (recaptchaVerifier) {
try {
await recaptchaVerifier.verify();
} catch (recaptchaError) {
console.error(
'Previous reCAPTCHA verification failed:',
recaptchaError
);
return;
}
recaptchaVerifier.clear();
}
if (recaptchaWrapperRef.value) {
recaptchaWrapperRef.value.innerHTML =
'<div id="recaptcha-container"></div>';
} else {
console.error('recaptchaWrapperRef is null');
return;
}
recaptchaVerifier = createRecaptchaVerifier('recaptcha-container');
try {
await recaptchaVerifier.verify();
} catch (recaptchaError) {
console.error('Error during reCAPTCHA verification:', recaptchaError);
throw recaptchaError;
}
confirmationResult = await signInWithPhoneNumber(
firebaseAuth,
phoneNumber.value,
recaptchaVerifier
);
} catch (error) {
console.error('Error during phone number verification:', error);
}
};
const resendSms = async () => {
if (!confirmationResult) {
console.error('사이트에 문제가 있습니다, 관리자에게 문의하십시오.');
onSmsfunction.value = false;
return;
}
try {
canResend.value = false;
if (recaptchaVerifier) {
recaptchaVerifier.clear();
if (recaptchaWrapperRef.value) {
recaptchaWrapperRef.value.innerHTML =
'<div id="recaptcha-container"></div>';
} else {
console.error('recaptchaWrapperRef is null');
return;
}
}
recaptchaVerifier = createRecaptchaVerifier('recaptcha-container');
confirmationResult = await signInWithPhoneNumber(
firebaseAuth,
phoneNumber.value,
recaptchaVerifier
);
let remaining = cooldownTime.value;
const timer = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(timer);
canResend.value = true;
}
}, 1000);
} catch (error) {
console.error('Error during SMS resend:', error);
canResend.value = true;
}
};
const verifySmsCode = async (): Promise<string | null> => {
try {
onSmsfunction.value = true;
if (confirmationResult) {
const result = await confirmationResult.confirm(smsCode.value);
phoneVerified.value = true;
await firebaseAuth.signOut();
onSmsfunction.value = false;
return result.user?.uid || null;
} else {
console.error('confirmationResult is null.');
onSmsfunction.value = false;
return null;
}
} catch (error) {
console.error('Error during SMS code verification:', error);
onSmsfunction.value = false;
return null;
}
};
/* ---------- Registration submit ---------- */
const reg_in_submission = ref(false);
const reg_show_alert = ref(false);
const reg_alert_variant = ref('bg-blue-500');
const reg_alert_msg = ref('계정 생성 중입니다. 잠시만 기다려주세요!');
const handleSubmit = async (_event: Event, values: Record<string, any>) => {
reg_show_alert.value = true;
reg_in_submission.value = true;
reg_alert_variant.value = 'bg-blue-500';
reg_alert_msg.value = '계정 생성 중입니다. 잠시만 기다려주세요!';
try {
const userUID = await verifySmsCode();
try {
if (userUID) {
await visitorRegister({
...values.controlledValues,
uid: userUID,
phone: phoneNumber.value,
});
}
} catch (error) {
console.error(error);
reg_in_submission.value = false;
reg_alert_variant.value = 'bg-red-500';
reg_alert_msg.value = '계정 생성 실패!';
return;
}
} catch (error) {
console.error('error Creating ID', error);
reg_in_submission.value = false;
reg_alert_variant.value = 'bg-red-500';
reg_alert_msg.value = '계정 생성 실패!';
return;
}
reg_in_submission.value = false;
reg_alert_variant.value = 'bg-green-500';
reg_alert_msg.value = '성공적으로 계정이 생성되었습니다!';
emits('register-success');
router.push('/');
};
async function visitorRegister(values: RegisterValues) {
try {
const { confirm_password, ...dataToSubmit } = values;
await userStore.visitorRegister(dataToSubmit);
} catch (error) {
console.log('visitorRegisterError', error);
}
}
</script>
<style scoped>
/* Chrome, Safari, Edge, Opera: remove number input arrows if any */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>

View File

@@ -0,0 +1,243 @@
<template>
<div
class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-12"
>
<div
class="w-full max-w-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-sm px-6 py-8 sm:px-8"
>
<!-- Title -->
<h1 class="text-center text-2xl font-bold text-gray-900 dark:text-white">
로그인
</h1>
<!-- Social logins -->
<div class="mt-8 space-y-3">
<!-- Google -->
<button
type="button"
@click="handleSocialLogin('google')"
class="w-full flex items-center gap-3 rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2.5 text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
<div
class="flex items-center justify-center h-6 w-6 rounded-full bg-white"
>
<!-- Placeholder icon: replace with real Google logo later -->
<font-awesome-icon
:icon="['fab', 'google']"
class="text-red-500 text-lg"
/>
</div>
<span class="flex-1 text-center">Google로 로그인하기</span>
</button>
<!-- Kakao -->
<button
type="button"
@click="handleSocialLogin('kakao')"
class="w-full flex items-center gap-3 rounded-md border border-yellow-400 bg-[#FEE500] px-3 py-2.5 text-sm font-medium text-gray-900 hover:bg-[#FDE64B] transition"
>
<div
class="flex items-center justify-center h-6 w-6 rounded-full bg-black/5"
>
<!-- Placeholder icon: Kakao logo image or icon later -->
<img
v-if="socialImages.kakao"
:src="socialImages.kakao"
alt="Kakao"
class="h-5 w-5 object-contain"
/>
</div>
<span class="flex-1 text-center">Kakao로 로그인하기</span>
</button>
<!-- Naver -->
<button
type="button"
@click="handleSocialLogin('naver')"
class="w-full flex items-center gap-3 rounded-md border border-green-600 bg-white dark:bg-gray-900 px-3 py-2.5 text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-green-50 dark:hover:bg-gray-800 transition"
>
<div
class="flex items-center justify-center h-6 w-6 rounded-full bg-green-600"
>
<img
v-if="socialImages.naver"
:src="socialImages.naver"
alt="Naver"
class="h-4 w-4 object-contain"
/>
</div>
<span class="flex-1 text-center">Naver로 로그인하기</span>
</button>
</div>
<!-- separator -->
<div class="mt-6 flex items-center">
<div class="h-px flex-1 bg-gray-200 dark:bg-gray-700"></div>
<span class="mx-3 text-xs text-gray-400 dark:text-gray-500">또는</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-gray-700"></div>
</div>
<!-- ID / Email + Password form -->
<form class="mt-6 space-y-4" @submit.prevent="handleSubmit">
<!-- 아이디 또는 이메일 -->
<div>
<label
for="email"
class="block text-sm font-medium text-gray-900 dark:text-white"
>
아이디 또는 이메일
</label>
<div class="mt-2">
<input
v-model="email"
type="text"
name="email"
id="email"
autocomplete="email"
required
class="block w-full rounded-md bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white outline-1 -outline-offset-1 outline-gray-300 dark:outline-white/10 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-black"
placeholder="example@normadbobu.com"
/>
</div>
</div>
<!-- 비밀번호 -->
<div>
<label
for="password"
class="block text-sm font-medium text-gray-900 dark:text-white"
>
비밀번호
</label>
<div class="mt-2">
<input
v-model="password"
type="password"
name="password"
id="password"
autocomplete="current-password"
required
class="block w-full rounded-md bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white outline-1 -outline-offset-1 outline-gray-300 dark:outline-white/10 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-black"
placeholder="●●●●●●●●"
/>
</div>
</div>
<!-- 로그인 상태 유지 -->
<div class="flex items-center justify-between">
<label
class="flex items-center gap-2 text-xs sm:text-sm text-gray-700 dark:text-gray-300"
>
<input
v-model="rememberMe"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
/>
<span>기억하기</span>
</label>
</div>
<!-- Alert -->
<div
v-if="login_show_alert"
:class="[
'text-white text-center text-sm font-semibold rounded-md p-3',
login_alert_variant,
]"
>
{{ login_alert_msg }}
</div>
<!-- 로그인 button -->
<button
type="submit"
:disabled="login_in_submission"
class="mt-2 flex w-full justify-center rounded-md bg-black px-3 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black disabled:opacity-60 disabled:cursor-not-allowed"
>
로그인
</button>
</form>
<!-- 아래 링크들 -->
<div
class="mt-4 flex items-center justify-between text-xs sm:text-sm text-gray-700 dark:text-gray-300"
>
<NuxtLink to="/register" class="hover:underline"> 회원가입 </NuxtLink>
<NuxtLink to="/find-account" class="hover:underline">
아이디 · 비밀번호 찾기
</NuxtLink>
</div>
<!-- small separator -->
<p class="mt-6 text-center text-xs text-gray-400 dark:text-gray-500">
또는
</p>
<!-- 비회원 예약 주문 조회 -->
<button
type="button"
class="mt-3 w-full rounded-md bg-slate-400 px-3 py-2.5 text-sm font-semibold text-white hover:bg-slate-500 transition"
>
비회원 예약 주문 조회
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from '#imports';
import useUserStore from '@/stores/user';
import { SOCIAL_IMAGES } from '@/data/assets';
import type { LoginValues } from '@/types';
const userStore = useUserStore();
const router = useRouter();
const email = ref('');
const password = ref('');
const rememberMe = ref(false); // currently not used in logic, placeholder for future
const login_in_submission = ref(false);
const login_show_alert = ref(false);
const login_alert_variant = ref('bg-blue-500');
const login_alert_msg = ref('로그인 중입니다. 잠시만 기다려주세요!');
const socialImages = SOCIAL_IMAGES;
// placeholder handler for future social login wiring
const handleSocialLogin = (provider: 'google' | 'kakao' | 'naver') => {
console.log(`Social login clicked: ${provider}`);
// TODO: implement real social login (Firebase Auth or other)
};
const handleSubmit = async () => {
const values: LoginValues = {
email: email.value,
password: password.value,
};
login_in_submission.value = true;
login_show_alert.value = true;
login_alert_variant.value = 'bg-blue-500';
login_alert_msg.value = '로그인 중입니다. 잠시만 기다려주세요!';
// 도메인 자동 보정: @normadbobu.com
if (!values.email.includes('@')) {
values.email += '@normadbobu.com';
}
try {
await userStore.authenticate(values);
} catch (error) {
login_in_submission.value = false;
login_alert_variant.value = 'bg-red-500';
login_alert_msg.value = '아이디 또는 비밀번호가 일치하지 않습니다.';
return;
}
login_alert_variant.value = 'bg-green-500';
login_alert_msg.value = '로그인에 성공하였습니다!';
router.push('/');
};
</script>

View File

@@ -362,7 +362,7 @@
<ul class="mt-4 list-disc pl-4 space-y-2"> <ul class="mt-4 list-disc pl-4 space-y-2">
<li> <li>
<strong>수집 항목:</strong> <strong>수집 항목:</strong>
소속(기관명/부서명), 성명, 생년월일, 연락처, 이메일, 주소 성명, 생년월일, 연락처, 이메일, 주소
</li> </li>
<li> <li>
<strong>수집 이용 목적:</strong> <strong>수집 이용 목적:</strong>
@@ -439,6 +439,8 @@ import { syncBoardAndUploadsData } from '@/utils/boardUtils';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
//types //types
import { UploadSettings } from '@/data/config'; import { UploadSettings } from '@/data/config';
import { sendBoardEmail } from '@/utils/api/sendBoardEmail';
import type { import type {
WadizBoard, WadizBoard,
BoardItem, BoardItem,
@@ -461,10 +463,12 @@ const userId = computed(() => userStore.docId);
const { $firebase } = useNuxtApp(); const { $firebase } = useNuxtApp();
const wadizesCollection = $firebase.wadizesCollection; const wadizesCollection = $firebase.wadizesCollection;
const currentCollection = wadizesCollection; const currentCollection = wadizesCollection;
const currentBoard = 'wadiz'; const currentBoard = 'wadizes';
const compData = { const compData = {
title: '공지사항 | NOTICE', title: 'WADIZES | 예약 신청',
}; };
import { generateWadizUploadedEmail } from '@/utils/emailTemplates/wadizUploaded';
//loading Message //loading Message
const loadingMessage = 'Uploading! 잠시만 기다려주세요...'; const loadingMessage = 'Uploading! 잠시만 기다려주세요...';
const isUploading = ref(false); const isUploading = ref(false);
@@ -598,6 +602,19 @@ const handleUpload = async () => {
})) as WadizBoard; })) as WadizBoard;
await handleUpdateBoard(boardPayload, currentCollection); await handleUpdateBoard(boardPayload, currentCollection);
//sendemail
try {
const { subject, html } = generateWadizUploadedEmail(boardPayload);
await sendBoardEmail({
access: 'public',
subject,
html,
action: 'created',
});
} catch (e) {
console.warn('Notification email failed', e);
// optional: toast but dont abort the UX
}
showAlert('성공적으로 수정되었습니다', 'bg-green-800'); showAlert('성공적으로 수정되었습니다', 'bg-green-800');
emit('success'); emit('success');
} else { } else {
@@ -628,7 +645,22 @@ const handleUpload = async () => {
'yyyy-MM-dd' 'yyyy-MM-dd'
); );
} }
//sendEmail
try {
const { subject, html } = generateWadizUploadedEmail(boardPayload);
await sendBoardEmail({
access: 'public',
subject,
html,
action: 'created',
});
} catch (e) {
console.warn('Notification email failed', e);
// optional: toast but dont abort the UX
}
await handleCreateBoard(boardPayload, currentCollection); await handleCreateBoard(boardPayload, currentCollection);
showAlert('성공적으로 생성되었습니다', 'bg-green-800', true); showAlert('성공적으로 생성되었습니다', 'bg-green-800', true);
emit('success'); emit('success');
} }

View File

@@ -0,0 +1,156 @@
<template>
<div>
<li
class="py-3 transition duration-300 hover:bg-gray-50 dark:hover:bg-gray-800 items-start border-b border-gray-200 dark:border-gray-700"
>
<div class="flex flex-col mx-4 justify-center md:flex-row text-normal">
<!-- Announcement icon or board number -->
<div
v-if="!showSelectBox"
class="pr-4 md:pr-8 text-gray-400 hidden md:block"
>
<template v-if="item.announcement">
<font-awesome-icon :icon="iconName" fade style="color: #990000" />
</template>
<template v-else>
{{ item.boards_number }}
</template>
</div>
<!-- Selection checkbox -->
<label class="flex items-center space-x-2">
<input
v-if="showSelectBox"
type="checkbox"
:checked="selected"
@change="selectItem"
class="mr-8 inline-block text-gray-600 dark:text-white"
/>
</label>
<!-- Title link -->
<NuxtLink
:to="`${routeName}/${item.docId}`"
class="cursor-pointer flex-1 pr-8 inline-block text-gray-600 dark:text-white hover:underline"
>
{{ truncateTitle(item.name) }}
</NuxtLink>
<!-- Display author only for Manager+ -->
<div
v-if="userRole >= ROLE_THRESHOLD.MANAGER"
class="text-xs text-gray-400 flex items-center pr-4"
>
{{ userDisplayName }}
</div>
<!-- Date -->
<div class="inline-block text-gray-400 text-left md:hidden">
{{ formatDate(item.created) }}
</div>
<div class="hidden md:inline-block text-gray-400 text-left">
{{ formatDate2(item.created) }}
</div>
</div>
</li>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
import { fetchUserDisplayName } from '@/utils/boardUtils';
import { useUserStore } from '@/stores/user';
import { ROLE_THRESHOLD } from '@/data/config';
import type { WadizBoard, BoardItem } from '@/types';
// Helper to normalize Firestore timestamp or other formats into JS Date
function toDate(raw: unknown): Date {
if (typeof raw === 'string') {
return new Date(raw);
}
if (
raw &&
typeof raw === 'object' &&
'toDate' in (raw as any) &&
typeof (raw as any).toDate === 'function'
) {
return (raw as any).toDate();
}
const sec = (raw as any)?.seconds ?? (raw as any)?._seconds;
if (typeof sec === 'number') {
return new Date(sec * 1000);
}
return new Date();
}
// Props
const props = defineProps<{
item: WadizBoard;
showSelectBox?: boolean;
selected?: boolean;
iconName?: [string, string];
routeName: string;
}>();
const emit = defineEmits<{
(e: 'select', item: WadizBoard): void;
}>();
// Reactive state
const userStore = useUserStore();
const userRole = computed(() => userStore.userRole);
const userDisplayName = ref('');
// Title truncation length
const truncatedLength = ref(6);
// Date formatting
const formatDate = (raw: unknown) => {
const d = toDate(raw);
const YYYY = d.getFullYear();
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
return `${YYYY}-${MM}-${DD}`;
};
const formatDate2 = (raw: unknown) => {
const d = toDate(raw);
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
return `${MM}-${DD}`;
};
// Title truncation based on screen width
const updateTruncatedLength = () => {
const w = window.innerWidth / 14;
truncatedLength.value = Math.round(Math.min(w, 60));
};
onMounted(() => {
updateTruncatedLength();
window.addEventListener('resize', updateTruncatedLength);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateTruncatedLength);
});
const truncateTitle = (t: string) =>
t.length > truncatedLength.value
? t.slice(0, truncatedLength.value) + '…'
: t;
// Fetch user display name for manager+
watch(
() => props.item.userId,
async (uid) => {
if (uid && userRole.value >= ROLE_THRESHOLD.MANAGER) {
userDisplayName.value = await fetchUserDisplayName(uid);
} else {
userDisplayName.value = uid || '';
}
},
{ immediate: true }
);
// Selection emit
const selectItem = () => {
emit('select', props.item);
};
</script>

View File

@@ -21,7 +21,7 @@ export function useBoardList<T extends BoardItem>(
title = 'Board', title = 'Board',
itemsPerPage = 20, itemsPerPage = 20,
defaultSort = 'desc', defaultSort = 'desc',
access = 'public', access,
loadingMessage = '잠시만 기다려주세요...', loadingMessage = '잠시만 기다려주세요...',
} = options; } = options;
/* UI + meta */ /* UI + meta */

View File

@@ -43,4 +43,5 @@ export const SOCIAL_IMAGES = {
naver: '/assets/img/logo/naver_resized.jpg', naver: '/assets/img/logo/naver_resized.jpg',
instagram: '/assets/img/logo/instagram.webp', instagram: '/assets/img/logo/instagram.webp',
kakao: '/assets/img/logo/kakao_resized.jpg', kakao: '/assets/img/logo/kakao_resized.jpg',
npay: '/assets/img/logo/npay.jpg',
}; };

View File

@@ -1,15 +1,16 @@
// @/data/config.ts // @/data/config.ts
export const BASE_NAV_ITEMS = [ export const BASE_NAV_ITEMS = [
{ name: '소개', href: '/about', icon: ['fas', 'info-circle'] }, { name: 'BOBU', href: '/about', icon: ['fas', 'info-circle'] },
{ name: '공유 오피스 예약', href: '/office', icon: ['fas', 'calendar-alt'] }, { name: 'Backpacking', href: '/office', icon: ['fas', 'calendar-alt'] },
{ {
name: '워크케이션 프로그램', name: 'Backpacking Community',
href: '/', href: '/',
icon: ['fas', 'umbrella-beach'], icon: ['fas', 'umbrella-beach'],
}, },
{ name: '쇼핑하기', href: '/shop', icon: ['fas', 'shopping-bag'] }, { name: 'WORKATION Inquiry', href: '/contact', icon: ['fas', 'envelope'] },
{ name: '문의하기', href: '/contact', icon: ['fas', 'envelope'] },
{ name: 'SHOP', href: '/shop', icon: ['fas', 'shopping-bag'] },
{ {
name: '와디즈 펀딩 참여자', name: '와디즈 펀딩 참여자',
href: '/wadiz', href: '/wadiz',

View File

@@ -1,17 +1,23 @@
<template> <template>
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white"> <div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<!-- Featured Projects Section --> <!-- Featured Projects Section -->
<!-- <FeaturesCarousel /> <FeaturesCarousel />
<AboutSection0 />
<AboutSection2 />
<AboutSection1 /> <AboutSection1 />
<AboutSection3 /> --> <AboutSection3 />
<AboutSection6 />
<AboutSection7 />
<AboutSection5 />
<!-- <AppWadiz /> --> <!-- <AppWadiz /> -->
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// import AppWadiz from '~/pages/wadiz/index.vue'; import AppWadiz from '~/pages/wadiz/index.vue';
import { MAIN } from '~/data/assets'; import { MAIN } from '~/data/assets';
definePageMeta({ // definePageMeta({
redirect: '/wadiz', // redirect: '/wadiz',
}); // });
</script> </script>

View File

@@ -1,6 +1,21 @@
<template> <template>
<AdminLogin /> <div v-if="userLoggedIn">
<button @click="userStore.signOut">로그아웃</button>
<NuxtLink to="/wadiz/manage">TO manage</NuxtLink>
</div>
<div v-else>
<UserLogin />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AdminLogin from "~/components/auth/admin-login.vue"; import UserLogin from '~/components/auth/user-login.vue';
const userStore = useUserStore();
const userLoggedIn = computed(() => userStore.userLoggedIn);
onMounted(() => {
if (userLoggedIn.value) {
navigateTo('/');
}
});
</script> </script>

View File

@@ -0,0 +1,20 @@
<template>
<div v-if="userLoggedIn">
<button @click="userStore.signOut">로그아웃</button>
<NuxtLink to="/wadiz/manage">TO manage</NuxtLink>
</div>
<div v-else>
<RegisterForm />
</div>
</template>
<script setup lang="ts">
import RegisterForm from '~/components/auth/register-form.vue';
const userStore = useUserStore();
const userLoggedIn = computed(() => userStore.userLoggedIn);
onMounted(() => {
if (userLoggedIn.value) {
navigateTo('/');
}
});
</script>

View File

@@ -0,0 +1,447 @@
<template>
<div class="mx-auto max-w-5xl w-full py-4 px-6 items-center">
<div class="border-b border-gray-200 dark:border-gray-700 pb-8">
<div class="mx-auto mt-10 max-w-lg space-y-8">
<!-- 1) 와디즈 결제 번호 -->
<div>
<label
for="paymentId"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
와디즈 결제 번호
</label>
<VeeField
name="paymentId"
id="paymentId"
type="text"
rules="required"
v-model="board.paymentId"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="paymentId" class="text-red-500 text-sm mt-1" />
</div>
<!-- 2) 성함 -->
<div>
<label
for="name"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
성함
</label>
<VeeField
name="name"
id="name"
type="text"
rules="required"
v-model="board.name"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="name" class="text-red-500 text-sm mt-1" />
</div>
<!-- 3) 주소 -->
<div>
<label
for="address"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
주소
</label>
<VeeField
name="address"
id="address"
type="text"
rules="required"
v-model="board.address"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600"
/>
<VeeErrorMessage name="address" class="text-red-500 text-sm mt-1" />
</div>
<!-- 4) 메일 -->
<div>
<label
for="email"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
메일
</label>
<VeeField
name="email"
id="email"
type="email"
rules="required|email"
v-model="board.email"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="email" class="text-red-500 text-sm mt-1" />
</div>
<!-- 5) 본인 연락처 -->
<div>
<label
for="phone"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
본인 연락처 (Your Phone)
</label>
<VeeField
name="phone"
id="phone"
type="tel"
rules="required"
v-model="board.phone"
placeholder="010-1234-5678"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="phone" class="text-red-500 text-sm mt-1" />
</div>
<!-- 6) 긴급 연락처 -->
<div>
<label
for="emergencyPhone"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
긴급 연락처 | 본인 제3자
</label>
<VeeField
name="emergencyPhone"
id="emergencyPhone"
type="tel"
rules="required"
v-model="board.emergencyPhone"
placeholder="010-0000-0000"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600"
/>
<VeeErrorMessage
name="emergencyPhone"
class="text-red-500 text-sm mt-1"
/>
</div>
<!-- 7) 예약 일정 선택 -->
<div>
<label
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
예약 일정 선택
</label>
<div class="mt-2 flex gap-4">
<!-- 시작일 -->
<div class="w-1/2">
<label
for="scheduleStart"
class="mb-1 block text-sm text-gray-700 dark:text-gray-300"
>
시작일
</label>
</div>
<!-- 종료일 -->
<div class="w-1/2">
<label
for="scheduleEnd"
class="mb-1 block text-sm text-gray-700 dark:text-gray-300"
>
종료일
</label>
<input
v-model="board.scheduleEnd"
id="scheduleEnd"
type="text"
disabled
placeholder="자동 계산 (1박 2일)"
class="w-full rounded-md bg-gray-100 dark:bg-gray-700 px-3 py-2 text-gray-400 dark:text-gray-400 border dark:border-gray-600"
/>
</div>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
예약불가 날짜: 2025 10 3()~5(), 연박 불가 / 체류가능일 :
··
</p>
</div>
<!-- 8) 본인 참석 여부 -->
<div>
<span
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
본인 참석 여부 (Are you attending?)
</span>
<div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center">
<VeeField
name="attending"
type="radio"
:value="'yes'"
v-model="board.attending"
class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span class="ml-2 text-gray-700 dark:text-gray-300"
> (Yes)</span
>
</label>
<label class="inline-flex items-center">
<VeeField
name="attending"
type="radio"
:value="'no'"
v-model="board.attending"
class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span class="ml-2 text-gray-700 dark:text-gray-300"
>아니오 (No)</span
>
</label>
</div>
<VeeErrorMessage name="attending" class="text-red-500 text-sm mt-1" />
</div>
<!-- 8-2) If "아니오", show alternate attendee fields -->
<div v-if="board.attending === 'no'" class="space-y-4">
<!-- 대리 참석자 성함 -->
<div>
<label
for="altName"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
대리 참석자 성함 (Alternate Attendee Name)
</label>
<VeeField
name="altName"
id="altName"
type="text"
v-model="board.altName"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="altName" class="text-red-500 text-sm mt-1" />
</div>
<!-- 대리 참석자 연락처 -->
<div>
<label
for="altPhone"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
대리 참석자 연락처 (Alternate Attendee Phone)
</label>
<VeeField
name="altPhone"
id="altPhone"
type="tel"
v-model="board.altPhone"
placeholder="010-1234-5678"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage
name="altPhone"
class="text-red-500 text-sm mt-1"
/>
</div>
</div>
<!-- 9) 참여 동반자 정보 -->
<div>
<label
for="companions"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
참여 동반자 정보 (선택)
</label>
<VeeField
name="companions"
id="companions"
type="text"
v-model="board.companions"
placeholder="동반자 성함 등"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border dark:border-gray-600"
/>
<VeeErrorMessage
name="companions"
class="text-red-500 text-sm mt-1"
/>
</div>
<!-- 10) 알레르기나 건강 특이사항 (선택) -->
<div>
<label
for="healthNotes"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
알레르기나 건강 특이사항 (선택)
</label>
<VeeField
name="healthNotes"
id="healthNotes"
v-model="board.healthNotes"
as="textarea"
rows="2"
placeholder="예: 견과류 알레르기"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border dark:border-gray-600"
/>
<VeeErrorMessage
name="healthNotes"
class="text-red-500 text-sm mt-1"
/>
</div>
<!-- 11) 전달하고 싶은 (선택) -->
<div>
<label
for="remarks"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
전달하고 싶은 (선택)
</label>
<VeeField
name="remarks"
id="remarks"
v-model="board.remarks"
as="textarea"
rows="4"
placeholder="예: 채팅으로 연락해주세요."
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border dark:border-gray-600"
/>
<VeeErrorMessage name="remarks" class="text-red-500 text-sm mt-1" />
</div>
<!-- Submit -->
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import useUserStore from '@/stores/user';
import AppBoardHeader from '@/components/boards/BoardHeader.vue';
import AppBoardAction from '@/components/boards/BoardAction.vue';
import AppBoardBody from '@/components/boards/BoardBody.vue';
import AppLoadingOverlay from '@/components/LoadingOverlay.vue';
import { loadBoardDetails, deleteSingle } from '@/utils/boardUtils';
import type {
BoardItem,
OrderByDirection,
WadizBoard,
FileItem,
} from '@/types';
const { $firebase } = useNuxtApp();
const storage = $firebase.storage;
//***Variables, Things you need to change****)
const wadizesCollection = $firebase.wadizesCollection;
import AppUploadWadizForm from '@/components/boards/wadiz/UploadWadizForm.vue';
import AppUploadWadiz from '@/pages/wadiz/upload.vue';
const currentCollection = wadizesCollection;
const currentBoardRouteName = '/wadiz';
const compData = {
title: '와디즈 | WADIZ',
routeName: '/wadiz/[docId]', // path to single notice view
itemsPerPage: 20,
defaultSort: 'desc' as OrderByDirection,
listRouteName: '/wadiz',
uploadRouteName: '/wadiz/upload',
};
//
const in_submission = ref(false);
const deletingMessage = '삭제 중! 잠시만 기다려주세요...';
//Reactive variables
const router = useRouter();
const route = useRoute(); // Access the route object
const docId = computed(() => route.params.docId as string);
const board: Ref<WadizBoard> = ref({
docId: '',
userId: '',
title: '',
description: '',
boardState: { state: 'processing' },
announcement: false,
created: '',
files: [],
//depreciated
boards_number: 0,
thumbnail: { name: '', url: '' } as FileItem,
ishidden: false,
//wadizes
name: '',
paymentId: '',
email: '',
phone: '',
address: '',
emergencyPhone: '',
scheduleStart: '',
scheduleEnd: '',
attending: 'yes',
altName: '',
altPhone: '',
companions: '',
healthNotes: '',
remarks: '',
});
const userStore = useUserStore();
const userRole = computed(() => userStore.userRole);
const toggleEdit = ref(false);
const handleDelete = async () => {
const result = await deleteSingle(board.value, currentCollection, storage);
if (result) {
router.push(currentBoardRouteName); // path string works directly in Nuxt3
}
};
const handleUpdateSuccess = async () => {
await loadBoardDetails(
board.value.docId,
board, // ref<BoardItem>
currentCollection,
router,
currentBoardRouteName,
userRole // Ref<number>
);
toggleEdit.value = false;
};
const toggleEditBoard = () => {
toggleEdit.value = !toggleEdit.value;
};
watch(docId, (newId) => {
if (!newId) return;
console.log('newId', newId);
loadBoardDetails(
newId,
board,
currentCollection,
router,
currentBoardRouteName,
userRole
);
});
onMounted(async () => {
if (docId.value) {
await loadBoardDetails(
docId.value,
board,
currentCollection,
router,
currentBoardRouteName,
userRole
);
// Anything here will run AFTER board.value has real data
console.log('after await:', board.value);
}
});
</script>
<style scoped>
.board {
word-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<section>
<app-boards-header
:h2data="compdata.title"
v-model="selectedSort"
:sortOptions="sortOptions"
/>
</section>
<div class="text-center">
<p v-if="!userLoggedIn">You are not logged in</p>
<p v-else>You are logged in for {{ userStore.userRole }}</p>
<button @click="verifySession">verifySession</button>
</div>
<app-board-list
v-model="selectedSort"
:title="compdata.title"
:sortOptions="sortOptions"
:userRole="userRole"
:current-page="currentPage"
:total-pages="totalPages"
:page-numbers="pageNumbers"
:show-select-boxes="showSelectBoxes"
:uploadRoute="currentUploadRoute"
@toggle-select-boxes="showSelectBoxes = !showSelectBoxes"
@delete-selected="onDeleteSelected"
@go-to-page="onGoToPage"
@prev-page="onPrevPage"
@next-page="onNextPage"
:isLoading="isLoading"
:loadingMessage="loadingMessage"
>
<template #list>
<app-board-list-single
v-for="item in currentItems"
:key="item.docId"
:userId="item.userId"
:item="item"
:showSelectBox="showSelectBoxes"
@select="onToggleSelect(item)"
:iconName="['fas', 'bell']"
:routeName="currentBoardRouteName"
/>
</template>
</app-board-list>
</div>
</template>
<script setup lang="ts">
import AppBoardsHeader from '@/components/boards/BoardHeader.vue';
import AppBoardList from '@/components/boards/BoardList.vue';
import AppBoardListSingle from '@/components/boards/wadiz/WadizListSingle.vue';
import type { OrderByDirection, BoardAccessMode, WadizBoard } from '@/types';
import { useUserStore } from '@/stores/user';
import { verifySession } from '@/utils/api/verifyFromFunction';
const userStore = useUserStore();
const userLoggedIn = computed(() => userStore.userLoggedIn);
//customize
const access: BoardAccessMode = 'public';
const currentCollection = 'wadizes';
const currentUploadRoute = '/wadiz/upload';
const currentBoardRouteName = '/wadiz';
const compData = {
title: '와디즈 관리',
itemsPerPage: 20,
defaultSort: 'desc' as OrderByDirection,
};
const loading = '게시물을 불러오는 중입니다...';
import { useBoardList } from '@/composables/useBoardList';
// destructure only the things you actually need
const {
isLoading,
loadingMessage,
compdata,
currentItems,
currentPage,
pageNumbers,
selectedSort,
sortOptions,
userRole,
totalPages,
selectedItems,
showSelectBoxes,
onToggleSelect,
onDeleteSelected,
onGoToPage,
onPrevPage,
onNextPage,
} = useBoardList<WadizBoard>(currentCollection, {
title: compData.title,
itemsPerPage: compData.itemsPerPage,
defaultSort: compData.defaultSort,
access: access,
loadingMessage: loading,
});
// no more fetchBoardsAndUpdateItems or onBeforeMount
</script>

View File

@@ -75,6 +75,7 @@ export const useUserStore = defineStore('user', {
everLoggedIn = true; everLoggedIn = true;
const idToken = await user.getIdToken(); const idToken = await user.getIdToken();
await createSession(idToken); await createSession(idToken);
await new Promise((resolve) => setTimeout(resolve, 100)); // Wait for 100 milliseconds
this.userLoggedIn = true; this.userLoggedIn = true;
this.initializeListener(); this.initializeListener();
} else if (everLoggedIn) { } else if (everLoggedIn) {

View File

@@ -1,12 +1,6 @@
import { useRuntimeConfig } from '#imports'; import { useRuntimeConfig } from '#imports';
import type { import type { BoardItem, BoardAccessMode, CursorResponse } from '~/types';
BoardItem,
BoardAccessMode,
CursorResponse,
UseBoardListOptions,
} from '~/types';
/* ---------- params accepted by this helper --------------------- */
interface FetchBoardsParams { interface FetchBoardsParams {
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
pageNumber?: number; pageNumber?: number;
@@ -15,9 +9,11 @@ interface FetchBoardsParams {
pageToken?: string; pageToken?: string;
} }
const FUNCTION_BASE = 'https://fetchboards-edvvp3hbnq-du.a.run.app'; // ⛳️ Split base URLs per function (if deployed separately)
const BASE_FETCH_BOARDS = 'https://fetchboards-edvvp3hbnq-du.a.run.app';
const BASE_FETCH_BOARDS_CURSOR =
'https://fetchboardscursor-edvvp3hbnq-du.a.run.app';
/* --------------------------------------------------------------- */
export async function fetchBoardsFromFunction<T extends BoardItem>( export async function fetchBoardsFromFunction<T extends BoardItem>(
collection: string, collection: string,
{ {
@@ -29,7 +25,7 @@ export async function fetchBoardsFromFunction<T extends BoardItem>(
}: FetchBoardsParams = {} }: FetchBoardsParams = {}
): Promise<CursorResponse<T>> { ): Promise<CursorResponse<T>> {
const isCursorMode = !!pageToken || pageNumber === undefined; const isCursorMode = !!pageToken || pageNumber === undefined;
const endpoint = isCursorMode ? 'fetchBoardsCursor' : 'fetchBoards'; const endpoint = isCursorMode ? BASE_FETCH_BOARDS_CURSOR : BASE_FETCH_BOARDS;
const params: Record<string, any> = { const params: Record<string, any> = {
collection, collection,
@@ -39,9 +35,11 @@ export async function fetchBoardsFromFunction<T extends BoardItem>(
...(isCursorMode ? { pageToken } : { pageNumber }), ...(isCursorMode ? { pageToken } : { pageNumber }),
}; };
return await $fetch<CursorResponse<T>>(`${FUNCTION_BASE}/${endpoint}`, { const result = await $fetch(endpoint, {
method: 'GET', method: 'GET',
params, params,
credentials: access !== 'public' ? 'include' : undefined, credentials: access !== 'public' ? 'include' : undefined,
}); });
return result as CursorResponse<T>;
} }

View File

@@ -0,0 +1,33 @@
import { useRuntimeConfig } from '#imports';
import type { BoardItem, BoardAccessMode } from '~/types';
interface FetchSingleItemParams {
collection: string;
docId: string;
access?: BoardAccessMode;
}
const FUNCTION_BASE = 'https://fetchsingleitem-edvvp3hbnq-du.a.run.app';
/**
* Fetch a single board item from Firebase HTTPS function
*/
export async function fetchSingleItemFromFunction<T extends BoardItem>({
collection,
docId,
access = 'public',
}: FetchSingleItemParams): Promise<T> {
const params = {
collection,
docId,
access,
};
const result = await $fetch(`${FUNCTION_BASE}`, {
method: 'GET',
params,
credentials: access !== 'public' ? 'include' : undefined,
});
return result as T;
}

View File

@@ -0,0 +1,24 @@
// utils/api/sendBoardMail.ts
import type { BoardAccessMode } from '~/types';
interface SendBoardMailParams {
access?: BoardAccessMode;
subject: string;
html: string;
action?: 'created' | 'updated' | 'deleted' | string;
}
const FUNCTION_BASE = 'https://boardmail-edvvp3hbnq-du.a.run.app';
export async function sendBoardEmail({
access = 'public',
action = 'updated',
subject,
html,
}: SendBoardMailParams): Promise<void> {
await $fetch(FUNCTION_BASE, {
method: 'POST',
body: { access, subject, html, action },
credentials: access !== 'public' ? 'include' : undefined,
});
}

View File

@@ -1,3 +1,5 @@
// utils/api/verifyFromFunction.ts
const VERIFY_URL = 'https://verifysession-edvvp3hbnq-du.a.run.app'; const VERIFY_URL = 'https://verifysession-edvvp3hbnq-du.a.run.app';
export type VerifiedSession = { export type VerifiedSession = {
@@ -5,9 +7,39 @@ export type VerifiedSession = {
email: string; email: string;
role: number; role: number;
}; };
export async function verifySession(): Promise<VerifiedSession> { export async function verifySession(): Promise<VerifiedSession> {
return await $fetch<VerifiedSession>(VERIFY_URL, { console.log('verifySession (client): Initiating GET request to', VERIFY_URL); // Log start
try {
// We use $fetchRaw here to inspect the full response, including headers
const response = await $fetch.raw<VerifiedSession>(VERIFY_URL, {
method: 'GET', method: 'GET',
credentials: 'include', // ensures session cookie is sent credentials: 'include', // ensures session cookie is sent
}); });
console.log('verifySession (client): Response status:', response.status);
console.log('verifySession (client): Response headers:', response.headers); // Log all response headers
// To check if a specific cookie was sent by the browser in the *request* (not response):
// This is hard to do directly with $fetch on the client-side *before* the request is sent,
// you'd typically use the Network tab for that.
// If you need the response body:
const data = await response._data;
console.log('verifySession (client): Response data:', data);
if (response.status === 200 && data) {
console.log('verifySession (client): Session verification successful.');
return data;
} else {
const errorMessage = `verifySession (client): Verification failed with status ${response.status}`;
console.error(errorMessage);
throw new Error(errorMessage);
}
} catch (error) {
console.error('verifySession (client): Error during verification:', error);
// Re-throw the error so it can be caught by the calling code
throw error;
}
} }

View File

@@ -149,6 +149,7 @@ export const loadBoardDetails = async (
router.push({ name: `${currentboard}list` }); router.push({ name: `${currentboard}list` });
} }
} }
console.log('board loaded', board.value);
} catch (error) { } catch (error) {
console.error('Error loading board details:', error); console.error('Error loading board details:', error);
} }

View File

@@ -0,0 +1,88 @@
// utils/emailTemplates/wadizUploaded.ts
import type { WadizBoard } from '~/types/boardItem';
export function generateWadizUploadedEmail(board: Partial<WadizBoard>) {
const subject = `${board.name || '신청자'}님의 예약 신청이 접수되었습니다`;
const html = `
<h2>예약 정보</h2>
<table>
${
board.name
? `<tr><td><strong>이름</strong></td><td>${board.name}</td></tr>`
: ''
}
${
board.paymentId
? `<tr><td><strong>결제 ID</strong></td><td>${board.paymentId}</td></tr>`
: ''
}
${
board.email
? `<tr><td><strong>이메일</strong></td><td>${board.email}</td></tr>`
: ''
}
${
board.phone
? `<tr><td><strong>전화번호</strong></td><td>${board.phone}</td></tr>`
: ''
}
${
board.address
? `<tr><td><strong>주소</strong></td><td>${board.address}</td></tr>`
: ''
}
${
board.emergencyPhone
? `<tr><td><strong>비상연락처</strong></td><td>${board.emergencyPhone}</td></tr>`
: ''
}
${
board.scheduleStart
? `<tr><td><strong>시작일</strong></td><td>${board.scheduleStart}</td></tr>`
: ''
}
${
board.scheduleEnd
? `<tr><td><strong>종료일</strong></td><td>${board.scheduleEnd}</td></tr>`
: ''
}
${
board.attending
? `<tr><td><strong>참석 여부</strong></td><td>${
board.attending === 'yes' ? '참석' : '불참'
}</td></tr>`
: ''
}
${
board.altName
? `<tr><td><strong>대리인 이름</strong></td><td>${board.altName}</td></tr>`
: ''
}
${
board.altPhone
? `<tr><td><strong>대리인 전화번호</strong></td><td>${board.altPhone}</td></tr>`
: ''
}
${
board.companions
? `<tr><td><strong>동반 인원</strong></td><td>${board.companions}</td></tr>`
: ''
}
${
board.healthNotes
? `<tr><td><strong>건강/알레르기</strong></td><td>${board.healthNotes}</td></tr>`
: ''
}
${
board.remarks
? `<tr><td><strong>기타 요청사항</strong></td><td>${board.remarks}</td></tr>`
: ''
}
</table>
<hr />
<p>이 메일은 예약 신청 확인용으로 자동 전송되었습니다.</p>
`;
return { subject, html };
}

5501
bobu/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
"@nuxtjs/sitemap": "^7.2.7", "@nuxtjs/sitemap": "^7.2.7",
"@nuxtjs/tailwindcss": "^6.13.2", "@nuxtjs/tailwindcss": "^6.13.2",
"@pinia/nuxt": "^0.10.1", "@pinia/nuxt": "^0.10.1",
"@sendgrid/mail": "^8.1.5",
"@vee-validate/i18n": "^4.15.0", "@vee-validate/i18n": "^4.15.0",
"@vee-validate/nuxt": "^4.15.0", "@vee-validate/nuxt": "^4.15.0",
"@vee-validate/rules": "^4.15.0", "@vee-validate/rules": "^4.15.0",
@@ -34,7 +35,7 @@
"firebase-functions": "^6.3.2", "firebase-functions": "^6.3.2",
"install": "^0.13.0", "install": "^0.13.0",
"npm": "^11.2.0", "npm": "^11.2.0",
"nuxt": "^3.16.0", "nuxt": "^3.17.0",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -6,7 +6,8 @@
"": { "": {
"name": "functions", "name": "functions",
"dependencies": { "dependencies": {
"firebase-admin": "^13.3.0", "@sendgrid/mail": "^8.1.5",
"firebase-admin": "^13.4.0",
"firebase-functions": "^6.3.2" "firebase-functions": "^6.3.2"
}, },
"devDependencies": { "devDependencies": {
@@ -1995,6 +1996,44 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sendgrid/client": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.5.tgz",
"integrity": "sha512-Jqt8aAuGIpWGa15ZorTWI46q9gbaIdQFA21HIPQQl60rCjzAko75l3D1z7EyjFrNr4MfQ0StusivWh8Rjh10Cg==",
"license": "MIT",
"dependencies": {
"@sendgrid/helpers": "^8.0.0",
"axios": "^1.8.2"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sendgrid/helpers": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz",
"integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/@sendgrid/mail": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.5.tgz",
"integrity": "sha512-W+YuMnkVs4+HA/bgfto4VHKcPKLc7NiZ50/NH2pzO6UHCCFuq8/GNB98YJlLEr/ESDyzAaDr7lVE7hoBwFTT3Q==",
"license": "MIT",
"dependencies": {
"@sendgrid/client": "^8.1.5",
"@sendgrid/helpers": "^8.0.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.27.8", "version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -2869,8 +2908,7 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
@@ -2888,6 +2926,33 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -3394,7 +3459,6 @@
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
}, },
@@ -3604,9 +3668,7 @@
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3652,7 +3714,6 @@
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
} }
@@ -3912,7 +3973,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -4654,9 +4714,9 @@
} }
}, },
"node_modules/firebase-admin": { "node_modules/firebase-admin": {
"version": "13.3.0", "version": "13.4.0",
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.3.0.tgz", "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.4.0.tgz",
"integrity": "sha512-MFxv86Aw2rjM/TpKwU86jN7YUFfN1jy6mREYZTLL1aW1rCpZFi4c70b9U12J9Xa4RbJkiXpWBAwth9IVSqR91A==", "integrity": "sha512-Y8DcyKK+4pl4B93ooiy1G8qvdyRMkcNFfBSh+8rbVcw4cW8dgG0VXCCTp5NUwub8sn9vSPsOwpb9tE2OuFmcfQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@fastify/busboy": "^3.0.0", "@fastify/busboy": "^3.0.0",
@@ -4743,6 +4803,26 @@
"license": "ISC", "license": "ISC",
"peer": true "peer": true
}, },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -5249,7 +5329,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
@@ -7819,6 +7898,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -15,7 +15,8 @@
}, },
"main": "lib/index.js", "main": "lib/index.js",
"dependencies": { "dependencies": {
"firebase-admin": "^13.3.0", "@sendgrid/mail": "^8.1.5",
"firebase-admin": "^13.4.0",
"firebase-functions": "^6.3.2" "firebase-functions": "^6.3.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -4,16 +4,19 @@ import { initializeApp, getApps } from 'firebase-admin/app';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { logger } from 'firebase-functions/v2'; import { logger } from 'firebase-functions/v2';
import { cookieExpiration, corsMiddlewareHandler } from '../config'; import { cookieExpiration, corsMiddlewareHandler } from '../config';
// ✅ Admin SDK init // ✅ Admin SDK init
if (!getApps().length) { if (!getApps().length) {
initializeApp(); // ✅ auto uses service account in Firebase Functions initializeApp();
} }
export const createSession = onRequest( export const createSession = onRequest(
{ region: 'asia-northeast3' }, { region: 'asia-northeast3' },
async (req, res) => { async (req, res) => {
return corsMiddlewareHandler(req, res, async () => { return corsMiddlewareHandler(req, res, async () => {
logger.info('countBoards: CORSenabled function called'); // Fix this logger message: it should be about createSession, not countBoards
logger.info('createSession: CORS-enabled function called');
if (req.method !== 'POST') { if (req.method !== 'POST') {
res.status(405).send('Method not allowed'); res.status(405).send('Method not allowed');
return; return;
@@ -38,7 +41,7 @@ export const createSession = onRequest(
httpOnly: true, httpOnly: true,
secure: true, secure: true,
maxAge: expiresIn / 1000, maxAge: expiresIn / 1000,
sameSite: 'lax' as const, sameSite: 'none' as const,
path: '/', path: '/',
}; };
@@ -48,6 +51,10 @@ export const createSession = onRequest(
); );
res.status(200).json({ success: true }); res.status(200).json({ success: true });
} catch (error) { } catch (error) {
logger.error(
'createSession: Invalid token or session cookie creation failed',
error,
); // Added more specific logging
res.status(401).send('Invalid token'); res.status(401).send('Invalid token');
} }
}); });

View File

@@ -1,11 +1,11 @@
// In your verifySession.ts (the API endpoint Cloud Function)
import { onRequest } from 'firebase-functions/v2/https'; import { onRequest } from 'firebase-functions/v2/https';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { getAuth } from 'firebase-admin/auth'; import { getAuth } from 'firebase-admin/auth';
import { initializeApp, getApps } from 'firebase-admin/app'; import { initializeApp, getApps } from 'firebase-admin/app';
import { corsMiddlewareHandler } from '../config'; import { corsMiddlewareHandler } from '../config';
import { logger } from 'firebase-functions/v2'; import { logger } from 'firebase-functions/v2'; // Ensure logger is imported
// ✅ Ensure Admin SDK is initialized
if (!getApps().length) { if (!getApps().length) {
initializeApp(); initializeApp();
} }
@@ -14,17 +14,31 @@ export const verifySession = onRequest(
{ region: 'asia-northeast3' }, { region: 'asia-northeast3' },
async (req, res) => { async (req, res) => {
return corsMiddlewareHandler(req, res, async () => { return corsMiddlewareHandler(req, res, async () => {
logger.info('verifySession: Function invoked'); logger.info('verifySession: Function invoked'); // This log is already there
// --- ADD THESE LOGS ---
logger.info(
'verifySession (endpoint): Raw headers.cookie:',
req.headers.cookie,
);
const cookies = cookie.parse(req.headers.cookie || '');
logger.info('verifySession (endpoint): Parsed cookies object:', cookies);
const session = cookies.__session;
logger.info(
'verifySession (endpoint): __session cookie value:',
session ? 'Found' : 'Not Found',
);
// --- END ADDED LOGS ---
if (req.method !== 'GET') { if (req.method !== 'GET') {
res.status(405).send('Method Not Allowed'); res.status(405).send('Method Not Allowed');
return; return;
} }
const cookies = cookie.parse(req.headers.cookie || '');
const session = cookies.__session;
if (!session) { if (!session) {
logger.warn(
'verifySession (endpoint): No __session cookie found, returning 401.',
); // Specific warn
res.status(401).send({ message: 'No session cookie' }); res.status(401).send({ message: 'No session cookie' });
return; return;
} }
@@ -32,14 +46,22 @@ export const verifySession = onRequest(
try { try {
const decoded = await getAuth().verifySessionCookie(session, true); const decoded = await getAuth().verifySessionCookie(session, true);
const role = decoded.role || 0; const role = decoded.role || 0;
logger.info(
'verifySession (endpoint): Session verified successfully for UID:',
decoded.uid,
); // Log success
res.status(200).json({ res.status(200).json({
uid: decoded.uid, uid: decoded.uid,
email: decoded.email, email: decoded.email,
role, role,
}); });
} catch (err) { } catch (err: unknown) {
logger.warn('verifySession: Session invalid or expired', err); // Use any for err to access message
logger.warn(
'verifySession (endpoint): Session invalid or expired, returning 401:',
err instanceof Error ? err.message : String(err),
); // Specific warn
res.status(401).send({ message: 'Invalid session' }); res.status(401).send({ message: 'Invalid session' });
} }
}); });

View File

@@ -0,0 +1,73 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { defineSecret } from 'firebase-functions/params';
import { initializeApp, getApps } from 'firebase-admin/app';
import sgMail from '@sendgrid/mail';
import type { BoardAccessMode } from '../types/boardItem';
import { corsMiddlewareHandler, SendEmail } from '../config';
/* ─── Boiler-plate init ───────────────────────────────────────── */
if (!getApps().length) initializeApp();
const SENDGRID_API_KEY = defineSecret('SENDGRID_API_KEY');
let isSendGridInitialized = false;
/* ─── Cloud Function entry ────────────────────────────────────── */
export const boardMail = onRequest(
{
region: 'asia-northeast3',
secrets: [SENDGRID_API_KEY], // ❷ declare dependency
},
async (req, res) => {
return corsMiddlewareHandler(req, res, async () => {
/* one-time SendGrid init per cold start */
if (!isSendGridInitialized) {
const apiKey = process.env.SENDGRID_API_KEY;
if (!apiKey) {
logger.error('SendGrid API key is undefined. Check secret setup.');
throw new Error('Missing SendGrid API key');
}
sgMail.setApiKey(apiKey);
isSendGridInitialized = true;
}
/* 1. Method guard --------------------------------------------------- */
if (req.method !== 'POST') {
res.status(405).send({ message: 'Method Not Allowed' });
return;
}
/* 2. Extract params ------------------------------------------------- */
const { access = 'public', subject, html, action = '' } = req.body ?? {};
if (!['public', 'private', 'admin'].includes(access)) {
logger.warn(`Invalid access mode: ${access}`);
res.status(400).send({ message: 'Bad access mode' });
return;
}
const effectiveAccess = access as BoardAccessMode; // ❹ always public/param
logger.info('boardMail: effectiveAccess', effectiveAccess);
if (!subject || !html) {
res.status(400).send({ message: 'Missing subject or html' });
return;
}
try {
await sgMail.send({
to: SendEmail.to,
from: SendEmail.from,
subject,
html,
});
logger.info(
`[boardMail] Email sent successfully via SendGrid [${action}]`,
);
res.status(200).send({ message: 'Email sent' });
} catch (err) {
logger.error('[boardMail] Error sending email', err as Error);
res.status(500).send({ message: 'Internal Server Error' });
}
});
},
);

View File

@@ -26,32 +26,22 @@ export const fetchBoards = onRequest(
return corsMiddlewareHandler(request, response, async () => { return corsMiddlewareHandler(request, response, async () => {
logger.info('fetchBoards: CORSenabled function called'); logger.info('fetchBoards: CORSenabled function called');
/* 1.Method guard ------------------------------------------------------------------ */ /* 1.Method guard */
if (request.method !== 'GET') { if (request.method !== 'GET') {
response.status(405).send({ message: 'Method Not Allowed' }); response.status(405).send({ message: 'Method Not Allowed' });
return; return;
} }
/* 2.Auth (optional) ---------------------------------------------------------------- */ /* 2.Read & validate query params EARLY */
let userRole = 0;
let userId: string | null = null;
const authUser = await verifySessionFromRequest(request);
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
/* 3.Read & validate query params --------------------------------------------------- */
const { const {
collection = '', collection = '',
sortOrder = 'desc', sortOrder = 'desc',
itemsPerPage = '10', itemsPerPage = '10',
access = 'public', access = 'public', // Default to 'public' if not provided
pageToken, pageToken,
} = request.query as Record<string, string>; } = request.query as Record<string, string>;
// ❶collection allowlist // ❶ collection allow-list
if ( if (
!ALLOWED_COLLECTIONS.has( !ALLOWED_COLLECTIONS.has(
collection as unknown as typeof ALLOWED_COLLECTIONS extends Set< collection as unknown as typeof ALLOWED_COLLECTIONS extends Set<
@@ -65,25 +55,45 @@ export const fetchBoards = onRequest(
return; return;
} }
// ❷access must be one of three strings // ❷ access must be one of three strings. Validate BEFORE auth for basic correctness.
if (!['public', 'private', 'admin'].includes(access)) { if (!['public', 'private', 'admin'].includes(access)) {
response.status(400).send({ message: 'Bad access mode' }); response.status(400).send({ message: 'Bad access mode' });
return; return;
} }
/* 3. Authentication and Authorization Check */
let userRole = 0;
let userId: string | null = null;
let authUser = null; // Initialize authUser to null
try {
authUser = await verifySessionFromRequest(request);
} catch (err) {
logger.warn('fetchBoards: Session verification failed', err);
}
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
// If user is NOT logged in (userId is null) AND they are requesting 'private' or 'admin' access
if (!userId && (access === 'private' || access === 'admin')) {
response
.status(401)
.send({ message: 'Unauthorized: Login required for this resource.' });
return; // Function stops here.
}
// ❸numeric arguments // ❸numeric arguments
const limit = Math.max(1, Math.min(Number(itemsPerPage) || 10, 100)); const limit = Math.max(1, Math.min(Number(itemsPerPage) || 10, 100));
const order = sortOrder === 'asc' ? 'asc' : 'desc'; const order = sortOrder === 'asc' ? 'asc' : 'desc';
/* 4.Build query with access control ----------------------------------------------- */ /* 4.Build query with access control ----------------------------------------------- */
const accessParam = (access ?? 'public') as BoardAccessMode;
const effectiveAccess = userId ? accessParam : 'public'; // force public if anonymous
let queryRef: Query<DocumentData>; let queryRef: Query<DocumentData>;
try { try {
queryRef = buildQueryWithAccessControl( queryRef = buildQueryWithAccessControl(
db.collection(collection), db.collection(collection),
effectiveAccess, access as BoardAccessMode, // Use the original 'access'
userId, userId,
userRole, userRole,
); );
@@ -141,7 +151,7 @@ export const fetchBoards = onRequest(
) )
: null; : null;
if (effectiveAccess === 'public') if (access === 'public')
response.set('Cache-Control', 'public,max-age=60'); response.set('Cache-Control', 'public,max-age=60');
response.status(200).send({ items, nextPageToken }); response.status(200).send({ items, nextPageToken });

View File

@@ -0,0 +1,112 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { getFirestore } from 'firebase-admin/firestore';
import { initializeApp, getApps } from 'firebase-admin/app';
import type { BoardItem, BoardAccessMode } from '../types/boardItem';
import { corsMiddlewareHandler, ALLOWED_COLLECTIONS } from '../config';
import {
isValidBoardItem,
verifySessionFromRequest,
buildQueryWithAccessControl,
} from '../utils';
if (!getApps().length) {
initializeApp();
}
const db = getFirestore();
export const fetchSingleItem = onRequest(
{ region: 'asia-northeast3' },
async (request, response) => {
return corsMiddlewareHandler(request, response, async () => {
logger.info('fetchSingleItem: CORS-enabled function called');
/* 1.Method guard ------------------------------------------------------------------ */
if (request.method !== 'GET') {
response.status(405).send({ message: 'Method Not Allowed' });
return;
}
// 2. Auth (optional)
let userRole = 0;
let userId: string | null = null;
const authUser = await verifySessionFromRequest(request);
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
// 3. Query params
const {
collection = '',
docId = '',
access = 'public',
} = request.query as Record<string, string>;
if (!collection || !docId) {
response.status(400).send({ message: 'Missing parameters' });
return;
}
// ❶collection allowlist
if (
!ALLOWED_COLLECTIONS.has(
collection as unknown as typeof ALLOWED_COLLECTIONS extends Set<
infer U
>
? U
: never,
)
) {
response.status(400).send({ message: 'Invalid collection' });
return;
}
if (!['public', 'private', 'admin'].includes(access)) {
response.status(400).send({ message: 'Bad access mode' });
return;
}
const accessParam = (access ?? 'public') as BoardAccessMode;
const effectiveAccess = userId ? accessParam : 'public'; // force public if anonymous
try {
// Build query with access control
const query = buildQueryWithAccessControl(
db.collection(collection),
effectiveAccess,
userId,
userRole,
).where('__name__', '==', docId);
const snap = await query.limit(1).get();
if (snap.empty) {
response
.status(404)
.send({ message: 'Item not found or access denied' });
return;
}
const docSnap = snap.docs[0];
const data = docSnap.data() as BoardItem;
if (!isValidBoardItem(data)) {
response.status(500).send({ message: 'Invalid item data' });
return;
}
if (access === 'public') {
response.set('Cache-Control', 'public,max-age=60');
}
response.status(200).send({ ...data, docId: docSnap.id });
return;
} catch (err) {
logger.error('fetchSingleItem: Firestore error', err);
response.status(500).send({ message: 'Internal Server Error' });
}
});
},
);

View File

@@ -1,2 +1,4 @@
export * from './countBoard'; export * from './countBoard';
export * from './fetchBoard'; export * from './fetchBoard';
export * from './fetchSingleItem';
export * from './boardMail';

View File

@@ -82,6 +82,7 @@ const MAX_FILE_SIZE_MB = 20;
const MAX_TOTAL_FILES = 10; const MAX_TOTAL_FILES = 10;
export const ALLOWED_COLLECTIONS = new Set([ export const ALLOWED_COLLECTIONS = new Set([
'notices', 'notices',
'wadizes' /* … */,
'projects' /* … */, 'projects' /* … */,
] as const); ] as const);
const ALLOWED_IMAGE_TYPES = [ const ALLOWED_IMAGE_TYPES = [
@@ -123,6 +124,12 @@ const ALLOWED_FILE_TYPES = [
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
export const SendEmail = {
to: 'bobu1104@naver.com',
from: 'movemama@manos.kr',
subject: '[보부웹앱] - 새로운 신청',
};
const isImageFile = (file: File): boolean => const isImageFile = (file: File): boolean =>
ALLOWED_IMAGE_TYPES.includes(file.type); ALLOWED_IMAGE_TYPES.includes(file.type);
const isVideoFile = (file: File): boolean => const isVideoFile = (file: File): boolean =>

View File

@@ -111,6 +111,23 @@ export type BoardItem = {
thumbnail?: ImageItem; //Leave it for previous data structure thumbnail?: ImageItem; //Leave it for previous data structure
}; };
// Board : Types // Board : Types
export interface WadizBoard extends BoardItem {
name: string;
paymentId: string;
email: string;
phone: string;
address: string;
emergencyPhone: string;
scheduleStart?: string | Date;
scheduleEnd?: string | Date;
attending: 'yes' | 'no';
altName?: string;
altPhone?: string;
companions?: string;
healthNotes?: string;
remarks?: string;
}
export interface ProjectBoard extends BoardItem { export interface ProjectBoard extends BoardItem {
subtitle: string; subtitle: string;
displayDate?: string; displayDate?: string;

View File

@@ -1,33 +1,60 @@
// Your verifyHelper.ts (or wherever this function is)
import { getAuth } from 'firebase-admin/auth'; import { getAuth } from 'firebase-admin/auth';
import type { Request } from 'express'; import type { Request } from 'express';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { logger } from 'firebase-functions/v2'; // <--- Make sure you import logger here
export type DecodedAuthInfo = { export type DecodedAuthInfo = {
uid: string; uid: string;
role: number; role: number;
}; };
/**
* Verifies the session cookie from the request and returns the decoded auth info.
* Throws an error if invalid or missing.
* @param {Request} req - The Express request object containing the session cookie
* @param {Response} [res] - Optional Express response object for sending error responses
*/
export async function verifySessionFromRequest( export async function verifySessionFromRequest(
req: Request, req: Request,
): Promise<DecodedAuthInfo | null> { ): Promise<DecodedAuthInfo | null> {
// --- ADD THESE LOGS ---
logger.info('verifySessionFromRequest: STARTING verification');
logger.info(
'verifySessionFromRequest: Raw headers.cookie:',
req.headers.cookie,
); // Log the raw cookie header
// --- END ADDED LOGS ---
const cookies = cookie.parse(req.headers.cookie || ''); const cookies = cookie.parse(req.headers.cookie || '');
// --- ADD THESE LOGS ---
logger.info('verifySessionFromRequest: Parsed cookies object:', cookies); // Log the parsed cookies object
// --- END ADDED LOGS ---
const session = cookies.__session; const session = cookies.__session;
// --- ADD THESE LOGS ---
logger.info(
'verifySessionFromRequest: __session cookie value:',
session ? 'Found (length: ' + session.length + ')' : 'Not Found',
);
// --- END ADDED LOGS ---
if (!session) { if (!session) {
logger.warn('verifySessionFromRequest: No __session cookie found.'); // Specific log for missing cookie
return null; return null;
} }
try { try {
const decoded = await getAuth().verifySessionCookie(session, true); const decoded = await getAuth().verifySessionCookie(session, true);
const role = (decoded.role || 0) as number; const role = (decoded.role || 0) as number;
logger.info(
'verifySessionFromRequest: Session cookie decoded successfully for UID:',
decoded.uid,
); // Log success
return { uid: decoded.uid, role }; return { uid: decoded.uid, role };
} catch (err) { } catch (err: unknown) {
console.error('[Auth] Session cookie verification failed:', err); // Use 'any' type for err to easily access its properties
logger.error(
'[Auth] Session cookie verification failed:',
err instanceof Error ? err.message : String(err),
); // Log the actual error message
return null; return null;
} }
} }