diff --git a/packages/app/src/components/CardShop.vue b/packages/app/src/components/CardShop.vue
index de026b0..c7e134c 100644
--- a/packages/app/src/components/CardShop.vue
+++ b/packages/app/src/components/CardShop.vue
@@ -30,35 +30,10 @@
class="card-row"
@tap="goToDetail(card.id)"
>
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ getCardTypeLabel(card.type) }}
-
- {{ card.name }}
-
- ¥
- {{ formatPrice(card.price) }}
-
-
- ¥{{ formatPrice(card.originalPrice) }}
-
-
+
+
@@ -100,7 +75,7 @@
import { ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared'
import { get } from '../utils/request'
-import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../utils/format'
+import { formatPrice, getCardCoverClass } from '../utils/format'
const cardTypes = ref([])
const loading = ref(false)
@@ -180,249 +155,51 @@ function goToAllCards() {
}
/* ══════════════════════════════════════════════════════════
- CARD COVER — Horizontal premium card design
+ CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
- width: 240rpx;
+ width: 200rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- gap: 0;
-
- /* Glow effect behind */
- &::before {
- content: '';
- position: absolute;
- top: -20rpx;
- left: -20rpx;
- right: -20rpx;
- bottom: -20rpx;
- background: inherit;
- filter: blur(24rpx) brightness(0.8);
- z-index: 0;
- opacity: 0.4;
- }
-}
-
-/* Left accent stripe */
-.cover-accent-bar {
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 6rpx;
- background: rgba(255, 255, 255, 0.4);
- z-index: 1;
}
/* Decorative circles */
.cover-deco {
position: absolute;
border-radius: 50%;
- z-index: 0;
pointer-events: none;
- &--tl {
- width: 60rpx;
- height: 60rpx;
- top: -16rpx;
- right: 20rpx;
- background: rgba(255, 255, 255, 0.1);
+ &--1 {
+ width: 100rpx;
+ height: 100rpx;
+ top: -30rpx;
+ right: -20rpx;
+ background: rgba(255, 255, 255, 0.4);
}
- &--br {
- width: 80rpx;
- height: 80rpx;
- bottom: -24rpx;
- left: -16rpx;
- background: rgba(255, 255, 255, 0.07);
- }
-}
-
-/* CSS-drawn icons */
-.cover-icon {
- width: 52rpx;
- height: 52rpx;
- position: relative;
- z-index: 2;
- flex-shrink: 0;
- margin-left: 20rpx;
-}
-
-/* 次卡 — stacked cards */
-.cover-icon--TIMES {
- &::before {
- content: '';
- position: absolute;
- bottom: 0;
- left: 50%;
- transform: translateX(-50%);
- width: 36rpx;
- height: 24rpx;
- border: 2rpx solid rgba(255, 255, 255, 0.85);
- border-radius: 5rpx;
- box-sizing: border-box;
- background: rgba(255, 255, 255, 0.12);
- }
- &::after {
- content: '';
- position: absolute;
- bottom: 10rpx;
- left: 50%;
- transform: translateX(-50%);
- width: 36rpx;
- height: 24rpx;
- border: 2rpx solid rgba(255, 255, 255, 1);
- border-radius: 5rpx;
- box-sizing: border-box;
- background: rgba(255, 255, 255, 0.2);
- }
-}
-
-/* 月卡 — calendar */
-.cover-icon--DURATION {
- &::before {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 36rpx;
- height: 30rpx;
- border: 2rpx solid rgba(255, 255, 255, 0.9);
- border-radius: 5rpx;
- box-sizing: border-box;
- }
- &::after {
- content: '';
- position: absolute;
- top: 9rpx;
- left: 50%;
- transform: translateX(-50%);
- width: 24rpx;
- height: 0;
- border-top: 2rpx solid rgba(255, 255, 255, 1);
- box-shadow:
- -6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
- 6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
- }
-}
-
-/* 体验卡 — star */
-.cover-icon--TRIAL {
- &::before {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 16rpx;
- height: 16rpx;
- border: 2rpx solid rgba(255, 255, 255, 1);
- border-radius: 50%;
- box-sizing: border-box;
+ &--2 {
+ width: 70rpx;
+ height: 70rpx;
+ bottom: -20rpx;
+ left: -10rpx;
background: rgba(255, 255, 255, 0.25);
}
- &::after {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 2rpx;
- height: 42rpx;
- background: rgba(255, 255, 255, 0.8);
- box-shadow:
- 0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
- 0 12rpx 0 0 rgba(255, 255, 255, 0.8),
- -12rpx 0 0 0 rgba(255, 255, 255, 0.8),
- 12rpx 0 0 0 rgba(255, 255, 255, 0.8),
- -8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
- 8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
- -8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
- 8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
- }
}
/* Card cover backgrounds */
.cover--times {
- background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
+ background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.cover--duration {
- background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
+ background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.cover--trial {
- background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
-}
-
-/* Right side text content */
-.cover-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: center;
- padding: 0 16rpx 0 12rpx;
- gap: 4rpx;
- z-index: 2;
-}
-
-.cover-badge {
- padding: 3rpx 10rpx;
- border-radius: 8rpx;
- background: rgba(255, 255, 255, 0.18);
- border: 1rpx solid rgba(255, 255, 255, 0.28);
-}
-
-.cover-badge-text {
- font-size: 16rpx;
- color: rgba(255, 255, 255, 0.9);
- font-weight: 600;
-}
-
-.cover-name {
- font-size: 24rpx;
- font-weight: 700;
- color: #ffffff;
- letter-spacing: 0.5rpx;
- line-height: 1.2;
- max-width: 130rpx;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.cover-price-row {
- display: flex;
- align-items: baseline;
- gap: 2rpx;
-}
-
-.cover-currency {
- font-size: 18rpx;
- font-weight: 600;
- color: rgba(255, 255, 255, 0.85);
-}
-
-.cover-price {
- font-size: 28rpx;
- font-weight: 800;
- color: #ffffff;
- line-height: 1;
-}
-
-.cover-original {
- font-size: 16rpx;
- color: rgba(255, 255, 255, 0.5);
- text-decoration: line-through;
+ background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
/* ── Card info — matches card-cover height ── */
diff --git a/packages/app/src/components/FlashSaleSection.vue b/packages/app/src/components/FlashSaleSection.vue
new file mode 100644
index 0000000..9914d39
--- /dev/null
+++ b/packages/app/src/components/FlashSaleSection.vue
@@ -0,0 +1,435 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ phaseLabel(sale.phase) }}
+
+
+
+
+ 距开始
+ 剩余
+
+ {{ getSaleCountdown(sale).h }}
+ :
+ {{ getSaleCountdown(sale).m }}
+ :
+ {{ getSaleCountdown(sale).s }}
+
+
+
+
+
+
+ {{ sale.title }}
+ {{ sale.cardType.name }}
+
+
+
+
+ ¥
+ {{ formatPrice(sale.flashPrice) }}
+
+ ¥{{ formatPrice(sale.originalPrice) }}
+
+
+
+
+
+
+
+
+ {{ sale.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `剩 ${sale.remainingStock}/${sale.totalStock}` }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json
index b345809..bbb2809 100644
--- a/packages/app/src/pages.json
+++ b/packages/app/src/pages.json
@@ -104,6 +104,18 @@
"style": {
"navigationStyle": "custom"
}
+ },
+ {
+ "path": "pages/admin/flash-sales",
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/flash-sale/detail",
+ "style": {
+ "navigationStyle": "custom"
+ }
}
],
"globalStyle": {
diff --git a/packages/app/src/pages/admin/card-types.vue b/packages/app/src/pages/admin/card-types.vue
index 05216d8..ffddae5 100644
--- a/packages/app/src/pages/admin/card-types.vue
+++ b/packages/app/src/pages/admin/card-types.vue
@@ -522,16 +522,16 @@ onMounted(fetchCardTypes)
justify-content: space-between;
}
-.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
-.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
-.header--trial { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
+.header--times { background: linear-gradient(90deg, #E8D5C4, #D4BFA8); }
+.header--duration { background: linear-gradient(90deg, #D8C8DC, #C4AECB); }
+.header--trial { background: linear-gradient(90deg, #C8D8D2, #A9C4BC); }
-.ct-type-label { font-size: 22rpx; font-weight: 600; color: #ffffff; letter-spacing: 2rpx; }
+.ct-type-label { font-size: 22rpx; font-weight: 600; color: $brand-color; letter-spacing: 2rpx; }
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
-.tag--on { background: rgba(255,255,255,0.2); }
-.tag--off { background: rgba(0,0,0,0.2); }
-.ct-status-text { font-size: 20rpx; color: #ffffff; }
+.tag--on { background: rgba(74, 64, 53, 0.1); }
+.tag--off { background: rgba(74, 64, 53, 0.08); }
+.ct-status-text { font-size: 20rpx; color: $brand-color; }
.ct-body { padding: 24rpx; }
diff --git a/packages/app/src/pages/admin/flash-sales.vue b/packages/app/src/pages/admin/flash-sales.vue
new file mode 100644
index 0000000..ee4dc59
--- /dev/null
+++ b/packages/app/src/pages/admin/flash-sales.vue
@@ -0,0 +1,863 @@
+
+
+
+
+
+
+ 共 {{ total }} 个秒杀活动
+
+ + 新建秒杀
+
+
+
+
+
+
+
+
+
+
+ ◈
+ 暂无秒杀活动,点击右上角新建
+
+
+
+
+
+
+
+
+
+
+
+ 关联卡种: {{ item.cardType.name }}
+
+
+
+
+ 秒杀价
+ ¥{{ formatPrice(item.flashPrice) }}
+
+
+ 原价
+ ¥{{ formatPrice(item.originalPrice) }}
+
+
+ 库存
+ {{ item.soldCount }}/{{ item.totalStock }}
+
+
+
+
+
+
+
+
+
+ {{ formatDateTime(item.startTime) }} — {{ formatDateTime(item.endTime) }}
+
+
+
+
+
+
+ 编辑
+
+
+ 上线
+
+
+ 结束
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 关联卡种
+
+
+ {{ cardTypeOptions[form.cardTypeIdx]?.label || '请选择' }}
+ ›
+
+
+
+
+
+ 活动标题
+
+
+
+
+ 原价(元)
+
+
+
+
+ 秒杀价(元)
+
+
+
+
+ 库存数量
+
+
+
+
+ 开始时间
+
+
+ {{ form.startDate || '选择日期' }}
+
+
+ {{ form.startTimeStr || '选择时间' }}
+
+
+
+
+
+ 结束时间
+
+
+ {{ form.endDate || '选择日期' }}
+
+
+ {{ form.endTimeStr || '选择时间' }}
+
+
+
+
+
+ 排序值
+
+
+
+
+ 活动说明
+
+
+
+
+
+
+
+ 取消
+
+
+ {{ submitting ? '保存中...' : '确认保存' }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/pages/admin/index.vue b/packages/app/src/pages/admin/index.vue
index 03c602e..9da3ef8 100644
--- a/packages/app/src/pages/admin/index.vue
+++ b/packages/app/src/pages/admin/index.vue
@@ -131,6 +131,21 @@
›
+
+
+
+
+ ◈
+
+
+ 秒杀管理
+ 创建和管理限时秒杀活动
+
+
+
+ ›
+
+
@@ -330,6 +345,7 @@ onMounted(() => {
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
+.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
.item-text-group {
diff --git a/packages/app/src/pages/card/detail.vue b/packages/app/src/pages/card/detail.vue
index c43de26..35978e3 100644
--- a/packages/app/src/pages/card/detail.vue
+++ b/packages/app/src/pages/card/detail.vue
@@ -37,28 +37,10 @@
class="card-row"
@tap="goToDetail(c.id)"
>
-
+
-
-
-
-
-
-
- {{ getCardTypeLabel(c.type) }}
-
- {{ c.name }}
-
- ¥
- {{ formatPrice(c.price) }}
-
-
- ¥{{ formatPrice(c.originalPrice) }}
-
-
+
+
@@ -461,15 +443,15 @@ onMounted(() => {
overflow: hidden;
&.hero--times {
- background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
+ background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
&.hero--duration {
- background: linear-gradient(135deg, #6c3483 0%, #9b59b6 100%);
+ background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
&.hero--trial {
- background: linear-gradient(135deg, #5a7a8a 0%, $primary-dark 100%);
+ background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
}
@@ -477,7 +459,7 @@ onMounted(() => {
.hero-deco {
position: absolute;
border-radius: 50%;
- background: rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.35);
pointer-events: none;
&--1 {
@@ -499,14 +481,14 @@ onMounted(() => {
align-self: flex-start;
padding: 8rpx 22rpx;
border-radius: 20rpx;
- background: rgba(255, 255, 255, 0.18);
- border: 1rpx solid rgba(255, 255, 255, 0.3);
+ background: rgba(74, 64, 53, 0.1);
+ border: 1rpx solid rgba(74, 64, 53, 0.15);
z-index: 1;
}
.hero-badge-text {
font-size: 22rpx;
- color: #fff;
+ color: $brand-color;
font-weight: 600;
letter-spacing: 1rpx;
}
@@ -514,7 +496,7 @@ onMounted(() => {
.hero-name {
font-size: 48rpx;
font-weight: 800;
- color: #fff;
+ color: $brand-color;
letter-spacing: 1rpx;
z-index: 1;
}
@@ -529,20 +511,20 @@ onMounted(() => {
.hero-currency {
font-size: 28rpx;
font-weight: 600;
- color: rgba(255, 255, 255, 0.85);
+ color: rgba(74, 64, 53, 0.7);
line-height: 1;
}
.hero-price {
font-size: 64rpx;
font-weight: 800;
- color: #fff;
+ color: $brand-color;
line-height: 1;
}
.hero-original {
font-size: 28rpx;
- color: rgba(255, 255, 255, 0.5);
+ color: rgba(74, 64, 53, 0.4);
text-decoration: line-through;
margin-left: 8rpx;
}
@@ -575,7 +557,7 @@ onMounted(() => {
.section-title {
font-size: 30rpx;
font-weight: 700;
- color: #1a1a1a;
+ color: $text-primary;
}
/* ── Info grid card ──────────────────────────────────── */
@@ -741,239 +723,49 @@ onMounted(() => {
}
/* ══════════════════════════════════════════════════════════
- CARD COVER — Horizontal premium card design
+ CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
- width: 240rpx;
+ width: 200rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
-
- &::before {
- content: '';
- position: absolute;
- top: -20rpx;
- left: -20rpx;
- right: -20rpx;
- bottom: -20rpx;
- background: inherit;
- filter: blur(24rpx) brightness(0.8);
- z-index: 0;
- opacity: 0.4;
- }
-}
-
-.cover-accent-bar {
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 6rpx;
- background: rgba(255, 255, 255, 0.4);
- z-index: 1;
}
.cover-deco {
position: absolute;
border-radius: 50%;
- z-index: 0;
pointer-events: none;
- &--tl {
- width: 60rpx;
- height: 60rpx;
- top: -16rpx;
- right: 20rpx;
- background: rgba(255, 255, 255, 0.1);
+ &--1 {
+ width: 100rpx;
+ height: 100rpx;
+ top: -30rpx;
+ right: -20rpx;
+ background: rgba(255, 255, 255, 0.4);
}
- &--br {
- width: 80rpx;
- height: 80rpx;
- bottom: -24rpx;
- left: -16rpx;
- background: rgba(255, 255, 255, 0.07);
- }
-}
-
-.cover-icon {
- width: 52rpx;
- height: 52rpx;
- position: relative;
- z-index: 2;
- flex-shrink: 0;
- margin-left: 20rpx;
-}
-
-.cover-icon--TIMES {
- &::before {
- content: '';
- position: absolute;
- bottom: 0;
- left: 50%;
- transform: translateX(-50%);
- width: 36rpx;
- height: 24rpx;
- border: 2rpx solid rgba(255, 255, 255, 0.85);
- border-radius: 5rpx;
- box-sizing: border-box;
- background: rgba(255, 255, 255, 0.12);
- }
- &::after {
- content: '';
- position: absolute;
- bottom: 10rpx;
- left: 50%;
- transform: translateX(-50%);
- width: 36rpx;
- height: 24rpx;
- border: 2rpx solid rgba(255, 255, 255, 1);
- border-radius: 5rpx;
- box-sizing: border-box;
- background: rgba(255, 255, 255, 0.2);
- }
-}
-
-.cover-icon--DURATION {
- &::before {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 36rpx;
- height: 30rpx;
- border: 2rpx solid rgba(255, 255, 255, 0.9);
- border-radius: 5rpx;
- box-sizing: border-box;
- }
- &::after {
- content: '';
- position: absolute;
- top: 9rpx;
- left: 50%;
- transform: translateX(-50%);
- width: 24rpx;
- height: 0;
- border-top: 2rpx solid rgba(255, 255, 255, 1);
- box-shadow:
- -6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
- 6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
- }
-}
-
-.cover-icon--TRIAL {
- &::before {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 16rpx;
- height: 16rpx;
- border: 2rpx solid rgba(255, 255, 255, 1);
- border-radius: 50%;
- box-sizing: border-box;
+ &--2 {
+ width: 70rpx;
+ height: 70rpx;
+ bottom: -20rpx;
+ left: -10rpx;
background: rgba(255, 255, 255, 0.25);
}
- &::after {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 2rpx;
- height: 42rpx;
- background: rgba(255, 255, 255, 0.8);
- box-shadow:
- 0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
- 0 12rpx 0 0 rgba(255, 255, 255, 0.8),
- -12rpx 0 0 0 rgba(255, 255, 255, 0.8),
- 12rpx 0 0 0 rgba(255, 255, 255, 0.8),
- -8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
- 8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
- -8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
- 8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
- }
}
.cover--times {
- background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
+ background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.cover--duration {
- background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
+ background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.cover--trial {
- background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
-}
-
-.cover-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: center;
- padding: 0 16rpx 0 12rpx;
- gap: 4rpx;
- z-index: 2;
-}
-
-.cover-badge {
- padding: 3rpx 10rpx;
- border-radius: 8rpx;
- background: rgba(255, 255, 255, 0.18);
- border: 1rpx solid rgba(255, 255, 255, 0.28);
-}
-
-.cover-badge-text {
- font-size: 16rpx;
- color: rgba(255, 255, 255, 0.9);
- font-weight: 600;
-}
-
-.cover-name {
- font-size: 24rpx;
- font-weight: 700;
- color: #ffffff;
- letter-spacing: 0.5rpx;
- line-height: 1.2;
- max-width: 130rpx;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.cover-price-row {
- display: flex;
- align-items: baseline;
- gap: 2rpx;
-}
-
-.cover-currency {
- font-size: 18rpx;
- font-weight: 600;
- color: rgba(255, 255, 255, 0.85);
-}
-
-.cover-price {
- font-size: 28rpx;
- font-weight: 800;
- color: #ffffff;
- line-height: 1;
-}
-
-.cover-original {
- font-size: 16rpx;
- color: rgba(255, 255, 255, 0.5);
- text-decoration: line-through;
+ background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
/* ── Card info — matches card-cover height ── */
diff --git a/packages/app/src/pages/flash-sale/detail.vue b/packages/app/src/pages/flash-sale/detail.vue
new file mode 100644
index 0000000..b5100e1
--- /dev/null
+++ b/packages/app/src/pages/flash-sale/detail.vue
@@ -0,0 +1,837 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ◈
+ 活动信息加载失败
+
+ 点击重试
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ phaseLabel }}
+
+
+
+ {{ detail.title }}
+
+
+
+ ¥
+ {{ formatPrice(detail.flashPrice) }}
+
+ 原价
+ ¥{{ formatPrice(detail.originalPrice) }}
+
+
+
+
+
+ 立省 ¥{{ formatPrice(detail.originalPrice - detail.flashPrice) }}
+
+
+
+
+
+ {{ detail.phase === FlashSalePhase.UPCOMING ? '距开始' : '距结束' }}
+
+
+ {{ countdown.h }}
+ :
+ {{ countdown.m }}
+ :
+ {{ countdown.s }}
+
+
+
+
+
+
+
+ 抢购进度
+
+ {{ detail.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `已抢 ${detail.soldCount}/${detail.totalStock}` }}
+
+
+
+
+
+
+
+
+
+
+ 📱
+
+ 提前授权手机号
+ 授权后抢购更快,也方便馆主联系您
+
+
+
+
+
+
+
+
+
+
+
+ {{ detail.cardType.name }}
+ 卡种
+
+
+ {{ detail.cardType.totalTimes }}
+ 课时次数
+
+
+ {{ detail.cardType.durationDays }}
+ 有效天数
+
+
+
+
+
+
+
+ {{ detail.description }}
+
+
+
+
+
+
+ •
+ 每位用户同一秒杀活动仅限参与一次
+
+
+ •
+ 购买后立即生效,有效期 {{ detail.cardType.durationDays }} 天
+
+
+ •
+ 共 {{ detail.cardType.totalTimes }} 次课时,可灵活预约
+
+
+ •
+ 需登录并授权手机号后方可参与秒杀
+
+
+ •
+ 建议提前完善账号信息及手机号授权,方便馆主联系
+
+
+ •
+ 秒杀卡不可退款,到期或课时用完后自动失效
+
+
+ •
+ 支持微信支付,安全便捷
+
+
+ * 本活动最终解释权归普拉提馆所有
+
+
+
+
+
+
+
+ 秒杀价
+
+ ¥
+ {{ formatPrice(detail.flashPrice) }}
+
+
+
+ {{ actionBtnText }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/pages/home/index.vue b/packages/app/src/pages/home/index.vue
index e9725c5..9d6f087 100644
--- a/packages/app/src/pages/home/index.vue
+++ b/packages/app/src/pages/home/index.vue
@@ -35,6 +35,9 @@
+
+
+
@@ -55,6 +58,7 @@ import BrandBanner from '../../components/BrandBanner.vue'
import StudioInfo from '../../components/StudioInfo.vue'
import QuickEntry from '../../components/QuickEntry.vue'
import UpcomingBooking from '../../components/UpcomingBooking.vue'
+import FlashSaleSection from '../../components/FlashSaleSection.vue'
import CardShop from '../../components/CardShop.vue'
import { useUserStore } from '../../stores/user'
@@ -84,6 +88,7 @@ onShareTimeline(() => {
// ─── Layout ───────────────────────────────────────────────
const refreshing = ref(false)
const cardShopRef = ref | null>(null)
+const flashSaleRef = ref | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0)
const pendingScrollToCardShop = ref(false)
@@ -121,8 +126,9 @@ async function refreshData() {
await Promise.allSettled(tasks)
- // Also refresh card shop
+ // Also refresh card shop and flash sales
cardShopRef.value?.fetchCardTypes()
+ flashSaleRef.value?.fetchFlashSales()
}
async function handleRefresh() {
diff --git a/packages/app/src/pages/profile/membership.vue b/packages/app/src/pages/profile/membership.vue
index 0c746f2..89cc744 100644
--- a/packages/app/src/pages/profile/membership.vue
+++ b/packages/app/src/pages/profile/membership.vue
@@ -1,7 +1,6 @@
-
- 💳
- 暂无会员卡
- 购买会员卡后即可预约课程
-
- 去购买
+
+
+
+ 还没有会员卡
+ 购买会员卡后即可预约课程
+
+ 去选购
+
@@ -29,7 +31,6 @@
@@ -37,56 +38,60 @@
-
-
+
+
+
-
-
@@ -153,13 +158,10 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
-// ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px')
-// ─── State ────────────────────────────────────────────────
const loading = ref(false)
const refreshing = ref(false)
-// ─── Computed from store ───────────────────────────────────
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const activeMemberships = computed(() =>
@@ -170,8 +172,6 @@ const inactiveMemberships = computed(() =>
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
)
-// ─── Helpers ──────────────────────────────────────────────
-
function statusLabel(status: MembershipStatus): string {
const map: Record = {
[MembershipStatus.ACTIVE]: '有效',
@@ -181,25 +181,22 @@ function statusLabel(status: MembershipStatus): string {
return map[status] ?? status
}
-function statusBadgeClass(status: MembershipStatus): string {
- if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
- if (status === MembershipStatus.USED_UP) return 'status-badge--used'
- return 'status-badge--expired'
+function inactiveStatusClass(status: MembershipStatus): string {
+ if (status === MembershipStatus.USED_UP) return 'mc-status--used'
+ return 'mc-status--expired'
}
-function stripClass(type: CardTypeCategory): string {
- if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
- if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
- return 'card-strip--times'
+function cardBgClass(type: CardTypeCategory): string {
+ if (type === CardTypeCategory.TRIAL) return 'mc--trial'
+ if (type === CardTypeCategory.DURATION) return 'mc--duration'
+ return 'mc--times'
}
-function headerClass(type: CardTypeCategory): string {
- if (type === CardTypeCategory.TRIAL) return 'card-header--trial'
- if (type === CardTypeCategory.DURATION) return 'card-header--duration'
- return 'card-header--times'
+function daysRemaining(m: MembershipWithCardType): number {
+ const diff = new Date(m.expireDate).getTime() - Date.now()
+ return Math.max(0, Math.ceil(diff / 86_400_000))
}
-// ─── Data loading ─────────────────────────────────────────
async function loadMemberships() {
loading.value = true
try {
@@ -221,7 +218,6 @@ function goStore() {
uni.switchTab({ url: '/pages/home/index' })
}
-// ─── Lifecycle ────────────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
@@ -231,313 +227,373 @@ onMounted(() => {
diff --git a/packages/app/src/stores/admin.ts b/packages/app/src/stores/admin.ts
index b1adbae..f5c0219 100644
--- a/packages/app/src/stores/admin.ts
+++ b/packages/app/src/stores/admin.ts
@@ -15,6 +15,9 @@ import type {
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
+ FlashSaleAdminItem,
+ CreateFlashSaleDto,
+ UpdateFlashSaleDto,
} from '@mp-pilates/shared'
export interface AdminStats {
@@ -206,6 +209,26 @@ export const useAdminStore = defineStore('admin', () => {
return get('/admin/stats')
}
+ // ── Flash sales ─────────────────────────────────────────────────
+ async function fetchFlashSales(params?: {
+ page?: number
+ limit?: number
+ }): Promise> {
+ return get>('/admin/flash-sales', params as Record)
+ }
+
+ async function createFlashSale(dto: CreateFlashSaleDto): Promise {
+ return post('/admin/flash-sales', dto as unknown as Record)
+ }
+
+ async function updateFlashSale(id: string, dto: UpdateFlashSaleDto): Promise {
+ return put(`/admin/flash-sales/${id}`, dto as unknown as Record)
+ }
+
+ async function deleteFlashSale(id: string): Promise<{ deleted: boolean }> {
+ return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
+ }
+
return {
// State
weekTemplates,
@@ -243,5 +266,10 @@ export const useAdminStore = defineStore('admin', () => {
publishDaySlots,
// Stats
fetchDashboardStats,
+ // Flash sales
+ fetchFlashSales,
+ createFlashSale,
+ updateFlashSale,
+ deleteFlashSale,
}
})
diff --git a/packages/app/src/stores/flash-sale.ts b/packages/app/src/stores/flash-sale.ts
new file mode 100644
index 0000000..d13269e
--- /dev/null
+++ b/packages/app/src/stores/flash-sale.ts
@@ -0,0 +1,43 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import type {
+ FlashSaleListItem,
+ FlashSaleDetail,
+ FlashSalePurchaseResponse,
+} from '@mp-pilates/shared'
+import { get, post } from '../utils/request'
+
+export const useFlashSaleStore = defineStore('flash-sale', () => {
+ const flashSales = ref([])
+ const loading = ref(false)
+
+ async function fetchFlashSales(): Promise {
+ loading.value = true
+ try {
+ const data = await get('/flash-sales')
+ flashSales.value = [...data]
+ return data
+ } catch {
+ flashSales.value = []
+ return []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function fetchDetail(id: string): Promise {
+ return get(`/flash-sales/${id}`)
+ }
+
+ async function purchase(id: string): Promise {
+ return post(`/flash-sales/${id}/purchase`)
+ }
+
+ return {
+ flashSales,
+ loading,
+ fetchFlashSales,
+ fetchDetail,
+ purchase,
+ }
+})
diff --git a/packages/app/src/utils/format.ts b/packages/app/src/utils/format.ts
index 6633b24..fcdac1b 100644
--- a/packages/app/src/utils/format.ts
+++ b/packages/app/src/utils/format.ts
@@ -1,4 +1,5 @@
import { CardTypeCategory } from '@mp-pilates/shared'
+import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
@@ -83,15 +84,73 @@ export function getCardGradientClass(type: CardTypeCategory | string): string {
return 'gradient--times'
}
-/** 会员卡进度百分比(剩余 / 总次数) */
+/** 会员卡进度百分比(剩余 / 总次数,clamp 到 0~100%) */
export function getMembershipProgressWidth(membership: MembershipLike): string {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
-/** 已使用次数 */
+/** 已使用次数(不低于 0,防止管理员调高剩余次数导致负值) */
export function getMembershipUsedTimes(membership: MembershipLike): number {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
- return membership.cardType.totalTimes - membership.remainingTimes
+ return Math.max(0, membership.cardType.totalTimes - membership.remainingTimes)
+}
+
+/** 格式化倒计时:HH:MM:SS */
+export function formatCountdown(targetTime: string): string {
+ const { h, m, s } = getCountdownParts(targetTime)
+ return `${h}:${m}:${s}`
+}
+
+/** 获取倒计时各部分(h/m/s 已补零) */
+export function getCountdownParts(targetTime: string): { readonly h: string; readonly m: string; readonly s: string } {
+ const diff = Math.max(0, new Date(targetTime).getTime() - Date.now())
+ return {
+ h: String(Math.floor(diff / 3_600_000)).padStart(2, '0'),
+ m: String(Math.floor((diff % 3_600_000) / 60_000)).padStart(2, '0'),
+ s: String(Math.floor((diff % 60_000) / 1000)).padStart(2, '0'),
+ }
+}
+
+/** 秒杀阶段中文标签 */
+export function getFlashSalePhaseLabel(phase: FlashSalePhase): string {
+ const map: Record = {
+ [FlashSalePhase.UPCOMING]: '即将开始',
+ [FlashSalePhase.ONGOING]: '抢购中',
+ [FlashSalePhase.SOLD_OUT]: '已售罄',
+ [FlashSalePhase.ENDED]: '已结束',
+ }
+ return map[phase]
+}
+
+/** 库存已售比例 */
+export function getStockRatio(soldCount: number, totalStock: number): number {
+ if (totalStock === 0) return 0
+ return soldCount / totalStock
+}
+
+/** 库存已售百分比字符串 */
+export function getStockPercent(soldCount: number, totalStock: number): string {
+ return `${Math.min(100, getStockRatio(soldCount, totalStock) * 100)}%`
+}
+
+/** 格式化日期时间为 MM-DD HH:mm */
+export function formatDateTime(dateStr: string): string {
+ const d = new Date(dateStr)
+ const month = String(d.getMonth() + 1).padStart(2, '0')
+ const day = String(d.getDate()).padStart(2, '0')
+ const hour = String(d.getHours()).padStart(2, '0')
+ const min = String(d.getMinutes()).padStart(2, '0')
+ return `${month}-${day} ${hour}:${min}`
+}
+
+/** 格式化 Date 为 YYYY-MM-DD(本地时间,用于 picker) */
+export function formatDateLocal(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+/** 格式化 Date 为 HH:MM(本地时间,用于 picker) */
+export function formatTimeLocal(d: Date): string {
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index faa41ce..944a948 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -51,6 +51,18 @@ enum OrderStatus {
REFUNDED
}
+enum FlashSaleStatus {
+ DRAFT
+ ACTIVE
+ ENDED
+}
+
+enum FlashSaleOrderStatus {
+ RESERVED
+ PAID
+ EXPIRED
+}
+
// ===== Models =====
model User {
@@ -67,6 +79,7 @@ model User {
memberships Membership[]
bookings Booking[]
orders Order[]
+ flashSaleOrders FlashSaleOrder[]
@@map("users")
}
@@ -87,6 +100,7 @@ model CardType {
memberships Membership[]
orders Order[]
+ flashSales FlashSale[]
@@map("card_types")
}
@@ -197,11 +211,13 @@ model Order {
status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id")
paidAt DateTime? @map("paid_at")
+ flashSaleId String? @map("flash_sale_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
- user User @relation(fields: [userId], references: [id])
- cardType CardType @relation(fields: [cardTypeId], references: [id])
+ user User @relation(fields: [userId], references: [id])
+ cardType CardType @relation(fields: [cardTypeId], references: [id])
+ flashSaleOrder FlashSaleOrder?
@@index([userId])
@@index([status])
@@ -223,3 +239,48 @@ model StudioConfig {
@@map("studio_config")
}
+
+model FlashSale {
+ id String @id @default(uuid())
+ cardTypeId String @map("card_type_id")
+ title String
+ originalPrice Decimal @map("original_price") @db.Decimal(10, 0)
+ flashPrice Decimal @map("flash_price") @db.Decimal(10, 0)
+ totalStock Int @map("total_stock")
+ soldCount Int @default(0) @map("sold_count")
+ startTime DateTime @map("start_time")
+ endTime DateTime @map("end_time")
+ status FlashSaleStatus @default(DRAFT)
+ description String? @db.Text
+ sortOrder Int @default(0) @map("sort_order")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ cardType CardType @relation(fields: [cardTypeId], references: [id])
+ orders FlashSaleOrder[]
+
+ @@index([status, startTime, endTime])
+ @@map("flash_sales")
+}
+
+model FlashSaleOrder {
+ id String @id @default(uuid())
+ flashSaleId String @map("flash_sale_id")
+ userId String @map("user_id")
+ orderId String? @unique @map("order_id")
+ status FlashSaleOrderStatus @default(RESERVED)
+ reservedAt DateTime @default(now()) @map("reserved_at")
+ paidAt DateTime? @map("paid_at")
+ expiredAt DateTime? @map("expired_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ flashSale FlashSale @relation(fields: [flashSaleId], references: [id])
+ user User @relation(fields: [userId], references: [id])
+ order Order? @relation(fields: [orderId], references: [id])
+
+ @@unique([flashSaleId, userId])
+ @@index([userId])
+ @@index([status])
+ @@map("flash_sale_orders")
+}
diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts
index d3d6104..4526eba 100644
--- a/packages/server/src/app.module.ts
+++ b/packages/server/src/app.module.ts
@@ -11,6 +11,7 @@ import { BookingModule } from './booking/booking.module'
import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
+import { FlashSaleModule } from './flash-sale/flash-sale.module'
@Module({
imports: [
@@ -28,6 +29,7 @@ import { AdminModule } from './admin/admin.module'
SchedulerModule,
PaymentModule,
AdminModule,
+ FlashSaleModule,
],
controllers: [AppController],
})
diff --git a/packages/server/src/flash-sale/dto/create-flash-sale.dto.ts b/packages/server/src/flash-sale/dto/create-flash-sale.dto.ts
new file mode 100644
index 0000000..a9da993
--- /dev/null
+++ b/packages/server/src/flash-sale/dto/create-flash-sale.dto.ts
@@ -0,0 +1,35 @@
+import { IsUUID, IsString, IsInt, IsDateString, IsOptional, Min, IsNumber } from 'class-validator'
+
+export class CreateFlashSaleDto {
+ @IsUUID()
+ cardTypeId!: string
+
+ @IsString()
+ title!: string
+
+ @IsNumber()
+ @Min(1)
+ originalPrice!: number
+
+ @IsNumber()
+ @Min(1)
+ flashPrice!: number
+
+ @IsInt()
+ @Min(1)
+ totalStock!: number
+
+ @IsDateString()
+ startTime!: string
+
+ @IsDateString()
+ endTime!: string
+
+ @IsOptional()
+ @IsString()
+ description?: string
+
+ @IsOptional()
+ @IsInt()
+ sortOrder?: number
+}
diff --git a/packages/server/src/flash-sale/dto/update-flash-sale.dto.ts b/packages/server/src/flash-sale/dto/update-flash-sale.dto.ts
new file mode 100644
index 0000000..a2c2a22
--- /dev/null
+++ b/packages/server/src/flash-sale/dto/update-flash-sale.dto.ts
@@ -0,0 +1,43 @@
+import { IsString, IsInt, IsDateString, IsOptional, Min, IsNumber, IsEnum } from 'class-validator'
+import { FlashSaleStatus } from '@mp-pilates/shared'
+
+export class UpdateFlashSaleDto {
+ @IsOptional()
+ @IsString()
+ title?: string
+
+ @IsOptional()
+ @IsNumber()
+ @Min(1)
+ originalPrice?: number
+
+ @IsOptional()
+ @IsNumber()
+ @Min(1)
+ flashPrice?: number
+
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ totalStock?: number
+
+ @IsOptional()
+ @IsDateString()
+ startTime?: string
+
+ @IsOptional()
+ @IsDateString()
+ endTime?: string
+
+ @IsOptional()
+ @IsString()
+ description?: string
+
+ @IsOptional()
+ @IsEnum(FlashSaleStatus)
+ status?: FlashSaleStatus
+
+ @IsOptional()
+ @IsInt()
+ sortOrder?: number
+}
diff --git a/packages/server/src/flash-sale/flash-sale-admin.controller.ts b/packages/server/src/flash-sale/flash-sale-admin.controller.ts
new file mode 100644
index 0000000..b4706c9
--- /dev/null
+++ b/packages/server/src/flash-sale/flash-sale-admin.controller.ts
@@ -0,0 +1,67 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Post,
+ Put,
+ Query,
+ UseGuards,
+ ValidationPipe,
+} from '@nestjs/common'
+import { UserRole } from '@mp-pilates/shared'
+import { JwtAuthGuard } from '../auth/jwt-auth.guard'
+import { RolesGuard } from '../auth/roles.guard'
+import { Roles } from '../auth/roles.decorator'
+import { FlashSaleService } from './flash-sale.service'
+import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
+import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
+
+@Controller('admin/flash-sales')
+@UseGuards(JwtAuthGuard, RolesGuard)
+@Roles(UserRole.ADMIN)
+export class FlashSaleAdminController {
+ constructor(private readonly flashSaleService: FlashSaleService) {}
+
+ /** POST /admin/flash-sales — create */
+ @Post()
+ create(
+ @Body(new ValidationPipe({ whitelist: true })) dto: CreateFlashSaleDto,
+ ) {
+ return this.flashSaleService.createFlashSale(dto)
+ }
+
+ /** GET /admin/flash-sales — list (paginated) */
+ @Get()
+ list(
+ @Query('page') page?: string,
+ @Query('limit') limit?: string,
+ ) {
+ return this.flashSaleService.getAdminFlashSales(
+ page ? parseInt(page, 10) : 1,
+ limit ? parseInt(limit, 10) : 20,
+ )
+ }
+
+ /** GET /admin/flash-sales/:id — detail */
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.flashSaleService.getFlashSaleDetail(id)
+ }
+
+ /** PUT /admin/flash-sales/:id — update */
+ @Put(':id')
+ update(
+ @Param('id') id: string,
+ @Body(new ValidationPipe({ whitelist: true })) dto: UpdateFlashSaleDto,
+ ) {
+ return this.flashSaleService.updateFlashSale(id, dto)
+ }
+
+ /** DELETE /admin/flash-sales/:id — delete (DRAFT only) */
+ @Delete(':id')
+ remove(@Param('id') id: string) {
+ return this.flashSaleService.deleteFlashSale(id)
+ }
+}
diff --git a/packages/server/src/flash-sale/flash-sale.controller.ts b/packages/server/src/flash-sale/flash-sale.controller.ts
new file mode 100644
index 0000000..744c807
--- /dev/null
+++ b/packages/server/src/flash-sale/flash-sale.controller.ts
@@ -0,0 +1,40 @@
+import {
+ Controller,
+ Get,
+ Param,
+ Post,
+ UseGuards,
+} from '@nestjs/common'
+import { JwtAuthGuard } from '../auth/jwt-auth.guard'
+import { CurrentUser } from '../common/decorators/current-user.decorator'
+import { FlashSaleService } from './flash-sale.service'
+
+@Controller('flash-sales')
+export class FlashSaleController {
+ constructor(private readonly flashSaleService: FlashSaleService) {}
+
+ /** GET /flash-sales — list active/upcoming (public) */
+ @Get()
+ getActiveFlashSales() {
+ return this.flashSaleService.getActiveFlashSales()
+ }
+
+ /** GET /flash-sales/:id — detail (optionally authenticated) */
+ @Get(':id')
+ getFlashSaleDetail(
+ @Param('id') id: string,
+ @CurrentUser('sub') userId?: string,
+ ) {
+ return this.flashSaleService.getFlashSaleDetail(id, userId)
+ }
+
+ /** POST /flash-sales/:id/purchase — requires auth */
+ @Post(':id/purchase')
+ @UseGuards(JwtAuthGuard)
+ purchase(
+ @Param('id') flashSaleId: string,
+ @CurrentUser('sub') userId: string,
+ ) {
+ return this.flashSaleService.purchase(flashSaleId, userId)
+ }
+}
diff --git a/packages/server/src/flash-sale/flash-sale.module.ts b/packages/server/src/flash-sale/flash-sale.module.ts
new file mode 100644
index 0000000..b5906fb
--- /dev/null
+++ b/packages/server/src/flash-sale/flash-sale.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common'
+import { PrismaModule } from '../prisma/prisma.module'
+import { PaymentModule } from '../payment/payment.module'
+import { FlashSaleService } from './flash-sale.service'
+import { FlashSaleController } from './flash-sale.controller'
+import { FlashSaleAdminController } from './flash-sale-admin.controller'
+
+@Module({
+ imports: [PrismaModule, PaymentModule],
+ controllers: [FlashSaleController, FlashSaleAdminController],
+ providers: [FlashSaleService],
+ exports: [FlashSaleService],
+})
+export class FlashSaleModule {}
diff --git a/packages/server/src/flash-sale/flash-sale.service.ts b/packages/server/src/flash-sale/flash-sale.service.ts
new file mode 100644
index 0000000..6ecc219
--- /dev/null
+++ b/packages/server/src/flash-sale/flash-sale.service.ts
@@ -0,0 +1,409 @@
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from '@nestjs/common'
+import { Prisma } from '@prisma/client'
+import {
+ FlashSaleStatus,
+ FlashSaleOrderStatus,
+ MembershipStatus,
+ OrderStatus,
+} from '@mp-pilates/shared'
+import { FlashSalePhase } from '@mp-pilates/shared'
+import { PrismaService } from '../prisma/prisma.service'
+import { WechatPayService, WxPaymentParams } from '../payment/wechat-pay.service'
+import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
+import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
+
+// ── Helpers ─────────────────────────────────────────────────
+function computePhase(sale: {
+ startTime: Date
+ endTime: Date
+ soldCount: number
+ totalStock: number
+ status: string
+}): FlashSalePhase {
+ if (sale.status === FlashSaleStatus.ENDED) return FlashSalePhase.ENDED
+ const now = new Date()
+ if (now < sale.startTime) return FlashSalePhase.UPCOMING
+ if (now > sale.endTime) return FlashSalePhase.ENDED
+ if (sale.soldCount >= sale.totalStock) return FlashSalePhase.SOLD_OUT
+ return FlashSalePhase.ONGOING
+}
+
+function toNumber(val: Prisma.Decimal | number): number {
+ return typeof val === 'number' ? val : Number(val)
+}
+
+// ── Service ─────────────────────────────────────────────────
+
+@Injectable()
+export class FlashSaleService {
+ private readonly logger = new Logger(FlashSaleService.name)
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly wechatPayService: WechatPayService,
+ ) {}
+
+ // ═══════════════════════════════════════════════════════════
+ // USER: List active/upcoming flash sales
+ // ═══════════════════════════════════════════════════════════
+ async getActiveFlashSales() {
+ const sales = await this.prisma.flashSale.findMany({
+ where: {
+ status: FlashSaleStatus.ACTIVE,
+ endTime: { gt: new Date() },
+ },
+ include: {
+ cardType: {
+ select: { name: true, type: true, totalTimes: true, durationDays: true },
+ },
+ },
+ orderBy: [{ sortOrder: 'asc' }, { startTime: 'asc' }],
+ })
+
+ return sales.map((s) => ({
+ ...s,
+ originalPrice: toNumber(s.originalPrice),
+ flashPrice: toNumber(s.flashPrice),
+ phase: computePhase(s),
+ remainingStock: s.totalStock - s.soldCount,
+ cardType: s.cardType,
+ }))
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // USER: Get detail (with participation check)
+ // ═══════════════════════════════════════════════════════════
+ async getFlashSaleDetail(id: string, userId?: string) {
+ const sale = await this.prisma.flashSale.findUnique({
+ where: { id },
+ include: {
+ cardType: {
+ select: {
+ name: true,
+ type: true,
+ totalTimes: true,
+ durationDays: true,
+ description: true,
+ },
+ },
+ },
+ })
+ if (!sale) throw new NotFoundException('秒杀活动不存在')
+
+ let hasParticipated = false
+ let userOrderStatus: FlashSaleOrderStatus | null = null
+
+ if (userId) {
+ const existing = await this.prisma.flashSaleOrder.findUnique({
+ where: { flashSaleId_userId: { flashSaleId: id, userId } },
+ })
+ if (existing && existing.status !== FlashSaleOrderStatus.EXPIRED) {
+ hasParticipated = true
+ userOrderStatus = existing.status as FlashSaleOrderStatus
+ }
+ }
+
+ return {
+ ...sale,
+ originalPrice: toNumber(sale.originalPrice),
+ flashPrice: toNumber(sale.flashPrice),
+ phase: computePhase(sale),
+ remainingStock: sale.totalStock - sale.soldCount,
+ cardType: { ...sale.cardType },
+ hasParticipated,
+ userOrderStatus,
+ serverTime: new Date().toISOString(),
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // PURCHASE — Atomic stock deduction
+ // ═══════════════════════════════════════════════════════════
+ async purchase(flashSaleId: string, userId: string) {
+ // ① Pre-validate (fast-fail before transaction)
+ const user = await this.prisma.user.findUnique({ where: { id: userId } })
+ if (!user) throw new NotFoundException('用户不存在')
+ if (!user.phone) throw new BadRequestException('请先授权手机号后再参与秒杀')
+
+ const sale = await this.prisma.flashSale.findUnique({
+ where: { id: flashSaleId },
+ include: { cardType: true },
+ })
+ if (!sale) throw new NotFoundException('秒杀活动不存在')
+ if (sale.status !== FlashSaleStatus.ACTIVE) {
+ throw new BadRequestException('秒杀活动未上线')
+ }
+
+ const now = new Date()
+ if (now < sale.startTime) throw new BadRequestException('秒杀尚未开始')
+ if (now > sale.endTime) throw new BadRequestException('秒杀已结束')
+
+ // ② Atomic transaction: reserve stock + create FlashSaleOrder + create Order
+ let result: { order: { id: string; orderNo: string; amount: Prisma.Decimal }; flashSaleOrderId: string }
+
+ try {
+ result = await this.prisma.$transaction(async (tx) => {
+ // ②-a: CAS optimistic lock stock deduction
+ const updated = await tx.flashSale.updateMany({
+ where: {
+ id: flashSaleId,
+ soldCount: { lt: sale.totalStock },
+ },
+ data: {
+ soldCount: { increment: 1 },
+ },
+ })
+
+ if (updated.count === 0) {
+ throw new BadRequestException('手慢了,已售罄')
+ }
+
+ // ②-b: Create Order with flash sale price
+ const orderNo = `FS${Date.now()}${Math.random().toString(36).substring(2, 8)}`
+
+ const order = await tx.order.create({
+ data: {
+ userId,
+ cardTypeId: sale.cardTypeId,
+ orderNo,
+ amount: sale.flashPrice,
+ status: OrderStatus.PENDING,
+ flashSaleId,
+ },
+ })
+
+ // ②-c: Create FlashSaleOrder (unique constraint prevents duplicate)
+ const flashSaleOrder = await tx.flashSaleOrder.create({
+ data: {
+ flashSaleId,
+ userId,
+ orderId: order.id,
+ status: FlashSaleOrderStatus.RESERVED,
+ },
+ })
+
+ return {
+ order: { id: order.id, orderNo: order.orderNo, amount: order.amount },
+ flashSaleOrderId: flashSaleOrder.id,
+ }
+ })
+ } catch (err) {
+ // Handle unique constraint violation (user already participated)
+ if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
+ throw new ConflictException('您已参与过此秒杀活动')
+ }
+ throw err
+ }
+
+ // ③ Create WeChat unified order (outside transaction — network call)
+ const paymentParams = await this.wechatPayService.createUnifiedOrder({
+ orderNo: result.order.orderNo,
+ amount: toNumber(result.order.amount),
+ openid: user.openid,
+ description: `秒杀-${sale.title}`,
+ })
+
+ return {
+ flashSaleOrderId: result.flashSaleOrderId,
+ order: {
+ id: result.order.id,
+ orderNo: result.order.orderNo,
+ amount: toNumber(result.order.amount),
+ },
+ paymentParams,
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // ADMIN: Create flash sale
+ // ═══════════════════════════════════════════════════════════
+ async createFlashSale(dto: CreateFlashSaleDto) {
+ const cardType = await this.prisma.cardType.findUnique({
+ where: { id: dto.cardTypeId },
+ })
+ if (!cardType) throw new NotFoundException('卡种不存在')
+
+ const startTime = new Date(dto.startTime)
+ const endTime = new Date(dto.endTime)
+ if (endTime <= startTime) {
+ throw new BadRequestException('结束时间必须晚于开始时间')
+ }
+
+ const sale = await this.prisma.flashSale.create({
+ data: {
+ cardTypeId: dto.cardTypeId,
+ title: dto.title,
+ originalPrice: dto.originalPrice,
+ flashPrice: dto.flashPrice,
+ totalStock: dto.totalStock,
+ startTime,
+ endTime,
+ description: dto.description ?? null,
+ sortOrder: dto.sortOrder ?? 0,
+ status: FlashSaleStatus.DRAFT,
+ },
+ include: {
+ cardType: { select: { name: true, type: true } },
+ },
+ })
+
+ return {
+ ...sale,
+ originalPrice: toNumber(sale.originalPrice),
+ flashPrice: toNumber(sale.flashPrice),
+ phase: computePhase(sale),
+ cardType: { ...sale.cardType },
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // ADMIN: Update flash sale
+ // ═══════════════════════════════════════════════════════════
+ async updateFlashSale(id: string, dto: UpdateFlashSaleDto) {
+ const existing = await this.prisma.flashSale.findUnique({ where: { id } })
+ if (!existing) throw new NotFoundException('秒杀活动不存在')
+
+ const data: Record = {}
+ if (dto.title !== undefined) data.title = dto.title
+ if (dto.originalPrice !== undefined) data.originalPrice = dto.originalPrice
+ if (dto.flashPrice !== undefined) data.flashPrice = dto.flashPrice
+ if (dto.totalStock !== undefined) {
+ if (dto.totalStock < existing.soldCount) {
+ throw new BadRequestException('库存不能小于已售数量')
+ }
+ data.totalStock = dto.totalStock
+ }
+ if (dto.startTime !== undefined) data.startTime = new Date(dto.startTime)
+ if (dto.endTime !== undefined) data.endTime = new Date(dto.endTime)
+ if (dto.description !== undefined) data.description = dto.description
+ if (dto.status !== undefined) data.status = dto.status
+ if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder
+
+ const sale = await this.prisma.flashSale.update({
+ where: { id },
+ data,
+ include: {
+ cardType: { select: { name: true, type: true } },
+ },
+ })
+
+ return {
+ ...sale,
+ originalPrice: toNumber(sale.originalPrice),
+ flashPrice: toNumber(sale.flashPrice),
+ phase: computePhase(sale),
+ cardType: { ...sale.cardType },
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // ADMIN: Delete flash sale (only DRAFT)
+ // ═══════════════════════════════════════════════════════════
+ async deleteFlashSale(id: string) {
+ const existing = await this.prisma.flashSale.findUnique({ where: { id } })
+ if (!existing) throw new NotFoundException('秒杀活动不存在')
+
+ if (existing.soldCount > 0) {
+ throw new BadRequestException('已有用户参与,无法删除,请结束活动')
+ }
+
+ await this.prisma.flashSale.delete({ where: { id } })
+ return { deleted: true }
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // ADMIN: List all flash sales (paginated)
+ // ═══════════════════════════════════════════════════════════
+ async getAdminFlashSales(page = 1, limit = 20) {
+ const skip = (page - 1) * limit
+
+ const [data, total] = await Promise.all([
+ this.prisma.flashSale.findMany({
+ include: {
+ cardType: { select: { name: true, type: true } },
+ },
+ orderBy: { createdAt: 'desc' },
+ skip,
+ take: limit,
+ }),
+ this.prisma.flashSale.count(),
+ ])
+
+ return {
+ data: data.map((s) => ({
+ ...s,
+ originalPrice: toNumber(s.originalPrice),
+ flashPrice: toNumber(s.flashPrice),
+ phase: computePhase(s),
+ cardType: s.cardType,
+ })),
+ total,
+ page,
+ limit,
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // SCHEDULER: Expire unpaid reservations (release stock)
+ // ═══════════════════════════════════════════════════════════
+ async expireUnpaidReservations(expireMinutes = 15): Promise {
+ const cutoff = new Date(Date.now() - expireMinutes * 60_000)
+
+ const expiredOrders = await this.prisma.flashSaleOrder.findMany({
+ where: {
+ status: FlashSaleOrderStatus.RESERVED,
+ reservedAt: { lt: cutoff },
+ },
+ })
+
+ if (expiredOrders.length === 0) return 0
+
+ // Group by flashSaleId to batch stock release
+ const stockDecrements = new Map()
+ const orderIds: string[] = []
+ const flashSaleOrderIds: string[] = []
+
+ for (const fo of expiredOrders) {
+ flashSaleOrderIds.push(fo.id)
+ stockDecrements.set(fo.flashSaleId, (stockDecrements.get(fo.flashSaleId) ?? 0) + 1)
+ if (fo.orderId) orderIds.push(fo.orderId)
+ }
+
+ try {
+ await this.prisma.$transaction([
+ // Batch mark all as expired
+ this.prisma.flashSaleOrder.updateMany({
+ where: { id: { in: flashSaleOrderIds } },
+ data: { status: FlashSaleOrderStatus.EXPIRED, expiredAt: new Date() },
+ }),
+ // Release stock per flash sale
+ ...Array.from(stockDecrements.entries()).map(([flashSaleId, count]) =>
+ this.prisma.flashSale.update({
+ where: { id: flashSaleId },
+ data: { soldCount: { decrement: count } },
+ }),
+ ),
+ // Cancel associated payment orders
+ ...(orderIds.length > 0
+ ? [
+ this.prisma.order.updateMany({
+ where: { id: { in: orderIds } },
+ data: { status: OrderStatus.REFUNDED },
+ }),
+ ]
+ : []),
+ ])
+ } catch (err) {
+ this.logger.error('Failed to batch-expire flash sale orders', err)
+ return 0
+ }
+
+ return expiredOrders.length
+ }
+}
diff --git a/packages/server/src/payment/payment.module.ts b/packages/server/src/payment/payment.module.ts
index c976c18..16a3ac9 100644
--- a/packages/server/src/payment/payment.module.ts
+++ b/packages/server/src/payment/payment.module.ts
@@ -8,6 +8,6 @@ import { WechatPayService } from './wechat-pay.service'
imports: [PrismaModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
- exports: [PaymentService],
+ exports: [PaymentService, WechatPayService],
})
export class PaymentModule {}
diff --git a/packages/server/src/payment/payment.service.ts b/packages/server/src/payment/payment.service.ts
index 2d89582..033d716 100644
--- a/packages/server/src/payment/payment.service.ts
+++ b/packages/server/src/payment/payment.service.ts
@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
-import { MembershipStatus, OrderStatus } from '@mp-pilates/shared'
+import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
@@ -136,6 +136,22 @@ export class PaymentService {
])
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
+
+ // ── Flash sale order: mark as PAID ──
+ if (existingOrder.flashSaleId) {
+ await this.prisma.flashSaleOrder.updateMany({
+ where: {
+ orderId: existingOrder.id,
+ status: FlashSaleOrderStatus.RESERVED,
+ },
+ data: {
+ status: FlashSaleOrderStatus.PAID,
+ paidAt: now,
+ },
+ })
+ this.logger.log(`Flash sale order marked PAID for orderNo=${notification.orderNo}`)
+ }
+
return this.buildSuccessXml()
}
diff --git a/packages/server/src/scheduler/scheduler.module.ts b/packages/server/src/scheduler/scheduler.module.ts
index 8e45a97..46ea2dd 100644
--- a/packages/server/src/scheduler/scheduler.module.ts
+++ b/packages/server/src/scheduler/scheduler.module.ts
@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { TimeSlotModule } from '../time-slot/time-slot.module'
+import { FlashSaleModule } from '../flash-sale/flash-sale.module'
import { SchedulerService } from './scheduler.service'
@Module({
imports: [
ScheduleModule.forRoot(),
TimeSlotModule,
+ FlashSaleModule,
],
providers: [SchedulerService],
})
diff --git a/packages/server/src/scheduler/scheduler.service.ts b/packages/server/src/scheduler/scheduler.service.ts
index 2c715b2..3dd647f 100644
--- a/packages/server/src/scheduler/scheduler.service.ts
+++ b/packages/server/src/scheduler/scheduler.service.ts
@@ -1,12 +1,16 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { SlotGeneratorService } from '../time-slot/slot-generator.service'
+import { FlashSaleService } from '../flash-sale/flash-sale.service'
@Injectable()
export class SchedulerService {
private readonly logger = new Logger(SchedulerService.name)
- constructor(private readonly slotGenerator: SlotGeneratorService) {}
+ constructor(
+ private readonly slotGenerator: SlotGeneratorService,
+ private readonly flashSaleService: FlashSaleService,
+ ) {}
/** 02:00 daily — generate slots 14 days ahead from week templates */
@Cron('0 2 * * *')
@@ -51,4 +55,17 @@ export class SchedulerService {
this.logger.error('[handleCompleteBookings] Failed to complete bookings', err)
}
}
+
+ /** Every 5 min — expire unpaid flash sale reservations older than 15min */
+ @Cron('*/5 * * * *')
+ async handleExpireFlashSaleReservations(): Promise {
+ try {
+ const count = await this.flashSaleService.expireUnpaidReservations(15)
+ if (count > 0) {
+ this.logger.log(`[handleExpireFlashSaleReservations] Expired ${count} unpaid reservations`)
+ }
+ } catch (err) {
+ this.logger.error('[handleExpireFlashSaleReservations] Failed', err)
+ }
+ }
}
diff --git a/packages/shared/src/enums.ts b/packages/shared/src/enums.ts
index c2ac82e..6eb686b 100644
--- a/packages/shared/src/enums.ts
+++ b/packages/shared/src/enums.ts
@@ -45,3 +45,16 @@ export enum OrderStatus {
PAID = 'PAID',
REFUNDED = 'REFUNDED',
}
+
+// ===== FlashSale =====
+export enum FlashSaleStatus {
+ DRAFT = 'DRAFT',
+ ACTIVE = 'ACTIVE',
+ ENDED = 'ENDED',
+}
+
+export enum FlashSaleOrderStatus {
+ RESERVED = 'RESERVED',
+ PAID = 'PAID',
+ EXPIRED = 'EXPIRED',
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index fb8ba63..dea9a56 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -7,6 +7,8 @@ export {
TimeSlotSource,
BookingStatus,
OrderStatus,
+ FlashSaleStatus,
+ FlashSaleOrderStatus,
} from './enums'
// Constants
@@ -54,4 +56,13 @@ export type {
PaginatedData,
PaginatedResponse,
PaginationQuery,
+ FlashSale,
+ FlashSaleListItem,
+ FlashSaleDetail,
+ FlashSaleAdminItem,
+ CreateFlashSaleDto,
+ UpdateFlashSaleDto,
+ FlashSalePurchaseResponse,
} from './types/index'
+
+export { FlashSalePhase } from './types/index'
diff --git a/packages/shared/src/types/flash-sale.ts b/packages/shared/src/types/flash-sale.ts
new file mode 100644
index 0000000..834126d
--- /dev/null
+++ b/packages/shared/src/types/flash-sale.ts
@@ -0,0 +1,97 @@
+import { FlashSaleStatus, FlashSaleOrderStatus, CardTypeCategory } from '../enums'
+
+// ── Computed sale phase (derived from server time) ──────────
+export enum FlashSalePhase {
+ UPCOMING = 'UPCOMING',
+ ONGOING = 'ONGOING',
+ SOLD_OUT = 'SOLD_OUT',
+ ENDED = 'ENDED',
+}
+
+// ── Core entity ─────────────────────────────────────────────
+export interface FlashSale {
+ readonly id: string
+ readonly cardTypeId: string
+ readonly title: string
+ readonly originalPrice: number
+ readonly flashPrice: number
+ readonly totalStock: number
+ readonly soldCount: number
+ readonly startTime: string
+ readonly endTime: string
+ readonly status: FlashSaleStatus
+ readonly description: string | null
+ readonly sortOrder: number
+ readonly createdAt: string
+ readonly updatedAt: string
+}
+
+// ── User-facing list item (home page) ───────────────────────
+export interface FlashSaleListItem extends FlashSale {
+ readonly phase: FlashSalePhase
+ readonly remainingStock: number
+ readonly cardType: {
+ readonly name: string
+ readonly type: CardTypeCategory
+ readonly totalTimes: number | null
+ readonly durationDays: number
+ }
+}
+
+// ── Detail (includes user participation status) ─────────────
+export interface FlashSaleDetail extends FlashSaleListItem {
+ readonly hasParticipated: boolean
+ readonly userOrderStatus: FlashSaleOrderStatus | null
+ readonly serverTime: string
+}
+
+// ── Admin DTOs ──────────────────────────────────────────────
+export interface CreateFlashSaleDto {
+ readonly cardTypeId: string
+ readonly title: string
+ readonly originalPrice: number
+ readonly flashPrice: number
+ readonly totalStock: number
+ readonly startTime: string
+ readonly endTime: string
+ readonly description?: string
+ readonly sortOrder?: number
+}
+
+export interface UpdateFlashSaleDto {
+ readonly title?: string
+ readonly originalPrice?: number
+ readonly flashPrice?: number
+ readonly totalStock?: number
+ readonly startTime?: string
+ readonly endTime?: string
+ readonly description?: string
+ readonly status?: FlashSaleStatus
+ readonly sortOrder?: number
+}
+
+// ── Purchase response ───────────────────────────────────────
+export interface FlashSalePurchaseResponse {
+ readonly flashSaleOrderId: string
+ readonly order: {
+ readonly id: string
+ readonly orderNo: string
+ readonly amount: number
+ }
+ readonly paymentParams: {
+ readonly timeStamp: string
+ readonly nonceStr: string
+ readonly package: string
+ readonly signType: string
+ readonly paySign: string
+ }
+}
+
+// ── Admin list item ─────────────────────────────────────────
+export interface FlashSaleAdminItem extends FlashSale {
+ readonly phase: FlashSalePhase
+ readonly cardType: {
+ readonly name: string
+ readonly type: CardTypeCategory
+ }
+}
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 8479266..b0f9aae 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -7,3 +7,13 @@ export type { Booking, BookingWithDetails, BookingWithUser, BookingStatusHistory
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
export type { StudioConfig, UpdateStudioConfigDto } from './studio'
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
+export type {
+ FlashSale,
+ FlashSaleListItem,
+ FlashSaleDetail,
+ FlashSaleAdminItem,
+ CreateFlashSaleDto,
+ UpdateFlashSaleDto,
+ FlashSalePurchaseResponse,
+} from './flash-sale'
+export { FlashSalePhase } from './flash-sale'