Pārlūkot izejas kodu

Enhance order and pricing components with first-order discount integration

- Added support for displaying first-order discounts in OrderBtn, PlaymateCards, SkillSelect, and Order components.
- Updated the Order composable to manage discount eligibility and calculation.
- Enhanced UI elements to show discount information, including new styles for discount tags.
- Localized discount-related strings in English, Indonesian, and Chinese for improved user experience.
0es 2 nedēļas atpakaļ
vecāks
revīzija
1dd2bf7a53

BIN
app/assets/images/common/discount-tip-1.png


BIN
app/assets/images/common/discount-tip-2.png


+ 52 - 5
app/components/common/OrderBtn.vue

@@ -2,11 +2,20 @@
 import { useI18n } from 'vue-i18n'
 import ChatIcon from '~/assets/icons/im/chat.svg'
 
-const props = defineProps<{
-  price: number | string
-  unit?: string
-  userId?: string
-}>()
+const props = withDefaults(
+  defineProps<{
+    price: number | string
+    unit?: string
+    userId?: string
+    /** Show first-order discount: strikethrough original + discount tag */
+    showDiscount?: boolean
+    /** Original price (when showDiscount, shown with strikethrough) */
+    originalPrice?: number | string
+    /** e.g. "90%" for tag "90% OFF" */
+    discountRatePercent?: string
+  }>(),
+  { showDiscount: false, originalPrice: undefined, discountRatePercent: '' },
+)
 
 const emit = defineEmits<{
   (e: 'click'): void
@@ -40,6 +49,14 @@ const handleOpenIm = () => {
           >
             /{{ unit }}
           </span>
+          <span
+            v-if="showDiscount && discountRatePercent"
+            class="order-price__tag"
+          >
+            <span>{{ t('order.detail.discountTagNewOnly') }}</span>
+            <span class="order-price__tag-sep" />
+            <span>{{ discountRatePercent }} {{ t('order.detail.discountTagOff') }}</span>
+          </span>
         </div>
       </div>
       <div
@@ -107,6 +124,36 @@ const handleOpenIm = () => {
       font-size: 12px;
       color: var(--color-text-secondary);
     }
+
+    .order-price__tag {
+      @include size(130px, 24px);
+      @include bg('~/assets/images/common/discount-tip-2.png');
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      position: absolute;
+      left: 16px;
+      top: 4px;
+      font-size: 11px;
+      font-weight: 400;
+      color: #fff;
+      line-height: 14px;
+
+      span {
+        font-size: 11px;
+        font-weight: 400;
+        color: #fff;
+        line-height: 14px;
+        margin-bottom: 4px;
+      }
+
+      .order-price__tag-sep {
+        width: 1px;
+        height: 12px;
+        background-color: rgba(255, 255, 255, 0.5);
+      }
+    }
   }
 }
 

+ 28 - 5
app/components/home/PlaymateCards.vue

@@ -56,15 +56,21 @@ const handlePlaymateClick = (id: string) => {
               class="avatar-audio absolute bottom-0"
             />
           </div>
-          <div class="flex items-center gap-0.5 mt-0.5">
+          <div class="flex items-center justify-center relative gap-0.5 mt-0.5">
             <div class="coin-icon" />
             <p class="text-xs text-nowrap">
-              <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="font-title font-semibold"
+                :class="{ 'text-[#FF6F32]': showDiscount(card) }"
+              >{{ formatPrice(displayPrice(card)) }}</span>
               <span class="text-[#8e8e8e]">/{{ card.unit }}</span>
             </p>
+            <div
+              v-if="showDiscount(card)"
+              class="discount-tip"
+            >
+              <span>{{ firstOrderDiscountStore.discountRatePercent }} OFF</span>
+            </div>
           </div>
         </div>
 
@@ -241,4 +247,21 @@ const handlePlaymateClick = (id: string) => {
     height: px2vw(72);
   }
 }
