feat: ¥1 首月特惠套餐(新店专享,每店限购一次,官网+App 同步上线)

- 后端:payPlans 新增 promo_first_month(¥1/30 天/标准版权益);下单强校验每店限购一次(仅 paid 计数,弃单可重下),已享用返回 409;新增 GET /license/promo-status 供前端置灰
- 官网:checkout 套餐段第二位插「🔥 首月特惠 ¥1」+ 推广横幅 + 划线原价 ¥299;首页价格区第二位插特惠卡(5 列);已享用自动置灰
- App:购买弹窗套餐段改三档(标准/特惠/高级),特惠选中显示推广横幅与划线价,已享用置灰并禁用提交
- 实付金额以 pay 侧为准,pay 需 seed promo_first_month 产品后生效

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 22:25:07 +08:00
parent c7de728c18
commit b3c9d98eae
9 changed files with 255 additions and 30 deletions
+11
View File
@@ -23,6 +23,17 @@ class LicensePlanGroup {
}
class LicensePlans {
/// 新店首月特惠:¥1 体验 30 天标准版全部功能,每个门店限购一次
/// (服务端强校验,前端据 GET /license/promo-status 置灰)。
static const promo = LicensePlan('promo_first_month', 1, 30);
static const promoOriginalPrice = 299; // 划线原价(=标准版月付)
static const promoFeats = [
'标准版全部功能,一分不少',
'单门店 · 单仓库 · 2 台客户端',
'1,000 张商品图片分享',
'每个门店限购一次',
];
static const standard = LicensePlanGroup(
name: '标准版',
feats: ['单门店 · 单仓库', '2 台客户端同时使用', '1,000 张商品图片分享'],
@@ -68,6 +68,18 @@ class LicenseRepository {
}
}
/// 本店首月特惠是否已享用(购买弹窗据此置灰特惠档)。
Future<bool> promoUsed() async {
try {
final resp = await _client.get('/license/promo-status');
final data = resp.data['data'] as Map<String, dynamic>?;
return data?['used'] as bool? ?? false;
} on DioException {
// 查询失败不阻塞弹窗:按未享用展示,下单时服务端仍会强校验
return false;
}
}
/// Deactivate (unbind) this device from its license.
Future<void> deactivate() async {
final deviceId = await DeviceId.get();
@@ -42,19 +42,32 @@ class _PurchaseDialog extends ConsumerStatefulWidget {
class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
_Phase _phase = _Phase.pick;
int _groupIdx = 0; // 0=标准版 1=高级版
int _tabIdx = 0; // 0=标准版 1=首月特惠 2=高级版
int _cycleIdx = 0; // 0=年付 1=月付
bool _submitting = false;
bool _checking = false;
bool _promoUsed = false; // 首月特惠每店限一次,已享用则置灰(服务端仍强校验)
PurchaseOrder? _order;
DateTime? _newExpiresAt;
Timer? _pollTimer;
LicensePlanGroup get _group => LicensePlans.groups[_groupIdx];
LicensePlan get _plan => _cycleIdx == 0 ? _group.annual : _group.monthly;
bool get _isPromo => _tabIdx == 1;
LicensePlanGroup get _group =>
_tabIdx == 0 ? LicensePlans.standard : LicensePlans.pro;
LicensePlan get _plan => _isPromo
? LicensePlans.promo
: (_cycleIdx == 0 ? _group.annual : _group.monthly);
String get _cycleLabel => _cycleIdx == 0 ? '年付' : '月付';
@override
void initState() {
super.initState();
ref.read(licenseRepositoryProvider).promoUsed().then((used) {
if (mounted && used) setState(() => _promoUsed = true);
});
}
@override
void dispose() {
_pollTimer?.cancel();
@@ -133,9 +146,15 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
DsButton('取消',
onPressed:
_submitting ? null : () => Navigator.of(context).pop()),
DsButton(_submitting ? '正在创建订单…' : '提交订单 · ¥${_fmt(_plan.price)}',
DsButton(
_submitting
? '正在创建订单…'
: _isPromo && _promoUsed
? '本店已享受过'
: '提交订单 · ¥${_fmt(_plan.price)}',
variant: DsBtnVariant.primary,
onPressed: _submitting ? null : _submit),
onPressed:
_submitting || (_isPromo && _promoUsed) ? null : _submit),
],
_Phase.waiting => [
DsButton('稍后再说', onPressed: () => Navigator.of(context).pop()),
@@ -153,24 +172,60 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
}
Widget _buildPick(AppTokens t) {
final promoGrayed = _isPromo && _promoUsed;
final feats = _isPromo ? LicensePlans.promoFeats : _group.feats;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Wrap(spacing: 10, runSpacing: 8, children: [
DsSeg(
items: [for (final g in LicensePlans.groups) g.name],
index: _groupIdx,
onChanged: (i) => setState(() => _groupIdx = i),
),
const SizedBox(width: 10),
DsSeg(
items: const ['年付', '月付'],
index: _cycleIdx,
onChanged: (i) => setState(() => _cycleIdx = i),
items: [
'标准版',
_promoUsed ? '首月特惠(已享受)' : '🔥 首月特惠 ¥1',
'高级版',
],
index: _tabIdx,
onChanged: (i) => setState(() => _tabIdx = i),
),
if (!_isPromo)
DsSeg(
items: const ['年付', '月付'],
index: _cycleIdx,
onChanged: (i) => setState(() => _cycleIdx = i),
),
]),
const SizedBox(height: 14),
if (_isPromo) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: promoGrayed ? t.bg : t.warnBg,
border: Border.all(color: promoGrayed ? t.border : t.warn),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(promoGrayed ? '本店已享受过首月特惠' : '新店专享 · 仅 ¥1 体验 30 天',
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w700,
color: promoGrayed ? t.faint : t.warn)),
const SizedBox(height: 3),
Text(
promoGrayed
? '首月特惠每个门店限购一次,可选择标准版或高级版继续续费。'
: '解锁标准版全部功能,原价 ¥299,每个门店限购一次——试过才知道多省心。',
style: TextStyle(
fontSize: AppDims.fsSm,
color: promoGrayed ? t.faint : t.text)),
],
),
),
const SizedBox(height: 10),
],
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
@@ -182,16 +237,18 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final f in _group.feats)
for (final f in feats)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: [
Icon(LucideIcons.check, size: 14, color: t.success),
Icon(LucideIcons.check,
size: 14, color: promoGrayed ? t.faint : t.success),
const SizedBox(width: 8),
Expanded(
child: Text(f,
style: TextStyle(
fontSize: AppDims.fsSm, color: t.text))),
fontSize: AppDims.fsSm,
color: promoGrayed ? t.faint : t.text))),
]),
),
],
@@ -202,14 +259,31 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text('${_group.name} · $_cycleLabel · ${_plan.days} 天授权',
child: Text(
_isPromo
? '首月特惠 · ${_plan.days} 天授权'
: '${_group.name} · $_cycleLabel · ${_plan.days} 天授权',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
if (_isPromo) ...[
Text('¥${_fmt(LicensePlans.promoOriginalPrice)}',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.faint,
decoration: TextDecoration.lineThrough,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
const SizedBox(width: 8),
],
Text('¥${_fmt(_plan.price)}',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: t.heading,
color: promoGrayed
? t.faint
: _isPromo
? t.warn
: t.heading,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
],