feat: 支持同卡种续卡叠加并修复过期登录态
支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -47,7 +47,10 @@
|
|||||||
<!-- Card info — aligns with card-cover height -->
|
<!-- Card info — aligns with card-cover height -->
|
||||||
<view class="card-info">
|
<view class="card-info">
|
||||||
<view class="info-top">
|
<view class="info-top">
|
||||||
|
<view class="card-name-row">
|
||||||
<text class="card-name">{{ card.name }}</text>
|
<text class="card-name">{{ card.name }}</text>
|
||||||
|
<text v-if="isRenewable(card)" class="renew-tag">续</text>
|
||||||
|
</view>
|
||||||
<text class="card-validity">有效期 {{ card.durationDays }} 天</text>
|
<text class="card-validity">有效期 {{ card.durationDays }} 天</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-bottom">
|
<view class="info-bottom">
|
||||||
@@ -80,15 +83,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { get } from '../utils/request'
|
import { get } from '../utils/request'
|
||||||
import { formatPrice, getCardCoverClass } from '../utils/format'
|
import { formatPrice, getCardCoverClass } from '../utils/format'
|
||||||
|
import { useUserStore } from '../stores/user'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const hasLoaded = ref(false)
|
const hasLoaded = ref(false)
|
||||||
|
|
||||||
|
const renewableCardTypeIds = computed(() => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const membership of userStore.memberships) {
|
||||||
|
if (membership.cardType.type !== CardTypeCategory.TRIAL) {
|
||||||
|
ids.add(membership.cardTypeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
function isRenewable(card: CardType): boolean {
|
||||||
|
return card.type !== CardTypeCategory.TRIAL && renewableCardTypeIds.value.has(card.id)
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCardTypes() {
|
async function fetchCardTypes() {
|
||||||
// Stale-While-Revalidate: only show skeleton on first load
|
// Stale-While-Revalidate: only show skeleton on first load
|
||||||
// Subsequent refreshes silently update data in background
|
// Subsequent refreshes silently update data in background
|
||||||
@@ -250,7 +270,15 @@ function goToAllCards() {
|
|||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
|
min-width: 0;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
@@ -261,6 +289,18 @@ function goToAllCards() {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.renew-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #5f7a6e;
|
||||||
|
background: rgba(143, 168, 154, 0.2);
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.card-validity {
|
.card-validity {
|
||||||
font-size: 23rpx;
|
font-size: 23rpx;
|
||||||
color: $text-secondary;
|
color: $text-secondary;
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Running low: thin accent strip -->
|
<!-- Running low: thin accent strip -->
|
||||||
<view v-if="isRunningLow" class="renew-strip" @tap="scrollToCardShop">
|
<view v-if="renewStripText" class="renew-strip" @tap="handleRenew">
|
||||||
<text class="renew-strip-text">仅剩 {{ lowestRemainingTimes }} 次 · 续卡保持节奏</text>
|
<text class="renew-strip-text">{{ renewStripText }}</text>
|
||||||
<text class="renew-strip-arrow">›</text>
|
<text class="renew-strip-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<view
|
<view
|
||||||
v-else
|
v-else
|
||||||
class="entry-pill pill-expired"
|
class="entry-pill pill-expired"
|
||||||
@tap="scrollToCardShop"
|
@tap="handleRenew"
|
||||||
>
|
>
|
||||||
<view class="pill-dot dot-expired" />
|
<view class="pill-dot dot-expired" />
|
||||||
<text class="pill-label">会员卡已到期</text>
|
<text class="pill-label">会员卡已到期</text>
|
||||||
@@ -91,6 +91,15 @@ function scrollToCardShop() {
|
|||||||
emit('scroll-to-card-shop')
|
emit('scroll-to-card-shop')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRenew() {
|
||||||
|
const cardTypeId = userStore.renewalHint?.cardTypeId
|
||||||
|
if (cardTypeId) {
|
||||||
|
uni.navigateTo({ url: `/pages/card/detail?id=${cardTypeId}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollToCardShop()
|
||||||
|
}
|
||||||
|
|
||||||
const activeMembershipLabel = computed(() => {
|
const activeMembershipLabel = computed(() => {
|
||||||
const active = userStore.activeMemberships
|
const active = userStore.activeMemberships
|
||||||
if (!active.length) return ''
|
if (!active.length) return ''
|
||||||
@@ -105,24 +114,19 @@ const activeMembershipLabel = computed(() => {
|
|||||||
return `${cardName} · 剩余 ${daysLeft} 天`
|
return `${cardName} · 剩余 ${daysLeft} 天`
|
||||||
})
|
})
|
||||||
|
|
||||||
const isRunningLow = computed(() => {
|
const renewStripText = computed(() => {
|
||||||
return userStore.activeMemberships.some(
|
const hint = userStore.renewalHint
|
||||||
(m) =>
|
if (!hint || !userStore.hasValidMembership) return ''
|
||||||
m.cardType.type === CardTypeCategory.TIMES &&
|
if (hint.kind === 'times_low') {
|
||||||
m.remainingTimes !== null &&
|
return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
|
||||||
m.remainingTimes <= 2,
|
}
|
||||||
)
|
if (hint.kind === 'days_low') {
|
||||||
})
|
return `还剩 ${hint.daysLeft ?? 0} 天到期 · 续卡不中断`
|
||||||
|
}
|
||||||
const lowestRemainingTimes = computed(() => {
|
if (hint.kind === 'trial_low') {
|
||||||
const timesCards = userStore.activeMemberships.filter(
|
return '体验课将尽 · 选购会员卡'
|
||||||
(m) =>
|
}
|
||||||
m.cardType.type === CardTypeCategory.TIMES &&
|
return ''
|
||||||
m.remainingTimes !== null &&
|
|
||||||
m.remainingTimes <= 2,
|
|
||||||
)
|
|
||||||
if (!timesCards.length) return 0
|
|
||||||
return Math.min(...timesCards.map((m) => m.remainingTimes as number))
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -254,8 +258,8 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-renew {
|
.action-renew {
|
||||||
background: #e0e0e0;
|
background: #e8efe9;
|
||||||
.pill-action-text { color: #555; }
|
.pill-action-text { color: #5f7a6e; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Renew strip (running low) ── */
|
/* ── Renew strip (running low) ── */
|
||||||
@@ -266,21 +270,21 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
margin-top: 12rpx;
|
margin-top: 12rpx;
|
||||||
padding: 14rpx 24rpx;
|
padding: 14rpx 24rpx;
|
||||||
background: linear-gradient(135deg, #FF6B35, #FF8E53);
|
background: linear-gradient(135deg, #8fa89a, #a8c0b4);
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-strip-text {
|
.renew-strip-text {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #fff;
|
color: #fffcfa;
|
||||||
letter-spacing: 0.5rpx;
|
letter-spacing: 0.5rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-strip-arrow {
|
.renew-strip-arrow {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 252, 250, 0.82);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -178,12 +178,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<view v-if="detailMembership.remainingTimes !== null && detailMembership.cardType?.totalTimes" class="mship-progress-wrap">
|
<view v-if="detailMembership.remainingTimes !== null && getMembershipTotalTimes(detailMembership)" class="mship-progress-wrap">
|
||||||
<view class="mship-progress-bar">
|
<view class="mship-progress-bar">
|
||||||
<view class="mship-progress-fill" :style="{ width: getMembershipProgressWidth(detailMembership) }" />
|
<view class="mship-progress-fill" :style="{ width: getMembershipProgressWidth(detailMembership) }" />
|
||||||
</view>
|
</view>
|
||||||
<text class="mship-progress-label">
|
<text class="mship-progress-label">
|
||||||
已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ detailMembership.cardType.totalTimes }} 次
|
已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ getMembershipTotalTimes(detailMembership) }} 次
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- Date info -->
|
<!-- Date info -->
|
||||||
@@ -313,7 +313,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { onReachBottom } from '@dcloudio/uni-app'
|
import { onReachBottom } from '@dcloudio/uni-app'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
|
import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import type { MemberSummary, UserMembership } from '../../stores/admin'
|
import type { MemberSummary, UserMembership } from '../../stores/admin'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="card-detail-page" :style="{ paddingTop: navBarHeight }">
|
<view class="card-detail-page" :class="{ 'page--renew': isRenewal }" :style="{ paddingTop: navBarHeight }">
|
||||||
<CustomNavBar title="购买会员卡" show-back />
|
<CustomNavBar :title="isRenewal ? '续卡' : '购买会员卡'" show-back />
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<view v-if="loading" class="loading-wrap">
|
<view v-if="loading" class="loading-wrap">
|
||||||
<view class="skeleton-header" />
|
<view class="skeleton-header" />
|
||||||
@@ -54,7 +54,10 @@
|
|||||||
<!-- Card info — aligns with card-cover height -->
|
<!-- Card info — aligns with card-cover height -->
|
||||||
<view class="card-info">
|
<view class="card-info">
|
||||||
<view class="info-top">
|
<view class="info-top">
|
||||||
|
<view class="card-name-row">
|
||||||
<text class="card-name">{{ c.name }}</text>
|
<text class="card-name">{{ c.name }}</text>
|
||||||
|
<text v-if="isOwnedForRenew(c)" class="renew-tag">续</text>
|
||||||
|
</view>
|
||||||
<text class="card-validity">有效期 {{ c.durationDays }} 天</text>
|
<text class="card-validity">有效期 {{ c.durationDays }} 天</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-bottom">
|
<view class="info-bottom">
|
||||||
@@ -85,7 +88,7 @@
|
|||||||
<!-- Card content (single card mode) -->
|
<!-- Card content (single card mode) -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
|
<view class="card-hero" :class="[cardData.coverUrl ? 'hero--custom' : heroClass, isRenewal ? 'hero--renew' : '']">
|
||||||
<!-- Cover image background -->
|
<!-- Cover image background -->
|
||||||
<image
|
<image
|
||||||
v-if="cardData.coverUrl"
|
v-if="cardData.coverUrl"
|
||||||
@@ -99,9 +102,15 @@
|
|||||||
<view class="hero-deco hero-deco--2" />
|
<view class="hero-deco hero-deco--2" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<view class="hero-badge-row">
|
||||||
<view class="hero-badge">
|
<view class="hero-badge">
|
||||||
<text class="hero-badge-text">{{ typeLabel }}</text>
|
<text class="hero-badge-text">{{ typeLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="isRenewal" class="hero-badge hero-badge--renew">
|
||||||
|
<text class="hero-badge-text">续卡</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="isRenewal" class="hero-kicker">把练习节奏接下去</text>
|
||||||
<text class="hero-name">{{ cardData.name }}</text>
|
<text class="hero-name">{{ cardData.name }}</text>
|
||||||
<view class="hero-price-row">
|
<view class="hero-price-row">
|
||||||
<text class="hero-currency">¥</text>
|
<text class="hero-currency">¥</text>
|
||||||
@@ -144,15 +153,49 @@
|
|||||||
<text class="desc-content">{{ cardData.description }}</text>
|
<text class="desc-content">{{ cardData.description }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="renewalPreview" class="renew-compare">
|
||||||
|
<view class="renew-compare-head">
|
||||||
|
<text class="renew-compare-kicker">续卡对照</text>
|
||||||
|
<text class="renew-compare-status">{{ renewalPreview.statusLabel }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="renew-compare-body">
|
||||||
|
<view class="renew-col">
|
||||||
|
<text class="renew-col-label">当前</text>
|
||||||
|
<text class="renew-col-value">{{ renewalPreview.currentHighlight }}</text>
|
||||||
|
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
|
||||||
|
<text class="renew-col-date">{{ renewalPreview.currentExpire }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="renew-arrow">
|
||||||
|
<view class="renew-arrow-line" />
|
||||||
|
<view class="renew-arrow-diamond" />
|
||||||
|
<view class="renew-arrow-line" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="renew-col renew-col--after">
|
||||||
|
<text class="renew-col-label">续后</text>
|
||||||
|
<text class="renew-col-value">{{ renewalPreview.afterHighlight }}</text>
|
||||||
|
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
|
||||||
|
<text class="renew-col-date">{{ renewalPreview.afterExpire }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="renew-compare-foot">
|
||||||
|
<text class="renew-compare-add">{{ renewalPreview.addedLabel }}</text>
|
||||||
|
<text v-if="renewalPreview.forfeitNote" class="renew-compare-note">{{ renewalPreview.forfeitNote }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Features list -->
|
<!-- Features list -->
|
||||||
<view class="features-card">
|
<view class="features-card">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<view class="section-dot" />
|
<view class="section-dot" />
|
||||||
<text class="section-title">购买须知</text>
|
<text class="section-title">{{ isRenewal ? '续卡须知' : '购买须知' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="feature-item">
|
<view class="feature-item">
|
||||||
<text class="feature-dot">•</text>
|
<text class="feature-dot">•</text>
|
||||||
<text class="feature-text">购买后立即生效,有效期 {{ cardData.durationDays }} 天</text>
|
<text class="feature-text">{{ isRenewal ? `续卡后有效期接续,再延长 ${cardData.durationDays} 天` : `购买后立即生效,有效期 ${cardData.durationDays} 天` }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="cardData.totalTimes" class="feature-item">
|
<view v-if="cardData.totalTimes" class="feature-item">
|
||||||
<text class="feature-dot">•</text>
|
<text class="feature-dot">•</text>
|
||||||
@@ -186,17 +229,17 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Bottom action bar -->
|
<!-- Bottom action bar -->
|
||||||
<view class="bottom-bar">
|
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
|
||||||
<view class="price-summary">
|
<view class="price-summary">
|
||||||
<text class="summary-label">实付金额</text>
|
<text class="summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
|
||||||
<text class="summary-price">¥{{ formatPrice(cardData.price) }}</text>
|
<text class="summary-price">¥{{ formatPrice(cardData.price) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
class="buy-btn"
|
class="buy-btn"
|
||||||
:class="{ 'buy-btn--loading': buying }"
|
:class="{ 'buy-btn--loading': buying, 'buy-btn--renew': isRenewal }"
|
||||||
@tap="handleBuy"
|
@tap="handleBuy"
|
||||||
>
|
>
|
||||||
<text class="buy-btn-text">{{ buying ? '支付中...' : '立即购买' }}</text>
|
<text class="buy-btn-text">{{ buying ? '支付中...' : (isRenewal ? '确认续卡' : '立即购买') }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -206,10 +249,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory, MembershipStatus, computeMembershipGrant, pickRenewalTarget, getMembershipDaysLeft } from '@mp-pilates/shared'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { get, post } from '../../utils/request'
|
import { get, post } from '../../utils/request'
|
||||||
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
|
import { formatPrice, getCardCoverClass } from '../../utils/format'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
@@ -261,6 +304,78 @@ const unitPrice = computed(() => {
|
|||||||
|
|
||||||
const cardData = computed<CardType>(() => card.value as CardType)
|
const cardData = computed<CardType>(() => card.value as CardType)
|
||||||
|
|
||||||
|
const renewalTarget = computed(() => {
|
||||||
|
if (!card.value || card.value.type === CardTypeCategory.TRIAL) return null
|
||||||
|
return pickRenewalTarget(userStore.memberships, card.value.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isRenewal = computed(() => !!renewalTarget.value)
|
||||||
|
|
||||||
|
const grantPreview = computed(() => {
|
||||||
|
if (!card.value || !renewalTarget.value) return null
|
||||||
|
return computeMembershipGrant({
|
||||||
|
existing: renewalTarget.value,
|
||||||
|
cardType: card.value,
|
||||||
|
now: new Date(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatStudioDate(value: Date | string): string {
|
||||||
|
const date = typeof value === 'string' ? new Date(value) : value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewalPreview = computed(() => {
|
||||||
|
const target = renewalTarget.value
|
||||||
|
const grant = grantPreview.value
|
||||||
|
const currentCard = card.value
|
||||||
|
if (!target || !grant || !currentCard) return null
|
||||||
|
|
||||||
|
const isTimes = grant.remainingTimes !== null
|
||||||
|
const isExpired =
|
||||||
|
target.status === MembershipStatus.EXPIRED ||
|
||||||
|
new Date(target.expireDate).getTime() <= Date.now()
|
||||||
|
const statusLabel =
|
||||||
|
target.status === MembershipStatus.USED_UP
|
||||||
|
? '次数已用完'
|
||||||
|
: isExpired
|
||||||
|
? '已到期'
|
||||||
|
: '使用中'
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTimes,
|
||||||
|
isExpired,
|
||||||
|
statusLabel,
|
||||||
|
currentHighlight: isTimes ? String(target.remainingTimes ?? 0) : String(Math.max(0, getMembershipDaysLeft(target.expireDate))),
|
||||||
|
afterHighlight: isTimes ? String(grant.remainingTimes ?? 0) : String(Math.max(0, getMembershipDaysLeft(grant.expireDate))),
|
||||||
|
highlightUnit: isTimes ? '次剩余' : '天有效',
|
||||||
|
currentExpire: formatStudioDate(target.expireDate),
|
||||||
|
afterExpire: formatStudioDate(grant.expireDate),
|
||||||
|
addedLabel: isTimes
|
||||||
|
? `本次续入 +${currentCard.totalTimes ?? 0} 次 · +${currentCard.durationDays} 天`
|
||||||
|
: `本次续入 +${currentCard.durationDays} 天`,
|
||||||
|
forfeitNote: isExpired && isTimes && (target.remainingTimes ?? 0) > 0
|
||||||
|
? '到期余次不结转,按新购次数起算'
|
||||||
|
: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const renewalPreviewText = computed(() => {
|
||||||
|
const preview = renewalPreview.value
|
||||||
|
if (!preview) return ''
|
||||||
|
if (preview.isTimes) {
|
||||||
|
return `续卡后剩余 ${preview.afterHighlight} 次,有效期至 ${preview.afterExpire}`
|
||||||
|
}
|
||||||
|
return `续卡后有效期至 ${preview.afterExpire}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function isOwnedForRenew(target: CardType): boolean {
|
||||||
|
return target.type !== CardTypeCategory.TRIAL && !!pickRenewalTarget(userStore.memberships, target.id)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Data loading ─────────────────────────────────────────
|
// ─── Data loading ─────────────────────────────────────────
|
||||||
async function loadCard() {
|
async function loadCard() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -324,8 +439,10 @@ async function handleBuy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '确认购买',
|
title: isRenewal.value ? '确认续卡' : '确认购买',
|
||||||
content: `确认购买「${card.value.name}」,实付 ¥${formatPrice(card.value.price)}?`,
|
content: isRenewal.value
|
||||||
|
? `确认续卡「${card.value.name}」,${renewalPreviewText.value},实付 ¥${formatPrice(card.value.price)}?`
|
||||||
|
: `确认购买「${card.value.name}」,实付 ¥${formatPrice(card.value.price)}?`,
|
||||||
confirmText: '确认支付',
|
confirmText: '确认支付',
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (!res.confirm) return
|
if (!res.confirm) return
|
||||||
@@ -364,7 +481,7 @@ async function doPurchase() {
|
|||||||
|
|
||||||
// Payment succeeded — refresh memberships then navigate
|
// Payment succeeded — refresh memberships then navigate
|
||||||
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
||||||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
uni.showToast({ title: isRenewal.value ? '续卡成功!' : '购买成功!', icon: 'success' })
|
||||||
await userStore.fetchMemberships()
|
await userStore.fetchMemberships()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.navigateTo({ url: '/pages/profile/membership' })
|
uni.navigateTo({ url: '/pages/profile/membership' })
|
||||||
@@ -391,6 +508,9 @@ onMounted(() => {
|
|||||||
isTrial.value = options.trial === '1'
|
isTrial.value = options.trial === '1'
|
||||||
showAll.value = options.showAll === '1'
|
showAll.value = options.showAll === '1'
|
||||||
loadCard()
|
loadCard()
|
||||||
|
if (userStore.loggedIn) {
|
||||||
|
userStore.fetchMemberships()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -401,6 +521,10 @@ onMounted(() => {
|
|||||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page--renew {
|
||||||
|
background: #f4f1eb;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Loading ─────────────────────────────────────────── */
|
/* ── Loading ─────────────────────────────────────────── */
|
||||||
.loading-wrap {
|
.loading-wrap {
|
||||||
padding: 0 0 32rpx;
|
padding: 0 0 32rpx;
|
||||||
@@ -473,19 +597,23 @@ onMounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&.hero--times {
|
&.hero--times {
|
||||||
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
|
background: linear-gradient(160deg, #f3e9de 0%, #e6d5c4 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hero--duration {
|
&.hero--duration {
|
||||||
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
|
background: linear-gradient(160deg, #eee6ea 0%, #dccfd6 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hero--trial {
|
&.hero--trial {
|
||||||
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
|
background: linear-gradient(160deg, #e4eee9 0%, #c9dbd3 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hero--custom {
|
&.hero--custom {
|
||||||
background: #333;
|
background: #5c534c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hero--renew {
|
||||||
|
background: linear-gradient(165deg, #e4ede8 0%, #f3ebe3 52%, #e8dcd0 100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +629,7 @@ onMounted(() => {
|
|||||||
.hero-deco {
|
.hero-deco {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.35);
|
background: rgba(255, 255, 255, 0.38);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
&--1 {
|
&--1 {
|
||||||
@@ -519,26 +647,57 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero--renew .hero-deco--1 {
|
||||||
|
background: rgba(143, 168, 154, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero--renew .hero-deco--2 {
|
||||||
|
background: rgba(255, 252, 248, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-badge {
|
.hero-badge {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 8rpx 22rpx;
|
padding: 8rpx 22rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
background: rgba(74, 64, 53, 0.1);
|
background: rgba(255, 252, 248, 0.45);
|
||||||
border: 1rpx solid rgba(74, 64, 53, 0.15);
|
border: 1rpx solid rgba(95, 122, 110, 0.18);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-badge--renew {
|
||||||
|
background: rgba(143, 168, 154, 0.22);
|
||||||
|
border-color: rgba(95, 122, 110, 0.22);
|
||||||
|
|
||||||
|
.hero-badge-text {
|
||||||
|
color: #5f7a6e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero-badge-text {
|
.hero-badge-text {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: $brand-color;
|
color: #5c534c;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-kicker {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #7a8f85;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-name {
|
.hero-name {
|
||||||
font-size: 48rpx;
|
font-size: 48rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: $brand-color;
|
color: #4f5d56;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 1rpx;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -553,20 +712,20 @@ onMounted(() => {
|
|||||||
.hero-currency {
|
.hero-currency {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(74, 64, 53, 0.7);
|
color: #7a8f85;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-price {
|
.hero-price {
|
||||||
font-size: 64rpx;
|
font-size: 64rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: $brand-color;
|
color: #4f5d56;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-original {
|
.hero-original {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: rgba(74, 64, 53, 0.4);
|
color: rgba(92, 83, 76, 0.4);
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
margin-left: 8rpx;
|
margin-left: 8rpx;
|
||||||
}
|
}
|
||||||
@@ -592,7 +751,7 @@ onMounted(() => {
|
|||||||
width: 6rpx;
|
width: 6rpx;
|
||||||
height: 28rpx;
|
height: 28rpx;
|
||||||
border-radius: 3rpx;
|
border-radius: 3rpx;
|
||||||
background: $primary-dark;
|
background: #8fa89a;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,13 +792,13 @@ onMounted(() => {
|
|||||||
.cell-value {
|
.cell-value {
|
||||||
font-size: 44rpx;
|
font-size: 44rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #1a1a1a;
|
color: #4f5d56;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-label {
|
.cell-label {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #999;
|
color: #9a9088;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Description card ────────────────────────────────── */
|
/* ── Description card ────────────────────────────────── */
|
||||||
@@ -656,6 +815,128 @@ onMounted(() => {
|
|||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.renew-compare {
|
||||||
|
background: linear-gradient(180deg, #fffcfa 0%, #f3eee6 100%);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 32rpx 28rpx 28rpx;
|
||||||
|
box-shadow: 0 10rpx 32rpx rgba(95, 122, 110, 0.08);
|
||||||
|
border: 1rpx solid rgba(143, 168, 154, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-compare-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-compare-kicker {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
color: #7a8f85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-compare-status {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #f7faf8;
|
||||||
|
background: #8fa89a;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-compare-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-col-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #a3988e;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-col-value {
|
||||||
|
font-size: 72rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #8a7e74;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-col--after .renew-col-value {
|
||||||
|
color: #5f7a6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-col-unit {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8a7e74;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-col-date {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #a3988e;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-arrow {
|
||||||
|
width: auto;
|
||||||
|
padding: 0 8rpx 28rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-arrow-line {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 2rpx;
|
||||||
|
background: rgba(143, 168, 154, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-arrow-diamond {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
background: #8fa89a;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-compare-foot {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 1rpx dashed rgba(143, 168, 154, 0.4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-compare-add {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5f7a6e;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-compare-note {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #a3988e;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Features card ───────────────────────────────────── */
|
/* ── Features card ───────────────────────────────────── */
|
||||||
.features-card {
|
.features-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -676,14 +957,14 @@ onMounted(() => {
|
|||||||
|
|
||||||
.feature-dot {
|
.feature-dot {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: $primary-dark;
|
color: #8fa89a;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-text {
|
.feature-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #555;
|
color: #6f675f;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,14 +974,19 @@ onMounted(() => {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: #fff;
|
background: rgba(255, 252, 250, 0.96);
|
||||||
border-top: 1rpx solid #f0ece8;
|
border-top: 1rpx solid #efe8df;
|
||||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
box-shadow: 0 -8rpx 28rpx rgba(95, 122, 110, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar--renew {
|
||||||
|
background: #fffcfa;
|
||||||
|
border-top-color: rgba(143, 168, 154, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-summary {
|
.price-summary {
|
||||||
@@ -711,24 +997,24 @@ onMounted(() => {
|
|||||||
|
|
||||||
.summary-label {
|
.summary-label {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #999;
|
color: #9a9088;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-price {
|
.summary-price {
|
||||||
font-size: 40rpx;
|
font-size: 40rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: $primary-dark;
|
color: #5f7a6e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buy-btn {
|
.buy-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
border-radius: 44rpx;
|
border-radius: 44rpx;
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
background: #6e8b7e;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
|
box-shadow: 0 8rpx 24rpx rgba(95, 122, 110, 0.22);
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
@@ -737,13 +1023,17 @@ onMounted(() => {
|
|||||||
&--loading {
|
&--loading {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--renew {
|
||||||
|
background: linear-gradient(135deg, #6e8b7e 0%, #8fa89a 100%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.buy-btn-text {
|
.buy-btn-text {
|
||||||
font-size: 32rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $primary-dark;
|
color: #fffcfa;
|
||||||
letter-spacing: 2rpx;
|
letter-spacing: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── All cards list ────────────────────────────────────── */
|
/* ── All cards list ────────────────────────────────────── */
|
||||||
@@ -849,6 +1139,25 @@ onMounted(() => {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renew-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #5f7a6e;
|
||||||
|
background: rgba(143, 168, 154, 0.2);
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.card-validity {
|
.card-validity {
|
||||||
font-size: 23rpx;
|
font-size: 23rpx;
|
||||||
color: $text-secondary;
|
color: $text-secondary;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
<view v-if="m.remainingTimes !== null" class="mc-center">
|
<view v-if="m.remainingTimes !== null" class="mc-center">
|
||||||
<text class="mc-big-num">{{ m.remainingTimes }}</text>
|
<text class="mc-big-num">{{ m.remainingTimes }}</text>
|
||||||
<text class="mc-big-unit">次剩余</text>
|
<text class="mc-big-unit">次剩余</text>
|
||||||
<view v-if="m.cardType.totalTimes" class="mc-progress">
|
<view v-if="getMembershipTotalTimes(m)" class="mc-progress">
|
||||||
<view class="mc-progress-track">
|
<view class="mc-progress-track">
|
||||||
<view
|
<view
|
||||||
class="mc-progress-fill"
|
class="mc-progress-fill"
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<text class="mc-progress-label">
|
<text class="mc-progress-label">
|
||||||
已用 {{ getMembershipUsedTimes(m) }},共 {{ m.cardType.totalTimes }} 次
|
已用 {{ getMembershipUsedTimes(m) }},共 {{ getMembershipTotalTimes(m) }} 次
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -94,6 +94,10 @@
|
|||||||
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="canRenewMembership(m)" class="mc-renew" @tap.stop="goRenew(m)">
|
||||||
|
<text class="mc-renew-text">续卡</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -133,6 +137,10 @@
|
|||||||
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap.stop="goRenew(m)">
|
||||||
|
<text class="mc-renew-text">续同款</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -153,7 +161,7 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
|
|||||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
|
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -215,9 +223,18 @@ async function onRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goStore() {
|
function goStore() {
|
||||||
|
uni.$emit('scrollToCardShop')
|
||||||
uni.switchTab({ url: '/pages/home/index' })
|
uni.switchTab({ url: '/pages/home/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRenewMembership(m: MembershipWithCardType): boolean {
|
||||||
|
return m.cardType.type !== CardTypeCategory.TRIAL
|
||||||
|
}
|
||||||
|
|
||||||
|
function goRenew(m: MembershipWithCardType) {
|
||||||
|
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
loadMemberships()
|
loadMemberships()
|
||||||
@@ -571,6 +588,32 @@ onMounted(() => {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-renew {
|
||||||
|
z-index: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: rgba(255, 252, 248, 0.42);
|
||||||
|
border: 1rpx solid rgba(143, 168, 154, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.85; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-renew--inactive {
|
||||||
|
background: rgba(143, 168, 154, 0.1);
|
||||||
|
border-color: rgba(143, 168, 154, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-renew-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5f7a6e;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── FAB ──────────────────────────────────── */
|
/* ── FAB ──────────────────────────────────── */
|
||||||
.fab {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import type {
|
|||||||
UserStatsResponse,
|
UserStatsResponse,
|
||||||
MembershipWithCardType,
|
MembershipWithCardType,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
import {
|
||||||
|
UserRole,
|
||||||
|
MembershipStatus,
|
||||||
|
getMembershipRenewalHint,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
||||||
|
import { setUnauthorizedHandler } from '../utils/session'
|
||||||
import { get, put } from '../utils/request'
|
import { get, put } from '../utils/request'
|
||||||
import { ROUTES } from '../utils/routes'
|
import { ROUTES } from '../utils/routes'
|
||||||
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
||||||
@@ -32,6 +37,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||||
)
|
)
|
||||||
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
||||||
|
const renewalHint = computed(() => getMembershipRenewalHint(memberships.value))
|
||||||
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
|
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -110,15 +116,21 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function clearSession() {
|
||||||
authLogout()
|
|
||||||
resetSubscriptionMessageTemplateCache()
|
|
||||||
token.value = ''
|
token.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
stats.value = null
|
stats.value = null
|
||||||
memberships.value = []
|
memberships.value = []
|
||||||
|
resetSubscriptionMessageTemplateCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authLogout()
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnauthorizedHandler(clearSession)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
stats,
|
stats,
|
||||||
@@ -129,6 +141,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
activeMemberships,
|
activeMemberships,
|
||||||
hasValidMembership,
|
hasValidMembership,
|
||||||
|
renewalHint,
|
||||||
inviteShareEligible,
|
inviteShareEligible,
|
||||||
login,
|
login,
|
||||||
loginWithSetup,
|
loginWithSetup,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FlashSalePhase } from '@mp-pilates/shared'
|
|||||||
/** Minimal membership shape needed by progress/usage helpers. */
|
/** Minimal membership shape needed by progress/usage helpers. */
|
||||||
interface MembershipLike {
|
interface MembershipLike {
|
||||||
readonly remainingTimes: number | null
|
readonly remainingTimes: number | null
|
||||||
|
readonly totalTimes?: number | null
|
||||||
readonly cardType: { readonly totalTimes: number | null }
|
readonly cardType: { readonly totalTimes: number | null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +85,24 @@ export function getCardGradientClass(type: CardTypeCategory | string): string {
|
|||||||
return 'gradient--times'
|
return 'gradient--times'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 会员卡累计购入次数(续卡后优先用卡上的快照) */
|
||||||
|
export function getMembershipTotalTimes(membership: MembershipLike): number | null {
|
||||||
|
return membership.totalTimes ?? membership.cardType.totalTimes ?? null
|
||||||
|
}
|
||||||
|
|
||||||
/** 会员卡进度百分比(剩余 / 总次数,clamp 到 0~100%) */
|
/** 会员卡进度百分比(剩余 / 总次数,clamp 到 0~100%) */
|
||||||
export function getMembershipProgressWidth(membership: MembershipLike): string {
|
export function getMembershipProgressWidth(membership: MembershipLike): string {
|
||||||
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
|
const totalTimes = getMembershipTotalTimes(membership)
|
||||||
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
|
if (membership.remainingTimes === null || !totalTimes) return '0%'
|
||||||
|
const pct = (membership.remainingTimes / totalTimes) * 100
|
||||||
return `${Math.max(0, Math.min(100, pct))}%`
|
return `${Math.max(0, Math.min(100, pct))}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已使用次数(不低于 0,防止管理员调高剩余次数导致负值) */
|
/** 已使用次数(不低于 0,防止管理员调高剩余次数导致负值) */
|
||||||
export function getMembershipUsedTimes(membership: MembershipLike): number {
|
export function getMembershipUsedTimes(membership: MembershipLike): number {
|
||||||
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
|
const totalTimes = getMembershipTotalTimes(membership)
|
||||||
return Math.max(0, membership.cardType.totalTimes - membership.remainingTimes)
|
if (membership.remainingTimes === null || !totalTimes) return 0
|
||||||
|
return Math.max(0, totalTimes - membership.remainingTimes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化倒计时:HH:MM:SS */
|
/** 格式化倒计时:HH:MM:SS */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
|
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
|
||||||
|
import { notifyUnauthorized } from './session'
|
||||||
|
|
||||||
// 统一使用线上服务地址
|
// 统一使用线上服务地址
|
||||||
const BASE_URL = 'https://focus.richarjiang.com/api'
|
const BASE_URL = 'https://focus.richarjiang.com/api'
|
||||||
@@ -25,8 +26,9 @@ export function request<T>(options: RequestOptions): Promise<T> {
|
|||||||
},
|
},
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401) {
|
||||||
uni.removeStorageSync('token')
|
if (!options.url.startsWith('/auth/login')) {
|
||||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
notifyUnauthorized()
|
||||||
|
}
|
||||||
reject(new Error('Unauthorized'))
|
reject(new Error('Unauthorized'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
22
packages/app/src/utils/session.ts
Normal file
22
packages/app/src/utils/session.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const UNAUTHORIZED_TOAST_GAP_MS = 2000
|
||||||
|
|
||||||
|
let unauthorizedHandler: (() => void) | null = null
|
||||||
|
let lastUnauthorizedAt = 0
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(handler: () => void): void {
|
||||||
|
unauthorizedHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear persisted token and in-memory session after the server rejects auth. */
|
||||||
|
export function notifyUnauthorized(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
const shouldToast = now - lastUnauthorizedAt > UNAUTHORIZED_TOAST_GAP_MS
|
||||||
|
lastUnauthorizedAt = now
|
||||||
|
|
||||||
|
uni.removeStorageSync('token')
|
||||||
|
unauthorizedHandler?.()
|
||||||
|
|
||||||
|
if (shouldToast) {
|
||||||
|
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `memberships` ADD COLUMN `total_times` INTEGER NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `orders` ADD COLUMN `membership_id` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `memberships_user_id_card_type_id_idx` ON `memberships`(`user_id`, `card_type_id`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `orders_membership_id_idx` ON `orders`(`membership_id`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `orders` ADD CONSTRAINT `orders_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill purchased-times snapshot for existing times/trial cards
|
||||||
|
UPDATE `memberships` `m`
|
||||||
|
INNER JOIN `card_types` `ct` ON `ct`.`id` = `m`.`card_type_id`
|
||||||
|
SET `m`.`total_times` = `ct`.`total_times`
|
||||||
|
WHERE `ct`.`total_times` IS NOT NULL AND `m`.`total_times` IS NULL;
|
||||||
@@ -147,6 +147,7 @@ model Membership {
|
|||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
cardTypeId String @map("card_type_id")
|
cardTypeId String @map("card_type_id")
|
||||||
remainingTimes Int? @map("remaining_times")
|
remainingTimes Int? @map("remaining_times")
|
||||||
|
totalTimes Int? @map("total_times")
|
||||||
startDate DateTime @map("start_date")
|
startDate DateTime @map("start_date")
|
||||||
expireDate DateTime @map("expire_date")
|
expireDate DateTime @map("expire_date")
|
||||||
status MembershipStatus @default(ACTIVE)
|
status MembershipStatus @default(ACTIVE)
|
||||||
@@ -156,9 +157,11 @@ model Membership {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
|
orders Order[]
|
||||||
inviteRewardGrants InviteRewardGrant[]
|
inviteRewardGrants InviteRewardGrant[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([userId, cardTypeId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("memberships")
|
@@map("memberships")
|
||||||
}
|
}
|
||||||
@@ -245,6 +248,7 @@ model Order {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
cardTypeId String @map("card_type_id")
|
cardTypeId String @map("card_type_id")
|
||||||
|
membershipId String? @map("membership_id")
|
||||||
orderNo String @unique @map("order_no")
|
orderNo String @unique @map("order_no")
|
||||||
amount Decimal @db.Decimal(10, 0)
|
amount Decimal @db.Decimal(10, 0)
|
||||||
status OrderStatus @default(PENDING)
|
status OrderStatus @default(PENDING)
|
||||||
@@ -256,11 +260,13 @@ model Order {
|
|||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||||
|
membership Membership? @relation(fields: [membershipId], references: [id])
|
||||||
flashSaleOrder FlashSaleOrder?
|
flashSaleOrder FlashSaleOrder?
|
||||||
inviteReferrals InviteReferral[]
|
inviteReferrals InviteReferral[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([membershipId])
|
||||||
@@map("orders")
|
@@map("orders")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import {
|
||||||
|
CardTypeCategory,
|
||||||
|
MembershipStatus,
|
||||||
|
RENEWAL_DAYS_THRESHOLD,
|
||||||
|
RENEWAL_TIMES_THRESHOLD,
|
||||||
|
computeMembershipGrant,
|
||||||
|
getMembershipRenewalHint,
|
||||||
|
pickRenewalTarget,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import type { RenewalHintMembership } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const now = new Date('2026-06-01T00:00:00Z')
|
||||||
|
|
||||||
|
const timesCard = {
|
||||||
|
type: CardTypeCategory.TIMES,
|
||||||
|
totalTimes: 10,
|
||||||
|
durationDays: 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationCard = {
|
||||||
|
type: CardTypeCategory.DURATION,
|
||||||
|
totalTimes: null,
|
||||||
|
durationDays: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
const trialCard = {
|
||||||
|
type: CardTypeCategory.TRIAL,
|
||||||
|
totalTimes: 1,
|
||||||
|
durationDays: 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
function membership(overrides: Partial<RenewalHintMembership> & { cardTypeId: string }): RenewalHintMembership {
|
||||||
|
return {
|
||||||
|
remainingTimes: null,
|
||||||
|
expireDate: '2026-12-01T00:00:00.000Z',
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
cardType: { type: CardTypeCategory.TIMES },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeMembershipGrant', () => {
|
||||||
|
it('creates a fresh grant when there is no existing membership', () => {
|
||||||
|
const result = computeMembershipGrant({ existing: null, cardType: timesCard, now })
|
||||||
|
|
||||||
|
expect(result.isRenewal).toBe(false)
|
||||||
|
expect(result.remainingTimes).toBe(10)
|
||||||
|
expect(result.totalTimes).toBe(10)
|
||||||
|
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('always treats TRIAL as a new grant even if an old trial exists', () => {
|
||||||
|
const result = computeMembershipGrant({
|
||||||
|
existing: {
|
||||||
|
remainingTimes: 0,
|
||||||
|
totalTimes: 1,
|
||||||
|
expireDate: '2026-08-01T00:00:00.000Z',
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
cardType: trialCard,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isRenewal).toBe(false)
|
||||||
|
expect(result.remainingTimes).toBe(1)
|
||||||
|
expect(result.totalTimes).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stacks times and extends expireDate for an active TIMES card', () => {
|
||||||
|
const expireDate = new Date('2026-09-01T00:00:00Z')
|
||||||
|
const result = computeMembershipGrant({
|
||||||
|
existing: {
|
||||||
|
remainingTimes: 3,
|
||||||
|
totalTimes: 10,
|
||||||
|
expireDate,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
cardType: timesCard,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isRenewal).toBe(true)
|
||||||
|
expect(result.remainingTimes).toBe(13)
|
||||||
|
expect(result.totalTimes).toBe(20)
|
||||||
|
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not inherit leftover times when the TIMES card is expired', () => {
|
||||||
|
const result = computeMembershipGrant({
|
||||||
|
existing: {
|
||||||
|
remainingTimes: 4,
|
||||||
|
totalTimes: 10,
|
||||||
|
expireDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
status: MembershipStatus.EXPIRED,
|
||||||
|
},
|
||||||
|
cardType: timesCard,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isRenewal).toBe(true)
|
||||||
|
expect(result.remainingTimes).toBe(10)
|
||||||
|
expect(result.totalTimes).toBe(10)
|
||||||
|
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extends a still-active DURATION card from its current expireDate', () => {
|
||||||
|
const expireDate = new Date('2026-07-01T00:00:00Z')
|
||||||
|
const result = computeMembershipGrant({
|
||||||
|
existing: {
|
||||||
|
remainingTimes: null,
|
||||||
|
totalTimes: null,
|
||||||
|
expireDate,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
cardType: durationCard,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isRenewal).toBe(true)
|
||||||
|
expect(result.remainingTimes).toBeNull()
|
||||||
|
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts an expired DURATION card from now', () => {
|
||||||
|
const result = computeMembershipGrant({
|
||||||
|
existing: {
|
||||||
|
remainingTimes: null,
|
||||||
|
totalTimes: null,
|
||||||
|
expireDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
status: MembershipStatus.EXPIRED,
|
||||||
|
},
|
||||||
|
cardType: durationCard,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isRenewal).toBe(true)
|
||||||
|
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMembershipRenewalHint', () => {
|
||||||
|
it('prefers a TIMES card running low over a DURATION card near expiry', () => {
|
||||||
|
const hint = getMembershipRenewalHint(
|
||||||
|
[
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'duration-1',
|
||||||
|
remainingTimes: null,
|
||||||
|
expireDate: new Date(now.getTime() + 3 * 86_400_000).toISOString(),
|
||||||
|
cardType: { type: CardTypeCategory.DURATION },
|
||||||
|
}),
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'times-1',
|
||||||
|
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||||
|
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
|
||||||
|
cardType: { type: CardTypeCategory.TIMES },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hint).toMatchObject({
|
||||||
|
kind: 'times_low',
|
||||||
|
cardTypeId: 'times-1',
|
||||||
|
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns days_low when a DURATION card is within the threshold', () => {
|
||||||
|
const hint = getMembershipRenewalHint(
|
||||||
|
[
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'duration-1',
|
||||||
|
remainingTimes: null,
|
||||||
|
expireDate: new Date(now.getTime() + RENEWAL_DAYS_THRESHOLD * 86_400_000).toISOString(),
|
||||||
|
cardType: { type: CardTypeCategory.DURATION },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hint).toMatchObject({
|
||||||
|
kind: 'days_low',
|
||||||
|
cardTypeId: 'duration-1',
|
||||||
|
daysLeft: RENEWAL_DAYS_THRESHOLD,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not recommend renewing a TRIAL card when times run low', () => {
|
||||||
|
const hint = getMembershipRenewalHint(
|
||||||
|
[
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'trial-1',
|
||||||
|
remainingTimes: 1,
|
||||||
|
expireDate: new Date(now.getTime() + 5 * 86_400_000).toISOString(),
|
||||||
|
cardType: { type: CardTypeCategory.TRIAL },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hint).toMatchObject({
|
||||||
|
kind: 'trial_low',
|
||||||
|
cardTypeId: null,
|
||||||
|
remainingTimes: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recommends the most recently expired non-trial card', () => {
|
||||||
|
const hint = getMembershipRenewalHint(
|
||||||
|
[
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'times-old',
|
||||||
|
remainingTimes: 0,
|
||||||
|
expireDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
status: MembershipStatus.EXPIRED,
|
||||||
|
cardType: { type: CardTypeCategory.TIMES },
|
||||||
|
}),
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'duration-latest',
|
||||||
|
remainingTimes: null,
|
||||||
|
expireDate: '2026-05-01T00:00:00.000Z',
|
||||||
|
status: MembershipStatus.EXPIRED,
|
||||||
|
cardType: { type: CardTypeCategory.DURATION },
|
||||||
|
}),
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'trial-1',
|
||||||
|
remainingTimes: 0,
|
||||||
|
expireDate: '2026-05-15T00:00:00.000Z',
|
||||||
|
status: MembershipStatus.USED_UP,
|
||||||
|
cardType: { type: CardTypeCategory.TRIAL },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hint).toMatchObject({
|
||||||
|
kind: 'expired',
|
||||||
|
cardTypeId: 'duration-latest',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when active cards are healthy', () => {
|
||||||
|
const hint = getMembershipRenewalHint(
|
||||||
|
[
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'times-1',
|
||||||
|
remainingTimes: 8,
|
||||||
|
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hint).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pickRenewalTarget', () => {
|
||||||
|
it('picks the latest expiring membership of the same card type', () => {
|
||||||
|
const target = pickRenewalTarget(
|
||||||
|
[
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'times-1',
|
||||||
|
expireDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'times-1',
|
||||||
|
expireDate: '2026-08-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'duration-1',
|
||||||
|
expireDate: '2026-12-01T00:00:00.000Z',
|
||||||
|
cardType: { type: CardTypeCategory.DURATION },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'times-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(target?.expireDate).toBe('2026-08-01T00:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not pick a TRIAL membership as a renewal target', () => {
|
||||||
|
const target = pickRenewalTarget(
|
||||||
|
[
|
||||||
|
membership({
|
||||||
|
cardTypeId: 'trial-1',
|
||||||
|
cardType: { type: CardTypeCategory.TRIAL },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'trial-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(target).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,6 +16,7 @@ const mockTimesCardType = {
|
|||||||
price: 150000,
|
price: 150000,
|
||||||
originalPrice: null,
|
originalPrice: null,
|
||||||
description: null,
|
description: null,
|
||||||
|
coverUrl: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
@@ -31,6 +32,7 @@ const mockDurationCardType = {
|
|||||||
price: 80000,
|
price: 80000,
|
||||||
originalPrice: null,
|
originalPrice: null,
|
||||||
description: null,
|
description: null,
|
||||||
|
coverUrl: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
@@ -48,6 +50,7 @@ const mockActiveMembership = {
|
|||||||
userId: 'user-001',
|
userId: 'user-001',
|
||||||
cardTypeId: mockTimesCardType.id,
|
cardTypeId: mockTimesCardType.id,
|
||||||
remainingTimes: 5,
|
remainingTimes: 5,
|
||||||
|
totalTimes: 10,
|
||||||
startDate: new Date('2024-01-01T00:00:00Z'),
|
startDate: new Date('2024-01-01T00:00:00Z'),
|
||||||
expireDate: new Date('2099-12-31T00:00:00Z'),
|
expireDate: new Date('2099-12-31T00:00:00Z'),
|
||||||
status: MembershipStatus.ACTIVE,
|
status: MembershipStatus.ACTIVE,
|
||||||
@@ -61,6 +64,7 @@ const mockDurationMembership = {
|
|||||||
id: 'mem-duration-001',
|
id: 'mem-duration-001',
|
||||||
cardTypeId: mockDurationCardType.id,
|
cardTypeId: mockDurationCardType.id,
|
||||||
remainingTimes: null,
|
remainingTimes: null,
|
||||||
|
totalTimes: null,
|
||||||
cardType: mockDurationCardType,
|
cardType: mockDurationCardType,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ const mockPrismaService = {
|
|||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -300,6 +305,160 @@ describe('MembershipService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── grantPurchasedCard ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('grantPurchasedCard()', () => {
|
||||||
|
const now = new Date('2026-06-01T00:00:00Z')
|
||||||
|
const tx = mockPrismaService as unknown as Parameters<MembershipService['grantPurchasedCard']>[0]
|
||||||
|
|
||||||
|
it('creates a new membership when the user has no card of that type', async () => {
|
||||||
|
mockPrismaService.membership.findFirst.mockResolvedValue(null)
|
||||||
|
const created = { ...mockActiveMembership, remainingTimes: 10, totalTimes: 10 }
|
||||||
|
mockPrismaService.membership.create.mockResolvedValue(created)
|
||||||
|
|
||||||
|
const result = await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||||
|
|
||||||
|
expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
userId: 'user-001',
|
||||||
|
cardTypeId: mockTimesCardType.id,
|
||||||
|
remainingTimes: 10,
|
||||||
|
totalTimes: 10,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
|
||||||
|
expect(result.remainingTimes).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stacks remaining times and extends expireDate for an active TIMES card', async () => {
|
||||||
|
const existing = {
|
||||||
|
...mockActiveMembership,
|
||||||
|
remainingTimes: 3,
|
||||||
|
totalTimes: 10,
|
||||||
|
expireDate: new Date('2026-09-01T00:00:00Z'),
|
||||||
|
}
|
||||||
|
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||||
|
mockPrismaService.membership.update.mockResolvedValue({
|
||||||
|
...existing,
|
||||||
|
remainingTimes: 13,
|
||||||
|
totalTimes: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||||
|
|
||||||
|
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
remainingTimes: 13,
|
||||||
|
totalTimes: 20,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||||
|
expireDate: Date
|
||||||
|
}
|
||||||
|
expect(updateData.expireDate.getTime()).toBe(
|
||||||
|
existing.expireDate.getTime() + mockTimesCardType.durationDays * 86_400_000,
|
||||||
|
)
|
||||||
|
expect(mockPrismaService.membership.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extends a DURATION card from the current expireDate when still active', async () => {
|
||||||
|
const existing = {
|
||||||
|
...mockDurationMembership,
|
||||||
|
expireDate: new Date('2026-07-01T00:00:00Z'),
|
||||||
|
}
|
||||||
|
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||||
|
mockPrismaService.membership.update.mockResolvedValue(existing)
|
||||||
|
|
||||||
|
await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
|
||||||
|
|
||||||
|
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
remainingTimes: null,
|
||||||
|
totalTimes: null,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||||
|
expireDate: Date
|
||||||
|
}
|
||||||
|
expect(updateData.expireDate.getTime()).toBe(
|
||||||
|
existing.expireDate.getTime() + mockDurationCardType.durationDays * 86_400_000,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not carry leftover times when renewing an expired TIMES card', async () => {
|
||||||
|
const existing = {
|
||||||
|
...mockActiveMembership,
|
||||||
|
remainingTimes: 4,
|
||||||
|
totalTimes: 10,
|
||||||
|
status: MembershipStatus.EXPIRED,
|
||||||
|
expireDate: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
}
|
||||||
|
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||||
|
mockPrismaService.membership.update.mockResolvedValue({
|
||||||
|
...existing,
|
||||||
|
remainingTimes: 10,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
|
||||||
|
await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||||
|
|
||||||
|
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
remainingTimes: 10,
|
||||||
|
totalTimes: 10,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||||
|
expireDate: Date
|
||||||
|
}
|
||||||
|
expect(updateData.expireDate.getTime()).toBe(
|
||||||
|
now.getTime() + mockTimesCardType.durationDays * 86_400_000,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('always creates a new membership for TRIAL cards', async () => {
|
||||||
|
const trialCardType = {
|
||||||
|
...mockTimesCardType,
|
||||||
|
id: 'ct-trial-001',
|
||||||
|
type: CardTypeCategory.TRIAL,
|
||||||
|
totalTimes: 1,
|
||||||
|
durationDays: 7,
|
||||||
|
}
|
||||||
|
mockPrismaService.membership.create.mockResolvedValue({
|
||||||
|
...mockActiveMembership,
|
||||||
|
cardTypeId: trialCardType.id,
|
||||||
|
remainingTimes: 1,
|
||||||
|
totalTimes: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
await service.grantPurchasedCard(tx, 'user-001', trialCardType, now)
|
||||||
|
|
||||||
|
expect(mockPrismaService.membership.findFirst).not.toHaveBeenCalled()
|
||||||
|
expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
cardTypeId: trialCardType.id,
|
||||||
|
remainingTimes: 1,
|
||||||
|
totalTimes: 1,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ─── createCardType ────────────────────────────────────────────────────
|
// ─── createCardType ────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('createCardType()', () => {
|
describe('createCardType()', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||||
import { CardType, Membership } from '@prisma/client'
|
import { CardType, Membership, Prisma } from '@prisma/client'
|
||||||
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
|
import { CardTypeCategory, MembershipStatus, computeMembershipGrant } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { CreateCardTypeDto } from './dto/create-card-type.dto'
|
import { CreateCardTypeDto } from './dto/create-card-type.dto'
|
||||||
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
|
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
|
||||||
@@ -119,6 +119,55 @@ export class MembershipService {
|
|||||||
return { ...updated, cardType: { ...updated.cardType } }
|
return { ...updated, cardType: { ...updated.cardType } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async grantPurchasedCard(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: string,
|
||||||
|
cardType: Pick<CardType, 'id' | 'type' | 'totalTimes' | 'durationDays'>,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<Membership> {
|
||||||
|
const existing =
|
||||||
|
cardType.type === CardTypeCategory.TRIAL
|
||||||
|
? null
|
||||||
|
: await tx.membership.findFirst({
|
||||||
|
where: { userId, cardTypeId: cardType.id },
|
||||||
|
orderBy: { expireDate: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const grant = computeMembershipGrant({
|
||||||
|
existing,
|
||||||
|
cardType: {
|
||||||
|
type: cardType.type,
|
||||||
|
totalTimes: cardType.totalTimes,
|
||||||
|
durationDays: cardType.durationDays,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!grant.isRenewal || !existing) {
|
||||||
|
return tx.membership.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
cardTypeId: cardType.id,
|
||||||
|
startDate: now,
|
||||||
|
expireDate: grant.expireDate,
|
||||||
|
remainingTimes: grant.remainingTimes,
|
||||||
|
totalTimes: grant.totalTimes,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.membership.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
remainingTimes: grant.remainingTimes,
|
||||||
|
totalTimes: grant.totalTimes,
|
||||||
|
expireDate: grant.expireDate,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Admin ─────────────────────────────────────────────────────────────────
|
// ─── Admin ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getAllCardTypes(): Promise<CardType[]> {
|
async getAllCardTypes(): Promise<CardType[]> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PaymentService } from '../payment.service'
|
|||||||
import { WechatPayService } from '../wechat-pay.service'
|
import { WechatPayService } from '../wechat-pay.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
import { InviteService } from '../../invite/invite.service'
|
import { InviteService } from '../../invite/invite.service'
|
||||||
|
import { MembershipService } from '../../membership/membership.service'
|
||||||
|
|
||||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ const mockCardType = {
|
|||||||
type: 'TIMES',
|
type: 'TIMES',
|
||||||
originalPrice: null,
|
originalPrice: null,
|
||||||
description: null,
|
description: null,
|
||||||
|
coverUrl: null,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -82,6 +84,11 @@ function buildPrismaMock() {
|
|||||||
},
|
},
|
||||||
membership: {
|
membership: {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
flashSaleOrder: {
|
||||||
|
updateMany: jest.fn(),
|
||||||
},
|
},
|
||||||
$transaction: jest.fn(),
|
$transaction: jest.fn(),
|
||||||
}
|
}
|
||||||
@@ -109,6 +116,7 @@ describe('PaymentService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
PaymentService,
|
PaymentService,
|
||||||
|
MembershipService,
|
||||||
{ provide: PrismaService, useValue: prisma },
|
{ provide: PrismaService, useValue: prisma },
|
||||||
{ provide: WechatPayService, useValue: wechat },
|
{ provide: WechatPayService, useValue: wechat },
|
||||||
{ provide: InviteService, useValue: mockInviteService },
|
{ provide: InviteService, useValue: mockInviteService },
|
||||||
@@ -226,31 +234,33 @@ describe('PaymentService', () => {
|
|||||||
})
|
})
|
||||||
prisma.order.findUnique.mockResolvedValue(pendingOrder)
|
prisma.order.findUnique.mockResolvedValue(pendingOrder)
|
||||||
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
|
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
|
||||||
prisma.$transaction.mockResolvedValue([])
|
prisma.membership.findFirst.mockResolvedValue(null)
|
||||||
|
prisma.membership.create.mockResolvedValue({
|
||||||
|
id: 'mem-new-1',
|
||||||
|
userId: pendingOrder.userId,
|
||||||
|
cardTypeId: pendingOrder.cardTypeId,
|
||||||
|
remainingTimes: mockCardType.totalTimes,
|
||||||
|
totalTimes: mockCardType.totalTimes,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks order as PAID and creates membership on valid callback', async () => {
|
it('marks order as PAID and grants a new membership on valid callback', async () => {
|
||||||
const result = await service.handleWxNotify(headers, successBody)
|
const result = await service.handleWxNotify(headers, successBody)
|
||||||
|
|
||||||
// $transaction called once with an array of two operations
|
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||||
const [transactionOps] = prisma.$transaction.mock.calls[0] as [unknown[]]
|
|
||||||
expect(transactionOps).toHaveLength(2)
|
|
||||||
|
|
||||||
// order.update was called with PAID status and transaction id
|
|
||||||
expect(prisma.order.update).toHaveBeenCalledWith(
|
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
status: OrderStatus.PAID,
|
status: OrderStatus.PAID,
|
||||||
wxTransactionId: successBody.transaction_id,
|
wxTransactionId: successBody.transaction_id,
|
||||||
|
membershipId: 'mem-new-1',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// membership.create was called
|
|
||||||
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
||||||
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
|
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
|
||||||
|
|
||||||
expect(result).toContain('SUCCESS')
|
expect(result).toContain('SUCCESS')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -275,7 +285,7 @@ describe('PaymentService', () => {
|
|||||||
|
|
||||||
const expectedExpireMs =
|
const expectedExpireMs =
|
||||||
membershipData.startDate.getTime() + mockCardType.durationDays * 86_400_000
|
membershipData.startDate.getTime() + mockCardType.durationDays * 86_400_000
|
||||||
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2) // within 100ms
|
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2)
|
||||||
expect(membershipData.startDate.getTime()).toBeGreaterThanOrEqual(beforeCall)
|
expect(membershipData.startDate.getTime()).toBeGreaterThanOrEqual(beforeCall)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -285,14 +295,15 @@ describe('PaymentService', () => {
|
|||||||
expect(prisma.membership.create).toHaveBeenCalledWith(
|
expect(prisma.membership.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
remainingTimes: mockCardType.totalTimes, // 10
|
remainingTimes: mockCardType.totalTimes,
|
||||||
|
totalTimes: mockCardType.totalTimes,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates membership with null remainingTimes for duration-based cardType', async () => {
|
it('creates membership with null remainingTimes for duration-based cardType', async () => {
|
||||||
const durationCardType = { ...mockCardType, totalTimes: null }
|
const durationCardType = { ...mockCardType, totalTimes: null, type: 'DURATION' }
|
||||||
prisma.cardType.findUnique.mockResolvedValue(durationCardType)
|
prisma.cardType.findUnique.mockResolvedValue(durationCardType)
|
||||||
|
|
||||||
await service.handleWxNotify(headers, successBody)
|
await service.handleWxNotify(headers, successBody)
|
||||||
@@ -301,6 +312,46 @@ describe('PaymentService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
remainingTimes: null,
|
remainingTimes: null,
|
||||||
|
totalTimes: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renews an existing same-type membership instead of creating another', async () => {
|
||||||
|
const existingMembership = {
|
||||||
|
id: 'mem-existing-1',
|
||||||
|
userId: pendingOrder.userId,
|
||||||
|
cardTypeId: pendingOrder.cardTypeId,
|
||||||
|
remainingTimes: 2,
|
||||||
|
totalTimes: 10,
|
||||||
|
expireDate: new Date('2099-01-01T00:00:00Z'),
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
}
|
||||||
|
prisma.membership.findFirst.mockResolvedValue(existingMembership)
|
||||||
|
prisma.membership.update.mockResolvedValue({
|
||||||
|
...existingMembership,
|
||||||
|
remainingTimes: 12,
|
||||||
|
totalTimes: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
await service.handleWxNotify(headers, successBody)
|
||||||
|
|
||||||
|
expect(prisma.membership.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: 'mem-existing-1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
remainingTimes: 12,
|
||||||
|
totalTimes: 20,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(prisma.membership.create).not.toHaveBeenCalled()
|
||||||
|
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
membershipId: 'mem-existing-1',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { PaymentService } from './payment.service'
|
|||||||
import { PaymentController } from './payment.controller'
|
import { PaymentController } from './payment.controller'
|
||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
import { InviteModule } from '../invite/invite.module'
|
import { InviteModule } from '../invite/invite.module'
|
||||||
|
import { MembershipModule } from '../membership/membership.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, InviteModule],
|
imports: [PrismaModule, InviteModule, MembershipModule],
|
||||||
controllers: [PaymentController],
|
controllers: [PaymentController],
|
||||||
providers: [PaymentService, WechatPayService],
|
providers: [PaymentService, WechatPayService],
|
||||||
exports: [PaymentService, WechatPayService],
|
exports: [PaymentService, WechatPayService],
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { CardType, Order } from '@prisma/client'
|
import { CardType, Order } from '@prisma/client'
|
||||||
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
|
import { OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
||||||
import { InviteService } from '../invite/invite.service'
|
import { InviteService } from '../invite/invite.service'
|
||||||
|
import { MembershipService } from '../membership/membership.service'
|
||||||
|
|
||||||
export interface CreateOrderResult {
|
export interface CreateOrderResult {
|
||||||
order: Order
|
order: Order
|
||||||
@@ -30,6 +31,7 @@ export class PaymentService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly wechatPayService: WechatPayService,
|
private readonly wechatPayService: WechatPayService,
|
||||||
private readonly inviteService: InviteService,
|
private readonly inviteService: InviteService,
|
||||||
|
private readonly membershipService: MembershipService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── User: create order ────────────────────────────────────────────────────
|
// ─── User: create order ────────────────────────────────────────────────────
|
||||||
@@ -118,32 +120,29 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const expireDate = new Date(now.getTime() + cardType.durationDays * 86_400_000)
|
|
||||||
|
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction(async (tx) => {
|
||||||
this.prisma.order.update({
|
const membership = await this.membershipService.grantPurchasedCard(
|
||||||
|
tx,
|
||||||
|
existingOrder.userId,
|
||||||
|
cardType,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
await tx.order.update({
|
||||||
where: { id: existingOrder.id },
|
where: { id: existingOrder.id },
|
||||||
data: {
|
data: {
|
||||||
status: OrderStatus.PAID,
|
status: OrderStatus.PAID,
|
||||||
wxTransactionId: notification.wxTransactionId,
|
wxTransactionId: notification.wxTransactionId,
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
|
membershipId: membership.id,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
this.prisma.membership.create({
|
})
|
||||||
data: {
|
|
||||||
userId: existingOrder.userId,
|
|
||||||
cardTypeId: existingOrder.cardTypeId,
|
|
||||||
startDate: now,
|
|
||||||
expireDate,
|
|
||||||
remainingTimes: cardType.totalTimes ?? null,
|
|
||||||
status: MembershipStatus.ACTIVE,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
||||||
|
|
||||||
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
|
this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)
|
||||||
|
|
||||||
// ── Flash sale order: mark as PAID ──
|
// ── Flash sale order: mark as PAID ──
|
||||||
if (existingOrder.flashSaleId) {
|
if (existingOrder.flashSaleId) {
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.membership.create({
|
return this.prisma.membership.create({
|
||||||
data: { userId, ...data },
|
data: { userId, totalTimes: dto.remainingTimes ?? null, ...data },
|
||||||
include: { cardType: true },
|
include: { cardType: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,27 @@ export {
|
|||||||
SUBSCRIPTION_MESSAGE_REQUEST_RESULTS,
|
SUBSCRIPTION_MESSAGE_REQUEST_RESULTS,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
|
|
||||||
|
export {
|
||||||
|
RENEWAL_TIMES_THRESHOLD,
|
||||||
|
RENEWAL_DAYS_THRESHOLD,
|
||||||
|
MEMBERSHIP_DAY_MS,
|
||||||
|
addDurationDays,
|
||||||
|
getMembershipDaysLeft,
|
||||||
|
canRenewCardType,
|
||||||
|
computeMembershipGrant,
|
||||||
|
pickRenewalTarget,
|
||||||
|
getMembershipRenewalHint,
|
||||||
|
} from './membership-renewal'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MembershipGrantExisting,
|
||||||
|
MembershipGrantCardType,
|
||||||
|
MembershipGrantResult,
|
||||||
|
MembershipRenewalHintKind,
|
||||||
|
MembershipRenewalHint,
|
||||||
|
RenewalHintMembership,
|
||||||
|
} from './membership-renewal'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type {
|
export type {
|
||||||
User,
|
User,
|
||||||
|
|||||||
203
packages/shared/src/membership-renewal.ts
Normal file
203
packages/shared/src/membership-renewal.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { CardTypeCategory, MembershipStatus } from './enums'
|
||||||
|
|
||||||
|
export const RENEWAL_TIMES_THRESHOLD = 2
|
||||||
|
export const RENEWAL_DAYS_THRESHOLD = 7
|
||||||
|
export const MEMBERSHIP_DAY_MS = 86_400_000
|
||||||
|
|
||||||
|
export interface MembershipGrantExisting {
|
||||||
|
readonly remainingTimes: number | null
|
||||||
|
readonly totalTimes: number | null
|
||||||
|
readonly expireDate: Date | string
|
||||||
|
readonly status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembershipGrantCardType {
|
||||||
|
readonly type: string
|
||||||
|
readonly totalTimes: number | null
|
||||||
|
readonly durationDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembershipGrantResult {
|
||||||
|
readonly remainingTimes: number | null
|
||||||
|
readonly expireDate: Date
|
||||||
|
readonly totalTimes: number | null
|
||||||
|
readonly isRenewal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MembershipRenewalHintKind = 'times_low' | 'days_low' | 'trial_low' | 'expired'
|
||||||
|
|
||||||
|
export interface MembershipRenewalHint {
|
||||||
|
readonly kind: MembershipRenewalHintKind
|
||||||
|
readonly cardTypeId: string | null
|
||||||
|
readonly remainingTimes: number | null
|
||||||
|
readonly daysLeft: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenewalHintMembership {
|
||||||
|
readonly cardTypeId: string
|
||||||
|
readonly remainingTimes: number | null
|
||||||
|
readonly expireDate: Date | string
|
||||||
|
readonly status: MembershipStatus
|
||||||
|
readonly cardType: {
|
||||||
|
readonly type: CardTypeCategory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDurationDays(from: Date, durationDays: number): Date {
|
||||||
|
return new Date(from.getTime() + durationDays * MEMBERSHIP_DAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMembershipDaysLeft(expireDate: Date | string, now: Date = new Date()): number {
|
||||||
|
return Math.ceil((new Date(expireDate).getTime() - now.getTime()) / MEMBERSHIP_DAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRenewCardType(type: CardTypeCategory): boolean {
|
||||||
|
return type !== CardTypeCategory.TRIAL
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMembershipGrant(params: {
|
||||||
|
readonly existing: MembershipGrantExisting | null
|
||||||
|
readonly cardType: MembershipGrantCardType
|
||||||
|
readonly now: Date
|
||||||
|
}): MembershipGrantResult {
|
||||||
|
const { existing, cardType, now } = params
|
||||||
|
|
||||||
|
if (!existing || cardType.type === CardTypeCategory.TRIAL) {
|
||||||
|
return {
|
||||||
|
remainingTimes: cardType.totalTimes ?? null,
|
||||||
|
expireDate: addDurationDays(now, cardType.durationDays),
|
||||||
|
totalTimes: cardType.totalTimes ?? null,
|
||||||
|
isRenewal: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingExpire = new Date(existing.expireDate)
|
||||||
|
const isExpired =
|
||||||
|
existing.status === MembershipStatus.EXPIRED || existingExpire.getTime() <= now.getTime()
|
||||||
|
const baseDate = existingExpire.getTime() > now.getTime() ? existingExpire : now
|
||||||
|
const expireDate = addDurationDays(baseDate, cardType.durationDays)
|
||||||
|
|
||||||
|
if (cardType.totalTimes == null) {
|
||||||
|
return {
|
||||||
|
remainingTimes: null,
|
||||||
|
expireDate,
|
||||||
|
totalTimes: null,
|
||||||
|
isRenewal: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return {
|
||||||
|
remainingTimes: cardType.totalTimes,
|
||||||
|
expireDate: addDurationDays(now, cardType.durationDays),
|
||||||
|
totalTimes: cardType.totalTimes,
|
||||||
|
isRenewal: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
remainingTimes: (existing.remainingTimes ?? 0) + cardType.totalTimes,
|
||||||
|
expireDate,
|
||||||
|
totalTimes: (existing.totalTimes ?? existing.remainingTimes ?? 0) + cardType.totalTimes,
|
||||||
|
isRenewal: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickRenewalTarget<T extends RenewalHintMembership>(
|
||||||
|
memberships: readonly T[],
|
||||||
|
cardTypeId: string,
|
||||||
|
): T | null {
|
||||||
|
const matches = memberships
|
||||||
|
.filter((m) => m.cardTypeId === cardTypeId && canRenewCardType(m.cardType.type))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
|
||||||
|
|
||||||
|
return matches[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsableMembership(membership: RenewalHintMembership, now: Date): boolean {
|
||||||
|
if (membership.status !== MembershipStatus.ACTIVE) return false
|
||||||
|
if (new Date(membership.expireDate).getTime() <= now.getTime()) return false
|
||||||
|
return membership.remainingTimes === null || membership.remainingTimes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMembershipRenewalHint(
|
||||||
|
memberships: readonly RenewalHintMembership[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): MembershipRenewalHint | null {
|
||||||
|
const usable = memberships.filter((m) => isUsableMembership(m, now))
|
||||||
|
|
||||||
|
if (usable.length > 0) {
|
||||||
|
const lowTimes = usable.filter(
|
||||||
|
(m) =>
|
||||||
|
m.cardType.type === CardTypeCategory.TIMES &&
|
||||||
|
m.remainingTimes !== null &&
|
||||||
|
m.remainingTimes <= RENEWAL_TIMES_THRESHOLD,
|
||||||
|
)
|
||||||
|
if (lowTimes.length > 0) {
|
||||||
|
const target = lowTimes.reduce((lowest, current) =>
|
||||||
|
(current.remainingTimes ?? 0) < (lowest.remainingTimes ?? 0) ? current : lowest,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
kind: 'times_low',
|
||||||
|
cardTypeId: target.cardTypeId,
|
||||||
|
remainingTimes: target.remainingTimes,
|
||||||
|
daysLeft: getMembershipDaysLeft(target.expireDate, now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowDays = usable.filter(
|
||||||
|
(m) =>
|
||||||
|
m.cardType.type === CardTypeCategory.DURATION &&
|
||||||
|
getMembershipDaysLeft(m.expireDate, now) <= RENEWAL_DAYS_THRESHOLD,
|
||||||
|
)
|
||||||
|
if (lowDays.length > 0) {
|
||||||
|
const target = lowDays.reduce((lowest, current) => {
|
||||||
|
const currentDays = getMembershipDaysLeft(current.expireDate, now)
|
||||||
|
const lowestDays = getMembershipDaysLeft(lowest.expireDate, now)
|
||||||
|
return currentDays < lowestDays ? current : lowest
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
kind: 'days_low',
|
||||||
|
cardTypeId: target.cardTypeId,
|
||||||
|
remainingTimes: null,
|
||||||
|
daysLeft: getMembershipDaysLeft(target.expireDate, now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowTrial = usable.filter(
|
||||||
|
(m) =>
|
||||||
|
m.cardType.type === CardTypeCategory.TRIAL &&
|
||||||
|
m.remainingTimes !== null &&
|
||||||
|
m.remainingTimes <= RENEWAL_TIMES_THRESHOLD,
|
||||||
|
)
|
||||||
|
if (lowTrial.length > 0) {
|
||||||
|
const target = lowTrial.reduce((lowest, current) =>
|
||||||
|
(current.remainingTimes ?? 0) < (lowest.remainingTimes ?? 0) ? current : lowest,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
kind: 'trial_low',
|
||||||
|
cardTypeId: null,
|
||||||
|
remainingTimes: target.remainingTimes,
|
||||||
|
daysLeft: getMembershipDaysLeft(target.expireDate, now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberships.length === 0) return null
|
||||||
|
|
||||||
|
const renewable = memberships
|
||||||
|
.filter((m) => canRenewCardType(m.cardType.type))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
|
||||||
|
|
||||||
|
const recommended = renewable[0]
|
||||||
|
return {
|
||||||
|
kind: 'expired',
|
||||||
|
cardTypeId: recommended?.cardTypeId ?? null,
|
||||||
|
remainingTimes: recommended?.remainingTimes ?? null,
|
||||||
|
daysLeft: recommended ? getMembershipDaysLeft(recommended.expireDate, now) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export interface Membership {
|
|||||||
readonly userId: string
|
readonly userId: string
|
||||||
readonly cardTypeId: string
|
readonly cardTypeId: string
|
||||||
readonly remainingTimes: number | null
|
readonly remainingTimes: number | null
|
||||||
|
readonly totalTimes: number | null
|
||||||
readonly startDate: string
|
readonly startDate: string
|
||||||
readonly expireDate: string
|
readonly expireDate: string
|
||||||
readonly status: MembershipStatus
|
readonly status: MembershipStatus
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface Order {
|
|||||||
readonly id: string
|
readonly id: string
|
||||||
readonly userId: string
|
readonly userId: string
|
||||||
readonly cardTypeId: string
|
readonly cardTypeId: string
|
||||||
|
readonly membershipId: string | null
|
||||||
readonly orderNo: string
|
readonly orderNo: string
|
||||||
readonly amount: number
|
readonly amount: number
|
||||||
readonly status: OrderStatus
|
readonly status: OrderStatus
|
||||||
|
|||||||
Reference in New Issue
Block a user