Compare commits
8 Commits
704c21ec7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2661ec376b | |||
| c9f19e28ba | |||
|
|
1eba2a0a49 | ||
| e6d5f0436d | |||
| 0f8f0c2f51 | |||
| f7b2ba7f9c | |||
| c76c49d42f | |||
| 8fd4066d42 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -67,3 +67,4 @@ node_modules/
|
||||
|
||||
# dataconnect generated files
|
||||
.dataconnect
|
||||
sendgrid.env
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
<template>
|
||||
<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
|
||||
v-for="(imgSrc, idx) in images"
|
||||
: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
|
||||
:src="imgSrc"
|
||||
@@ -13,7 +36,6 @@
|
||||
/>
|
||||
</Slide>
|
||||
|
||||
<!-- Add navigation arrows and pagination indicators -->
|
||||
<template #addons>
|
||||
<Navigation />
|
||||
<Pagination />
|
||||
@@ -21,54 +43,26 @@
|
||||
</Carousel>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Carousel, Slide, Navigation, Pagination } from 'vue3-carousel';
|
||||
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));
|
||||
|
||||
// Carousel configuration
|
||||
const config = {
|
||||
height: 500, // fixed height (px); adjust as needed (500px here)
|
||||
itemsToShow: 1, // always show one slide at a time
|
||||
gap: 0, // no horizontal gap between slides
|
||||
wrapAround: true, // loop infinitely
|
||||
mouseWheel: false, // disable mouse-wheel navigation
|
||||
autoplay: 4000, // change slide every 4 seconds
|
||||
height: 500,
|
||||
itemsToShow: 1,
|
||||
wrapAround: true,
|
||||
autoplay: 4000,
|
||||
pauseAutoplayOnHover: true,
|
||||
breakpoints: {
|
||||
1280: { itemsToShow: 1, height: 500 }, // ≥2xl
|
||||
1024: { itemsToShow: 1, height: 450 }, // ≥xl
|
||||
768: { itemsToShow: 1, height: 400 }, // ≥md
|
||||
0: { itemsToShow: 1, height: 300 }, // mobile
|
||||
1280: { height: 500 },
|
||||
1024: { height: 450 },
|
||||
768: { height: 400 },
|
||||
0: { height: 300 },
|
||||
},
|
||||
};
|
||||
</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>
|
||||
|
||||
@@ -1,54 +1,120 @@
|
||||
<template>
|
||||
<header class="relative isolate z-10 bg-white dark:bg-gray-900 shadow-sm">
|
||||
<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"
|
||||
>
|
||||
<!-- MobileSidebar Open Button -->
|
||||
<!-- Left: MobileSidebar Button -->
|
||||
<div class="flex justify-start">
|
||||
<!-- <button
|
||||
<button
|
||||
type="button"
|
||||
class="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700 dark:text-white"
|
||||
@click="mobileMenuOpen = true"
|
||||
aria-label="Open main menu"
|
||||
>
|
||||
<Bars3Icon class="size-6" aria-hidden="true" />
|
||||
</button> -->
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<!-- Logo -->
|
||||
<div class="flex">
|
||||
<!-- Center: Logo -->
|
||||
<div class="flex justify-center">
|
||||
<NuxtLink to="/" class="-m-1.5 p-1.5" aria-label="Bobu Home">
|
||||
<span class="sr-only">Bobu</span>
|
||||
<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"
|
||||
alt="Bobu Logo Light"
|
||||
/>
|
||||
<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"
|
||||
alt="Bobu Logo Dark"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</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 -->
|
||||
<div class="flex justify-end">
|
||||
<!-- <HeaderActions /> -->
|
||||
<!-- Right: Buttons -->
|
||||
<div class="flex justify-end items-center">
|
||||
<!-- 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>
|
||||
</nav>
|
||||
|
||||
<!-- MobileSidebar -->
|
||||
|
||||
<Dialog
|
||||
@@ -117,6 +183,19 @@
|
||||
</div>
|
||||
|
||||
<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 로그인/회원가입 -->
|
||||
<NuxtLink
|
||||
v-if="!userStore.userLoggedIn"
|
||||
|
||||
3436
bobu/app/components/PersonalInformation.vue
Normal file
3436
bobu/app/components/PersonalInformation.vue
Normal file
File diff suppressed because it is too large
Load Diff
2243
bobu/app/components/TermsofUse.vue
Normal file
2243
bobu/app/components/TermsofUse.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,20 +8,25 @@
|
||||
<p
|
||||
class="text-center text-2xl font-extrabold text-indigo-500 dark:text-indigo-400"
|
||||
>
|
||||
와디즈 펀딩 참여자 발송 페이지
|
||||
가벼운 채비로 떠나는 정선 백패킹 포레스트
|
||||
</p>
|
||||
<figure class="mt-10">
|
||||
<blockquote
|
||||
class="text-center text-lg font-normal text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<p>안녕하세요, 메이커 보부입니다!</p>
|
||||
<p>보부의 첫 여정에 마음을 더해주셔서 감사합니다.</p>
|
||||
<p>여러분의 따뜻한 응원 덕분에,</p>
|
||||
<p>정선의 자연속 1박 2일의 쉼을 준비할 수 있었습니다.</p>
|
||||
<p>자연에 기대어, 잠시 천천히 머물러보는 시간을 선물해드릴게요.</p>
|
||||
<p class="mt-4 font-semibold">
|
||||
“보부와 함께할 하루, 이제 예약으로 이어집니다."
|
||||
<p>안녕하세요. 주식회사 보부입니다.</p>
|
||||
<p>정선 백패킹 포레스트 예약 시스템입니다.</p>
|
||||
<br />
|
||||
<p>
|
||||
원활한 운영을 위해 아래 '참여자 정보 입력하기'를 클릭 후 문항을
|
||||
작성해주시기 바랍니다.
|
||||
</p>
|
||||
<p>
|
||||
정성스러운 답변은 더 나은 경험을 준비하는 데 큰 도움이 됩니다.
|
||||
</p>
|
||||
<br />
|
||||
|
||||
<p>감사합니다.</p>
|
||||
</blockquote>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
44
bobu/app/components/about/AboutSection0.vue
Normal file
44
bobu/app/components/about/AboutSection0.vue
Normal 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>
|
||||
@@ -1,76 +1,84 @@
|
||||
<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">
|
||||
<!-- Title -->
|
||||
<h2
|
||||
class="text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl"
|
||||
>
|
||||
노마드소셜클럽 보부<br />
|
||||
<span class="text-xl font-medium text-gray-600 dark:text-gray-300"
|
||||
>Nomad Social Club BOBU</span
|
||||
>
|
||||
<span class="text-xl font-medium text-gray-600 dark:text-gray-300">
|
||||
Nomad Social Club BOBU
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Image Grid -->
|
||||
<div
|
||||
class="mt-12 mx-auto grid max-w-4xl grid-cols-2 gap-6 px-4 sm:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="(imgSrc, idx) in images"
|
||||
:key="idx"
|
||||
class="flex justify-center"
|
||||
class="mt-10 mx-auto grid max-w-3xl grid-cols-2 sm:grid-cols-3 gap-4 px-4"
|
||||
>
|
||||
<img
|
||||
v-for="(imgSrc, idx) in images"
|
||||
:key="idx"
|
||||
:src="imgSrc"
|
||||
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>
|
||||
|
||||
<!-- Description -->
|
||||
<div
|
||||
class="mt-12 mx-auto max-w-2xl px-4 text-center text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<!-- Description Text -->
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link Buttons with Logos -->
|
||||
<div class="mt-8 flex justify-center space-x-6 px-4">
|
||||
<!-- 네이버플레이스 -->
|
||||
<!-- Link Buttons -->
|
||||
<div class="mt-10 flex justify-center px-4">
|
||||
<!-- 네이버 예약 (Place or Reservation) -->
|
||||
<a
|
||||
:href="socialLinks.naver"
|
||||
target="_blank"
|
||||
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
|
||||
:src="socialImages.naver"
|
||||
alt="네이버플레이스 로고"
|
||||
alt="네이버 예약 로고"
|
||||
class="h-6 w-6 object-contain"
|
||||
/>
|
||||
<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>
|
||||
<span>공유 오피스 예약하기</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -81,14 +89,11 @@ import { computed } from 'vue';
|
||||
import { MAIN2_IMAGES, SOCIAL_IMAGES } from '@/data/assets';
|
||||
import { SOCIAL_LINKS } from '@/data/config';
|
||||
|
||||
// 1) MAIN2_IMAGES → 배열
|
||||
const images = computed<string[]>(() => Object.values(MAIN2_IMAGES));
|
||||
|
||||
// 2) SOCIAL_IMAGES (로고)와 SOCIAL_LINKS (URL) 노출
|
||||
const images = [MAIN2_IMAGES.main1, MAIN2_IMAGES.main2, MAIN2_IMAGES.main3];
|
||||
const socialImages = SOCIAL_IMAGES;
|
||||
const socialLinks = SOCIAL_LINKS;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Tailwind 유틸리티만으로 충분하므로 추가 CSS 불필요 */
|
||||
/* Tailwind only */
|
||||
</style>
|
||||
|
||||
@@ -1,97 +1,77 @@
|
||||
<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 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"
|
||||
<section
|
||||
class="bg-white pt-20 pb-16 sm:pt-24 sm:pb-24 xl:pb-32 dark:bg-gray-900"
|
||||
>
|
||||
<!-- Quote + Image Card -->
|
||||
<div class="lg:pr-4">
|
||||
<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
|
||||
class="absolute inset-0 w-full h-full object-cover brightness-105 saturate-100"
|
||||
src="/assets/img/shoot.jpg"
|
||||
alt="Bobu team visual"
|
||||
class="absolute inset-0 size-full rounded-2xl bg-gray-200 object-cover shadow-2xl dark:bg-gray-700 dark:shadow-none"
|
||||
:src="MAIN2_IMAGES.main3"
|
||||
alt="BOBU Nomad Social Club"
|
||||
/>
|
||||
<!-- dark overlay ↔ lighten in light mode -->
|
||||
<div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
|
||||
<figure class="relative isolate">
|
||||
<blockquote class="mt-6 text-xl font-semibold text-white">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Text block -->
|
||||
<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>
|
||||
{{ $t('about.quote') }}
|
||||
‘노마드 (생활인구)’<br />
|
||||
‘소셜 (관계 맺음)’<br />
|
||||
‘클럽 (공간)’
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption class="mt-6 text-sm text-gray-300">
|
||||
<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">
|
||||
<!-- Middle: 첫 문단 -->
|
||||
<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>
|
||||
<h1
|
||||
class="mt-2 text-4xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-5xl"
|
||||
<!-- Bottom: 두 번째 문단 -->
|
||||
<p
|
||||
class="mt-4 text-base/7 sm:text-lg/8 text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{{ $t('about.sectionTitle') }}
|
||||
</h1>
|
||||
|
||||
<div class="max-w-xl">
|
||||
<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">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
정선을 찾는 생활인구와 노마드 워커들을 위한 일(WORK)과 쉼(STOP)의
|
||||
공간을 운영하며 로컬을 경험하고 즐길 수 있는 콘텐츠를 제작합니다.
|
||||
</p>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const stats = [
|
||||
{ label: 'Founded', value: '2018' },
|
||||
{ label: 'Projects Delivered', value: '120+' },
|
||||
{ label: 'Technologies', value: 'FILM / XR / 3D' },
|
||||
{ label: 'Countries', value: '3' },
|
||||
];
|
||||
import { MAIN2_IMAGES } from '@/data/assets'; // Presentational only – no logic needed
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Tailwind utilities handle all dark/light styling */
|
||||
/* Tailwind handles visuals */
|
||||
</style>
|
||||
|
||||
@@ -1,94 +1,107 @@
|
||||
<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-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>
|
||||
<section class="bg-white dark:bg-gray-900 py-16 sm:py-24">
|
||||
<div class="mx-auto max-w-4xl px-4">
|
||||
<!-- Heading -->
|
||||
<header class="text-center">
|
||||
<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>
|
||||
</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>
|
||||
</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 setup lang="ts">
|
||||
import { MAIN2_IMAGES, SOCIAL_IMAGES } from '@/data/assets';
|
||||
|
||||
const imageList = [
|
||||
MAIN2_IMAGES.main1,
|
||||
MAIN2_IMAGES.main2,
|
||||
MAIN2_IMAGES.main3,
|
||||
MAIN2_IMAGES.main4,
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
Ensure you have the Tailwind line-clamp plugin enabled for “line-clamp-2”.
|
||||
*/
|
||||
.pill {
|
||||
@apply rounded-full bg-white px-3 py-1 shadow-sm dark:bg-gray-900/60;
|
||||
}
|
||||
</style>
|
||||
|
||||
92
bobu/app/components/about/AboutSection5.vue
Normal file
92
bobu/app/components/about/AboutSection5.vue
Normal 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>
|
||||
67
bobu/app/components/about/AboutSection6.vue
Normal file
67
bobu/app/components/about/AboutSection6.vue
Normal 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>
|
||||
52
bobu/app/components/about/AboutSection7.vue
Normal file
52
bobu/app/components/about/AboutSection7.vue
Normal 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>
|
||||
721
bobu/app/components/auth/register-form.vue
Normal file
721
bobu/app/components/auth/register-form.vue
Normal 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>
|
||||
위의 이용약관에 동의하십니까?
|
||||
</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>
|
||||
위와 같이 개인정보를 수집·이용하는 데 동의하십니까?
|
||||
</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>
|
||||
243
bobu/app/components/auth/user-login.vue
Normal file
243
bobu/app/components/auth/user-login.vue
Normal 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>
|
||||
@@ -362,7 +362,7 @@
|
||||
<ul class="mt-4 list-disc pl-4 space-y-2">
|
||||
<li>
|
||||
<strong>수집 항목:</strong>
|
||||
소속(기관명/부서명), 성명, 생년월일, 연락처, 이메일, 주소
|
||||
성명, 생년월일, 연락처, 이메일, 주소
|
||||
</li>
|
||||
<li>
|
||||
<strong>수집 및 이용 목적:</strong>
|
||||
@@ -439,6 +439,8 @@ import { syncBoardAndUploadsData } from '@/utils/boardUtils';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
//types
|
||||
import { UploadSettings } from '@/data/config';
|
||||
import { sendBoardEmail } from '@/utils/api/sendBoardEmail';
|
||||
|
||||
import type {
|
||||
WadizBoard,
|
||||
BoardItem,
|
||||
@@ -461,10 +463,12 @@ const userId = computed(() => userStore.docId);
|
||||
const { $firebase } = useNuxtApp();
|
||||
const wadizesCollection = $firebase.wadizesCollection;
|
||||
const currentCollection = wadizesCollection;
|
||||
const currentBoard = 'wadiz';
|
||||
const currentBoard = 'wadizes';
|
||||
const compData = {
|
||||
title: '공지사항 | NOTICE',
|
||||
title: 'WADIZES | 예약 신청',
|
||||
};
|
||||
import { generateWadizUploadedEmail } from '@/utils/emailTemplates/wadizUploaded';
|
||||
|
||||
//loading Message
|
||||
const loadingMessage = 'Uploading! 잠시만 기다려주세요...';
|
||||
const isUploading = ref(false);
|
||||
@@ -598,6 +602,19 @@ const handleUpload = async () => {
|
||||
})) as WadizBoard;
|
||||
|
||||
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 don’t abort the UX
|
||||
}
|
||||
showAlert('성공적으로 수정되었습니다', 'bg-green-800');
|
||||
emit('success');
|
||||
} else {
|
||||
@@ -628,7 +645,22 @@ const handleUpload = async () => {
|
||||
'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 don’t abort the UX
|
||||
}
|
||||
await handleCreateBoard(boardPayload, currentCollection);
|
||||
|
||||
showAlert('성공적으로 생성되었습니다', 'bg-green-800', true);
|
||||
emit('success');
|
||||
}
|
||||
|
||||
156
bobu/app/components/boards/wadiz/WadizListSingle.vue
Normal file
156
bobu/app/components/boards/wadiz/WadizListSingle.vue
Normal 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>
|
||||
@@ -21,7 +21,7 @@ export function useBoardList<T extends BoardItem>(
|
||||
title = 'Board',
|
||||
itemsPerPage = 20,
|
||||
defaultSort = 'desc',
|
||||
access = 'public',
|
||||
access,
|
||||
loadingMessage = '잠시만 기다려주세요...',
|
||||
} = options;
|
||||
/* UI + meta */
|
||||
|
||||
@@ -43,4 +43,5 @@ export const SOCIAL_IMAGES = {
|
||||
naver: '/assets/img/logo/naver_resized.jpg',
|
||||
instagram: '/assets/img/logo/instagram.webp',
|
||||
kakao: '/assets/img/logo/kakao_resized.jpg',
|
||||
npay: '/assets/img/logo/npay.jpg',
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// @/data/config.ts
|
||||
|
||||
export const BASE_NAV_ITEMS = [
|
||||
{ name: '소개', href: '/about', icon: ['fas', 'info-circle'] },
|
||||
{ name: '공유 오피스 예약', href: '/office', icon: ['fas', 'calendar-alt'] },
|
||||
{ name: 'BOBU', href: '/about', icon: ['fas', 'info-circle'] },
|
||||
{ name: 'Backpacking', href: '/office', icon: ['fas', 'calendar-alt'] },
|
||||
{
|
||||
name: '워크케이션 프로그램',
|
||||
name: 'Backpacking Community',
|
||||
href: '/',
|
||||
icon: ['fas', 'umbrella-beach'],
|
||||
},
|
||||
{ name: '쇼핑하기', href: '/shop', icon: ['fas', 'shopping-bag'] },
|
||||
{ name: '문의하기', href: '/contact', icon: ['fas', 'envelope'] },
|
||||
{ name: 'WORKATION Inquiry', href: '/contact', icon: ['fas', 'envelope'] },
|
||||
|
||||
{ name: 'SHOP', href: '/shop', icon: ['fas', 'shopping-bag'] },
|
||||
{
|
||||
name: '와디즈 펀딩 참여자',
|
||||
href: '/wadiz',
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<!-- Featured Projects Section -->
|
||||
<!-- <FeaturesCarousel />
|
||||
<FeaturesCarousel />
|
||||
<AboutSection0 />
|
||||
<AboutSection2 />
|
||||
<AboutSection1 />
|
||||
<AboutSection3 /> -->
|
||||
<AboutSection3 />
|
||||
<AboutSection6 />
|
||||
<AboutSection7 />
|
||||
<AboutSection5 />
|
||||
|
||||
<!-- <AppWadiz /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// import AppWadiz from '~/pages/wadiz/index.vue';
|
||||
import AppWadiz from '~/pages/wadiz/index.vue';
|
||||
import { MAIN } from '~/data/assets';
|
||||
definePageMeta({
|
||||
redirect: '/wadiz',
|
||||
});
|
||||
// definePageMeta({
|
||||
// redirect: '/wadiz',
|
||||
// });
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
20
bobu/app/pages/register.vue
Normal file
20
bobu/app/pages/register.vue
Normal 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>
|
||||
447
bobu/app/pages/wadiz/[docId].vue
Normal file
447
bobu/app/pages/wadiz/[docId].vue
Normal 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>
|
||||
101
bobu/app/pages/wadiz/manage.vue
Normal file
101
bobu/app/pages/wadiz/manage.vue
Normal 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>
|
||||
@@ -75,6 +75,7 @@ export const useUserStore = defineStore('user', {
|
||||
everLoggedIn = true;
|
||||
const idToken = await user.getIdToken();
|
||||
await createSession(idToken);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100)); // Wait for 100 milliseconds
|
||||
this.userLoggedIn = true;
|
||||
this.initializeListener();
|
||||
} else if (everLoggedIn) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { useRuntimeConfig } from '#imports';
|
||||
import type {
|
||||
BoardItem,
|
||||
BoardAccessMode,
|
||||
CursorResponse,
|
||||
UseBoardListOptions,
|
||||
} from '~/types';
|
||||
import type { BoardItem, BoardAccessMode, CursorResponse } from '~/types';
|
||||
|
||||
/* ---------- params accepted by this helper --------------------- */
|
||||
interface FetchBoardsParams {
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
pageNumber?: number;
|
||||
@@ -15,9 +9,11 @@ interface FetchBoardsParams {
|
||||
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>(
|
||||
collection: string,
|
||||
{
|
||||
@@ -29,7 +25,7 @@ export async function fetchBoardsFromFunction<T extends BoardItem>(
|
||||
}: FetchBoardsParams = {}
|
||||
): Promise<CursorResponse<T>> {
|
||||
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> = {
|
||||
collection,
|
||||
@@ -39,9 +35,11 @@ export async function fetchBoardsFromFunction<T extends BoardItem>(
|
||||
...(isCursorMode ? { pageToken } : { pageNumber }),
|
||||
};
|
||||
|
||||
return await $fetch<CursorResponse<T>>(`${FUNCTION_BASE}/${endpoint}`, {
|
||||
const result = await $fetch(endpoint, {
|
||||
method: 'GET',
|
||||
params,
|
||||
credentials: access !== 'public' ? 'include' : undefined,
|
||||
});
|
||||
|
||||
return result as CursorResponse<T>;
|
||||
}
|
||||
|
||||
33
bobu/app/utils/api/fetchSingleItemFromFunction.ts
Normal file
33
bobu/app/utils/api/fetchSingleItemFromFunction.ts
Normal 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;
|
||||
}
|
||||
24
bobu/app/utils/api/sendBoardEmail.ts
Normal file
24
bobu/app/utils/api/sendBoardEmail.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// utils/api/verifyFromFunction.ts
|
||||
|
||||
const VERIFY_URL = 'https://verifysession-edvvp3hbnq-du.a.run.app';
|
||||
|
||||
export type VerifiedSession = {
|
||||
@@ -5,9 +7,39 @@ export type VerifiedSession = {
|
||||
email: string;
|
||||
role: number;
|
||||
};
|
||||
|
||||
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',
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ export const loadBoardDetails = async (
|
||||
router.push({ name: `${currentboard}list` });
|
||||
}
|
||||
}
|
||||
console.log('board loaded', board.value);
|
||||
} catch (error) {
|
||||
console.error('Error loading board details:', error);
|
||||
}
|
||||
|
||||
88
bobu/app/utils/emailTemplates/wadizUploaded.ts
Normal file
88
bobu/app/utils/emailTemplates/wadizUploaded.ts
Normal 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
5501
bobu/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
"@nuxtjs/sitemap": "^7.2.7",
|
||||
"@nuxtjs/tailwindcss": "^6.13.2",
|
||||
"@pinia/nuxt": "^0.10.1",
|
||||
"@sendgrid/mail": "^8.1.5",
|
||||
"@vee-validate/i18n": "^4.15.0",
|
||||
"@vee-validate/nuxt": "^4.15.0",
|
||||
"@vee-validate/rules": "^4.15.0",
|
||||
@@ -34,7 +35,7 @@
|
||||
"firebase-functions": "^6.3.2",
|
||||
"install": "^0.13.0",
|
||||
"npm": "^11.2.0",
|
||||
"nuxt": "^3.16.0",
|
||||
"nuxt": "^3.17.0",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
|
||||
BIN
bobu/public/assets/img/logo/npay.jpg
Normal file
BIN
bobu/public/assets/img/logo/npay.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
109
functions/package-lock.json
generated
109
functions/package-lock.json
generated
@@ -6,7 +6,8 @@
|
||||
"": {
|
||||
"name": "functions",
|
||||
"dependencies": {
|
||||
"firebase-admin": "^13.3.0",
|
||||
"@sendgrid/mail": "^8.1.5",
|
||||
"firebase-admin": "^13.4.0",
|
||||
"firebase-functions": "^6.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1995,6 +1996,44 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
@@ -2869,8 +2908,7 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
@@ -2888,6 +2926,33 @@
|
||||
"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": {
|
||||
"version": "29.7.0",
|
||||
"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",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -3604,9 +3668,7 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3652,7 +3714,6 @@
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
@@ -3912,7 +3973,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -4654,9 +4714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/firebase-admin": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.3.0.tgz",
|
||||
"integrity": "sha512-MFxv86Aw2rjM/TpKwU86jN7YUFfN1jy6mREYZTLL1aW1rCpZFi4c70b9U12J9Xa4RbJkiXpWBAwth9IVSqR91A==",
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.4.0.tgz",
|
||||
"integrity": "sha512-Y8DcyKK+4pl4B93ooiy1G8qvdyRMkcNFfBSh+8rbVcw4cW8dgG0VXCCTp5NUwub8sn9vSPsOwpb9tE2OuFmcfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^3.0.0",
|
||||
@@ -4743,6 +4803,26 @@
|
||||
"license": "ISC",
|
||||
"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": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -5249,7 +5329,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -7819,6 +7898,12 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"dependencies": {
|
||||
"firebase-admin": "^13.3.0",
|
||||
"@sendgrid/mail": "^8.1.5",
|
||||
"firebase-admin": "^13.4.0",
|
||||
"firebase-functions": "^6.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,16 +4,19 @@ import { initializeApp, getApps } from 'firebase-admin/app';
|
||||
import * as cookie from 'cookie';
|
||||
import { logger } from 'firebase-functions/v2';
|
||||
import { cookieExpiration, corsMiddlewareHandler } from '../config';
|
||||
|
||||
// ✅ Admin SDK init
|
||||
if (!getApps().length) {
|
||||
initializeApp(); // ✅ auto uses service account in Firebase Functions
|
||||
initializeApp();
|
||||
}
|
||||
|
||||
export const createSession = onRequest(
|
||||
{ region: 'asia-northeast3' },
|
||||
async (req, res) => {
|
||||
return corsMiddlewareHandler(req, res, async () => {
|
||||
logger.info('countBoards: CORS‑enabled function called');
|
||||
// Fix this logger message: it should be about createSession, not countBoards
|
||||
logger.info('createSession: CORS-enabled function called');
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).send('Method not allowed');
|
||||
return;
|
||||
@@ -38,7 +41,7 @@ export const createSession = onRequest(
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
maxAge: expiresIn / 1000,
|
||||
sameSite: 'lax' as const,
|
||||
sameSite: 'none' as const,
|
||||
path: '/',
|
||||
};
|
||||
|
||||
@@ -48,6 +51,10 @@ export const createSession = onRequest(
|
||||
);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'createSession: Invalid token or session cookie creation failed',
|
||||
error,
|
||||
); // Added more specific logging
|
||||
res.status(401).send('Invalid token');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// In your verifySession.ts (the API endpoint Cloud Function)
|
||||
import { onRequest } from 'firebase-functions/v2/https';
|
||||
import * as cookie from 'cookie';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
import { initializeApp, getApps } from 'firebase-admin/app';
|
||||
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) {
|
||||
initializeApp();
|
||||
}
|
||||
@@ -14,17 +14,31 @@ export const verifySession = onRequest(
|
||||
{ region: 'asia-northeast3' },
|
||||
async (req, res) => {
|
||||
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') {
|
||||
res.status(405).send('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const cookies = cookie.parse(req.headers.cookie || '');
|
||||
const session = cookies.__session;
|
||||
|
||||
if (!session) {
|
||||
logger.warn(
|
||||
'verifySession (endpoint): No __session cookie found, returning 401.',
|
||||
); // Specific warn
|
||||
res.status(401).send({ message: 'No session cookie' });
|
||||
return;
|
||||
}
|
||||
@@ -32,14 +46,22 @@ export const verifySession = onRequest(
|
||||
try {
|
||||
const decoded = await getAuth().verifySessionCookie(session, true);
|
||||
const role = decoded.role || 0;
|
||||
logger.info(
|
||||
'verifySession (endpoint): Session verified successfully for UID:',
|
||||
decoded.uid,
|
||||
); // Log success
|
||||
|
||||
res.status(200).json({
|
||||
uid: decoded.uid,
|
||||
email: decoded.email,
|
||||
role,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('verifySession: Session invalid or expired', err);
|
||||
} catch (err: unknown) {
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
|
||||
73
functions/src/board/boardMail.ts
Normal file
73
functions/src/board/boardMail.ts
Normal 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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -26,32 +26,22 @@ export const fetchBoards = onRequest(
|
||||
return corsMiddlewareHandler(request, response, async () => {
|
||||
logger.info('fetchBoards: CORS‑enabled function called');
|
||||
|
||||
/* 1.Method guard ------------------------------------------------------------------ */
|
||||
/* 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.Read & validate query params --------------------------------------------------- */
|
||||
/* 2.Read & validate query params EARLY */
|
||||
const {
|
||||
collection = '',
|
||||
sortOrder = 'desc',
|
||||
itemsPerPage = '10',
|
||||
access = 'public',
|
||||
access = 'public', // Default to 'public' if not provided
|
||||
pageToken,
|
||||
} = request.query as Record<string, string>;
|
||||
|
||||
// ❶collection allow‑list
|
||||
// ❶ collection allow-list
|
||||
if (
|
||||
!ALLOWED_COLLECTIONS.has(
|
||||
collection as unknown as typeof ALLOWED_COLLECTIONS extends Set<
|
||||
@@ -65,25 +55,45 @@ export const fetchBoards = onRequest(
|
||||
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)) {
|
||||
response.status(400).send({ message: 'Bad access mode' });
|
||||
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
|
||||
const limit = Math.max(1, Math.min(Number(itemsPerPage) || 10, 100));
|
||||
const order = sortOrder === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
/* 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>;
|
||||
try {
|
||||
queryRef = buildQueryWithAccessControl(
|
||||
db.collection(collection),
|
||||
effectiveAccess,
|
||||
access as BoardAccessMode, // Use the original 'access'
|
||||
userId,
|
||||
userRole,
|
||||
);
|
||||
@@ -141,7 +151,7 @@ export const fetchBoards = onRequest(
|
||||
)
|
||||
: null;
|
||||
|
||||
if (effectiveAccess === 'public')
|
||||
if (access === 'public')
|
||||
response.set('Cache-Control', 'public,max-age=60');
|
||||
|
||||
response.status(200).send({ items, nextPageToken });
|
||||
|
||||
112
functions/src/board/fetchSingleItem.ts
Normal file
112
functions/src/board/fetchSingleItem.ts
Normal 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 allow‑list
|
||||
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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './countBoard';
|
||||
export * from './fetchBoard';
|
||||
export * from './fetchSingleItem';
|
||||
export * from './boardMail';
|
||||
|
||||
@@ -82,6 +82,7 @@ const MAX_FILE_SIZE_MB = 20;
|
||||
const MAX_TOTAL_FILES = 10;
|
||||
export const ALLOWED_COLLECTIONS = new Set([
|
||||
'notices',
|
||||
'wadizes' /* … */,
|
||||
'projects' /* … */,
|
||||
] as const);
|
||||
const ALLOWED_IMAGE_TYPES = [
|
||||
@@ -123,6 +124,12 @@ const ALLOWED_FILE_TYPES = [
|
||||
|
||||
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 =>
|
||||
ALLOWED_IMAGE_TYPES.includes(file.type);
|
||||
const isVideoFile = (file: File): boolean =>
|
||||
|
||||
@@ -111,6 +111,23 @@ export type BoardItem = {
|
||||
thumbnail?: ImageItem; //Leave it for previous data structure
|
||||
};
|
||||
// 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 {
|
||||
subtitle: string;
|
||||
displayDate?: string;
|
||||
|
||||
@@ -1,33 +1,60 @@
|
||||
// Your verifyHelper.ts (or wherever this function is)
|
||||
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
import type { Request } from 'express';
|
||||
import * as cookie from 'cookie';
|
||||
import { logger } from 'firebase-functions/v2'; // <--- Make sure you import logger here
|
||||
|
||||
export type DecodedAuthInfo = {
|
||||
uid: string;
|
||||
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(
|
||||
req: Request,
|
||||
): 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 || '');
|
||||
|
||||
// --- ADD THESE LOGS ---
|
||||
logger.info('verifySessionFromRequest: Parsed cookies object:', cookies); // Log the parsed cookies object
|
||||
// --- END ADDED LOGS ---
|
||||
|
||||
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) {
|
||||
logger.warn('verifySessionFromRequest: No __session cookie found.'); // Specific log for missing cookie
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = await getAuth().verifySessionCookie(session, true);
|
||||
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 };
|
||||
} catch (err) {
|
||||
console.error('[Auth] Session cookie verification failed:', err);
|
||||
} catch (err: unknown) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user