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:
@@ -43,6 +43,8 @@ func (h *PayHandler) Purchase(c *gin.Context) {
|
|||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrUnknownPlan):
|
case errors.Is(err, service.ErrUnknownPlan):
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrPromoUsed):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
default:
|
default:
|
||||||
log.Printf("[pay] purchase failed shop=%d biz_code=%s: %v", middleware.GetShopID(c), req.BizCode, err)
|
log.Printf("[pay] purchase failed shop=%d biz_code=%s: %v", middleware.GetShopID(c), req.BizCode, err)
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "下单失败,请稍后重试"})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "下单失败,请稍后重试"})
|
||||||
@@ -66,6 +68,16 @@ func (h *PayHandler) PurchaseStatus(c *gin.Context) {
|
|||||||
util.RespondSuccess(c, st)
|
util.RespondSuccess(c, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromoStatus GET /api/v1/license/promo-status — 本店首月特惠是否已享用(前端据此置灰特惠档)。
|
||||||
|
func (h *PayHandler) PromoStatus(c *gin.Context) {
|
||||||
|
used, err := h.svc.PromoUsed(middleware.GetShopID(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
util.RespondSuccess(c, gin.H{"used": used})
|
||||||
|
}
|
||||||
|
|
||||||
// Callback POST /api/v1/pay/callback — pay 支付成功 webhook(公开路由,HMAC 验签)。
|
// Callback POST /api/v1/pay/callback — pay 支付成功 webhook(公开路由,HMAC 验签)。
|
||||||
// 契约:验签失败回 401;受理成功回 200 + {"code":"SUCCESS"},否则 pay 每 60s 重试 24h。
|
// 契约:验签失败回 401;受理成功回 200 + {"code":"SUCCESS"},否则 pay 每 60s 重试 24h。
|
||||||
func (h *PayHandler) Callback(c *gin.Context) {
|
func (h *PayHandler) Callback(c *gin.Context) {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
// 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
|
// 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
|
||||||
license.POST("/purchase", payH.Purchase)
|
license.POST("/purchase", payH.Purchase)
|
||||||
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
|
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
|
||||||
|
license.GET("/promo-status", payH.PromoStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
|
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ var (
|
|||||||
ErrPaySignature = errors.New("签名校验失败")
|
ErrPaySignature = errors.New("签名校验失败")
|
||||||
ErrPayAmount = errors.New("回调金额与订单不符")
|
ErrPayAmount = errors.New("回调金额与订单不符")
|
||||||
ErrPurchaseNotFound = errors.New("购买记录不存在")
|
ErrPurchaseNotFound = errors.New("购买记录不存在")
|
||||||
|
ErrPromoUsed = errors.New("首月特惠每个门店限购一次,本店已享受过")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PromoBizCode 新店首月特惠(¥1/30 天标准版),每个门店仅可购买一次。
|
||||||
|
const PromoBizCode = "promo_first_month"
|
||||||
|
|
||||||
// payPlan biz_code → 权益映射(与 pay 侧 seed 的套餐一一对应,金额权威在 pay,此处 price 仅作前端展示核对)。
|
// payPlan biz_code → 权益映射(与 pay 侧 seed 的套餐一一对应,金额权威在 pay,此处 price 仅作前端展示核对)。
|
||||||
type payPlan struct {
|
type payPlan struct {
|
||||||
Days int
|
Days int
|
||||||
@@ -52,6 +56,8 @@ type payPlan struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var payPlans = map[string]payPlan{
|
var payPlans = map[string]payPlan{
|
||||||
|
PromoBizCode: {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
|
||||||
|
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
|
||||||
"monthly_standard": {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
|
"monthly_standard": {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
|
||||||
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
|
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
|
||||||
"annual_standard": {Days: 365, Tier: "standard", Type: "annual", MaxDevices: 2,
|
"annual_standard": {Days: 365, Tier: "standard", Type: "annual", MaxDevices: 2,
|
||||||
@@ -91,6 +97,15 @@ func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode string) (*Pur
|
|||||||
if _, ok := payPlans[bizCode]; !ok {
|
if _, ok := payPlans[bizCode]; !ok {
|
||||||
return nil, ErrUnknownPlan
|
return nil, ErrUnknownPlan
|
||||||
}
|
}
|
||||||
|
if bizCode == PromoBizCode {
|
||||||
|
used, err := s.PromoUsed(shopID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if used {
|
||||||
|
return nil, ErrPromoUsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
productID, err := s.productID(bizCode)
|
productID, err := s.productID(bizCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -394,6 +409,15 @@ func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus,
|
|||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromoUsed 返回本店是否已享受过首月特惠(已支付的特惠单存在即视为已用)。
|
||||||
|
func (s *PayService) PromoUsed(shopID uint64) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := s.db.Model(&model.LicensePurchase{}).
|
||||||
|
Where("shop_id = ? AND product_biz_code = ? AND status = ?", shopID, PromoBizCode, "paid").
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- ④ 查单兜底 ----------
|
// ---------- ④ 查单兜底 ----------
|
||||||
|
|
||||||
// StartPayReconcile 后台每 60s 对 pending 超 5 分钟的购买单主动查 pay 对账,
|
// StartPayReconcile 后台每 60s 对 pending 超 5 分钟的购买单主动查 pay 对账,
|
||||||
|
|||||||
@@ -275,6 +275,42 @@ func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) {
|
|||||||
assert.ErrorIs(t, err, ErrPayNotConfigured)
|
assert.ErrorIs(t, err, ErrPayNotConfigured)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 首月特惠限购 ----------
|
||||||
|
|
||||||
|
func TestCreatePurchase_PromoOncePerShop(t *testing.T) {
|
||||||
|
db := testutil.SetupTestDB()
|
||||||
|
shop := testutil.CreateTestShop(db, "PAY009")
|
||||||
|
other := testutil.CreateTestShop(db, "PAY010")
|
||||||
|
svc := newTestPaySvc(db, "http://pay.invalid")
|
||||||
|
|
||||||
|
// 未买过:不触发限购(pay 地址无效会走到下单失败,但不是 ErrPromoUsed)
|
||||||
|
used, err := svc.PromoUsed(shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, used)
|
||||||
|
_, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode)
|
||||||
|
assert.NotErrorIs(t, err, ErrPromoUsed)
|
||||||
|
|
||||||
|
// pending 单不算已享用(可能弃单),仍可重新下单
|
||||||
|
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-0")
|
||||||
|
used, err = svc.PromoUsed(shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, used, "pending 不算已享用")
|
||||||
|
|
||||||
|
// 已支付的特惠单存在 → 已享用,再购直接拒绝
|
||||||
|
p := createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-1")
|
||||||
|
require.NoError(t, db.Model(p).Update("status", "paid").Error)
|
||||||
|
used, err = svc.PromoUsed(shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, used)
|
||||||
|
_, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode)
|
||||||
|
assert.ErrorIs(t, err, ErrPromoUsed)
|
||||||
|
|
||||||
|
// 多租户隔离:别家买过不影响本店
|
||||||
|
used, err = svc.PromoUsed(other.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, used)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Status ----------
|
// ---------- Status ----------
|
||||||
|
|
||||||
func TestStatus_ScopedToShop(t *testing.T) {
|
func TestStatus_ScopedToShop(t *testing.T) {
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ class LicensePlanGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LicensePlans {
|
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(
|
static const standard = LicensePlanGroup(
|
||||||
name: '标准版',
|
name: '标准版',
|
||||||
feats: ['单门店 · 单仓库', '2 台客户端同时使用', '1,000 张商品图片分享'],
|
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.
|
/// Deactivate (unbind) this device from its license.
|
||||||
Future<void> deactivate() async {
|
Future<void> deactivate() async {
|
||||||
final deviceId = await DeviceId.get();
|
final deviceId = await DeviceId.get();
|
||||||
|
|||||||
@@ -42,19 +42,32 @@ class _PurchaseDialog extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
|
class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
|
||||||
_Phase _phase = _Phase.pick;
|
_Phase _phase = _Phase.pick;
|
||||||
int _groupIdx = 0; // 0=标准版 1=高级版
|
int _tabIdx = 0; // 0=标准版 1=首月特惠 2=高级版
|
||||||
int _cycleIdx = 0; // 0=年付 1=月付
|
int _cycleIdx = 0; // 0=年付 1=月付
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
bool _checking = false;
|
bool _checking = false;
|
||||||
|
bool _promoUsed = false; // 首月特惠每店限一次,已享用则置灰(服务端仍强校验)
|
||||||
|
|
||||||
PurchaseOrder? _order;
|
PurchaseOrder? _order;
|
||||||
DateTime? _newExpiresAt;
|
DateTime? _newExpiresAt;
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
|
|
||||||
LicensePlanGroup get _group => LicensePlans.groups[_groupIdx];
|
bool get _isPromo => _tabIdx == 1;
|
||||||
LicensePlan get _plan => _cycleIdx == 0 ? _group.annual : _group.monthly;
|
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 ? '年付' : '月付';
|
String get _cycleLabel => _cycleIdx == 0 ? '年付' : '月付';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
ref.read(licenseRepositoryProvider).promoUsed().then((used) {
|
||||||
|
if (mounted && used) setState(() => _promoUsed = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
@@ -133,9 +146,15 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
|
|||||||
DsButton('取消',
|
DsButton('取消',
|
||||||
onPressed:
|
onPressed:
|
||||||
_submitting ? null : () => Navigator.of(context).pop()),
|
_submitting ? null : () => Navigator.of(context).pop()),
|
||||||
DsButton(_submitting ? '正在创建订单…' : '提交订单 · ¥${_fmt(_plan.price)}',
|
DsButton(
|
||||||
|
_submitting
|
||||||
|
? '正在创建订单…'
|
||||||
|
: _isPromo && _promoUsed
|
||||||
|
? '本店已享受过'
|
||||||
|
: '提交订单 · ¥${_fmt(_plan.price)}',
|
||||||
variant: DsBtnVariant.primary,
|
variant: DsBtnVariant.primary,
|
||||||
onPressed: _submitting ? null : _submit),
|
onPressed:
|
||||||
|
_submitting || (_isPromo && _promoUsed) ? null : _submit),
|
||||||
],
|
],
|
||||||
_Phase.waiting => [
|
_Phase.waiting => [
|
||||||
DsButton('稍后再说', onPressed: () => Navigator.of(context).pop()),
|
DsButton('稍后再说', onPressed: () => Navigator.of(context).pop()),
|
||||||
@@ -153,24 +172,60 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPick(AppTokens t) {
|
Widget _buildPick(AppTokens t) {
|
||||||
|
final promoGrayed = _isPromo && _promoUsed;
|
||||||
|
final feats = _isPromo ? LicensePlans.promoFeats : _group.feats;
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(children: [
|
Wrap(spacing: 10, runSpacing: 8, children: [
|
||||||
DsSeg(
|
DsSeg(
|
||||||
items: [for (final g in LicensePlans.groups) g.name],
|
items: [
|
||||||
index: _groupIdx,
|
'标准版',
|
||||||
onChanged: (i) => setState(() => _groupIdx = i),
|
_promoUsed ? '首月特惠(已享受)' : '🔥 首月特惠 ¥1',
|
||||||
),
|
'高级版',
|
||||||
const SizedBox(width: 10),
|
],
|
||||||
DsSeg(
|
index: _tabIdx,
|
||||||
items: const ['年付', '月付'],
|
onChanged: (i) => setState(() => _tabIdx = i),
|
||||||
index: _cycleIdx,
|
|
||||||
onChanged: (i) => setState(() => _cycleIdx = i),
|
|
||||||
),
|
),
|
||||||
|
if (!_isPromo)
|
||||||
|
DsSeg(
|
||||||
|
items: const ['年付', '月付'],
|
||||||
|
index: _cycleIdx,
|
||||||
|
onChanged: (i) => setState(() => _cycleIdx = i),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 14),
|
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(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
@@ -182,16 +237,18 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (final f in _group.feats)
|
for (final f in feats)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
child: Row(children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(f,
|
child: Text(f,
|
||||||
style: TextStyle(
|
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,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
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)),
|
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)}',
|
Text('¥${_fmt(_plan.price)}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: t.heading,
|
color: promoGrayed
|
||||||
|
? t.faint
|
||||||
|
: _isPromo
|
||||||
|
? t.warn
|
||||||
|
: t.heading,
|
||||||
fontFamily: AppFonts.mono,
|
fontFamily: AppFonts.mono,
|
||||||
fontFamilyFallback: AppFonts.monoFallback)),
|
fontFamilyFallback: AppFonts.monoFallback)),
|
||||||
],
|
],
|
||||||
|
|||||||
+41
-9
@@ -16,10 +16,15 @@ permalink: /checkout/
|
|||||||
<h2>选择套餐</h2>
|
<h2>选择套餐</h2>
|
||||||
<div class="seg" id="planSeg">
|
<div class="seg" id="planSeg">
|
||||||
<button class="seg-btn on" data-v="standard">标准版</button>
|
<button class="seg-btn on" data-v="standard">标准版</button>
|
||||||
|
<button class="seg-btn promo" data-v="promo" id="promoSegBtn">🔥 首月特惠 ¥1</button>
|
||||||
<button class="seg-btn" data-v="pro">高级版</button>
|
<button class="seg-btn" data-v="pro">高级版</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="promo-banner" id="promoBanner" style="display:none">
|
||||||
|
<b>新店专享 · 仅 ¥1 体验 30 天</b>
|
||||||
|
解锁标准版全部功能,原价 ¥299,每个门店限购一次——试过才知道多省心。
|
||||||
|
</div>
|
||||||
<ul class="co-feats" id="planFeats"></ul>
|
<ul class="co-feats" id="planFeats"></ul>
|
||||||
<div class="seg-label">计费周期</div>
|
<div class="seg-label" id="cycleLabel">计费周期</div>
|
||||||
<div class="seg" id="cycleSeg">
|
<div class="seg" id="cycleSeg">
|
||||||
<button class="seg-btn on" data-v="annual">年付 <span class="seg-tip">省两个月</span></button>
|
<button class="seg-btn on" data-v="annual">年付 <span class="seg-tip">省两个月</span></button>
|
||||||
<button class="seg-btn" data-v="monthly">月付</button>
|
<button class="seg-btn" data-v="monthly">月付</button>
|
||||||
@@ -54,7 +59,7 @@ permalink: /checkout/
|
|||||||
<div class="co-card co-summary">
|
<div class="co-card co-summary">
|
||||||
<h2>费用汇总</h2>
|
<h2>费用汇总</h2>
|
||||||
<div class="sum-row"><span id="sumName">标准版 · 年付</span><span id="sumBase">¥2,999</span></div>
|
<div class="sum-row"><span id="sumName">标准版 · 年付</span><span id="sumBase">¥2,999</span></div>
|
||||||
<div class="sum-row muted"><span>优惠</span><span>− ¥0</span></div>
|
<div class="sum-row muted"><span>优惠</span><span id="sumOff">− ¥0</span></div>
|
||||||
<div class="sum-div"></div>
|
<div class="sum-div"></div>
|
||||||
<div class="sum-total"><span class="lab">应付总额</span><span class="val" id="sumTotal">¥2,999</span></div>
|
<div class="sum-total"><span class="lab">应付总额</span><span class="val" id="sumTotal">¥2,999</span></div>
|
||||||
<div class="auth-alert" id="coErr" style="margin-top:12px"></div>
|
<div class="auth-alert" id="coErr" style="margin-top:12px"></div>
|
||||||
@@ -80,6 +85,11 @@ permalink: /checkout/
|
|||||||
monthly: { biz: 'monthly_standard', price: 299 },
|
monthly: { biz: 'monthly_standard', price: 299 },
|
||||||
annual: { biz: 'annual_standard', price: 2999 },
|
annual: { biz: 'annual_standard', price: 2999 },
|
||||||
},
|
},
|
||||||
|
promo: {
|
||||||
|
name: '首月特惠',
|
||||||
|
feats: ['标准版全部功能,一分不少', '单门店 · 单仓库 · 2 台客户端', '1,000 张商品图片分享', '每个门店限购一次'],
|
||||||
|
single: { biz: 'promo_first_month', price: 1, orig: 299 },
|
||||||
|
},
|
||||||
pro: {
|
pro: {
|
||||||
name: '高级版',
|
name: '高级版',
|
||||||
feats: ['单门店 · 多仓库', '5 台客户端同时使用', '10,000 张商品图片分享', '免费 AI 周度 / 月度商业数据分析'],
|
feats: ['单门店 · 多仓库', '5 台客户端同时使用', '10,000 张商品图片分享', '免费 AI 周度 / 月度商业数据分析'],
|
||||||
@@ -92,29 +102,51 @@ permalink: /checkout/
|
|||||||
var cycle = 'annual';
|
var cycle = 'annual';
|
||||||
|
|
||||||
function fmt(n) { return '¥' + n.toLocaleString('zh-CN'); }
|
function fmt(n) { return '¥' + n.toLocaleString('zh-CN'); }
|
||||||
|
function selected() { return plan === 'promo' ? PLANS.promo.single : PLANS[plan][cycle]; }
|
||||||
function render() {
|
function render() {
|
||||||
var p = PLANS[plan], c = p[cycle];
|
var p = PLANS[plan], c = selected(), isPromo = plan === 'promo';
|
||||||
var days = cycle === 'annual' ? 365 : 30;
|
var days = isPromo || cycle === 'monthly' ? 30 : 365;
|
||||||
|
var cyc = isPromo ? '仅 ¥1 · 30 天' : (cycle === 'annual' ? '年付' : '月付');
|
||||||
|
$('promoBanner').style.display = isPromo ? 'block' : 'none';
|
||||||
|
$('cycleLabel').style.display = isPromo ? 'none' : '';
|
||||||
|
$('cycleSeg').style.display = isPromo ? 'none' : '';
|
||||||
$('planFeats').innerHTML = p.feats.map(function (f) {
|
$('planFeats').innerHTML = p.feats.map(function (f) {
|
||||||
return '<li><svg class="ic"><use href="#i-check"/></svg>' + f + '</li>';
|
return '<li><svg class="ic"><use href="#i-check"/></svg>' + f + '</li>';
|
||||||
}).join('');
|
}).join('');
|
||||||
$('itName').textContent = p.name + '授权 · ' + (cycle === 'annual' ? '年付' : '月付');
|
$('itName').textContent = p.name + '授权 · ' + cyc;
|
||||||
$('itDur').textContent = days + ' 天授权 · 到期时间自动叠加';
|
$('itDur').textContent = days + ' 天授权 · 到期时间自动叠加';
|
||||||
$('itAmt').textContent = fmt(c.price);
|
$('itAmt').textContent = fmt(c.price);
|
||||||
$('sumName').textContent = p.name + ' · ' + (cycle === 'annual' ? '年付' : '月付');
|
$('sumName').textContent = p.name + (isPromo ? '(原价 ¥299/月)' : ' · ' + cyc);
|
||||||
$('sumBase').textContent = fmt(c.price);
|
$('sumBase').textContent = fmt(isPromo ? c.orig : c.price);
|
||||||
|
$('sumOff').textContent = '− ' + fmt(isPromo ? c.orig - c.price : 0);
|
||||||
$('sumTotal').textContent = fmt(c.price);
|
$('sumTotal').textContent = fmt(c.price);
|
||||||
document.querySelectorAll('#planSeg .seg-btn').forEach(function (b) { b.classList.toggle('on', b.dataset.v === plan); });
|
document.querySelectorAll('#planSeg .seg-btn').forEach(function (b) { b.classList.toggle('on', b.dataset.v === plan); });
|
||||||
document.querySelectorAll('#cycleSeg .seg-btn').forEach(function (b) { b.classList.toggle('on', b.dataset.v === cycle); });
|
document.querySelectorAll('#cycleSeg .seg-btn').forEach(function (b) { b.classList.toggle('on', b.dataset.v === cycle); });
|
||||||
}
|
}
|
||||||
document.querySelectorAll('#planSeg .seg-btn').forEach(function (b) {
|
document.querySelectorAll('#planSeg .seg-btn').forEach(function (b) {
|
||||||
b.addEventListener('click', function () { plan = b.dataset.v; render(); });
|
b.addEventListener('click', function () {
|
||||||
|
if (b.disabled) return;
|
||||||
|
plan = b.dataset.v; render();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
document.querySelectorAll('#cycleSeg .seg-btn').forEach(function (b) {
|
document.querySelectorAll('#cycleSeg .seg-btn').forEach(function (b) {
|
||||||
b.addEventListener('click', function () { cycle = b.dataset.v; render(); });
|
b.addEventListener('click', function () { cycle = b.dataset.v; render(); });
|
||||||
});
|
});
|
||||||
render();
|
render();
|
||||||
|
|
||||||
|
// 首月特惠每店限一次:已享用则该档置灰不可选
|
||||||
|
jiuAuth.authFetch(API, '/api/v1/license/promo-status')
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j || !j.data || !j.data.used) return;
|
||||||
|
var b = $('promoSegBtn');
|
||||||
|
b.disabled = true;
|
||||||
|
b.classList.add('used');
|
||||||
|
b.innerHTML = '首月特惠(已享受)';
|
||||||
|
if (plan === 'promo') { plan = 'standard'; render(); }
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
|
||||||
function showErr(msg) { var el = $('coErr'); el.textContent = msg; el.classList.add('show'); }
|
function showErr(msg) { var el = $('coErr'); el.textContent = msg; el.classList.add('show'); }
|
||||||
$('coBtn').addEventListener('click', function () {
|
$('coBtn').addEventListener('click', function () {
|
||||||
$('coErr').classList.remove('show');
|
$('coErr').classList.remove('show');
|
||||||
@@ -122,7 +154,7 @@ permalink: /checkout/
|
|||||||
jiuAuth.authFetch(API, '/api/v1/license/purchase', {
|
jiuAuth.authFetch(API, '/api/v1/license/purchase', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ biz_code: PLANS[plan][cycle].biz }),
|
body: JSON.stringify({ biz_code: selected().biz }),
|
||||||
})
|
})
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
// authFetch 已自动用 refresh_token 续签重试过,仍 401 才是真过期
|
// authFetch 已自动用 refresh_token 续签重试过,仍 401 才是真过期
|
||||||
|
|||||||
+24
-1
@@ -190,7 +190,7 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱
|
|||||||
<h2>按门店付费,明码标价</h2>
|
<h2>按门店付费,明码标价</h2>
|
||||||
<p>先免费试用,再按需选档;时长兑换券续期,无隐藏费用、无自动扣款。</p>
|
<p>先免费试用,再按需选档;时长兑换券续期,无隐藏费用、无自动扣款。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="plans p4">
|
<div class="plans p5">
|
||||||
<div class="plan">
|
<div class="plan">
|
||||||
<div class="pname">免费版</div>
|
<div class="pname">免费版</div>
|
||||||
<div class="price">¥0<small> / 30 天</small></div>
|
<div class="price">¥0<small> / 30 天</small></div>
|
||||||
@@ -202,6 +202,17 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱
|
|||||||
</ul>
|
</ul>
|
||||||
<a class="btn ghost" data-plan="free" href="/register/">立即试用</a>
|
<a class="btn ghost" data-plan="free" href="/register/">立即试用</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="plan promo">
|
||||||
|
<div class="pname">首月特惠</div>
|
||||||
|
<div class="price">¥1<span class="was">¥299</span><small> / 30 天</small></div>
|
||||||
|
<div class="psub">一块钱用一个月,每店限一次</div>
|
||||||
|
<ul>
|
||||||
|
<li><svg class="ic"><use href="#i-check"/></svg>标准版全部功能,一分不少</li>
|
||||||
|
<li><svg class="ic"><use href="#i-check"/></svg>2 台客户端 · 1,000 张图片</li>
|
||||||
|
<li><svg class="ic"><use href="#i-check"/></svg>试过才知道多省心</li>
|
||||||
|
</ul>
|
||||||
|
<a class="btn primary" data-plan="promo" id="promoPlanBtn" href="/register/">¥1 立即体验</a>
|
||||||
|
</div>
|
||||||
<div class="plan hot">
|
<div class="plan hot">
|
||||||
<div class="pname">标准版</div>
|
<div class="pname">标准版</div>
|
||||||
<div class="price">¥299<small> / 月</small></div>
|
<div class="price">¥299<small> / 月</small></div>
|
||||||
@@ -277,15 +288,27 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱
|
|||||||
标准/高级按钮 → 「购买 / 续费」进 /checkout/;免费版按钮 → 「进入系统」。 */
|
标准/高级按钮 → 「购买 / 续费」进 /checkout/;免费版按钮 → 「进入系统」。 */
|
||||||
(function () {
|
(function () {
|
||||||
if (!window.jiuAuth || !jiuAuth.loggedIn()) return;
|
if (!window.jiuAuth || !jiuAuth.loggedIn()) return;
|
||||||
|
var API = '{{ site.appBaseUrl }}';
|
||||||
document.querySelectorAll('[data-plan]').forEach(function (a) {
|
document.querySelectorAll('[data-plan]').forEach(function (a) {
|
||||||
var plan = a.dataset.plan;
|
var plan = a.dataset.plan;
|
||||||
if (plan === 'free') {
|
if (plan === 'free') {
|
||||||
a.textContent = '进入系统';
|
a.textContent = '进入系统';
|
||||||
a.href = '/app/';
|
a.href = '/app/';
|
||||||
|
} else if (plan === 'promo') {
|
||||||
|
a.href = '/checkout/?plan=promo';
|
||||||
} else {
|
} else {
|
||||||
a.textContent = '购买 / 续费';
|
a.textContent = '购买 / 续费';
|
||||||
a.href = '/checkout/?plan=' + plan;
|
a.href = '/checkout/?plan=' + plan;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 首月特惠每店限一次:已享用则置灰
|
||||||
|
jiuAuth.authFetch(API, '/api/v1/license/promo-status')
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j || !j.data || !j.data.used) return;
|
||||||
|
var b = document.getElementById('promoPlanBtn');
|
||||||
|
if (b) { b.textContent = '已享受过'; b.classList.remove('primary'); b.classList.add('ghost', 'disabled'); b.removeAttribute('href'); }
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user