|
|
|
@ -38,10 +38,13 @@ type PlanGroup = { |
|
|
|
const PLAN_INLINE_TAG_RE = /<\/?(?:mark|title|section)>/gi; |
|
|
|
const HIGHLIGHT_CLASS_NAME = "desc_highlight"; |
|
|
|
const SUBSCRIBE_PLAN_ASSETS = { |
|
|
|
crown: new URL("@/assets/images/subscription/crown.png", import.meta.url).href, |
|
|
|
check: new URL("@/assets/images/home/feature-check-icon.png", import.meta.url).href, |
|
|
|
pointsIcon: new URL("@/assets/images/points/points-active.png", import.meta.url).href, |
|
|
|
}; |
|
|
|
const FIGMA_TEMP_ASSETS = { |
|
|
|
// Figma temporary asset. Replace with a local exported icon if the design asset is added to the repo.
|
|
|
|
goal: "https://www.figma.com/api/mcp/asset/b627a414-493d-430e-bbba-2d862a46e127", |
|
|
|
}; |
|
|
|
|
|
|
|
function getPlanKey(plan: ProductPlan, index: number): string { |
|
|
|
if (plan.id != null && plan.id !== "") { |
|
|
|
@ -65,6 +68,27 @@ function formatPlanTierLabel(plan: ProductPlan): string { |
|
|
|
return String(coins); |
|
|
|
} |
|
|
|
|
|
|
|
function formatCurrencyAmount(value: number): string { |
|
|
|
if (!Number.isFinite(value)) { |
|
|
|
return "0"; |
|
|
|
} |
|
|
|
|
|
|
|
if (Number.isInteger(value)) { |
|
|
|
return String(value); |
|
|
|
} |
|
|
|
|
|
|
|
return value.toFixed(2).replace(/\.?0+$/, ""); |
|
|
|
} |
|
|
|
|
|
|
|
function formatPointPriceValue(plan: ProductPlan, price: number): string | null { |
|
|
|
const coins = Number(plan.coins); |
|
|
|
if (!Number.isFinite(price) || !Number.isFinite(coins) || price <= 0 || coins <= 0) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
|
|
|
|
return formatCurrencyAmount((price / coins) * 100); |
|
|
|
} |
|
|
|
|
|
|
|
/** 去掉 mark/title/section 标签,保留纯文本 */ |
|
|
|
function stripPlanInlineTags(text: string): string { |
|
|
|
return text.replace(PLAN_INLINE_TAG_RE, "").trim(); |
|
|
|
@ -282,9 +306,6 @@ export default function SubscribePlanList({ embedded = false, onBillingCycleChan |
|
|
|
const price = Number(plan.price ?? 0) / 100; // 价格单位为分,需要除以100转换为元
|
|
|
|
const original = Number(plan.original_price_amount) > 0 ? Number(plan.original_price_amount) : null; // 原价
|
|
|
|
const level = Number(plan.level ?? -1); // 会员等级
|
|
|
|
const titleBadges: Array<{ type: string; text: string }> = []; //
|
|
|
|
if (plan.recommended) titleBadges.push({ type: "recommended", text: t("subscribe.recommended_plan") }); |
|
|
|
if (plan.custom_info?.rightTags) titleBadges.push({ type: "top", text: plan.custom_info.rightTags }); |
|
|
|
const isLevel4Plan = level === 4; |
|
|
|
const isFreePlan = price <= 0; |
|
|
|
const isPurchasedLevel4Plan = isLevel4Plan && userMemberLevel === 4; // 已购买4级会员 Future Creator 课程
|
|
|
|
@ -295,7 +316,7 @@ export default function SubscribePlanList({ embedded = false, onBillingCycleChan |
|
|
|
? t("subscribe.current_plan") |
|
|
|
: userMemberLevel > level // 当前会员等级大于计划等级
|
|
|
|
? t("subscribe.already_member_level", { level: t(memberLevelMap[userMemberLevel] ?? memberLevelMap[0]) }) |
|
|
|
: t("subscribe.subscribe_now"); // 订阅按钮文本
|
|
|
|
: t("subscribe.plan_cta_goal"); // 订阅按钮文本
|
|
|
|
|
|
|
|
// 点击CTA按钮
|
|
|
|
const handleCtaClick = () => { |
|
|
|
@ -309,6 +330,10 @@ export default function SubscribePlanList({ embedded = false, onBillingCycleChan |
|
|
|
// 渲染描述段落
|
|
|
|
const priceUnit = isLevel4Plan ? t("subscribe.per_100_days") : plan.planCategory === "yearly" ? t("subscribe.per_year") : t("subscribe.per_month"); |
|
|
|
const pointsPeriod = isLevel4Plan ? t("subscribe.points_granted_once") : plan.planCategory === "yearly" ? (plan.pointsGrantMode === "monthly" ? t("subscribe.points_period_monthly") : t("subscribe.points_period_yearly")) : t("subscribe.points_period_monthly"); |
|
|
|
const pointPriceValue = formatPointPriceValue(plan, price); |
|
|
|
const pointPriceText = plan.custom_info?.point_amount ?? (pointPriceValue ? t("subscribe.plan_point_price", { value: pointPriceValue }) : ""); |
|
|
|
const discountText = price > 0 ? (plan.custom_info?.discount_info ?? t("subscribe.plan_default_discount")) : ""; |
|
|
|
const descriptionLines = splitPlanDescriptionLines(plan.description || ""); |
|
|
|
const renderDescSegments = (line: string) => |
|
|
|
getPlanDescriptionSegments(line).map((seg, segIdx) => ( |
|
|
|
<span key={segIdx} className={seg.className}> |
|
|
|
@ -322,29 +347,19 @@ export default function SubscribePlanList({ embedded = false, onBillingCycleChan |
|
|
|
<div className={joinCls("plan_item", "plan_item__header")}> |
|
|
|
<div className="plan_item__title-row"> |
|
|
|
<div className="plan_item__title"> |
|
|
|
{price > 0 ? <img className="plan_item__crown" alt="" src={SUBSCRIBE_PLAN_ASSETS.crown} /> : null} |
|
|
|
<span>{plan.title}</span> |
|
|
|
</div> |
|
|
|
{titleBadges.length > 0 ? ( |
|
|
|
<div className="plan_item__title-badges"> |
|
|
|
{titleBadges.map((badge) => ( |
|
|
|
<span key={`${badge.type}-${badge.text}`} className={joinCls("plan_item__title-badge", `plan_item__title-badge_${badge.type}`)}> |
|
|
|
{badge.text} |
|
|
|
</span> |
|
|
|
))} |
|
|
|
</div> |
|
|
|
) : null} |
|
|
|
{discountText ? <span className="plan_item__discount-badge">{discountText}</span> : null} |
|
|
|
</div> |
|
|
|
|
|
|
|
<div className="plan_item__price-block"> |
|
|
|
<div className="plan_item__price"> |
|
|
|
<span className="plan_item__price-symbol">¥</span> |
|
|
|
<span className={joinCls("plan_item__price-amount", !price && "plan_item__price-amount_free")}>{price}</span> |
|
|
|
<span className={joinCls("plan_item__price-amount", !price && "plan_item__price-amount_free")}>{formatCurrencyAmount(price)}</span> |
|
|
|
<span className="plan_item__price-unit">{priceUnit}</span> |
|
|
|
{original != null && original > price ? <span className="plan_item__price-original">¥{original}</span> : null} |
|
|
|
{plan.custom_info?.discount_info && price > 0 ? <span className="plan_item__price-discount">{plan.custom_info.discount_info}</span> : null} |
|
|
|
</div> |
|
|
|
{plan.custom_info?.point_amount ? <p className="plan_item__price-subtitle">{plan.custom_info.point_amount}</p> : null} |
|
|
|
{pointPriceText ? <p className="plan_item__price-subtitle">{pointPriceText}</p> : null} |
|
|
|
</div> |
|
|
|
|
|
|
|
{price > 0 && plan.coins != null ? ( |
|
|
|
@ -429,20 +444,36 @@ export default function SubscribePlanList({ embedded = false, onBillingCycleChan |
|
|
|
</div> |
|
|
|
|
|
|
|
<div className={joinCls("plan_item", "plan_item__features")}> |
|
|
|
<ul className="plan_item__feature-list"> |
|
|
|
{splitPlanDescriptionLines(plan.description || "").map((line, lineIdx) => |
|
|
|
isSectionLine(line) ? ( |
|
|
|
<li key={lineIdx} className={joinCls("plan_feature", isTitleLine(line) ? "plan_feature_title" : "plan_feature_section")}> |
|
|
|
{renderDescSegments(line)} |
|
|
|
</li> |
|
|
|
) : ( |
|
|
|
<li key={lineIdx} className="plan_feature"> |
|
|
|
<img className="plan_feature__check" src={SUBSCRIBE_PLAN_ASSETS.check} alt="" /> |
|
|
|
<span className="plan_feature__text">{renderDescSegments(line)}</span> |
|
|
|
</li> |
|
|
|
), |
|
|
|
)} |
|
|
|
</ul> |
|
|
|
<div className="plan_goal"> |
|
|
|
<div className="plan_goal__title"> |
|
|
|
<img className="plan_goal__icon" src={FIGMA_TEMP_ASSETS.goal} alt="" /> |
|
|
|
<span>{t("subscribe.plan_goal_title")}</span> |
|
|
|
</div> |
|
|
|
<p className="plan_goal__desc"> |
|
|
|
<span>{t("subscribe.plan_goal_line1")}</span> |
|
|
|
<span>{t("subscribe.plan_goal_line2")}</span> |
|
|
|
</p> |
|
|
|
</div> |
|
|
|
|
|
|
|
{descriptionLines.length > 0 ? ( |
|
|
|
<div className="plan_description"> |
|
|
|
<div className="plan_description__divider" aria-hidden="true" /> |
|
|
|
<ul className="plan_description__list"> |
|
|
|
{descriptionLines.map((line, lineIdx) => |
|
|
|
isSectionLine(line) ? ( |
|
|
|
<li key={lineIdx} className={joinCls("plan_feature", isTitleLine(line) ? "plan_feature_title" : "plan_feature_section")}> |
|
|
|
{renderDescSegments(line)} |
|
|
|
</li> |
|
|
|
) : ( |
|
|
|
<li key={lineIdx} className="plan_feature"> |
|
|
|
<img className="plan_feature__check" src={SUBSCRIBE_PLAN_ASSETS.check} alt="" /> |
|
|
|
<span className="plan_feature__text">{renderDescSegments(line)}</span> |
|
|
|
</li> |
|
|
|
), |
|
|
|
)} |
|
|
|
</ul> |
|
|
|
</div> |
|
|
|
) : null} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|