예약 포털 (Vue3 + Firebase) - 서비스 오픈까지

39. Vue3 + Firebase 프로젝트 '우리 동네' - 쿠폰 관리 시스템

그랜파 개발자 2025. 6. 28. 21:21

쿠폰 관리 시스템

10,000원 주문 금액에 대해 1,000원 할인 쿠폰을 발급하는 시스템을 생각합니다.

 

주문 결제를 할 때 장바구니에서 사용할 수 있는 쿠폰의 목록을 보입니다.

주문 총금액 10,000원당 1,000원 할인 쿠폰을 발행하는 것이니

쿠폰의 목록에는 1,000원 할인 쿠폰 여러장이 보입니다.

이들 쿠폰 중 한장 또는 여러장을 선택하여 할인된 금액으로 결제할 수 있습니다.

 

결제를 하면 할인을 뺀 최종 결제 금액과 발급한 쿠폰의 수를 비교하여 

새로 쿠폰을 발급해야 한다면 1,000원 할인 쿠폰을 발급합니다.

 

마이페이지에서 내 쿠폰을 조회할 수 있습니다.

내 쿠폰에는 쿠폰이 발급된 날짜와 사용날짜 그리고 사용 가능 여부를 확인할 수 있습니다.

 

https://github.com/inetsos/downtown

 

GitHub - inetsos/downtown: 동네 포털 - Vue 3 + Firebase

동네 포털 - Vue 3 + Firebase. Contribute to inetsos/downtown development by creating an account on GitHub.

github.com

 

할인 쿠폰 사용

useCoupons

  • Vue에서 사용할 수 있는 커스텀 컴포저블 함수로 Firestore를 이용해 쿠폰 관련 비즈니스 로직을 처리하는 역할을 합니다.
// src/composables/useCoupons.js
import { ref } from 'vue'
import { db } from '@/firebase'
import {
  collection,
  query,
  where,
  getDocs,
  addDoc,
  updateDoc,
  doc,
  getDoc,
} from 'firebase/firestore'