+.discount-tip {
+  @include size(58px, 21px);
+  @include bg('~/assets/images/common/discount-tip-1.png');
+  display: flex;
+  justify-content: center;
+  align-items: flex-end;
+  position: absolute;
+  bottom: -21px;
+
+  span {
+    color: #FFF;
+    font-size: 11px;
+    font-weight: 400;
+    line-height: 14px;
+    margin-bottom: 2px;
+  }
+}
 </style>

+ 109 - 12
app/components/popup/Order.vue

@@ -4,7 +4,7 @@ import { useOrderPopup } from '~/composables/useOrderPopup'
 import OrderCheckSvg from '~/assets/icons/order/check.svg'
 import ConfirmDialog from '~/components/common/ConfirmDialog.vue'
 
-const { state, totalPrice, close, confirm, setQuantity } = useOrderPopup()
+const { state, totalPrice, hasDiscount, discountAmount, discountRatePercent, close, confirm, setQuantity } = useOrderPopup()
 const { wallet } = useAuth()
 const { openRecharge } = useRecharge()
 const { t } = useI18n()
@@ -74,17 +74,22 @@ const handleCancel = () => {
         <div class="order-popup__divider" />
 
         <div class="order-popup__price-row">
-          <div class="order-popup__rate-info">
-            <span
-              class="order-popup__coin order-popup__coin--small"
-              aria-hidden="true"
-            />
-            <span class="order-popup__rate-value">
-              {{ state.rate.toLocaleString() }}
-            </span>
-            <span class="order-popup__rate-unit">
-              {{ state.unit }}
-            </span>
+          <div class="order-popup__price-left">
+            <p class="order-popup__price-label">
+              {{ t('order.detail.unitPrice') }}
+            </p>
+            <div class="order-popup__rate-info">
+              <span
+                class="order-popup__coin order-popup__coin--small"
+                aria-hidden="true"
+              />
+              <span class="order-popup__rate-value">
+                {{ state.rate.toLocaleString() }}
+              </span>
+              <span class="order-popup__rate-unit">
+                / {{ state.unit }}
+              </span>
+            </div>
           </div>
 
           <template v-if="state.specificQuantity == null">
@@ -100,6 +105,27 @@ const handleCancel = () => {
           </template>
         </div>
 
+        <div
+          v-if="hasDiscount"
+          class="order-popup__discount-row"
+        >
+          <div class="order-popup__discount-left">
+            <span class="order-popup__discount-label">{{ t('order.detail.discount') }}</span>
+            <span class="order-popup__discount-tag">
+              <span>{{ t('order.detail.discountTagNewOnly') }}</span>
+              <span class="order-popup__discount-tag-sep" />
+              <span>{{ discountRatePercent }} {{ t('order.detail.discountTagOff') }}</span>
+            </span>
+          </div>
+          <div class="order-popup__discount-amount">
+            <span
+              class="order-popup__coin order-popup__coin--discount"
+              aria-hidden="true"
+            />
+            <span class="order-popup__discount-value">-{{ discountAmount.toLocaleString() }}</span>
+          </div>
+        </div>
+
         <div class="order-popup__total-row">
           <span class="order-popup__total-label">
             {{ t('order.detail.totalLabel') }}
@@ -269,6 +295,77 @@ const handleCancel = () => {
     justify-content: space-between;
   }
 
+  &__price-left {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  &__price-label {
+    font-size: 12px;
+    font-weight: 600;
+    color: #1d2129;
+  }
+
+  &__discount-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 6px 0 7px;
+    margin-top: 12px;
+  }
+
+  &__discount-left {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  &__discount-label {
+    font-size: 12px;
+    font-weight: 600;
+    color: #1d2129;
+  }
+
+  &__discount-tag {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    padding: 4px 6px;
+    border-radius: 4px;
+    background-color: #ff6f32;
+    font-size: 11px;
+    font-weight: 400;
+    color: #fff;
+    line-height: 14px;
+
+    &-sep {
+      width: 1px;
+      height: 12px;
+      background-color: rgba(255, 255, 255, 0.5);
+    }
+  }
+
+  &__discount-amount {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+  }
+
+  &__coin--discount {
+    @include size(20px);
+    @include bg('~/assets/images/common/coin.png');
+    filter: none;
+  }
+
+  &__discount-value {
+    font-family: var(--font-title);
+    font-size: 16px;
+    font-weight: 600;
+    color: #ff6f32;
+    line-height: 17px;
+  }
+
   &__rate-info {
     display: flex;
     align-items: center;

+ 29 - 6
app/components/popup/SkillSelect.vue

@@ -67,18 +67,21 @@ const showDiscount = (price: number) =>
             </div>
           </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"
+              class="pay-popup-skill__amount"
+              :class="{ 'text-[#FF6F32]': showDiscount(skill.price) }"
             >
-              {{ skill.price }}
+              {{ displayPrice(skill.price) }}
             </span>
             <span class="pay-popup-skill__unit">
               /{{ skill.unit }}
             </span>
+            <div
+              v-if="showDiscount(skill.price)"
+              class="discount-tip"
+            >
+              <span>{{ firstOrderDiscountStore.discountRatePercent }} OFF</span>
+            </div>
           </div>
         </article>
       </div>
@@ -172,6 +175,26 @@ const showDiscount = (price: number) =>
     font-size: 14px;
     font-weight: 600;
     color: #1d2129;
+    position: relative;
+
+    .discount-tip {
+      display: flex;
+      height: 20px;
+      padding: 0 6px;
+      border-radius: 4px;
+      background: #FF6F32;
+      justify-content: center;
+      align-items: center;
+      position: absolute;
+      left: -65px;
+
+      span {
+        color: #FFF;
+        font-size: 11px;
+        font-weight: 400;
+        line-height: 14px;
+      }
+    }
   }
 
   &__unit {

+ 34 - 2
app/composables/useOrderPopup.ts

@@ -1,15 +1,20 @@
 import { computed, reactive, readonly, ref } from 'vue'
+import { storeToRefs } from 'pinia'
 import { useApi } from '~/composables/useApi'
 import { skillApi } from '~/api/skill'
+import { useAuthStore } from '~/stores/auth'
+import { useFirstOrderDiscountStore } from '~/stores/firstOrderDiscount'
 
 export interface OrderPopupOptions {
   avatar: string
   name: string
   productType: string
-  /** price per unit (e.g. coins per hour) */
+  /** Original price per unit (e.g. coins per hour). First unit may be discounted; rest at this rate. */
   rate: number
   /** unit text, for example "/h" */
   unit?: string
+  /** Skill owner userNo (for first-order discount: exclude own products) */
+  ownerUserNo?: string
   /** minimum quantity allowed */
   minQuantity?: number
   /** maximum quantity allowed */
@@ -33,8 +38,11 @@ export interface OrderPopupState {
   avatar: string
   name: string
   productType: string
+  /** Original unit price */
   rate: number
   unit: string
+  /** Skill owner userNo for discount eligibility */
+  ownerUserNo: string | null
   quantity: number
   minQuantity: number
   maxQuantity?: number
@@ -57,6 +65,7 @@ const state = reactive<OrderPopupState>({
   productType: '',
   rate: 0,
   unit: '/h',
+  ownerUserNo: null,
   quantity: DEFAULT_MIN_QUANTITY,
   minQuantity: DEFAULT_MIN_QUANTITY,
   maxQuantity: undefined,
@@ -67,7 +76,25 @@ const state = reactive<OrderPopupState>({
 })
 const confirmHandler = ref<((orderId: string, totalPrice: number) => void) | null>(null)
 
-const totalPrice = computed(() => state.quantity * state.rate)
+// Lazy store access inside computed so Pinia is only used when composable runs in component context
+const firstUnitPrice = computed(() => {
+  const firstOrderStore = useFirstOrderDiscountStore()
+  const { userProfile } = storeToRefs(useAuthStore())
+  return firstOrderStore.getDisplayPrice(state.rate, state.ownerUserNo ?? undefined, userProfile.value?.userNo)
+})
+const hasDiscount = computed(() => {
+  const firstOrderStore = useFirstOrderDiscountStore()
+  const { userProfile } = storeToRefs(useAuthStore())
+  return firstOrderStore.isDiscountEligible(state.rate, state.ownerUserNo ?? undefined, userProfile.value?.userNo)
+})
+const discountAmount = computed(() =>
+  hasDiscount.value && state.quantity >= 1 ? state.rate - firstUnitPrice.value : 0,
+)
+const totalPrice = computed(() =>
+  state.quantity <= 0
+    ? 0
+    : firstUnitPrice.value + (state.quantity - 1) * state.rate,
+)
 
 const clampQuantity = (value: number): number => {
   if (state.specificQuantity != null) return state.specificQuantity
@@ -88,6 +115,7 @@ const open = (options: OrderPopupOptions) => {
   state.productType = options.productType
   state.rate = options.rate
   state.unit = options.unit ?? '/h'
+  state.ownerUserNo = options.ownerUserNo ?? null
   state.minQuantity = options.minQuantity ?? DEFAULT_MIN_QUANTITY
   state.maxQuantity = options.maxQuantity
   state.specificQuantity = options.specificQuantity
@@ -157,6 +185,10 @@ export const useOrderPopup = () => {
   return {
     state: readonly(state),
     totalPrice,
+    firstUnitPrice,
+    hasDiscount,
+    discountAmount,
+    discountRatePercent: computed(() => useFirstOrderDiscountStore().discountRatePercent),
     open,
     close,
     confirm,

+ 14 - 1
app/pages/user/category.vue

@@ -34,6 +34,15 @@ const displayPrice = computed(() => {
   )
 })
 const formattedRate = computed(() => new Intl.NumberFormat('en-US').format(displayPrice.value))
+const showDiscountOnCategory = computed(() =>
+  skillDetail.value
+    ? firstOrderDiscountStore.isDiscountEligible(
+        skillDetail.value.price,
+        skillDetail.value.userNo,
+        userProfile.value?.userNo,
+      )
+    : false,
+)
 
 const highlightImage = computed(() => {
   if (!skillDetail.value || !skillDetail.value.images?.length)
@@ -57,8 +66,9 @@ const handleOrderClick = () => {
     avatar: skillDetail.value.avatar,
     name: skillDetail.value.nickname,
     productType: skillDetail.value.categoryName,
-    rate: displayPrice.value,
+    rate: skillDetail.value.price,
     unit: skillDetail.value.unit,
+    ownerUserNo: skillDetail.value.userNo,
     minQuantity: 1,
     defaultQuantity: 1,
     onConfirm: (orderId, totalPrice) => {
@@ -226,6 +236,9 @@ onMounted(() => {
         :user-id="skillDetail?.userNo ?? ''"
         :price="formattedRate"
         :unit="skillDetail?.unit ?? ''"
+        :show-discount="showDiscountOnCategory"
+        :original-price="skillDetail ? new Intl.NumberFormat('en-US').format(skillDetail.price) : undefined"
+        :discount-rate-percent="firstOrderDiscountStore.discountRatePercent"
         @click="handleOrderClick"
       />
     </footer>

+ 31 - 7
app/pages/user/profile.vue

@@ -44,6 +44,9 @@ const selectedSkill = computed(() => {
 const displayPriceForSkill = (price: number) =>
   firstOrderDiscountStore.getDisplayPrice(price, playmateInfo.value?.userNo, userProfile.value?.userNo)
 
+const showDiscountForSkill = (price: number) =>
+  firstOrderDiscountStore.isDiscountEligible(price, playmateInfo.value?.userNo, userProfile.value?.userNo)
+
 const totalPrice = computed(() => {
   if (!selectedSkill.value)
     return 0
@@ -217,8 +220,9 @@ const handleOrder = () => {
     avatar: playmateInfo.value.avatar,
     name: playmateInfo.value.nickname,
     productType: selectedSkill.value.name,
-    rate: displayPriceForSkill(selectedSkill.value.price),
+    rate: selectedSkill.value.price,
     unit: selectedSkill.value.unit,
+    ownerUserNo: playmateInfo.value.userNo,
     specificQuantity: quantity.value,
     // 普通技能下单,传入 skillId
     skillId: selectedSkill.value.id,
@@ -398,15 +402,15 @@ const handleOpenIm = () => {
                   <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">
                     /{{ skill.unit }}
                   </span>
+                  <div
+                    v-if="showDiscountForSkill(skill.price)"
+                    class="discount-tip"
+                  >
+                    <span>{{ firstOrderDiscountStore.discountRatePercent }} OFF</span>
+                  </div>
                 </div>
                 <van-icon
                   name="arrow"
@@ -776,6 +780,26 @@ const handleOpenIm = () => {
     gap: 2px;
     font-size: 14px;
     font-weight: 600;
+    position: relative;
+
+    .discount-tip {
+      display: flex;
+      height: 20px;
+      padding: 0 6px;
+      border-radius: 4px;
+      background: #FF6F32;
+      justify-content: center;
+      align-items: center;
+      position: absolute;
+      left: -65px;
+
+      span {
+        color: #FFF;
+        font-size: 11px;
+        font-weight: 400;
+        line-height: 14px;
+      }
+    }
   }
 
   &__amount {

+ 12 - 2
app/stores/firstOrderDiscount.ts

@@ -14,6 +14,16 @@ export const useFirstOrderDiscountStore = defineStore('firstOrderDiscount', {
   }),
 
   getters: {
+    /**
+     * Format discount rate for display: e.g. 0.1 (1折) => "90%" (90% off).
+     */
+    discountRatePercent: (state) => {
+      const rate = state.discountRate
+      if (rate <= 0 || rate >= 1)
+        return '0%'
+      return `${Math.round((1 - rate) * 100)}%`
+    },
+
     /**
      * Get display price for a skill: apply discount when has chance, skill is eligible, and not own.
      * @param price - Original price (coins)
@@ -25,7 +35,7 @@ export const useFirstOrderDiscountStore = defineStore('firstOrderDiscount', {
         return price
       if (ownerUserNo && currentUserNo && ownerUserNo === currentUserNo)
         return price
-      if (state.eligiblePrice > 0 && price > state.eligiblePrice)
+      if (state.eligiblePrice > 0 && price !== state.eligiblePrice)
         return price
       return Math.round(price * state.discountRate)
     },
@@ -36,7 +46,7 @@ export const useFirstOrderDiscountStore = defineStore('firstOrderDiscount', {
         return false
       if (ownerUserNo && currentUserNo && ownerUserNo === currentUserNo)
         return false
-      if (state.eligiblePrice > 0 && price > state.eligiblePrice)
+      if (state.eligiblePrice > 0 && price !== state.eligiblePrice)
         return false
       return true
     },

+ 4 - 0
i18n/locales/en.json

@@ -99,6 +99,10 @@
       "quantity": "Quantity",
       "orderNo": "Order",
       "purchaseTime": "Purchase time",
+      "unitPrice": "Unit price",
+      "discount": "Discount",
+      "discountTagNewOnly": "New Only",
+      "discountTagOff": "OFF",
       "totalLabel": "Total",
       "refundReasonTitle": "Refund reason",
       "refundReasonEmpty": "No refund reason provided",

+ 4 - 0
i18n/locales/id.json

@@ -99,6 +99,10 @@
       "quantity": "Jumlah pembelian",
       "orderNo": "Nomor pesanan",
       "purchaseTime": "Waktu pembelian",
+      "unitPrice": "Harga satuan",
+      "discount": "Diskon",
+      "discountTagNewOnly": "Hanya baru",
+      "discountTagOff": "OFF",
       "totalLabel": "Total",
       "refundReasonTitle": "Alasan refund",
       "refundReasonEmpty": "Tidak ada alasan refund",

+ 4 - 0
i18n/locales/zh.json

@@ -99,6 +99,10 @@
       "quantity": "购买数量",
       "orderNo": "订单编号",
       "purchaseTime": "购买时间",
+      "unitPrice": "单价",
+      "discount": "优惠",
+      "discountTagNewOnly": "首单专享",
+      "discountTagOff": "OFF",
       "totalLabel": "共计",
       "refundReasonTitle": "退款原因",
       "refundReasonEmpty": "暂无退款原因",