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:
@@ -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)),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user