export function useCoupons() {
  const loading = ref(false)
  const error = ref(null)

  // ✅ 누적 주문 금액 기준 쿠폰 발급
  const issueCouponsByTotalSpent = async (userId, companyId, companyName) => {
    loading.value = true
    error.value = null
    try {
      // 1. 해당 사용자의 누적 결제 금액 조회
      const ordersColRef = collection(db, 'companies', companyId, 'orders')
      const ordersSnap = await getDocs(query(ordersColRef, where('userId', '==', userId)))

      const totalSpent = ordersSnap.docs.reduce((sum, doc) => {
        return sum + (doc.data().finalAmount || 0)
      }, 0)

      // 2. 1만원당 1000원 쿠폰 1장 지급
      const expectedCouponCount = Math.floor(totalSpent / 10000)

      // 3. 이미 발급된 동일 조건 쿠폰 수 확인
      const couponsColRef = collection(db, 'companies', companyId, 'coupons')
      const issuedSnap = await getDocs(
        query(
          couponsColRef,
          where('userId', '==', userId),
          where('type', '==', 'fixed_discount'),
          where('value', '==', 1000)
        )
      )

      const alreadyIssued = issuedSnap.size
      const toIssue = expectedCouponCount - alreadyIssued

      // 4. 새로 발급해야 할 쿠폰 등록
      for (let i = 0; i < toIssue; i++) {
        await addDoc(couponsColRef, {
          userId,
          companyId,       // companyId 추가
          companyName,     // companyName 추가
          type: 'fixed_discount',
          value: 1000,
          issuedAt: new Date(),
          used: false,
        })
      }
    } catch (err) {
      error.value = err
      console.error('쿠폰 발급 오류:', err)
    } finally {
      loading.value = false
    }
  }

  // 모든 회사에 있는 해당 사용자의 쿠폰을 가져옴
  const fetchAllCoupons = async (userId) => {
    try {
      // 1. 모든 회사(company) 문서를 가져옴
      const companiesSnap = await getDocs(collection(db, 'companies'))

      // 2. 각 회사의 coupons 서브컬렉션에서 해당 유저의 쿠폰을 검색
      const couponPromises = companiesSnap.docs.map(async (companyDoc) => {
        const companyId = companyDoc.id
        const couponsColRef = collection(db, 'companies', companyId, 'coupons')
        const q = query(couponsColRef, where('userId', '==', userId))
        const snap = await getDocs(q)
        return snap.docs.map(doc => ({
          id: doc.id,
          companyId,
          ...doc.data(),
        }))
      })

      const results = await Promise.all(couponPromises)

      // 3. 2차원 배열을 평탄화
      return results.flat()
    } catch (err) {
      console.error('모든 쿠폰 조회 오류:', err)
      return []
    }
  }

  // ✅ 사용 가능한 쿠폰 조회
  const fetchAvailableCoupons = async (userId, companyId) => {
    try {
      const couponsColRef = collection(db, 'companies', companyId, 'coupons')
      const q = query(
        couponsColRef,
        where('userId', '==', userId),
        where('used', '==', false)
      )
      const snap = await getDocs(q)
      return snap.docs.map(doc => ({ id: doc.id, ...doc.data() }))
    } catch (err) {
      error.value = err
      console.error('쿠폰 조회 오류:', err)
      return []
    }
  }

  // ✅ 단일 쿠폰 사용
  const useCoupon = async (couponId, companyId) => {
    try {
      const couponRef = doc(db, 'companies', companyId, 'coupons', couponId)
      const couponSnap = await getDoc(couponRef)

      if (!couponSnap.exists()) throw new Error('쿠폰이 존재하지 않습니다.')

      const couponData = couponSnap.data()
      if (couponData.used) throw new Error('이미 사용된 쿠폰입니다.')

      await updateDoc(couponRef, {
        used: true,
        usedAt: new Date(),
      })
    } catch (err) {
      error.value = err
      console.error('쿠폰 사용 오류:', err)
      throw err
    }
  }

  // ✅ 복수 쿠폰 사용
  const useMultipleCoupons = async (couponIds = [], companyId) => {
    if (!Array.isArray(couponIds) || couponIds.length === 0) return

    try {
      const updatePromises = couponIds.map(async id => {
        const couponRef = doc(db, 'companies', companyId, 'coupons', id)
        const couponSnap = await getDoc(couponRef)

        if (!couponSnap.exists()) throw new Error(`쿠폰(${id})이 존재하지 않습니다.`)
        if (couponSnap.data().used) throw new Error(`쿠폰(${id})은 이미 사용됨.`)

        return updateDoc(couponRef, {
          used: true,
          usedAt: new Date(),
        })
      })

      await Promise.all(updatePromises)
    } catch (err) {
      error.value = err
      console.error('여러 쿠폰 사용 오류:', err)
      throw err
    }
  }

  return {
    loading,
    error,
    issueCouponsByTotalSpent,
    fetchAllCoupons,
    fetchAvailableCoupons,
    useCoupon,
    useMultipleCoupons,
  }
}

 

🌱 기본 구조

  • loading: 비동기 작업 중 로딩 상태 관리
  • error: 에러 발생 시 저장
  • db: Firebase Firestore 인스턴스

1. ✅ issueCouponsByTotalSpent(userId, companyId, companyName)

기능: 
  사용자의 누적 결제 금액에 따라 쿠폰 자동 발급 (1만 원당 1,000원 쿠폰)

 

로직:

  • 주문 내역에서 finalAmount 누적 계산
  • 1만 원당 한 장씩 쿠폰 예상 발급 개수 계산
  • 동일 조건으로 이미 발급된 쿠폰 개수 파악
  • 추가로 발급해야 할 만큼 쿠폰 생성 (중복 방지)

2. 🎁 fetchAllCoupons(userId)

기능:
   모든 회사의 쿠폰 서브컬렉션에서 해당 사용자의 쿠폰 전부 조회

 

로직:

  • 모든 회사 문서를 가져온 뒤
  • 각각의 회사에서 유저 쿠폰들을 조회
  • 최종적으로 flat()으로 쿠폰 배열 정리

