feat: 支持邀请好友功能
This commit is contained in:
@@ -58,7 +58,7 @@
|
||||
<text class="card-times-unit">课时</text>
|
||||
</view>
|
||||
<view class="price-row">
|
||||
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
|
||||
<text class="price-current">¥{{ formatPrice(invite.price(card.price)) }}</text>
|
||||
<text
|
||||
v-if="card.originalPrice && card.originalPrice > card.price"
|
||||
class="price-original"
|
||||
@@ -69,6 +69,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text v-if="invite.eligible" class="renew-tag">95 折</text>
|
||||
<!-- Arrow -->
|
||||
<text class="card-arrow">›</text>
|
||||
</view>
|
||||
@@ -82,6 +83,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInviteStore } from '../stores/invite'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import type { CardType } from '@mp-pilates/shared'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
@@ -89,6 +91,7 @@ import { get } from '../utils/request'
|
||||
import { formatPrice, getCardCoverClass } from '../utils/format'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
const invite = useInviteStore()
|
||||
const userStore = useUserStore()
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -52,8 +52,9 @@
|
||||
<view class="price-area">
|
||||
<view class="flash-price-row">
|
||||
<text class="flash-currency">¥</text>
|
||||
<text class="flash-price">{{ formatPrice(sale.flashPrice) }}</text>
|
||||
<text class="flash-price">{{ formatPrice(invite.price(sale.flashPrice)) }}</text>
|
||||
</view>
|
||||
<text v-if="invite.eligible" class="original-price">好友 95 折</text>
|
||||
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
|
||||
</view>
|
||||
|
||||
@@ -78,6 +79,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInviteStore } from "../stores/invite"
|
||||
const invite = useInviteStore()
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { FlashSalePhase } from '@mp-pilates/shared'
|
||||
import type { FlashSaleListItem } from '@mp-pilates/shared'
|
||||
|
||||
57
packages/app/src/components/InviteCard.vue
Normal file
57
packages/app/src/components/InviteCard.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<view class="invite-entry">
|
||||
<view class="invite-heading" @tap="open">
|
||||
<view><text class="eyebrow">好友享 95 折 · 一起变好</text><text class="entry-title">邀请好友领课时 <text>↗</text></text></view>
|
||||
<view class="gift"><text class="gift-number">1</text><text>节免费课</text></view>
|
||||
</view>
|
||||
<view class="code-line" @tap="copy"><text>{{ invite.code ? '我的邀请码' : '登录领取专属邀请码' }}</text><text class="code">{{ invite.code }}</text><text v-if="invite.code">复制</text></view>
|
||||
<view v-if="visible" class="veil" @tap="visible = false" @touchmove.stop.prevent>
|
||||
<view class="sheet" @tap.stop>
|
||||
<button class="close" @tap="visible = false" aria-label="关闭">×</button>
|
||||
<text class="eyebrow">A LITTLE GIFT, SHARED</text>
|
||||
<text class="headline">好朋友,好好练。</text>
|
||||
<text class="subtitle">把你的练习日常,分享给在意的人。</text>
|
||||
<view class="benefits"><view><text class="benefit-num">1<text> 节</text></text><text>你得免费课</text></view><view><text class="benefit-num">95<text> 折</text></text><text>好友购卡优惠</text></view></view>
|
||||
<view class="ticket" @tap="copy"><text>你的专属邀请码 · 点击复制</text><text class="ticket-code">{{ invite.code }}</text></view>
|
||||
<text class="rules">好友确认邀请码,体验卡、次卡和期限卡均享 95 折。每位好友首次成功购买非体验卡,你得 1 节免费课;购买或完成体验课不触发赠课。</text>
|
||||
<text class="rules">赠课自动存入「我的会员卡」,365 天内可预约。每位好友仅绑定一位邀请人。</text>
|
||||
<button class="share" open-type="share" :disabled="!invite.code">分享给微信好友</button>
|
||||
<text class="footnote">送朋友一份心意,也给自己一次练习</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useInviteStore } from '../stores/invite'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { getErrorMessage } from '../utils/auth'
|
||||
const invite = useInviteStore()
|
||||
const user = useUserStore()
|
||||
const visible = ref(false)
|
||||
async function open() {
|
||||
try {
|
||||
if (!user.loggedIn) await user.login()
|
||||
await invite.refresh()
|
||||
visible.value = true
|
||||
} catch (err) { uni.showToast({ title: getErrorMessage(err, '暂时无法获取邀请码,请重试'), icon: 'none' }) }
|
||||
}
|
||||
function copy() {
|
||||
if (!invite.code) { open(); return }
|
||||
uni.setClipboardData({ data: invite.code })
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.invite-entry { margin: 24rpx 32rpx; padding: 28rpx; background: #eaf0e7; border: 1rpx solid #d9e3d4; border-radius: 26rpx; color: #435b4c; }
|
||||
.invite-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.eyebrow { display: block; font-size: 19rpx; letter-spacing: 3rpx; color: #788976; }
|
||||
.entry-title { display: block; font-size: 29rpx; margin-top: 12rpx; font-weight: 500; }
|
||||
.gift { display: flex; flex-direction: column; align-items: center; font-size: 18rpx; }.gift-number { font-family: Georgia, serif; font-size: 56rpx; line-height: 1; }
|
||||
.code-line { display: flex; align-items: center; gap: 18rpx; border-top: 1rpx solid #d2dece; padding-top: 20rpx; margin-top: 22rpx; font-size: 21rpx; }.code { flex: 1; letter-spacing: 4rpx; font-family: monospace; font-size: 27rpx; }
|
||||
.veil { position: fixed; inset: 0; z-index: 400; background: rgba(32,43,35,.48); display: flex; align-items: center; padding: 30rpx; }
|
||||
.sheet { position: relative; width: 100%; max-height: 85vh; overflow-y: auto; box-sizing: border-box; padding: 48rpx 34rpx 32rpx; border-radius: 32rpx; background: #fcfaf5; }.close { position: absolute; right: 18rpx; top: 12rpx; margin: 0; background: transparent; color: #7b8477; font-size: 36rpx; }.close::after,.share::after { border: none; }
|
||||
.headline { display: block; margin-top: 20rpx; font-family: 'Songti SC',serif; font-size: 46rpx; }.subtitle { display: block; margin-top: 12rpx; font-size: 23rpx; color: #87907f; }
|
||||
.benefits { display: flex; margin: 30rpx 0; padding: 25rpx 0; background: #eaf0e5; border-radius: 22rpx; }.benefits>view { flex: 1; text-align: center; font-size: 24rpx; }.benefits>view+view { border-left: 1rpx solid #cbd8c5; }.benefit-num { display: block; font: 66rpx Georgia,serif; margin-bottom: 10rpx; }.benefit-num text { font-size: 26rpx; }
|
||||
.ticket { padding: 22rpx; text-align: center; border: 1rpx dashed #b7c5af; border-radius: 16rpx; font-size: 21rpx; color: #7a8772; }.ticket-code { display: block; margin-top: 12rpx; font: 42rpx monospace; letter-spacing: 10rpx; color: #435b4c; }
|
||||
.rules { display: block; margin-top: 18rpx; font-size: 21rpx; line-height: 1.8; color: #7d8076; }.share { margin-top: 26rpx; border-radius: 999rpx; background: #526e58; color: #fff; font-size: 27rpx; line-height: 86rpx; }.footnote { display: block; text-align: center; margin-top: 16rpx; font-size: 19rpx; color: #93978a; }
|
||||
</style>
|
||||
@@ -5,6 +5,21 @@
|
||||
:style="{ paddingTop: navBarHeight }"
|
||||
>
|
||||
<CustomNavBar :title="pageTitle" show-back />
|
||||
<view class="invite-banner" @tap="inviteVisible = true; inviteInput = invite.pendingCode">
|
||||
<text>{{ invite.eligible ? '好友礼遇 · 已享 95 折' : '好友礼遇 · 领取 95 折购卡优惠' }}</text>
|
||||
<text class="invite-banner-note">{{ invite.eligible ? '体验卡、次卡、期限卡均适用' : '填写邀请码,和朋友一起开始练习 ›' }}</text>
|
||||
</view>
|
||||
<view v-if="inviteVisible" class="purchase-sheet-layer" @touchmove.stop.prevent>
|
||||
<view class="purchase-sheet">
|
||||
<text class="sheet-kicker">A GIFT FROM YOUR FRIEND</text>
|
||||
<text class="sheet-title">朋友送你一份 95 折礼遇</text>
|
||||
<text class="invite-banner-note">确认邀请码后,购卡自动减免 5%。体验卡也可享受。</text>
|
||||
<input v-model="inviteInput" class="invite-input" maxlength="6" placeholder="填写 6 位好友邀请码" :disabled="inviteBusy" />
|
||||
<text v-if="inviteError" class="invite-error">{{ inviteError }}</text>
|
||||
<text class="sheet-note-text">每人仅绑定一位好友。首次成功购买非体验卡,好友获得 1 节免费课;体验卡不触发赠课。</text>
|
||||
<view class="sheet-actions"><button class="sheet-cancel" :disabled="inviteBusy" @tap="inviteVisible = false">稍后再说</button><button class="sheet-confirm" :disabled="inviteBusy || inviteInput.length !== 6" @tap="confirmInvite">{{ inviteBusy ? '领取中…' : '确认领取' }}</button></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-wrap">
|
||||
<view class="skeleton-pass" />
|
||||
@@ -64,10 +79,11 @@
|
||||
<text class="card-benefit">{{ cardAccessLabel(c) }}</text>
|
||||
<view class="card-price-row">
|
||||
<view class="price-stack">
|
||||
<text class="card-price">¥{{ formatPrice(c.price) }}</text>
|
||||
<text v-if="getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text>
|
||||
<text class="card-price">¥{{ formatPrice(invite.pendingCode && !invite.eligible ? Math.round(c.price * 95 / 100) : invite.price(c.price)) }}</text>
|
||||
<text v-if="invite.pendingCode && !invite.eligible" class="save-tag">领取后 95 折</text>
|
||||
<text v-if="(!invite.pendingCode || invite.eligible) && getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text>
|
||||
</view>
|
||||
<text class="card-unit">{{ getUnitPriceLabel(c) }}</text>
|
||||
<text v-if="!invite.pendingCode || invite.eligible" class="card-unit">{{ getUnitPriceLabel(c) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -129,7 +145,7 @@
|
||||
|
||||
<view class="hero-price-row">
|
||||
<text class="hero-currency">¥</text>
|
||||
<text class="hero-price">{{ formatPrice(cardData.price) }}</text>
|
||||
<text class="hero-price">{{ formatPrice(invite.price(cardData.price)) }}</text>
|
||||
<text
|
||||
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
|
||||
class="hero-original"
|
||||
@@ -239,7 +255,7 @@
|
||||
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
|
||||
<view class="price-summary">
|
||||
<text class="price-summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
|
||||
<text class="price-summary-value">¥{{ formatPrice(cardData.price) }}</text>
|
||||
<text class="price-summary-value">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
|
||||
<text class="price-summary-hint">{{ bottomPriceHint }}</text>
|
||||
</view>
|
||||
<button
|
||||
@@ -270,7 +286,7 @@
|
||||
<text class="sheet-card-type">{{ typeLabel }}</text>
|
||||
<text class="sheet-card-name">{{ cardData.name }}</text>
|
||||
</view>
|
||||
<text class="sheet-card-price">¥{{ formatPrice(cardData.price) }}</text>
|
||||
<text class="sheet-card-price">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
|
||||
</view>
|
||||
<view class="sheet-card-meta">
|
||||
<text>{{ cardAccessLabel(cardData) }}</text>
|
||||
@@ -319,8 +335,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInviteStore } from '../../stores/invite'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||||
import {
|
||||
CardTypeCategory,
|
||||
@@ -348,6 +365,30 @@ interface MyOrderStatusResponse {
|
||||
type PaymentConfirmationResult = 'paid' | 'pending' | 'unavailable' | 'unauthorized'
|
||||
type PaymentStatusIssue = 'none' | 'order-check' | 'membership-sync' | 'reauthentication'
|
||||
|
||||
const invite = useInviteStore()
|
||||
const inviteVisible = ref(false)
|
||||
const inviteInput = ref('')
|
||||
const inviteBusy = ref(false)
|
||||
const inviteError = ref('')
|
||||
onLoad((options) => {
|
||||
if (options?.inviteCode) {
|
||||
invite.pendingCode = options.inviteCode.toUpperCase()
|
||||
inviteInput.value = invite.pendingCode
|
||||
inviteVisible.value = true
|
||||
}
|
||||
})
|
||||
async function confirmInvite() {
|
||||
if (inviteBusy.value) return
|
||||
inviteBusy.value = true
|
||||
inviteError.value = ''
|
||||
try {
|
||||
if (!userStore.loggedIn) await userStore.login()
|
||||
await invite.confirm(inviteInput.value.trim().toUpperCase())
|
||||
inviteVisible.value = false
|
||||
uni.showToast({ title: '95 折优惠已领取', icon: 'success' })
|
||||
} catch (err) { inviteError.value = getErrorMessage(err, '领取失败,请重试') }
|
||||
finally { inviteBusy.value = false }
|
||||
}
|
||||
const userStore = useUserStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const cardId = ref('')
|
||||
@@ -578,7 +619,7 @@ function cardAccessLabel(cardType: CardType): string {
|
||||
function getUnitPrice(cardType: CardType): string {
|
||||
const divisor = cardType.totalTimes === null ? cardType.durationDays : cardType.totalTimes
|
||||
if (!divisor) return '-'
|
||||
return '¥' + String(Math.round(cardType.price / divisor / 100))
|
||||
return '¥' + String(Math.round(invite.price(cardType.price) / divisor / 100))
|
||||
}
|
||||
|
||||
function getUnitPriceLabel(cardType: CardType): string {
|
||||
@@ -586,6 +627,7 @@ function getUnitPriceLabel(cardType: CardType): string {
|
||||
}
|
||||
|
||||
function getSavingsLabel(cardType: CardType): string {
|
||||
if (invite.eligible) return '好友价 · 95 折'
|
||||
if (!cardType.originalPrice || cardType.originalPrice <= cardType.price) return ''
|
||||
return '省 ¥' + formatPrice(cardType.originalPrice - cardType.price)
|
||||
}
|
||||
@@ -698,6 +740,7 @@ async function preparePurchaseConfirmation() {
|
||||
if (!card.value || isPurchaseBusy.value) return
|
||||
purchasePreparing.value = true
|
||||
try {
|
||||
await invite.refresh()
|
||||
const refreshed = await refreshMembershipContext()
|
||||
if (!userStore.loggedIn) {
|
||||
showLoginPrompt()
|
||||
@@ -708,6 +751,8 @@ async function preparePurchaseConfirmation() {
|
||||
return
|
||||
}
|
||||
purchaseConfirmVisible.value = true
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
purchasePreparing.value = false
|
||||
}
|
||||
@@ -824,10 +869,8 @@ async function doPurchase() {
|
||||
uni.showLoading({ title: '创建订单...' })
|
||||
|
||||
try {
|
||||
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
||||
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
||||
cardTypeId: card.value.id,
|
||||
inviterId: isTrialCard.value && inviterId ? inviterId : undefined,
|
||||
})
|
||||
|
||||
uni.hideLoading()
|
||||
@@ -872,6 +915,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (userStore.loggedIn) void invite.refresh().catch(() => {})
|
||||
if (!paymentConfirming.value && !paymentPending.value) {
|
||||
void refreshMembershipContext()
|
||||
}
|
||||
@@ -883,6 +927,11 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.invite-banner { margin: 24rpx 32rpx 0; padding: 24rpx; border-radius: 20rpx; background: #eaf0e5; color: #526e58; font-size: 27rpx; }
|
||||
.invite-banner-note { display: block; margin-top: 10rpx; color: #7b8974; font-size: 22rpx; line-height: 1.6; }
|
||||
.invite-input { margin: 26rpx 0; padding: 24rpx; border: 1rpx dashed #a9b99e; border-radius: 16rpx; font: 34rpx monospace; letter-spacing: 6rpx; height: 55rpx; text-align: center; }
|
||||
.invite-error { display: block; color: #ad604c; font-size: 23rpx; margin-bottom: 16rpx; }
|
||||
|
||||
.card-detail-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
<!-- Price row -->
|
||||
<view class="hero-price-row">
|
||||
<text class="hero-currency">¥</text>
|
||||
<text class="hero-price">{{ formatPrice(detail.flashPrice) }}</text>
|
||||
<text v-if="invite.eligible" class="hero-discount-text">好友 95 折</text>
|
||||
<text class="hero-price">{{ formatPrice(invite.price(detail.flashPrice)) }}</text>
|
||||
<view class="hero-original-wrap">
|
||||
<text class="hero-original-label">原价</text>
|
||||
<text class="hero-original">¥{{ formatPrice(detail.originalPrice) }}</text>
|
||||
@@ -49,7 +50,7 @@
|
||||
|
||||
<!-- Discount badge -->
|
||||
<view class="hero-discount-badge">
|
||||
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - detail.flashPrice) }}</text>
|
||||
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - invite.price(detail.flashPrice)) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Countdown -->
|
||||
@@ -186,7 +187,7 @@
|
||||
<text class="bar-price-label">秒杀价</text>
|
||||
<view class="bar-price-row">
|
||||
<text class="bar-currency">¥</text>
|
||||
<text class="bar-price">{{ formatPrice(detail.flashPrice) }}</text>
|
||||
<text class="bar-price">{{ formatPrice(invite.price(detail.flashPrice)) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
@@ -202,6 +203,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInviteStore } from "../../stores/invite"
|
||||
const invite = useInviteStore()
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
FlashSalePhase,
|
||||
@@ -295,7 +298,7 @@ const actionBtnText = computed(() => {
|
||||
if (!userStore.loggedIn) return '登录后参与'
|
||||
if (!userStore.user?.phone) return '授权手机号后参与'
|
||||
if (buying.value) return '抢购中...'
|
||||
return `¥${formatPrice(d.flashPrice)} 立即抢购`
|
||||
return `¥${formatPrice(invite.price(d.flashPrice))} 立即抢购`
|
||||
})
|
||||
|
||||
const actionBtnClass = computed(() => {
|
||||
@@ -360,10 +363,15 @@ async function handleAction() {
|
||||
return
|
||||
}
|
||||
|
||||
try { await invite.refresh() } catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm purchase
|
||||
uni.showModal({
|
||||
title: '确认抢购',
|
||||
content: `确认以 ¥${formatPrice(detail.value.flashPrice)} 抢购「${detail.value.title}」?`,
|
||||
content: `确认以 ¥${formatPrice(invite.price(detail.value.flashPrice))} 抢购「${detail.value.title}」?`,
|
||||
confirmText: '确认抢购',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
||||
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
|
||||
|
||||
<InviteCard />
|
||||
|
||||
<!-- Menu section: always visible -->
|
||||
<ProfileMenu
|
||||
:is-admin="isAdmin"
|
||||
@@ -29,6 +31,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InviteCard from '../../components/InviteCard.vue'
|
||||
import { useInviteStore } from '../../stores/invite'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -40,6 +44,7 @@ import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
||||
import UserCard from '../../components/UserCard.vue'
|
||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||
|
||||
const invite = useInviteStore()
|
||||
const userStore = useUserStore()
|
||||
const bookingStore = useBookingStore()
|
||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||
@@ -61,8 +66,8 @@ const upcomingBookingCount = computed(
|
||||
// ─── 微信分享 ───────────────────────────────────────────────
|
||||
onShareAppMessage(() => {
|
||||
return {
|
||||
title: '我的普拉提会所,记录每一次进步',
|
||||
path: '/pages/profile/index',
|
||||
title: invite.code ? '送你 95 折购卡礼,一起练普拉提' : '一起练普拉提',
|
||||
path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index',
|
||||
imageUrl: '',
|
||||
}
|
||||
})
|
||||
@@ -82,6 +87,7 @@ onShow(async () => {
|
||||
activityRefreshKey.value += 1
|
||||
if (loggedIn.value) {
|
||||
await Promise.all([
|
||||
invite.refresh().catch(() => {}),
|
||||
userStore.fetchProfile(),
|
||||
userStore.fetchStats(),
|
||||
userStore.fetchMemberships(),
|
||||
|
||||
@@ -1,504 +1,14 @@
|
||||
<template>
|
||||
<view class="invite-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="邀请好友" show-back />
|
||||
|
||||
<scroll-view class="invite-scroll" scroll-y>
|
||||
<view class="hero-card">
|
||||
<view class="hero-glow hero-glow--one" />
|
||||
<view class="hero-glow hero-glow--two" />
|
||||
<text class="hero-badge">会员专享裂变活动</text>
|
||||
<text class="hero-title">邀 3 位好友体验并核销</text>
|
||||
<text class="hero-subtitle">好友购买体验课并完成上课后,会员卡立即奖励 1 节正课次数。</text>
|
||||
|
||||
<view class="hero-stats">
|
||||
<view class="hero-stat">
|
||||
<text class="hero-stat-value">{{ summary?.qualifiedInviteCount ?? 0 }}</text>
|
||||
<text class="hero-stat-label">已完成邀请</text>
|
||||
</view>
|
||||
<view class="hero-stat hero-stat--accent">
|
||||
<text class="hero-stat-value">{{ summary?.rewardedTimes ?? 0 }}</text>
|
||||
<text class="hero-stat-label">已得奖励</text>
|
||||
</view>
|
||||
<view class="hero-stat">
|
||||
<text class="hero-stat-value">{{ summary?.nextRewardRemainingCount ?? 3 }}</text>
|
||||
<text class="hero-stat-label">距下次奖励</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-shell">
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: progressWidth }" />
|
||||
</view>
|
||||
<text class="progress-caption">本轮进度 {{ summary?.currentCycleQualifiedCount ?? 0 }}/{{ summary?.rewardRuleInvitesRequired ?? 3 }}</text>
|
||||
</view>
|
||||
|
||||
<button class="share-btn" open-type="share">
|
||||
立即邀请好友
|
||||
</button>
|
||||
<text class="share-hint">分享后,新用户登录并购买体验课即可自动绑定邀请关系。</text>
|
||||
</view>
|
||||
|
||||
<view class="steps-card">
|
||||
<text class="section-title">活动规则</text>
|
||||
<view v-for="item in ruleSteps" :key="item.title" class="step-item">
|
||||
<view class="step-index">{{ item.index }}</view>
|
||||
<view class="step-body">
|
||||
<text class="step-title">{{ item.title }}</text>
|
||||
<text class="step-desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="referrals-card">
|
||||
<view class="section-head">
|
||||
<text class="section-title">邀请进度</text>
|
||||
<text class="section-meta">待完成 {{ summary?.pendingInviteCount ?? 0 }} 人</text>
|
||||
</view>
|
||||
|
||||
<view v-if="summary?.referrals?.length" class="referral-list">
|
||||
<view v-for="item in summary.referrals" :key="item.id" class="referral-item">
|
||||
<image v-if="item.inviteeAvatarUrl" class="referral-avatar" :src="item.inviteeAvatarUrl" mode="aspectFill" />
|
||||
<view v-else class="referral-avatar referral-avatar--placeholder">友</view>
|
||||
<view class="referral-main">
|
||||
<text class="referral-name">{{ item.inviteeNickname || '新好友' }}</text>
|
||||
<text class="referral-time">邀请于 {{ formatDateTime(item.invitedAt) }}</text>
|
||||
</view>
|
||||
<text class="referral-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-block">
|
||||
<text class="empty-title">还没有邀请记录</text>
|
||||
<text class="empty-desc">先分享给 3 位好友,完成一次体验闭环就会在这里点亮进度。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="reward-card">
|
||||
<view class="section-head">
|
||||
<text class="section-title">奖励记录</text>
|
||||
<text class="section-meta">累计 {{ summary?.rewardedTimes ?? 0 }} 节</text>
|
||||
</view>
|
||||
<view v-if="summary?.rewardGrants?.length" class="reward-list">
|
||||
<view v-for="item in summary.rewardGrants" :key="item.id" class="reward-item">
|
||||
<text class="reward-item-title">完成 {{ item.qualifiedReferralCount }} 位好友核销</text>
|
||||
<text class="reward-item-time">{{ formatDateTime(item.grantedAt) }}</text>
|
||||
<text class="reward-item-tag">+{{ item.rewardTimes }} 节</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-block empty-block--warm">
|
||||
<text class="empty-title">还未获得奖励</text>
|
||||
<text class="empty-desc">每 3 位好友完成体验核销,系统自动增加 1 节真实会员课次。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template><view :style="{ paddingTop: navBarHeight }"><CustomNavBar title="邀请好友" show-back /><InviteCard /></view></template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { InviteReferralStatus } from '@mp-pilates/shared'
|
||||
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import InviteCard from '../../components/InviteCard.vue'
|
||||
import { useInviteStore } from '../../stores/invite'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const inviteStore = useInviteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
const summary = computed(() => inviteStore.activity)
|
||||
const progressWidth = computed(() => {
|
||||
const current = summary.value?.currentCycleQualifiedCount ?? 0
|
||||
const total = summary.value?.rewardRuleInvitesRequired ?? 3
|
||||
return `${Math.min(100, (current / total) * 100)}%`
|
||||
})
|
||||
|
||||
const ruleSteps = [
|
||||
{ index: '01', title: '分享活动页', desc: '会员用户把活动页转发给微信好友或朋友圈。' },
|
||||
{ index: '02', title: '好友购买体验课', desc: '新好友通过你的分享进入,并成功购买体验课。' },
|
||||
{ index: '03', title: '体验课完成核销', desc: '好友到店体验并被老师核销后,这次邀请记为有效。' },
|
||||
{ index: '04', title: '满 3 人自动加课', desc: '每累计 3 位有效邀请,系统自动给你的会员卡增加 1 节。' },
|
||||
]
|
||||
|
||||
onLoad((query) => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
const inviterId = typeof query?.inviterId === 'string' ? query.inviterId : ''
|
||||
if (inviterId) {
|
||||
uni.setStorageSync('invite_inviter_id', inviterId)
|
||||
}
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
if (!userStore.loggedIn) {
|
||||
return
|
||||
}
|
||||
await Promise.all([
|
||||
userStore.fetchProfile(),
|
||||
inviteStore.fetchActivity(),
|
||||
])
|
||||
})
|
||||
|
||||
onShareAppMessage(() => ({
|
||||
title: '邀 3 位好友体验核销,立得 1 节会员正课',
|
||||
path: summary.value?.sharePath || `/pages/profile/invite?inviterId=${userStore.user?.id || ''}`,
|
||||
imageUrl: '',
|
||||
}))
|
||||
|
||||
onShareTimeline(() => ({
|
||||
title: '邀 3 位好友体验核销,立得 1 节会员正课',
|
||||
query: `inviterId=${userStore.user?.id || ''}`,
|
||||
}))
|
||||
|
||||
function statusLabel(status: InviteReferralStatus): string {
|
||||
const map: Record<InviteReferralStatus, string> = {
|
||||
[InviteReferralStatus.REGISTERED]: '已注册',
|
||||
[InviteReferralStatus.TRIAL_PURCHASED]: '已购体验课',
|
||||
[InviteReferralStatus.QUALIFIED]: '已完成核销',
|
||||
}
|
||||
return map[status]
|
||||
}
|
||||
|
||||
function statusClass(status: InviteReferralStatus): string {
|
||||
if (status === InviteReferralStatus.QUALIFIED) return 'referral-status--done'
|
||||
if (status === InviteReferralStatus.TRIAL_PURCHASED) return 'referral-status--paid'
|
||||
return 'referral-status--registered'
|
||||
}
|
||||
const invite = useInviteStore()
|
||||
const user = useUserStore()
|
||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||
onShow(() => { if (user.loggedIn) invite.refresh().catch(() => {}) })
|
||||
onShareAppMessage(() => ({ title: '送你 95 折购卡礼,一起练普拉提', path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index' }))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.invite-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 142, 83, 0.28), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(255, 214, 102, 0.34), transparent 26%),
|
||||
linear-gradient(180deg, #fff5db 0%, #ffe7ea 30%, #fef7ff 100%);
|
||||
}
|
||||
|
||||
.invite-scroll {
|
||||
height: 100vh;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.steps-card,
|
||||
.referrals-card,
|
||||
.reward-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 36rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 18rpx 50rpx rgba(157, 70, 42, 0.08);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #ff7a45 0%, #ff4d6d 48%, #ffb347 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.28;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.hero-glow--one {
|
||||
width: 260rpx;
|
||||
height: 260rpx;
|
||||
top: -90rpx;
|
||||
right: -40rpx;
|
||||
}
|
||||
|
||||
.hero-glow--two {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
bottom: -50rpx;
|
||||
left: -40rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 10rpx 18rpx;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
padding: 24rpx 18rpx;
|
||||
border-radius: 26rpx;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
.hero-stat--accent {
|
||||
background: rgba(75, 16, 16, 0.22);
|
||||
}
|
||||
|
||||
.hero-stat-value {
|
||||
display: block;
|
||||
font-size: 46rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-stat-label {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.progress-shell {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 20rpx;
|
||||
border-radius: 999rpx;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #fff7ad 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.progress-caption {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
margin-top: 28rpx;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #ff5a3c;
|
||||
background: linear-gradient(90deg, #fff7e4 0%, #ffffff 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.share-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.share-hint {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.steps-card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 247, 234, 0.92));
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #30201a;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.section-meta {
|
||||
font-size: 22rpx;
|
||||
color: #9b6b55;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: flex-start;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid rgba(214, 171, 134, 0.2);
|
||||
}
|
||||
|
||||
.step-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.step-index {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 20rpx;
|
||||
text-align: center;
|
||||
line-height: 64rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff8f5a 0%, #ff4d6d 100%);
|
||||
}
|
||||
|
||||
.step-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #36231d;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
color: #7b5d52;
|
||||
}
|
||||
|
||||
.referrals-card {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fff7fb 100%);
|
||||
}
|
||||
|
||||
.reward-card {
|
||||
background: linear-gradient(180deg, #fffdf5 0%, #fff2dc 100%);
|
||||
}
|
||||
|
||||
.referral-item,
|
||||
.reward-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid rgba(221, 196, 177, 0.35);
|
||||
}
|
||||
|
||||
.referral-item:last-child,
|
||||
.reward-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.referral-avatar {
|
||||
width: 78rpx;
|
||||
height: 78rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 18rpx;
|
||||
background: #ffd9c8;
|
||||
}
|
||||
|
||||
.referral-avatar--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #ff6f3c;
|
||||
}
|
||||
|
||||
.referral-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.referral-name,
|
||||
.reward-item-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #31211a;
|
||||
}
|
||||
|
||||
.referral-time,
|
||||
.reward-item-time {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #8b6d62;
|
||||
}
|
||||
|
||||
.referral-status,
|
||||
.reward-item-tag {
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.referral-status--registered {
|
||||
color: #9c5e2f;
|
||||
background: #fff0de;
|
||||
}
|
||||
|
||||
.referral-status--paid {
|
||||
color: #c44f1f;
|
||||
background: #ffe0d1;
|
||||
}
|
||||
|
||||
.referral-status--done,
|
||||
.reward-item-tag {
|
||||
color: #0f7a53;
|
||||
background: #dff7ea;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
padding: 36rpx 0 10rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-block--warm {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #5f4337;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.8;
|
||||
color: #9c7d70;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ function goStore() {
|
||||
}
|
||||
|
||||
function canRenewMembership(m: MembershipWithCardType): boolean {
|
||||
return m.cardType.type !== CardTypeCategory.TRIAL
|
||||
return m.cardType.isActive && m.cardType.type !== CardTypeCategory.TRIAL
|
||||
}
|
||||
|
||||
function goRenew(m: MembershipWithCardType) {
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||
import { get } from '../utils/request'
|
||||
import { get, post } from '../utils/request'
|
||||
|
||||
export const useInviteStore = defineStore('invite', () => {
|
||||
const activity = ref<InviteActivitySummary | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchActivity() {
|
||||
loading.value = true
|
||||
const code = ref('')
|
||||
const eligible = ref(false)
|
||||
const pendingCode = ref('')
|
||||
let revision = 0
|
||||
let confirming = false
|
||||
async function refresh() {
|
||||
if (confirming) return
|
||||
const requestRevision = ++revision
|
||||
const result = await get<{ inviteCode: string; discountEligible: boolean }>('/invite/code')
|
||||
if (requestRevision !== revision) return
|
||||
code.value = result.inviteCode
|
||||
eligible.value = result.discountEligible
|
||||
}
|
||||
async function confirm(value: string) {
|
||||
const requestRevision = ++revision
|
||||
confirming = true
|
||||
try {
|
||||
activity.value = await get<InviteActivitySummary>('/invite/activity')
|
||||
return activity.value
|
||||
const result = await post<{ inviteCode: string; discountEligible: boolean }>('/invite/confirm', { code: value })
|
||||
if (requestRevision !== revision) throw new Error('登录状态已变化,请重试')
|
||||
code.value = result.inviteCode
|
||||
eligible.value = result.discountEligible
|
||||
pendingCode.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
confirming = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activity,
|
||||
loading,
|
||||
fetchActivity,
|
||||
function reset() {
|
||||
revision++
|
||||
code.value = ''
|
||||
eligible.value = false
|
||||
pendingCode.value = ''
|
||||
}
|
||||
function price(value: number) { return eligible.value ? Math.round(value * 95 / 100) : value }
|
||||
return { code, eligible, pendingCode, refresh, confirm, reset, price }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useInviteStore } from './invite'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type {
|
||||
@@ -46,6 +47,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const result = await wxLogin()
|
||||
token.value = result.token
|
||||
user.value = result.user
|
||||
await useInviteStore().refresh().catch(() => {})
|
||||
syncSubscriptionTemplates(result.user)
|
||||
return { user: result.user, isNewUser: result.isNewUser }
|
||||
} catch (err) {
|
||||
@@ -72,6 +74,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
if (!isLoggedIn()) return
|
||||
try {
|
||||
user.value = await get<UserProfileResponse>('/user/profile')
|
||||
await useInviteStore().refresh().catch(() => {})
|
||||
syncSubscriptionTemplates(user.value)
|
||||
return user.value
|
||||
} catch (err) {
|
||||
@@ -120,6 +123,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
function clearSession() {
|
||||
token.value = ''
|
||||
useInviteStore().reset()
|
||||
user.value = null
|
||||
stats.value = null
|
||||
memberships.value = []
|
||||
|
||||
Reference in New Issue
Block a user