ソースを参照

Add first-order discount feature and integrate into various components

- Implemented a new store for managing first-order discount chances, including fetching and applying discount rates.
- Updated multiple components (PlaymateCards, SkillSelect, Order pages, User profile) to utilize the first-order discount logic for displaying prices.
- Added a new API endpoint for checking first-order discount eligibility.
- Enhanced user experience by showing discounted prices and indicating eligibility in relevant UI elements.
0es 2 週間 前
コミット
65fc8fdf4e

+ 9 - 0
app/api/skill.ts

@@ -4,6 +4,7 @@
 
 import { http } from '~/utils/request'
 import type {
+  FirstOrderDiscountChanceVo,
   NextVOSkillOrderInfoVo,
   NextVOSkillSearchVo,
   OrderPaymentVo,
@@ -131,4 +132,12 @@ export const skillApi = {
   orderStar(data: OrderStarDTO) {
     return http.post<unknown>('/skill/order/star', data)
   },
+
+  /**
+   * 查询当前用户是否有首单1折机会
+   * 对应后端接口:POST /skill/firstOrder/discount/chance
+   */
+  firstOrderDiscountChance() {
+    return http.post<FirstOrderDiscountChanceVo>('/skill/firstOrder/discount/chance')
+  },
 }

+ 17 - 1
app/components/home/PlaymateCards.vue

@@ -1,6 +1,9 @@
 <script setup lang="ts">
+import { storeToRefs } from 'pinia'
 import LocateSvg from '~/assets/icons/locate.svg'
 import type { SkillSearchVo } from '~/types/api'
+import { useFirstOrderDiscountStore } from '~/stores/firstOrderDiscount'
+import { useAuthStore } from '~/stores/auth'
 
 export type PlaymateCard = SkillSearchVo
 
@@ -8,11 +11,21 @@ const props = defineProps<{
   cards: PlaymateCard[]
 }>()
 
+const firstOrderDiscountStore = useFirstOrderDiscountStore()
+const { userProfile } = storeToRefs(useAuthStore())
+
 const formatPrice = (price: number) => new Intl.NumberFormat().format(price)
 const img = useImage()
 const router = useRouter()
 const { t } = useI18n()
 
+/** Display price for list: apply first-order discount when eligible (exclude own products). */
+const displayPrice = (card: PlaymateCard) =>
+  firstOrderDiscountStore.getDisplayPrice(card.price, card.userNo, userProfile.value?.userNo)
+
+const showDiscount = (card: PlaymateCard) =>
+  firstOrderDiscountStore.isDiscountEligible(card.price, card.userNo, userProfile.value?.userNo)
+
 const handlePlaymateClick = (id: string) => {
   router.push(`/user/category?id=${id}`)
 }
@@ -46,7 +59,10 @@ const handlePlaymateClick = (id: string) => {
           <div class="flex items-center gap-0.5 mt-0.5">
             <div class="coin-icon" />
             <p class="text-xs text-nowrap">
-              <span class="font-title font-semibold">{{ formatPrice(card.price) }}</span>
+              <span class="font-title font-semibold">{{ formatPrice(displayPrice(card)) }}</span>
+              <template v-if="showDiscount(card)">
+                <span class="text-[#8e8e8e] line-through ml-0.5">{{ formatPrice(card.price) }}</span>
+              </template>
               <span class="text-[#8e8e8e]">/{{ card.unit }}</span>
             </p>
           </div>

+ 30 - 8
app/components/popup/SkillSelect.vue

@@ -1,14 +1,22 @@
 <script setup lang="ts">
+import { storeToRefs } from 'pinia'
 import { useI18n } from 'vue-i18n'
 import type { PlaymateInfoVO } from '~/types/api'
-
-const props = defineProps<{
-  show: boolean
-  skills: PlaymateInfoVO['skills']
-  activeSkillId: string
-  quantity: number
-  formattedTotalPrice: string | number
-}>()
+import { useFirstOrderDiscountStore } from '~/stores/firstOrderDiscount'
+import { useAuthStore } from '~/stores/auth'
+
+const props = withDefaults(
+  defineProps<{
+    show: boolean
+    skills: PlaymateInfoVO['skills']
+    activeSkillId: string
+    quantity: number
+    formattedTotalPrice: string | number
+    /** When provided, display price uses first-order discount (exclude own products). */
+    ownerUserNo?: string
+  }>(),
+  { ownerUserNo: undefined },
+)
 
 const emit = defineEmits<{
   'update:show': [value: boolean]
@@ -18,6 +26,14 @@ const emit = defineEmits<{
 }>()
 
 const { t } = useI18n()
+const firstOrderDiscountStore = useFirstOrderDiscountStore()
+const { userProfile } = storeToRefs(useAuthStore())
+
+const displayPrice = (price: number) =>
+  firstOrderDiscountStore.getDisplayPrice(price, props.ownerUserNo, userProfile.value?.userNo)
+
+const showDiscount = (price: number) =>
+  firstOrderDiscountStore.isDiscountEligible(price, props.ownerUserNo, userProfile.value?.userNo)
 </script>
 
 <template>
@@ -52,6 +68,12 @@ const { t } = useI18n()
           </div>
           <div class="pay-popup-skill__price">
             <span class="pay-popup-skill__amount">
+              {{ displayPrice(skill.price) }}
+            </span>
+            <span
+              v-if="showDiscount(skill.price)"
+              class="pay-popup-skill__original text-[#8e8e8e] line-through ml-0.5"
+            >
               {{ skill.price }}
             </span>
             <span class="pay-popup-skill__unit">

+ 1 - 0
app/composables/useOrderPopup.ts

@@ -147,6 +147,7 @@ const confirm = async () => {
   }
 
   refreshUser()
+  await useFirstOrderDiscountStore().fetchChance()
 
   // 支付成功后关闭弹窗
   state.visible = false

+ 8 - 2
app/pages/order/detail.vue

@@ -39,6 +39,7 @@ const router = useRouter()
 const { t } = useI18n()
 const img = useImage()
 const { request } = useApi()
+const firstOrderDiscountStore = useFirstOrderDiscountStore()
 const detail = ref<OrderDetail | null>(null)
 
 const showRatingPopup = ref(false)
@@ -173,20 +174,25 @@ const handleConfirmAction = async () => {
 
   if (action === 'complete') {
     const res = await request(() => skillApi.orderFinish({ id: current.id }))
-    if (res !== null)
+    if (res !== null) {
       await loadOrderDetail()
+      await firstOrderDiscountStore.fetchChance()
+    }
   }
   else if (action === 'cancel') {
     const res = await request(() => skillApi.orderCancel({ id: current.id }))
     if (res !== null) {
       showToast(t('order.toast.cancelSuccess'))
       await loadOrderDetail()
+      await firstOrderDiscountStore.fetchChance()
     }
   }
   else if (action === 'delete') {
     const res = await request(() => skillApi.orderDelete({ id: current.id }))
-    if (res !== null)
+    if (res !== null) {
+      await firstOrderDiscountStore.fetchChance()
       router.back()
+    }
   }
 
   confirmDialogVisible.value = false

+ 9 - 2
app/pages/order/index.vue

@@ -16,6 +16,7 @@ const { t } = useI18n()
 const { request } = useApi()
 const route = useRoute()
 const router = useRouter()
+const firstOrderDiscountStore = useFirstOrderDiscountStore()
 
 const orders = ref<SkillOrderInfoVo[]>([])
 const pageSize = 10
@@ -175,20 +176,25 @@ const handleConfirmAction = async () => {
 
   if (action === 'complete') {
     const res = await request(() => skillApi.orderFinish({ id: current.orderNo }))
-    if (res !== null)
+    if (res !== null) {
       await reloadOrders()
+      await firstOrderDiscountStore.fetchChance()
+    }
   }
   else if (action === 'cancel') {
     const res = await request(() => skillApi.orderCancel({ id: current.orderNo }))
     if (res !== null) {
       showToast(t('order.toast.cancelSuccess'))
       await reloadOrders()
+      await firstOrderDiscountStore.fetchChance()
     }
   }
   else if (action === 'delete') {
     const res = await request(() => skillApi.orderDelete({ id: current.orderNo }))
-    if (res !== null)
+    if (res !== null) {
       await reloadOrders()
+      await firstOrderDiscountStore.fetchChance()
+    }
   }
 
   confirmDialogVisible.value = false
@@ -214,6 +220,7 @@ const handleSubmitRating = async (score: number) => {
     ratingScore.value = score
     showToast(t('profile.toast.thanksForRating'))
     await reloadOrders()
+    await firstOrderDiscountStore.fetchChance()
   }
 }
 

+ 1 - 0
app/pages/order/record.vue

@@ -148,6 +148,7 @@ const handlePlaymateAction = async (payload: { orderNo: string }, type: 1 | 2 |
       }
       showToast(t(toastKeyMap[type]))
       await reloadOrders()
+      await useFirstOrderDiscountStore().fetchChance()
     }
   }
   finally {

+ 10 - 5
app/pages/user/category.vue

@@ -19,16 +19,21 @@ const { open } = useOrderPopup()
 const { request } = useApi()
 const { userProfile } = useAuth()
 const { t } = useI18n()
+const firstOrderDiscountStore = useFirstOrderDiscountStore()
 
 const skillDetail = ref<SkillDetailVo | null>(null)
 
 const isMine = computed(() => skillDetail.value?.userNo === userProfile.value?.userNo)
-const formattedRate = computed(() => {
+const displayPrice = computed(() => {
   if (!skillDetail.value)
-    return '0'
-
-  return new Intl.NumberFormat('en-US').format(skillDetail.value.price)
+    return 0
+  return firstOrderDiscountStore.getDisplayPrice(
+    skillDetail.value.price,
+    skillDetail.value.userNo,
+    userProfile.value?.userNo,
+  )
 })
+const formattedRate = computed(() => new Intl.NumberFormat('en-US').format(displayPrice.value))
 
 const highlightImage = computed(() => {
   if (!skillDetail.value || !skillDetail.value.images?.length)
@@ -52,7 +57,7 @@ const handleOrderClick = () => {
     avatar: skillDetail.value.avatar,
     name: skillDetail.value.nickname,
     productType: skillDetail.value.categoryName,
-    rate: skillDetail.value.price,
+    rate: displayPrice.value,
     unit: skillDetail.value.unit,
     minQuantity: 1,
     defaultQuantity: 1,

+ 18 - 4
app/pages/user/profile.vue

@@ -24,6 +24,7 @@ const { open } = useOrderPopup()
 const { request } = useApi()
 const { userProfile } = useAuth()
 const { openConversation: openImPopup } = useImPopup()
+const firstOrderDiscountStore = useFirstOrderDiscountStore()
 
 const playmateInfo = ref<PlaymateInfoVO | null>(null)
 
@@ -40,9 +41,15 @@ const selectedSkill = computed(() => {
   return skills.value.find(item => item.id === activeSkillId.value) ?? skills.value[0]
 })
 
-const totalPrice = computed(
-  () => (selectedSkill.value ? selectedSkill.value.price * quantity.value : 0),
-)
+const displayPriceForSkill = (price: number) =>
+  firstOrderDiscountStore.getDisplayPrice(price, playmateInfo.value?.userNo, userProfile.value?.userNo)
+
+const totalPrice = computed(() => {
+  if (!selectedSkill.value)
+    return 0
+  const unitPrice = displayPriceForSkill(selectedSkill.value.price)
+  return unitPrice * quantity.value
+})
 
 const formattedTotalPrice = computed(() => new Intl.NumberFormat('en-US').format(totalPrice.value))
 
@@ -210,7 +217,7 @@ const handleOrder = () => {
     avatar: playmateInfo.value.avatar,
     name: playmateInfo.value.nickname,
     productType: selectedSkill.value.name,
-    rate: selectedSkill.value.price,
+    rate: displayPriceForSkill(selectedSkill.value.price),
     unit: selectedSkill.value.unit,
     specificQuantity: quantity.value,
     // 普通技能下单,传入 skillId
@@ -389,6 +396,12 @@ const handleOpenIm = () => {
                 </div>
                 <div class="skill-card__price">
                   <span class="skill-card__amount">
+                    {{ formatNumber(displayPriceForSkill(skill.price)) }}
+                  </span>
+                  <span
+                    v-if="firstOrderDiscountStore.isDiscountEligible(skill.price, playmateInfo?.userNo, userProfile?.userNo)"
+                    class="text-[#8e8e8e] line-through ml-0.5"
+                  >
                     {{ formatNumber(skill.price) }}
                   </span>
                   <span class="skill-card__unit">
@@ -457,6 +470,7 @@ const handleOpenIm = () => {
       :skills="skills"
       :active-skill-id="activeSkillId"
       :formatted-total-price="formattedTotalPrice"
+      :owner-user-no="playmateInfo?.userNo"
       @select-skill="handleSelectSkill"
       @order="handleOrder"
     />

+ 12 - 6
app/plugins/auth.client.ts

@@ -2,15 +2,23 @@ export default defineNuxtPlugin({
   name: 'auth',
   async setup(nuxtApp) {
     const { isAuthenticated, isPlaymate, restoreAuth, refreshAuth, refreshUser, refreshPlaymateInfo } = useAuth()
+    const firstOrderDiscountStore = useFirstOrderDiscountStore()
 
     nuxtApp.hook('app:mounted', () => {
-      watch(isAuthenticated, (newIsAuthenticated) => {
+      restoreAuth()
+
+      watch(isAuthenticated, async (newIsAuthenticated) => {
+        console.log('newIsAuthenticated', newIsAuthenticated)
         if (newIsAuthenticated) {
-          refreshAuth()
-          refreshUser()
+          await refreshAuth()
+          await refreshUser()
+          await firstOrderDiscountStore.fetchChance()
+        }
+        else {
+          firstOrderDiscountStore.clear()
         }
       }, {
-        immediate: false,
+        immediate: true,
       })
       watch(isPlaymate, (newIsPlaymate) => {
         if (newIsPlaymate) {
@@ -19,8 +27,6 @@ export default defineNuxtPlugin({
       }, {
         immediate: false,
       })
-
-      restoreAuth()
     })
   },
 })

+ 71 - 0
app/stores/firstOrderDiscount.ts

@@ -0,0 +1,71 @@
+import { defineStore } from 'pinia'
+import { skillApi } from '~/api/skill'
+import type { FirstOrderDiscountChanceVo } from '~/types/api'
+
+/**
+ * Store for first-order discount (首单1折) chance.
+ * Bound to the current user; refresh on app start after login and when order status changes.
+ */
+export const useFirstOrderDiscountStore = defineStore('firstOrderDiscount', {
+  state: (): FirstOrderDiscountChanceVo => ({
+    hasChance: false,
+    discountRate: 0,
+    eligiblePrice: 0,
+  }),
+
+  getters: {
+    /**
+     * Get display price for a skill: apply discount when has chance, skill is eligible, and not own.
+     * @param price - Original price (coins)
+     * @param ownerUserNo - Skill owner userNo (optional)
+     * @param currentUserNo - Current user's userNo; when equal to ownerUserNo, discount is not applied
+     */
+    getDisplayPrice: state => (price: number, ownerUserNo?: string, currentUserNo?: string) => {
+      if (!state.hasChance || state.discountRate <= 0)
+        return price
+      if (ownerUserNo && currentUserNo && ownerUserNo === currentUserNo)
+        return price
+      if (state.eligiblePrice > 0 && price > state.eligiblePrice)
+        return price
+      return Math.round(price * state.discountRate)
+    },
+
+    /** Whether discount is applied for this skill (for showing original + discounted) */
+    isDiscountEligible: state => (price: number, ownerUserNo?: string, currentUserNo?: string) => {
+      if (!state.hasChance || state.discountRate <= 0)
+        return false
+      if (ownerUserNo && currentUserNo && ownerUserNo === currentUserNo)
+        return false
+      if (state.eligiblePrice > 0 && price > state.eligiblePrice)
+        return false
+      return true
+    },
+  },
+
+  actions: {
+    setChance(data: FirstOrderDiscountChanceVo) {
+      this.hasChance = data.hasChance
+      this.discountRate = data.discountRate
+      this.eligiblePrice = data.eligiblePrice
+    },
+
+    clear() {
+      this.hasChance = false
+      this.discountRate = 0
+      this.eligiblePrice = 0
+    },
+
+    /** Fetch current user's first-order discount chance. Call when logged in. */
+    async fetchChance() {
+      try {
+        const data = await skillApi.firstOrderDiscountChance()
+        this.setChance(data)
+        return data
+      }
+      catch {
+        this.clear()
+        return null
+      }
+    },
+  },
+})

+ 12 - 0
app/types/api/skill.ts

@@ -84,6 +84,16 @@ export interface SkillSearchDTO {
   page: NextDTO
 }
 
+// 首单折扣机会 - 对应接口 POST /skill/firstOrder/discount/chance
+export interface FirstOrderDiscountChanceVo {
+  /** 当前是否有首单1折机会 */
+  hasChance: boolean
+  /** 当前首单折扣率(例如 0.1 表示 1 折) */
+  discountRate: number
+  /** 可参与优惠的 SKU 单价(金币) */
+  eligiblePrice: number
+}
+
 // 技能商品 - 列表项
 export interface SkillSearchVo {
   id: string
@@ -112,6 +122,8 @@ export interface SkillSearchVo {
   categoryIcon: string
   // 品类名称
   categoryName: string
+  /** 用户编号(可选,用于排除当前用户自己的商品不展示折后价) */
+  userNo?: string
 }
 
 // 技能商品列表 - 游标分页响应