3. 🔎 fetchAvailableCoupons(userId, companyId)

기능:
  특정 회사에서 사용 가능한(used: false) 쿠폰 목록 조회

 

4. ✂️ useCoupon(couponId, companyId)

기능:
  단일 쿠폰을 사용 처리

 

검증 로직 포함:

  • 쿠폰이 존재하는지 확인
  • 이미 사용됐는지 확인
  • 사용 여부 업데이트 (used: true, usedAt 저장)

5. 🧾 useMultipleCoupons(couponIds, companyId)

기능:
  복수 개의 쿠폰을 동시에 사용 처리

 

로직:

  • 쿠폰 하나하나 확인 후 사용 여부 업데이트
  • 병렬로 처리 (Promise.all)

🔁 반환값

return {
  loading,
  error,
  issueCouponsByTotalSpent,
  fetchAllCoupons,
  fetchAvailableCoupons,
  useCoupon,
  useMultipleCoupons
}

각 기능은 컴포넌트에서 import { useCoupons } from '@/composables/useCoupons'  후 호출해서 사용할 수 있어요.

 

쿠폰 사용 - CartPage.vue

  • 장바구니에서 주문하기 결제를 할 때 쿠폰을 사용합니다.
<!-- src/views/CartPage.vue -->
<template>
  <v-container>
    <v-card>
      <v-card-title class="text-h6">
        장바구니
        <span v-if="isAnonymous" class="ml-1"> - 비회원 주문</span>
      </v-card-title>
      <v-divider />

      <v-card-text v-if="cartItems.length">
        <v-row>
          <v-col v-for="(item, index) in cartItems" :key="index" cols="12">
            <v-card class="pa-2 d-flex flex-column align-center" elevation="1" rounded="lg">
              <div class="font-weight-bold text-subtitle-1 mb-1">[{{ item.categoryName }}] {{ item.name }}</div>
              <div class="text-body-2 text-grey-darken-1 mb-1">옵션: {{ item.option?.name || '없음' }}</div>
              <div class="text-body-2 mb-2">토핑: {{ item.toppings?.length ? item.toppings.map(t => t.name).join(', ') : '없음' }}</div>

              <div class="d-flex align-center mb-2" style="gap: 10px;">
                <v-btn icon size="x-small" @click="updateQuantity(index, -1)">
                  <v-icon>mdi-minus</v-icon>
                </v-btn>
                <span>{{ item.quantity }}</span>
                <v-btn icon size="x-small" @click="updateQuantity(index, 1)">
                  <v-icon>mdi-plus</v-icon>
                </v-btn>
              </div>

              <div class="text-body-1 text-primary font-weight-bold mb-3">
                {{ calcItemPrice(item).toLocaleString() }}원
              </div>

              <v-btn size="small" color="error" @click="removeItem(index)">삭제</v-btn>
            </v-card>
          </v-col>
        </v-row>

        <v-divider class="my-4" />

        <!-- 쿠폰 선택 영역 (회원만) -->
        <v-row dense no-gutters class="mt-1 mb-1">
          <v-col
            cols="12"
            md="6"
            class="py-0 my-0"
            v-for="coupon in availableCoupons"
            :key="coupon.id"
          >
            <v-checkbox
              v-model="selectedCouponIds"
              :value="coupon.id"
              :disabled="loading"
              density="compact"
              style="height: 32px;"
            >
              <template #label>
                <span>
                  <strong class="text-primary">
                    {{ coupon.value.toLocaleString() }}원 할인 쿠폰
                  </strong>
                  <span class="text-grey-darken-1">
                     - 발급일: {{ formatDate(coupon.issuedAt) }}
                    </span>
                </span>
              </template>
            </v-checkbox>
          </v-col>
        </v-row>

        <div class="text-right font-weight-bold text-h6 mt-2">
          총 합계: {{ totalAmount.toLocaleString() }}원<br />
          할인 합계: {{ discountAmount.toLocaleString() }}원<br />
          최종 결제 금액: {{ totalAfterDiscount.toLocaleString() }}원
        </div>
      </v-card-text>

      <v-card-text v-else>
        <v-alert type="info">장바구니에 담긴 항목이 없습니다.</v-alert>
      </v-card-text>

      <!-- 비회원 입력 영역 -->
      <v-card-text v-if="isAnonymous && cartItems.length">
        <v-text-field
          v-model="guestName"
          label="이름"
          prepend-inner-icon="mdi-account"
          required
          class="mb-2"
        />
        <v-text-field
          v-model="guestPhone"
          label="전화번호"
          prepend-inner-icon="mdi-phone"
          required
        />
      </v-card-text>

      <v-row class="mt-2 mb-4" dense justify="center" style="gap: 10px;">
        <v-col cols="auto">
          <v-btn color="success" :loading="loading" :disabled="loading" style="min-width: 120px;" @click="proceedToOrder">
            주문하기
          </v-btn>
        </v-col>
        <v-col cols="auto">
          <v-btn color="primary" style="min-width: 120px;" @click="goToMenu">메뉴 보기</v-btn>
        </v-col>
      </v-row>
    </v-card>
  </v-container>

  <!-- 스크롤 상단 버튼 -->
  <v-btn
    v-show="showScrollTop"
    icon
    color="primary"
    class="scroll-top-btn"
    @click="scrollToTop"
  >
    <v-icon>mdi-arrow-up</v-icon>
  </v-btn>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { useOrder } from '@/composables/useOrder'
import { useCoupons } from '@/composables/useCoupons'

const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()

const cartItems = ref([])
const guestName = ref('')
const guestPhone = ref('')
const isGuest = ref(false)

const isAnonymous = computed(() => authStore.user?.isAnonymous === true)
const isMember = computed(() => !!authStore.user && !authStore.user.isAnonymous)

const { loading, createOrder } = useOrder()
const { fetchAvailableCoupons, useMultipleCoupons } = useCoupons()

const availableCoupons = ref([])
const selectedCouponIds = ref([])

const totalAmount = computed(() =>
  cartItems.value.reduce((total, item) => total + calcItemPrice(item), 0)
)

const discountAmount = computed(() => {
  // 선택 쿠폰들의 할인액 총합 (value가 1000원 고정으로 가정)
  // 실제 value 필드를 더 정확히 계산할 수도 있음
  return availableCoupons.value
    .filter(c => selectedCouponIds.value.includes(c.id))
    .reduce((sum, c) => sum + (c.value || 0), 0)
})

const totalAfterDiscount = computed(() => totalAmount.value - discountAmount.value)

const formatDate = (timestamp) => {
  if (!timestamp) return ''
  // Firestore Timestamp 대응
  if (timestamp.toDate) return timestamp.toDate().toLocaleDateString()
  if (timestamp.seconds) return new Date(timestamp.seconds * 1000).toLocaleDateString()
  return new Date(timestamp).toLocaleDateString()
}

const proceedToOrder = async () => {
  if (!cartItems.value.length) {
    alert('장바구니가 비어있습니다.')
    return
  }

  const companyId = route.query.companyId
  if (!companyId) {
    alert('회사 ID가 없습니다.')
    return
  }

  if (isAnonymous.value && (!guestName.value || !guestPhone.value)) {
    alert('비회원 주문 시 이름과 전화번호를 입력해주세요.')
    return
  }

  if (totalAfterDiscount.value < 0) {
    alert('할인액이 총 금액보다 클 수 없습니다.')
    return
  }

  if (isAnonymous.value) isGuest.value = true

  const orderData = {
    userId: authStore.user?.uid || 'guest',
    userName: authStore.profile?.name || guestName.value || 'guest',
    userPhone: guestPhone.value || null,
    isGuest: isGuest.value,
    companyName: route.query.companyName || null,
    items: cartItems.value.map(item => ({
      categoryId: item.categoryId,
      categoryName: item.categoryName,
      menuId: item.menuId,
      name: item.name,
      price: item.price,
      quantity: item.quantity,
      toppings: item.toppings || [],
      option: item.option || null,
      imageUrl: item.imageUrl || null,
    })),
    totalAmount: totalAmount.value,
    discountAmount: discountAmount.value,
    finalAmount: totalAfterDiscount.value,
    createdAt: new Date(),
    couponIdsUsed: selectedCouponIds.value,
  }

  try {
    const orderId = await createOrder(companyId, orderData)

    if (isMember.value && selectedCouponIds.value.length > 0) {
      // 쿠폰 여러장 병렬 사용 처리
      await useMultipleCoupons(selectedCouponIds.value, companyId)
    }

    alert(`주문이 접수되었습니다! 주문 ID: ${orderId}`)

    localStorage.removeItem('cart')
    cartItems.value = []
    selectedCouponIds.value = []

    router.push('/')
  } catch (e) {
    console.error(e)
    alert('주문 처리 중 오류가 발생했습니다.')
  }
}

const goToMenu = () => {
  router.push({
    path: '/order',
    query: {
      companyId: route.query.companyId,
      companyName: route.query.companyName,
      username: authStore.profile?.name || 'guest',
      returnToCart: true
    }
  })
}

const updateQuantity = (index, delta) => {
  const item = cartItems.value[index]
  item.quantity = Math.max(1, item.quantity + delta)
  localStorage.setItem('cart', JSON.stringify(cartItems.value))
}

const removeItem = (index) => {
  cartItems.value.splice(index, 1)
  localStorage.setItem('cart', JSON.stringify(cartItems.value))
}

const calcItemPrice = (item) => {
  const base = item.price || 0
  const toppingSum = item.toppings?.reduce((sum, t) => sum + Number(t.price), 0) || 0
  return (base + toppingSum) * (item.quantity || 1)
}

const showScrollTop = ref(false)

onMounted(async () => {
  const saved = localStorage.getItem('cart')
  cartItems.value = saved ? JSON.parse(saved).map(item => ({
    ...item,
    quantity: item.quantity || 1
  })) : []

  if (isMember.value) {
    try {
      availableCoupons.value = await fetchAvailableCoupons(authStore.user.uid, route.query.companyId)
    } catch (err) {
      console.error('쿠폰 불러오기 실패:', err)
    }
  }

  window.addEventListener('scroll', scrollHandler)
})

const scrollHandler = () => {
  showScrollTop.value = window.scrollY > 200
}
const scrollToTop = () => {
  window.scrollTo({ top: 0, behavior: 'smooth' })
}

onUnmounted(() => window.removeEventListener('scroll', scrollHandler))
</script>

<style scoped>
.scroll-top-btn {
  position: fixed;
  bottom: 30px;
  right: 30px;
  z-index: 1000;
}
</style>

 

CartPage.vue 설명

✅ 페이지 역할

CartPage.vue 컴포넌트는 사용자가 장바구니에 담은 항목을 확인하고,

회원/비회원 여부에 따라 쿠폰을 적용하고 주문을 진행하는 기능을 제공합니다.

 

📦 장바구니 영역 ()

  • 항목별로 카테고리 / 메뉴명 / 옵션 / 토핑 / 수량 변경 UI / 항목 금액 표시
  • 수량 버튼(mdi-minus, mdi-plus)으로 갯수 조정, 로컬 스토리지 동기화
  • 삭제 버튼을 통해 항목 제거 가능
  • 상품 가격 계산: base price + toppings * quantity

🎟️ 쿠폰 선택 UI

  • 회원만 사용 가능: v-if="isMember" 조건에 따라 쿠폰 UI 노출
  • v-checkbox 컴포넌트로 선택, 각 쿠폰의 금액 강조
  • 체크한 쿠폰들의 value 값을 합산해 할인 금액 계산

👤 비회원 정보 입력 (v-if="isAnonymous && cartItems.length")

  • 이름과 전화번호를 필수로 입력해야 주문 가능
  • guestName, guestPhone에 입력 바인딩됨

💰 결제 금액 요약

  • 총 합계: 각 항목의 가격을 합산
  • 할인 합계: 선택된 쿠폰의 금액 총합
  • 최종 결제 금액: 총 합계에서 할인액 차감

📤 주문 처리 (proceedToOrder)

  • 필수값 검사: 장바구니 비어 있음 / 비회원 정보 입력 누락 / 할인 초과 여부 등
  • 주문 정보(orderData) 생성 후 Firebase의 useOrder().createOrder()로 전송
  • 주문 완료 시 쿠폰 사용 처리 (useMultipleCoupons)
  • 주문 성공 시 localStorage 초기화 후 홈으로 이동

🔃 쿠폰 불러오기 (onMounted)

  • 로그인된 회원이라면 fetchAvailableCoupons()을 통해 회사별 쿠폰을 가져옴
  • 비회원은 이 과정 생략

📜 기타 편의 기능

  • 스크롤이 아래로 내려가면 상단으로 이동 버튼 (mdi-arrow-up) 노출
  • 메뉴로 돌아가기 버튼 (goToMenu())

✨ Vuetify UI 커스터마이징 포인트

  • density="compact"와 height: 32px을 사용해 쿠폰 목록 UI를 컴팩트하게 구성
  • 할인 금액 강조를 위해 사용하여 시각적 포인트 부여

내 쿠폰 조회 

마이페이지 - MyPage.vue

 

  • src/views/MyPage.vue
<!-- src/views/MyPage.vue -->
<template>
  <v-container class="py-6">
    <v-row justify="center" align="center" dense>
      <v-col cols="12" md="6" lg="4">
        <v-card class="pa-4" elevation="2" @click="goToOrders" hover>
          <v-card-title class="text-h6">📦 주문 내역</v-card-title>
          <v-card-text class="text-body-2 text-grey-darken-1">
            지금까지 주문한 내역을 확인할 수 있어요.
          </v-card-text>
        </v-card>
      </v-col>

      <v-col cols="12" md="6" lg="4">
        <v-card class="pa-4" elevation="2" @click="goToReservations" hover>
          <v-card-title class="text-h6">📅 예약 내역</v-card-title>
          <v-card-text class="text-body-2 text-grey-darken-1">
            예약한 시간과 상태를 확인할 수 있어요.
          </v-card-text>
        </v-card>
      </v-col>

      <!-- ✅ 내 쿠폰 보기 -->
      <v-col cols="12" md="6" lg="4">
        <v-card class="pa-4" elevation="2" @click="goToCoupons" hover>
          <v-card-title class="text-h6">🎟️ 내 쿠폰</v-card-title>
          <v-card-text class="text-body-2 text-grey-darken-1">
            사용 가능한 쿠폰을 확인할 수 있어요.
          </v-card-text>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script setup>
import { useRouter } from 'vue-router'

const router = useRouter()

const goToOrders = () => {
  router.push('/my-orders')
}

const goToReservations = () => {
  router.push('/my-reservations')
}

const goToCoupons = () => {
  router.push('/my-coupons') // 해당 경로에 쿠폰 목록 페이지가 있어야 합니다.
}
</script>

 

내 쿠폰 - MyCoupons.vue

 

  • src/views/MyCoupons.vue
<!-- src/views/MyCoupons.vue -->
<template>
  <v-container class="py-6">
    <v-card>
      <v-card-title class="text-h6">🎟️ 보유 쿠폰</v-card-title>
      <v-divider />

      <v-card-text>
        <v-alert type="info" v-if="loading">쿠폰을 불러오는 중입니다...</v-alert>
        <v-alert type="info" v-else-if="coupons.length === 0">발급된 쿠폰이 없습니다.</v-alert>

        <!-- ✅ 회사명 기준으로 그룹화 렌더링 -->
        <div v-else>
          <div v-for="(group, companyName) in groupedCoupons" :key="companyName" class="mb-4">
            <h4 class="text-subtitle-1 font-weight-bold mb-2">{{ companyName }}</h4>
            <v-list density="compact">
              <v-list-item v-for="coupon in group" :key="coupon.id">
                <v-list-item-title class="font-weight-bold">
                  <v-icon color="primary" size="small">mdi-ticket-percent</v-icon>
                  {{ coupon.value.toLocaleString() }}원 할인 쿠폰
                </v-list-item-title>

                <v-list-item-subtitle>
                  발급일: {{ formatDate(coupon.issuedAt) }}
                  <template v-if="coupon.used && coupon.usedAt">
                    ・ 사용일: {{ formatDate(coupon.usedAt) }}
                  </template>
                </v-list-item-subtitle>
                <v-list-item-subtitle>
                  상태: 
                  <span :class="coupon.used ? 'text-grey' : 'text-success'">
                    <strong>{{ coupon.used ? '사용 완료' : '사용 가능' }}</strong>
                  </span>
                </v-list-item-subtitle>
              </v-list-item>
            </v-list>

          </div>
        </div>
      </v-card-text>
    </v-card>
  </v-container>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue'
import { useAuthStore } from '@/stores/authStore'
import { useCoupons } from '@/composables/useCoupons'

const authStore = useAuthStore()
const coupons = ref([])
const loading = ref(true)

const { fetchAllCoupons } = useCoupons()

const formatDate = (timestamp) => {
  if (!timestamp) return ''
  if (timestamp.toDate) return timestamp.toDate().toLocaleDateString()
  if (timestamp.seconds) return new Date(timestamp.seconds * 1000).toLocaleDateString()
  return new Date(timestamp).toLocaleDateString()
}

// ✅ 회사명 기준으로 그룹화된 쿠폰 목록 계산
const groupedCoupons = computed(() => {
  const groups = {}
  for (const coupon of coupons.value) {
    const name = coupon.companyName || '알 수 없는 상점'
    if (!groups[name]) groups[name] = []
    groups[name].push(coupon)
  }
  return groups
})

onMounted(async () => {
  try {
    if (authStore.user?.uid) {
      coupons.value = await fetchAllCoupons(authStore.user.uid)
    }
  } catch (err) {
    console.error('쿠폰 불러오기 실패:', err)
  } finally {
    loading.value = false
  }
})
</script>

 

📌 주요 기능

  • 사용자 인증 정보를 통해 해당 사용자의 모든 회사 쿠폰 불러오기
  • 쿠폰 데이터를 회사명 기준으로 그룹화
  • 발급일, 사용일, 사용 상태 등을 시각적으로 구분해 리스트 형태로 출력
  • 쿠폰 데이터 로딩 시 v-alert로 상태 안내

🔍 핵심 구조

coupons: 모든 쿠폰 목록 (ref)

  • Firestore로부터 불러온 결과를 담는 배열
  • useCoupons()에서 제공하는 fetchAllCoupons(userId) 호출로 세팅

groupedCoupons: 회사명 기준 그룹화 (computed)

const groupedCoupons = computed(() => {
  const groups = {}
  for (const coupon of coupons.value) {
    const name = coupon.companyName || '알 수 없는 상점'
    if (!groups[name]) groups[name] = []
    groups[name].push(coupon)
  }
  return groups
})
  • 각 회사 이름을 키로 묶어서 그룹별로 쿠폰을 렌더링하기 위한 데이터 구조를 만듦

<v-list> 렌더링

  • 회사명 헤더 () 아래에 해당 회사의 쿠폰들을 v-list-item으로 나열
  • 쿠폰 금액, 발급일, 사용일, 상태를 표시

상태 표현

<span :class="coupon.used ? 'text-grey' : 'text-success'">
  <strong>{{ coupon.used ? '사용 완료' : '사용 가능' }}</strong>
</span>
  • 사용 여부에 따라 색상과 텍스트를 다르게 처리 (회색 or 녹색)

⚙️ onMounted 훅

onMounted(async () => {
  if (authStore.user?.uid) {
    coupons.value = await fetchAllCoupons(authStore.user.uid)
  }
})

 

  • 페이지가 마운트될 때, 인증된 사용자 ID로 쿠폰 목록을 불러와서 coupons에 저장