Browse Source

Merge branch 'subscribe-plan-list' into version/1.2.14

# Conflicts:
#	src/components/subscribe-plan-list/index.tsx
#	src/locales/en-US.json
#	src/locales/zh-CN.json
#	src/locales/zh-TW.json
#	src/pages/subscribe/index.tsx
version/1.2.14
leiyuee 5 days ago
parent
commit
905c15d9ed
  1. 10
      src/api/api.ts
  2. BIN
      src/assets/images/subscription/subscribe-plan-feature-title-icon.png
  3. BIN
      src/assets/images/subscription/subscribe-plan-goal-icon.png
  4. 523
      src/components/subscribe-plan-list/index.scss
  5. 402
      src/components/subscribe-plan-list/index.tsx
  6. 69
      src/dialog/pay-subscribe/index.tsx
  7. 16
      src/locales/en-US.json
  8. 16
      src/locales/zh-CN.json
  9. 16
      src/locales/zh-TW.json
  10. 13
      src/pages/Teaching/index.scss
  11. 66
      src/pages/Teaching/index.tsx
  12. 21
      src/pages/subscribe/index.scss
  13. 1
      src/utils/index.ts
  14. 2
      src/utils/openLatestPrivacyPolicyLink.ts

10
src/api/api.ts

@ -25,10 +25,14 @@ type ListApiResponse<T> = {
data?: ListApiData<T>;
};
export type ProductPlanCustomInfo = {
rightTags?: string;
discount_info?: string;
point_amount?: string;
illustrator?: string;
buttonText?: string;
bonus_gift?: string;
goal_title?: string;
goal_description?: string;
feature_title?: string;
new_user?: string | boolean;
};
export type ProductPlan = ApiParams & {
id?: EntityId;
@ -36,11 +40,11 @@ export type ProductPlan = ApiParams & {
name?: string;
description?: string;
planCategory?: string;
recommended?: boolean;
price?: number | string;
price_amount?: number | string;
original_price_amount?: number | string;
level?: number | string;
recommended?: boolean;
coins?: number | string;
pointsGrantMode?: string;
custom_info?: ProductPlanCustomInfo;

BIN
src/assets/images/subscription/subscribe-plan-feature-title-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/assets/images/subscription/subscribe-plan-goal-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

523
src/components/subscribe-plan-list/index.scss

@ -23,7 +23,7 @@
align-items: center;
justify-content: center;
width: 100%;
margin-bottom: 9px;
margin-bottom: 60px;
box-sizing: border-box;
.billing_cycle {
@ -114,15 +114,15 @@
.plan-grid {
display: grid;
grid-template-columns: repeat(4, 357px);
grid-template-columns: repeat(4, 313px);
align-items: start;
justify-content: center;
gap: 10px;
gap: 12px;
width: 100%;
}
.plan_grid_item {
width: 357px;
width: 313px;
min-width: 0;
}
@ -131,6 +131,69 @@
flex-direction: column;
width: 100%;
min-width: 0;
gap: 0;
}
.plan_card.plan_card_new-user,
.plan_card.plan_card_recommended {
box-sizing: border-box;
border-radius: 26px 26px 0 0;
}
.plan_item__highlight-frame {
display: flex;
flex-direction: column;
width: calc(100% + 2px);
max-width: none;
min-width: 0;
margin: -32px 0 0 -1px;
border: 1px solid transparent;
border-radius: 26px;
box-sizing: border-box;
background: transparent;
}
.plan_item__new-user-label {
border-color: #00dcca;
background: #00dcca;
}
.plan_item__recommended-label {
border-color: #6f47f5;
background: #6f47f5;
}
.plan_item__highlight-label {
display: flex;
align-items: flex-start;
justify-content: center;
width: 100%;
height: 31px;
padding-top: 6px;
border-radius: 25px 25px 0 0;
box-sizing: border-box;
background: transparent;
color: #333333;
font-size: 14px;
font-weight: 700;
line-height: 24px;
text-align: center;
white-space: nowrap;
}
.plan_item__recommended-label .plan_item__highlight-label {
color: #ffffff;
}
.plan_item__highlight-content {
display: flex;
flex-direction: column;
width: 313px;
max-width: 100%;
min-width: 0;
margin: 0 auto;
border-radius: 26px;
background: #ffffff;
}
.plan_item__header {
@ -138,21 +201,29 @@
z-index: 2;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 287px;
padding: 24px 20px;
gap: 16px;
min-height: 266px;
padding: 20px;
border-radius: 26px;
box-sizing: border-box;
background: #ffffff;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.plan_card.plan_card_new-user .plan_item__header {
background: linear-gradient(180deg, #ddfefb 0%, #ffffff 24.04%);
}
.plan_card.plan_card_recommended .plan_item__header {
background: linear-gradient(180deg, #f3edff 0%, #ffffff 24.04%);
}
.plan_item__title-row {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
min-height: 20px;
}
.plan_item__title {
@ -160,17 +231,9 @@
align-items: center;
min-width: 0;
color: #333333;
font-size: 20px;
font-weight: 500;
line-height: 28px;
}
.plan_item__crown {
flex: 0 0 auto;
width: 24px;
height: 24px;
margin-right: 8px;
display: block;
font-size: 16px;
font-weight: 700;
line-height: 20px;
}
.plan_item__title span {
@ -180,37 +243,30 @@
white-space: nowrap;
}
.plan_item__title-badges {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
max-width: 124px;
}
.plan_item__title-badge {
.plan_item__discount-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 15px;
border-radius: 26px;
min-width: 66px;
max-width: 96px;
height: 20px;
padding: 0 10px;
overflow: hidden;
border-radius: 100px;
box-sizing: border-box;
background: #f3efff;
color: #6f47f5;
font-size: 12px;
font-weight: 500;
line-height: 26px;
font-weight: 400;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan_item__title-badge_recommended {
color: #e55936;
background: #ffe3c3;
}
.plan_item__title-badge_top {
color: #6f47f5;
background: #ebe5ff;
.plan_card.plan_card_new-user .plan_item__discount-badge {
background: #f0fffe;
color: #12a397;
}
.plan_item__price-block {
@ -224,7 +280,6 @@
display: flex;
align-items: flex-start;
min-height: 44px;
padding-right: 72px;
box-sizing: border-box;
}
@ -240,7 +295,7 @@
margin-left: 2px;
color: #333333;
font-size: 36px;
font-weight: 500;
font-weight: 700;
line-height: 44px;
}
@ -252,7 +307,7 @@
margin-top: 18px;
margin-left: 4px;
color: #666666;
font-size: 16px;
font-size: 12px;
font-weight: 400;
line-height: 24px;
white-space: nowrap;
@ -263,35 +318,16 @@
margin-top: 18px;
margin-left: 4px;
color: #999999;
font-size: 16px;
font-weight: 500;
font-size: 12px;
font-weight: 400;
line-height: 24px;
text-decoration: line-through;
white-space: nowrap;
}
.plan_item__price-discount {
position: absolute;
top: 20px;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 10px;
border-radius: 9px;
box-sizing: border-box;
background: #ebe5ff;
color: #333333;
font-size: 12px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.plan_item__price-subtitle {
margin: 0;
color: #666666;
color: #999999;
font-size: 12px;
font-weight: 400;
line-height: 24px;
@ -301,7 +337,7 @@
position: relative;
display: flex;
align-items: center;
width: 314px;
width: 273px;
max-width: 100%;
height: 49px;
padding: 0 12px;
@ -311,6 +347,10 @@
background: #f3efff;
}
.plan_card.plan_card_new-user .plan_points {
background: #f0fffe;
}
.plan_points__content {
position: relative;
z-index: 1;
@ -378,7 +418,7 @@
}
.plan_item__cta.arco-btn:not(.arco-btn-disabled) {
background: linear-gradient(90deg, #333333 0%, #6f47f5 100%) !important;
background: #333333 !important;
}
.plan_item__cta.arco-btn-disabled,
@ -392,7 +432,7 @@
.plan_tier_selector {
width: 100%;
max-width: 314px;
max-width: 273px;
height: 44px;
margin: 0 auto;
box-sizing: border-box;
@ -488,76 +528,144 @@
}
.plan_commission {
position: relative;
width: 314px;
display: flex;
align-items: center;
width: 100%;
max-width: 100%;
height: 35px;
min-height: 24px;
padding: 0;
box-sizing: border-box;
overflow: hidden;
border-radius: 8px;
background: #f5f2ff;
background: transparent;
}
.plan_commission .desc_highlight {
margin-right: 0;
color: #6f47f5;
font-weight: 500;
font-weight: 700;
}
.plan_commission__text {
position: absolute;
top: 50%;
left: 18px;
z-index: 1;
display: block;
overflow: hidden;
width: 178px;
height: 24px;
transform: translateY(-50%);
width: 100%;
min-width: 0;
color: #333333;
font-size: 14px;
font-weight: 500;
font-size: 12px;
font-weight: 400;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan_commission__badge {
position: absolute;
top: 8px;
right: 10px;
.plan_item__features {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 43px;
height: 20px;
padding: 0;
border: 1px solid #6f47f5;
border-radius: 5px;
margin-top: -58px;
padding: 70px 20px 20px;
border-radius: 26px;
box-sizing: border-box;
background: transparent;
color: #6f47f5;
font-size: 12px;
font-weight: 500;
line-height: normal;
white-space: nowrap;
background: #ffffff;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.plan_item__features {
position: relative;
z-index: 1;
min-height: 510px;
margin-top: -10px;
padding: 46px 20px 24px;
.plan_goal {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
min-height: 102px;
padding: 20px;
border-radius: 26px;
box-sizing: border-box;
background: linear-gradient(0deg, #ffffff 70%, #f4f4f5 100%);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
background: #f9f6ff;
}
.plan_card.plan_card_new-user .plan_goal {
background: #f0fffe;
}
.plan_goal__title {
display: flex;
align-items: center;
gap: 5px;
max-width: 100%;
color: #6f47f5;
font-size: 14px;
font-weight: 700;
line-height: 28px;
}
.plan_card.plan_card_new-user .plan_goal__title {
color: #049d90;
}
.plan_goal__icon {
display: block;
flex: 0 0 auto;
width: 15px;
height: 15px;
object-fit: contain;
}
.plan_card.plan_card_new-user .plan_goal__icon {
filter: brightness(0) saturate(100%) invert(42%) sepia(98%) saturate(800%) hue-rotate(135deg) brightness(92%) contrast(97%);
}
.plan_goal__title span {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan_item__feature-list {
.plan_goal__desc {
display: flex;
flex-direction: column;
margin: 6px 0 0;
color: #333333;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.plan_goal__desc:first-child {
margin-top: 0;
}
.plan_description {
width: 100%;
}
.plan_feature_header {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
min-width: 0;
}
.plan_feature_header__icon {
display: block;
flex: 0 0 auto;
width: 16px;
height: 14px;
object-fit: contain;
}
.plan_feature_header__text {
display: block;
overflow: hidden;
min-width: 0;
color: #6f47f5;
font-size: 14px;
font-weight: 700;
line-height: 28px;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan_description__list {
margin: 0;
padding: 0;
list-style: none;
@ -567,8 +675,8 @@
display: flex;
align-items: flex-start;
min-width: 0;
margin: 0 0 10px;
color: #666666;
margin: 0;
color: #333333;
font-size: 12px;
font-weight: 400;
line-height: 24px;
@ -578,42 +686,49 @@
margin-bottom: 0;
}
.plan_feature_divider {
display: block;
width: 100%;
height: 1px;
margin: 20px 0;
padding: 0;
background: #d9d9db;
}
.plan_feature.plan_feature_section,
.plan_feature.plan_feature_title {
display: block;
margin: 0 0 10px;
color: #666666;
font-size: 12px;
font-weight: 500;
line-height: 24px;
color: #333333;
font-size: 14px;
font-weight: 700;
line-height: 28px;
}
.plan_feature__check {
flex: 0 0 auto;
display: block;
width: 14px;
height: 14px;
margin-top: 5px;
margin-right: 8px;
width: 16px;
height: 16px;
margin-top: 3px;
margin-right: 5px;
}
.plan_feature__text {
flex: 1 1 auto;
min-width: 0;
color: #666666;
color: #333333;
font-size: 12px;
font-weight: 500;
font-weight: 400;
line-height: 24px;
}
.desc_highlight {
color: #6f47f5;
font-weight: 500;
font-weight: 700;
}
}
@media (max-width: 820px) {
.plans {
.plans__points-row {
@ -629,161 +744,13 @@
}
.plan-grid {
grid-template-columns: repeat(4, 320px);
grid-template-columns: repeat(4, 313px);
width: max-content;
justify-content: flex-start;
}
.plan_grid_item {
width: 320px;
}
.plan_item__header {
min-height: 287px;
}
.plan_item__title-row {
flex-direction: column;
}
.plan_item__title-badges {
max-width: 100%;
justify-content: flex-start;
}
.plan_item__price {
padding-right: 0;
}
.plan_item__price-discount {
position: static;
margin-top: 20px;
margin-left: 8px;
width: 313px;
}
}
}
/* Popi Future Creator 开通成功弹窗(createPortal → body) */
.subscribe-fc-modal-overlay {
position: fixed;
inset: 0;
z-index: 10050;
box-sizing: border-box;
margin: 0;
padding: 16px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(51, 51, 51, 0.3);
pointer-events: auto;
touch-action: none;
overscroll-behavior: contain;
}
.subscribe-fc-modal-dialog {
position: relative;
box-sizing: border-box;
width: 100%;
max-width: 413px;
padding: 45px;
border-radius: 9px;
background: #ffffff;
overflow: visible;
touch-action: auto;
}
.subscribe-fc-modal-dialog__close {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
width: 20px;
height: 20px;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.subscribe-fc-modal-dialog__close img {
display: block;
width: 20px;
height: 20px;
object-fit: cover;
}
.subscribe-fc-modal-dialog__body {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.subscribe-fc-modal-dialog__title {
margin: 0;
width: 100%;
text-align: center;
font-size: 18px;
font-weight: 700;
line-height: 22px;
color: #333333;
}
.subscribe-fc-modal-dialog__success {
margin: 17px 0 0;
width: 100%;
text-align: center;
font-size: 18px;
font-weight: 700;
line-height: 22px;
color: #5c3bc7;
}
.subscribe-fc-modal-dialog__qrWrap {
box-sizing: border-box;
width: 221px;
height: 221px;
margin-top: 10px;
flex-shrink: 0;
}
.subscribe-fc-modal-dialog__qr {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.subscribe-fc-modal-dialog__qrPlaceholder {
width: 100%;
height: 100%;
background: #f0f0f0;
border-radius: 4px;
}
.subscribe-fc-modal-dialog__tip {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin: 10px 0 0;
width: 100%;
font-size: 12px;
font-weight: 400;
line-height: 22px;
color: #666666;
}
.subscribe-fc-modal-dialog__tipIcon {
flex: 0 0 auto;
display: block;
width: 14px;
height: 14px;
object-fit: cover;
}

402
src/components/subscribe-plan-list/index.tsx

@ -1,12 +1,10 @@
import { Button } from "@arco-design/web-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPlanList } from "@/api/api";
import type { ProductPlan } from "@/api/api";
import { getMemberLevelLabel } from "@/constants";
// import { memberLevelMap } from "@/constants";
import { openPaySubscribeDialog } from "@/dialog";
import { useMemberLevelStore } from "@/store/useMemberLevelStore";
import { useParamConfigStore } from "@/store/useParamConfigStore";
import { useUserStore } from "@/store/useUserStore";
@ -39,10 +37,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 SUBSCRIBE_PLAN_LOCAL_ASSETS = {
goal: new URL("@/assets/images/subscription/subscribe-plan-goal-icon.png", import.meta.url).href,
featureTitle: new URL("@/assets/images/subscription/subscribe-plan-feature-title-icon.png", import.meta.url).href,
};
function getPlanKey(plan: ProductPlan, index: number): string {
if (plan.id != null && plan.id !== "") {
@ -66,6 +67,39 @@ 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);
}
function getNewUserLabel(value: ProductPlanCustomInfo["new_user"], fallback: string): string {
if (typeof value === "string") {
return value.trim();
}
if (value === true) {
return fallback;
}
return "";
}
/** 去掉 mark/title/section 标签,保留纯文本 */
function stripPlanInlineTags(text: string): string {
return text.replace(PLAN_INLINE_TAG_RE, "").trim();
@ -135,7 +169,7 @@ function parseMarkSegments(text: string): PlanDescriptionSegment[] {
/** 将描述拆成权益列表行 */
function splitPlanDescriptionLines(description: string): string[] {
return description.split("\n").filter((l) => l.trim() !== "");
return description.split(/\n|\\n/).filter((l) => l.trim() !== "");
}
/** 套餐描述行:解析 mark 片段;分组标题行解析外壳内的 mark */
@ -158,12 +192,10 @@ export default function SubscribePlanList({ embedded = false, onBillingCycleChan
const { t } = useTranslation();
const user = useUserStore((s) => s.user);
const serviceQrCode = useParamConfigStore((s) => s.config?.systemConfig?.serviceQrCode ?? "");
const memberLevelMap = useMemberLevelStore((s) => s.memberLevelMap);
const [billingCycle, setBillingCycleState] = useState<SubscribeBillingCycle>("monthly");
const [planList, setPlanList] = useState<ProductPlan[]>([]);
const [selectedPlanKeyByGroup, setSelectedPlanKeyByGroup] = useState<Record<string, string>>({});
const [futureCreatorModalOpen, setFutureCreatorModalOpen] = useState(false);
const userMemberLevel = Number(user?.memberLevel ?? 0);
// 拉取套餐列表
@ -223,13 +255,7 @@ export default function SubscribePlanList({ embedded = false, onBillingCycleChan
void openPaySubscribeDialog({ type: "subscribe", plan });
}, []);
const openFutureCreatorModal = useCallback(() => {
setFutureCreatorModalOpen(true);
}, []);
const joinCls = (...parts: Array<string | false | undefined | null>) => parts.filter(Boolean).join(" ");
const closeIconSrc = new URL("@/assets/images/common/icons/close.png", import.meta.url).href;
const wechatIconSrc = new URL("@/assets/images/common/payments/wxpay-logo.png", import.meta.url).href;
return (
<div className={joinCls("plans", embedded && "plans_embedded")}>
@ -284,34 +310,37 @@ 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 教学项目
const isPurchasedLevel4Plan = isLevel4Plan && userMemberLevel === 4; // 已购买4级会员 Future Creator 课程
const ctaDisabled = isPurchasedLevel4Plan ? false : userMemberLevel === level || userMemberLevel > level; // 按钮是否禁用
const userMemberLevelLabel = getMemberLevelLabel(memberLevelMap, userMemberLevel, t);
const ctaLabel = isPurchasedLevel4Plan
? t("subscribe.contact_service")
: userMemberLevel === level // 当前会员等级
? t("subscribe.current_plan")
: userMemberLevel > level // 当前会员等级大于计划等级
? t("subscribe.already_member_level", { level: userMemberLevelLabel })
? t("subscribe.already_member_level", { level: t(memberLevelMap[userMemberLevel] ?? memberLevelMap[0]) })
: t("subscribe.subscribe_now"); // 订阅按钮文本
// 点击CTA按钮
const handleCtaClick = () => {
if (isPurchasedLevel4Plan) {
openFutureCreatorModal();
return;
}
showPaySubscribe(plan);
};
// 渲染描述段落
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 priceUnit = plan.planCategory === "yearly" ? t("subscribe.per_year") : t("subscribe.per_month");
const pointsPeriod = 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 goalTitle = plan.custom_info?.goal_title?.trim() ?? "";
const goalDescriptionLines = splitPlanDescriptionLines(plan.custom_info?.goal_description ?? "");
const showPlanGoal = goalTitle !== "" || goalDescriptionLines.length > 0;
const featureTitle = plan.custom_info?.feature_title?.trim() ?? "";
const newUserLabel = getNewUserLabel(plan.custom_info?.new_user, t("subscribe.plan_new_user"));
const isNewUserPlan = newUserLabel !== "";
const recommendedLabel = plan.recommended === true && !isNewUserPlan ? t("subscribe.plan_recommended") : "";
const isRecommendedPlan = recommendedLabel !== "";
const renderDescSegments = (line: string) =>
getPlanDescriptionSegments(line).map((seg, segIdx) => (
<span key={segIdx} className={seg.className}>
@ -319,188 +348,173 @@ export default function SubscribePlanList({ embedded = false, onBillingCycleChan
</span>
));
return (
<div className="plan_grid_item" key={groupKey}>
<div className="plan_card">
<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}
const planContent = (
<>
<div className={joinCls("plan_item", "plan_item__header")}>
<div className="plan_item__title-row">
<div className="plan_item__title">
<span>{plan.title}</span>
</div>
{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="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}
<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")}>{formatCurrencyAmount(price)}</span>
<span className="plan_item__price-unit">{priceUnit}</span>
{original != null && original > price ? <span className="plan_item__price-original">{original}</span> : null}
</div>
{pointPriceText ? <p className="plan_item__price-subtitle">{pointPriceText}</p> : null}
</div>
{price > 0 && plan.coins != null ? (
<div className="plan_points">
<span className="plan_points__content">
<span className="plan_points__icon-wrap" aria-hidden="true">
<img className="plan_points__icon" alt="" src={SUBSCRIBE_PLAN_ASSETS.pointsIcon} />
</span>
<span className="plan_points__value">{plan.coins}</span>
<span className="plan_points__unit">
{t("subscribe.points_unit")}
{pointsPeriod}
</span>
{price > 0 && plan.coins != null ? (
<div className="plan_points">
<span className="plan_points__content">
<span className="plan_points__icon-wrap" aria-hidden="true">
<img className="plan_points__icon" alt="" src={SUBSCRIBE_PLAN_ASSETS.pointsIcon} />
</span>
<span className="plan_points__value">{plan.coins}</span>
<span className="plan_points__unit">
{t("subscribe.points_unit")}
{pointsPeriod}
</span>
</span>
</div>
) : null}
<Button type="primary" className={joinCls("plan_item__cta", isFreePlan && "plan_item__cta_free")} disabled={ctaDisabled} onClick={handleCtaClick}>
{ctaLabel}
</Button>
{planGroup.plans.length > 1 ? (
<div className="plan_tier_selector" role="radiogroup" aria-label={plan.title ?? ""}>
<div className="plan_tier_selector__labels">
{planGroup.plans.map((item) => {
const active = item.key === selectedPlanItem.key;
return (
<button
key={`${item.key}-label`}
type="button"
className={joinCls("plan_tier_selector__label", active && "plan_tier_selector__label_active")}
role="radio"
aria-checked={active}
onClick={() => {
setSelectedPlanKeyByGroup((prev) => ({ ...prev, [groupKey]: item.key }));
}}
>
{formatPlanTierLabel(item.plan)}
</button>
);
})}
</div>
) : null}
<Button
type="primary"
className={joinCls("plan_item__cta", isFreePlan && "plan_item__cta_free", isPurchasedLevel4Plan && "plan_item__cta_contact", isLevel4Plan && !isPurchasedLevel4Plan && "plan_item__cta_pro")}
disabled={ctaDisabled}
onClick={handleCtaClick}
>
{ctaLabel}
</Button>
{planGroup.plans.length > 1 ? (
<div className="plan_tier_selector" role="radiogroup" aria-label={plan.title ?? ""}>
<div className="plan_tier_selector__labels">
{planGroup.plans.map((item) => {
const active = item.key === selectedPlanItem.key;
return (
<button
key={`${item.key}-label`}
type="button"
className={joinCls("plan_tier_selector__label", active && "plan_tier_selector__label_active")}
role="radio"
aria-checked={active}
onClick={() => {
setSelectedPlanKeyByGroup((prev) => ({ ...prev, [groupKey]: item.key }));
}}
>
{formatPlanTierLabel(item.plan)}
</button>
);
})}
</div>
<div className="plan_tier_selector__track">
<span className="plan_tier_selector__track-line" aria-hidden="true" />
<span className="plan_tier_selector__track-line plan_tier_selector__track-line_active" style={{ width: `calc((100% - 15px) * ${String(selectedTierProgress)})` }} aria-hidden="true" />
{planGroup.plans.map((item, itemIndex) => {
const active = item.key === selectedPlanItem.key;
const highlighted = itemIndex <= selectedPlanIndex;
return (
<button
key={`${item.key}-dot`}
type="button"
className={joinCls("plan_tier_selector__dot", highlighted && "plan_tier_selector__dot_active")}
aria-label={formatPlanTierLabel(item.plan)}
aria-pressed={active}
onClick={() => {
setSelectedPlanKeyByGroup((prev) => ({ ...prev, [groupKey]: item.key }));
}}
/>
);
})}
</div>
<div className="plan_tier_selector__track">
<span className="plan_tier_selector__track-line" aria-hidden="true" />
<span className="plan_tier_selector__track-line plan_tier_selector__track-line_active" style={{ width: `calc((100% - 15px) * ${String(selectedTierProgress)})` }} aria-hidden="true" />
{planGroup.plans.map((item, itemIndex) => {
const active = item.key === selectedPlanItem.key;
const highlighted = itemIndex <= selectedPlanIndex;
return (
<button
key={`${item.key}-dot`}
type="button"
className={joinCls("plan_tier_selector__dot", highlighted && "plan_tier_selector__dot_active")}
aria-label={formatPlanTierLabel(item.plan)}
aria-pressed={active}
onClick={() => {
setSelectedPlanKeyByGroup((prev) => ({ ...prev, [groupKey]: item.key }));
}}
/>
);
})}
</div>
) : null}
</div>
) : null}
{plan.custom_info?.bonus_gift ? (
<div className="plan_commission">
<span className="plan_commission__text">
<span className="desc_highlight">{t("subscribe.bonus_gift_prefix")}</span>
{plan.custom_info.bonus_gift}
</span>
</div>
) : null}
</div>
{plan.custom_info?.illustrator ? (
<div className="plan_commission">
<span className="plan_commission__text">
<span className="desc_highlight">{t("subscribe.commission_highlight")}</span>
{plan.custom_info.illustrator}
</span>
<span className="plan_commission__badge">{t("subscribe.limited_time_badge")}</span>
</div>
) : null}
</div>
<div className={joinCls("plan_item", "plan_item__features")}>
{showPlanGoal ? (
<div className="plan_goal">
{goalTitle ? (
<div className="plan_goal__title">
<img className="plan_goal__icon" src={SUBSCRIBE_PLAN_LOCAL_ASSETS.goal} alt="" />
<span>{goalTitle}</span>
</div>
) : null}
{goalDescriptionLines.length > 0 ? (
<p className="plan_goal__desc">
{goalDescriptionLines.map((line, lineIdx) => (
<span key={lineIdx}>{line}</span>
))}
</p>
) : null}
</div>
) : null}
{descriptionLines.length > 0 ? (
<div className="plan_description">
{featureTitle ? (
<>
<div className="plan_feature_divider" aria-hidden="true" />
<div className="plan_feature_header">
<img className="plan_feature_header__icon" src={SUBSCRIBE_PLAN_LOCAL_ASSETS.featureTitle} alt="" />
<span className="plan_feature_header__text">{featureTitle}</span>
</div>
</>
) : null}
<ul className="plan_description__list">
{descriptionLines.map((line, lineIdx) =>
isSectionLine(line) ? (
<Fragment key={lineIdx}>
{isTitleLine(line) ? <li className="plan_feature_divider" aria-hidden="true" /> : null}
<li key={lineIdx} className={joinCls("plan_feature", isTitleLine(line) ? "plan_feature_title" : "plan_feature_section")}>
{renderDescSegments(line)}
</li>
</Fragment>
) : (
<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>
</>
);
const highlightLabel = isNewUserPlan ? newUserLabel : recommendedLabel;
const highlightClassName = isNewUserPlan ? "plan_item__new-user-label" : isRecommendedPlan ? "plan_item__recommended-label" : "";
<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>
return (
<div className="plan_grid_item" key={groupKey}>
<div className={joinCls("plan_card", isNewUserPlan && "plan_card_new-user", isRecommendedPlan && "plan_card_recommended")}>
{highlightLabel ? (
<div className={joinCls("plan_item__highlight-frame", highlightClassName)}>
<div className="plan_item__highlight-label">{highlightLabel}</div>
<div className="plan_item__highlight-content">{planContent}</div>
</div>
) : (
planContent
)}
</div>
</div>
);
})}
</div>
</div>
{futureCreatorModalOpen
? createPortal(
<div
className="subscribe-fc-modal-overlay"
role="presentation"
onClick={() => {
setFutureCreatorModalOpen(false);
}}
>
<div
id="subscribe-future-creator-dialog"
className="subscribe-fc-modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="subscribe-future-creator-title"
onClick={(e) => {
e.stopPropagation();
}}
>
<button
type="button"
className="subscribe-fc-modal-dialog__close"
onClick={() => {
setFutureCreatorModalOpen(false);
}}
aria-label={t("dialog.common.closeAria")}
>
<img src={closeIconSrc} alt="" />
</button>
<div className="subscribe-fc-modal-dialog__body">
<h3 id="subscribe-future-creator-title" className="subscribe-fc-modal-dialog__title">
{t("subscribe.future_creator_title")}
</h3>
<p className="subscribe-fc-modal-dialog__success">{t("subscribe.future_creator_success")}</p>
<div className="subscribe-fc-modal-dialog__qrWrap">
{serviceQrCode ? <img className="subscribe-fc-modal-dialog__qr" src={resolveOssImageThumbUrl(serviceQrCode)} alt={t("contactService.qrAlt")} /> : <div className="subscribe-fc-modal-dialog__qrPlaceholder" aria-hidden />}
</div>
<p className="subscribe-fc-modal-dialog__tip">
<img className="subscribe-fc-modal-dialog__tipIcon" src={wechatIconSrc} alt="" width={14} height={14} />
<span>{t("subscribe.future_creator_wechat_tip")}</span>
</p>
</div>
</div>
</div>,
document.body,
)
: null}
</div>
);
}

69
src/dialog/pay-subscribe/index.tsx

@ -11,7 +11,7 @@ import { getToken } from "@/utils/auth";
import { useUserStore } from "@/store/useUserStore";
import { checkGatewayPayment, checkGatewayWxPayment, checkPointsPayment, checkPointsWxPayment, createPointsOrder, createPointsOrderByWxPay, createSubscriptionOrder, createSubscriptionOrderByWxPay } from "@/api/api";
import type { PaymentOrderData, PaymentStatusData, ProductPlan } from "@/api/api";
import { openLatestPrivacyPolicyLink, PRIVACY_POLICY_KEY_FUTURE_CREATOR, PRIVACY_POLICY_KEY_PAYMENT_TERMS } from "@/utils/openLatestPrivacyPolicyLink";
import { openLatestPrivacyPolicyLink, PRIVACY_POLICY_KEY_PAYMENT_TERMS } from "@/utils/openLatestPrivacyPolicyLink";
import "./index.scss";
type PayType = "subscribe" | "points";
@ -152,7 +152,6 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
}, [payType, plan?.name, plan?.title, t]);
const originalPrice = Number(plan?.original_price_amount ?? 0);
const discountInfo = plan?.custom_info?.discount_info;
const isFutureCreatorPlan = Number(plan?.level ?? 0) === 4;
/** 支付宝始终蒙层;微信仅首次同意前蒙层 */
const showAgreementOverlay = paymentMethod === "ALIPAY" || !wxAgreed;
@ -541,29 +540,16 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
{t("subscribe.read_before_pay")}
<br />
{isFutureCreatorPlan ? (
<a
href="#"
className="pay_subscribe_modal__overlay-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_FUTURE_CREATOR);
}}
>
{t("subscribe.future_creator_payment_terms")}
</a>
) : (
<a
href="#"
className="pay_subscribe_modal__overlay-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_PAYMENT_TERMS);
}}
>
{t("subscribe.payment_terms")}
</a>
)}
<a
href="#"
className="pay_subscribe_modal__overlay-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_PAYMENT_TERMS);
}}
>
{t("subscribe.payment_terms")}
</a>
</p>
<button type="button" className="pay_subscribe_modal__agree-btn" disabled={submitting} onClick={() => void handleAgreeAndPay()}>
{t("subscribe.agree_and_pay")}
@ -583,29 +569,16 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
<p className="pay_subscribe_modal__footer-terms">
{t("subscribe.read_before_pay")}
{isFutureCreatorPlan ? (
<a
href="#"
className="pay_subscribe_modal__footer-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_FUTURE_CREATOR);
}}
>
{t("subscribe.future_creator_payment_terms")}
</a>
) : (
<a
href="#"
className="pay_subscribe_modal__footer-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_PAYMENT_TERMS);
}}
>
{t("subscribe.payment_terms")}
</a>
)}
<a
href="#"
className="pay_subscribe_modal__footer-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_PAYMENT_TERMS);
}}
>
{t("subscribe.payment_terms")}
</a>
</p>
</div>
);

16
src/locales/en-US.json

@ -581,7 +581,6 @@
"subscribe.creator_member": "Creator",
"subscribe.per_month": "/month",
"subscribe.per_year": "/year",
"subscribe.per_100_days": "100 days",
"subscribe.current_plan": "Current plan",
"subscribe.subscribe_now": "Sponsor now",
"subscribe.contact_service": "Purchased — contact support",
@ -593,7 +592,6 @@
"subscribe.yuan": " yuan",
"subscribe.read_before_pay": "Please read before payment",
"subscribe.payment_terms": "'Popi.art paid service agreement '",
"subscribe.future_creator_payment_terms": "Popi Future Creator Plan User Service Agreement",
"subscribe.agree_and_pay": "Agree and Pay",
"subscribe.please_complete_wechat_payment": "Please complete payment in WeChat",
"subscribe.please_complete_alipay_payment": "Please complete payment in Alipay",
@ -610,17 +608,13 @@
"subscribe.promo_carousel_label": "Membership benefit campaigns",
"subscribe.billing_badge_hot": "HOT",
"subscribe.billing_badge_value": "Best value",
"subscribe.recommended_plan": "Recommended",
"subscribe.discount_off_8": "OFF 8%",
"subscribe.discount_off_15": "OFF 15%",
"subscribe.other_cycle_unavailable": "Other billing cycles are not available yet. Please choose single purchase",
"subscribe.points_unit": "points /",
"subscribe.points_period_monthly": "month",
"subscribe.points_period_yearly": "year",
"subscribe.points_granted_once": "Granted in full once",
"subscribe.commission_highlight": "1-on-1 artist commission",
"subscribe.commission_suffix": ": character art ×1",
"subscribe.limited_time_badge": "Limited",
"subscribe.bonus_gift_prefix": "Bonus: ",
"subscribe.pay_result_timeout": "Payment check timed out. Please check your order or account later",
"subscribe.pay_notice_timeout_title": "Result not confirmed yet",
"subscribe.pay_notice_missing_plan_title": "Cannot start payment",
@ -635,6 +629,14 @@
"subscribe.buy_now": "Buy now",
"subscribe.points_package": "Points package",
"subscribe.already_member_level": "You are already \"{{level}}\"",
"subscribe.plan_cta_goal": "Finish the first work",
"subscribe.plan_default_discount": "30% off limited offer",
"subscribe.plan_point_price": "100 points ≈ ¥{{value}}",
"subscribe.plan_new_user": "Best for beginners",
"subscribe.plan_recommended": "Recommended",
"subscribe.plan_goal_title": "More trials / steadier output",
"subscribe.plan_goal_line1": "Combine images, characters, videos,",
"subscribe.plan_goal_line2": "and shots into the first publishable work",
"subscribe.qa_title": "FAQ",
"subscribe.qa_subtitle": "Popi points and content rights",
"subscribe.qa_footnote_line1": "Note: The rules above are for FAQ display on the official site. Actual terms follow",

16
src/locales/zh-CN.json

@ -581,7 +581,6 @@
"subscribe.creator_member": "Creator",
"subscribe.per_month": "每月",
"subscribe.per_year": "每年",
"subscribe.per_100_days": "100天",
"subscribe.current_plan": "当前计划",
"subscribe.subscribe_now": "立即开通",
"subscribe.contact_service": "已购买,联系客服",
@ -593,7 +592,6 @@
"subscribe.yuan": "元",
"subscribe.read_before_pay": "支付前请阅读",
"subscribe.payment_terms": "《“Popi.art”付费服务协议》",
"subscribe.future_creator_payment_terms": "Popi Future Creator 计划用户服务协议",
"subscribe.agree_and_pay": "同意并支付",
"subscribe.please_complete_wechat_payment": "请微信扫码完成支付",
"subscribe.please_complete_alipay_payment": "请在跳转页面扫码完成支付",
@ -610,17 +608,13 @@
"subscribe.promo_carousel_label": "会员权益活动",
"subscribe.billing_badge_hot": "HOT",
"subscribe.billing_badge_value": "性价比",
"subscribe.recommended_plan": "主推计划",
"subscribe.discount_off_8": "OFF 8%",
"subscribe.discount_off_15": "OFF 15%",
"subscribe.other_cycle_unavailable": "其他周期暂未开放,请选择单次购买",
"subscribe.points_unit": "积分/",
"subscribe.points_period_monthly": "每月",
"subscribe.points_period_yearly": "每年",
"subscribe.points_granted_once": "一次全额获得",
"subscribe.commission_highlight": "画师一对一约稿",
"subscribe.commission_suffix": ":角色图×1",
"subscribe.limited_time_badge": "限时",
"subscribe.bonus_gift_prefix": "附赠:",
"subscribe.pay_result_timeout": "支付结果查询超时,请稍后在订单或账户中查看",
"subscribe.pay_notice_timeout_title": "尚未确认结果",
"subscribe.pay_notice_missing_plan_title": "无法发起支付",
@ -635,6 +629,14 @@
"subscribe.buy_now": "立即购买",
"subscribe.points_package": "积分包",
"subscribe.already_member_level": "你已是{{level}}",
"subscribe.plan_cta_goal": "完成第一条作品",
"subscribe.plan_default_discount": "限时7折",
"subscribe.plan_point_price": "每100积分≈¥{{value}}元",
"subscribe.plan_new_user": "新人首选",
"subscribe.plan_recommended": "官方推荐",
"subscribe.plan_goal_title": "多次试错 / 稳定出作品",
"subscribe.plan_goal_line1": "把图片、角色、视频和镜头",
"subscribe.plan_goal_line2": "组合成第一条可发布作品",
"subscribe.qa_title": "常见问题",
"subscribe.qa_subtitle": "Popi 积分与内容权益规则",
"subscribe.qa_footnote_line1": "注:以上规则用于官网常见问题展示,具体执行以",

16
src/locales/zh-TW.json

@ -582,7 +582,6 @@
"subscribe.creator_member": "Creator",
"subscribe.per_month": "每月",
"subscribe.per_year": "每年",
"subscribe.per_100_days": "100天",
"subscribe.current_plan": "目前方案",
"subscribe.subscribe_now": "立即贊助",
"subscribe.contact_service": "已購買,聯絡客服",
@ -594,7 +593,6 @@
"subscribe.yuan": "元",
"subscribe.read_before_pay": "支付前請閱讀",
"subscribe.payment_terms": "《“Popi.art”付費服務協議》",
"subscribe.future_creator_payment_terms": "Popi Future Creator 計劃用戶服務協議",
"subscribe.agree_and_pay": "同意並支付",
"subscribe.please_complete_wechat_payment": "請微信掃碼完成支付",
"subscribe.please_complete_alipay_payment": "請在跳轉頁面掃碼完成支付",
@ -611,17 +609,13 @@
"subscribe.promo_carousel_label": "會員權益活動",
"subscribe.billing_badge_hot": "HOT",
"subscribe.billing_badge_value": "性價比",
"subscribe.recommended_plan": "主推計劃",
"subscribe.discount_off_8": "OFF 8%",
"subscribe.discount_off_15": "OFF 15%",
"subscribe.other_cycle_unavailable": "其他週期暫未開放,請選擇單次購買",
"subscribe.points_unit": "積分/",
"subscribe.points_period_monthly": "每月",
"subscribe.points_period_yearly": "每年",
"subscribe.points_granted_once": "一次全額獲得",
"subscribe.commission_highlight": "畫師一對一約稿",
"subscribe.commission_suffix": ":角色圖×1",
"subscribe.limited_time_badge": "限時",
"subscribe.bonus_gift_prefix": "附贈:",
"subscribe.pay_result_timeout": "支付結果查詢超時,請稍後在訂單或帳戶中查看",
"subscribe.pay_notice_timeout_title": "尚未確認結果",
"subscribe.pay_notice_missing_plan_title": "無法發起支付",
@ -636,6 +630,14 @@
"subscribe.buy_now": "立即購買",
"subscribe.points_package": "積分包",
"subscribe.already_member_level": "你已是{{level}}",
"subscribe.plan_cta_goal": "完成第一條作品",
"subscribe.plan_default_discount": "限時7折",
"subscribe.plan_point_price": "每100積分≈¥{{value}}元",
"subscribe.plan_new_user": "新人首選",
"subscribe.plan_recommended": "官方推薦",
"subscribe.plan_goal_title": "多次試錯 / 穩定出作品",
"subscribe.plan_goal_line1": "把圖片、角色、影片和鏡頭",
"subscribe.plan_goal_line2": "組合成第一條可發布作品",
"subscribe.qa_title": "常見問題",
"subscribe.qa_subtitle": "Popi 積分與內容權益規則",
"subscribe.qa_footnote_line1": "註:以上規則用於官網常見問題展示,具體執行以",

13
src/pages/Teaching/index.scss

@ -237,9 +237,12 @@
justify-content: center;
width: 68px;
height: 24px;
padding: 0;
border: 0;
border-radius: 100px;
background: #ff9a27;
color: #ffffff;
cursor: pointer;
font-size: 12px;
font-weight: 700;
line-height: 17px;
@ -247,6 +250,16 @@
backdrop-filter: blur(15px);
}
.teachingPage__memberAction:hover,
.teachingPage__memberAction:focus-visible {
background: #ff8a00;
}
.teachingPage__memberAction:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.teachingPage__toolbar {
display: flex;
align-items: center;

66
src/pages/Teaching/index.tsx

@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import { getTeachingCategoryList, getTeachingContentList } from "@/api/api";
import type { TeachingCategoryItem, TeachingContentItem } from "@/api/api";
import { getMemberLevelLabel, hasMemberLevelLabel, ROUTES } from "@/constants";
import { openSubscribePlanDialog } from "@/dialog";
import { useMemberLevelStore } from "@/store/useMemberLevelStore";
import "./index.scss";
@ -111,6 +112,10 @@ export default function Teaching() {
const shouldShowTeachingStatus = isTeachingListLoading || teachingListError != null || teachingItems.length === 0;
const teachingStatusText = isTeachingListLoading ? t("common.loading") : teachingListError?.message || t("pages.teaching.empty");
const handleOpenSubscribePlan = (): void => {
void openSubscribePlanDialog();
};
useEffect(() => {
let cancelled = false;
@ -201,40 +206,43 @@ export default function Teaching() {
</span>
) : null}
</button>
<button
type="button"
className={[
"teachingPage__memberPanel",
isMemberPanelActive ? "teachingPage__memberPanel_expanded" : "teachingPage__memberPanel_collapsed",
].join(" ")}
aria-pressed={isMemberPanelActive}
onClick={() => {
setActiveCommunityPanelId("member");
}}
>
{isMemberPanelActive ? (
{isMemberPanelActive ? (
<section className="teachingPage__memberPanel teachingPage__memberPanel_expanded" aria-label={t("pages.teaching.member.expandedTitleLine1")}>
<span className="teachingPage__memberBackground" aria-hidden="true">
<img className="teachingPage__memberBackgroundImage" src={TEACHING_ASSETS.memberBackground} alt="" />
</span>
) : null}
<span className="teachingPage__memberCopy">
<span className="teachingPage__memberTitle">
{isMemberPanelActive ? (
<>
<span>{t("pages.teaching.member.expandedTitleLine1")}</span>
<span>{t("pages.teaching.member.expandedTitleLine2")}</span>
</>
) : (
t("pages.teaching.member.title")
)}
<span className="teachingPage__memberCopy">
<span className="teachingPage__memberTitle">
<span>{t("pages.teaching.member.expandedTitleLine1")}</span>
<span>{t("pages.teaching.member.expandedTitleLine2")}</span>
</span>
<span className="teachingPage__memberDescription">
<span>{t("pages.teaching.member.descriptionLine1")}</span>
<span>{t("pages.teaching.member.descriptionLine2")}</span>
</span>
</span>
<span className="teachingPage__memberDescription">
<span>{t("pages.teaching.member.descriptionLine1")}</span>
<span>{t("pages.teaching.member.descriptionLine2")}</span>
<button type="button" className="teachingPage__memberAction" onClick={handleOpenSubscribePlan}>
{t("pages.teaching.member.action")}
</button>
</section>
) : (
<button
type="button"
className="teachingPage__memberPanel teachingPage__memberPanel_collapsed"
aria-pressed={isMemberPanelActive}
onClick={() => {
setActiveCommunityPanelId("member");
}}
>
<span className="teachingPage__memberCopy">
<span className="teachingPage__memberTitle">{t("pages.teaching.member.title")}</span>
<span className="teachingPage__memberDescription">
<span>{t("pages.teaching.member.descriptionLine1")}</span>
<span>{t("pages.teaching.member.descriptionLine2")}</span>
</span>
</span>
</span>
{isMemberPanelActive ? <span className="teachingPage__memberAction">{t("pages.teaching.member.action")}</span> : null}
</button>
</button>
)}
</div>
</div>

21
src/pages/subscribe/index.scss

@ -50,9 +50,9 @@
.subscribe-overview-card {
position: relative;
display: grid;
grid-template-columns: 146px minmax(0, 518px);
grid-template-columns: 313px minmax(0, 1fr);
align-items: start;
gap: 77px;
gap: 20px;
height: 132px;
padding: 20px 30px;
border-radius: 26px;
@ -102,6 +102,7 @@
}
.user-plan__meta {
flex: 1 1 auto;
min-width: 0;
}
@ -109,13 +110,15 @@
display: flex;
align-items: center;
gap: 5px;
width: 100%;
min-width: 0;
height: 20px;
}
.user-plan__name {
flex: 0 0 220px;
overflow: hidden;
max-width: 72px;
width: 220px;
color: #333333;
font-size: 16px;
font-weight: 400;
@ -126,6 +129,7 @@
.user-plan__tag {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-width: 39px;
@ -413,8 +417,8 @@
}
.subscribe-overview-card {
grid-template-columns: 146px minmax(0, 1fr);
gap: 42px;
grid-template-columns: 313px minmax(0, 1fr);
gap: 20px;
}
.subscribe-promo-card.subscribe-promo-card_membership {
@ -458,6 +462,11 @@
margin-top: 0;
}
.user-plan__name {
flex: 1 1 auto;
width: auto;
}
.points-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
@ -489,4 +498,4 @@
}
}
}
}
}

1
src/utils/index.ts

@ -12,7 +12,6 @@ export {
openLatestPrivacyPolicyLink,
PRIVACY_POLICY_KEY_AI_USE_NOTICE,
PRIVACY_POLICY_KEY_EVIDENCE_PRESERVATION,
PRIVACY_POLICY_KEY_FUTURE_CREATOR,
PRIVACY_POLICY_KEY_MUSIC_USE_NOTICE,
PRIVACY_POLICY_KEY_PAYMENT_TERMS,
PRIVACY_POLICY_KEY_PRIVACY_POLICY,

2
src/utils/openLatestPrivacyPolicyLink.ts

@ -14,8 +14,6 @@ export const PRIVACY_POLICY_KEY_PAYMENT_TERMS = "web.paymentTerms";
export const PRIVACY_POLICY_KEY_EVIDENCE_PRESERVATION = "web.popiartEvidencePreservationAgreement";
/** 内容协议 key:音乐功能使用须知 */
export const PRIVACY_POLICY_KEY_MUSIC_USE_NOTICE = "web.musicUseNotice";
/** 内容协议 key:Popi Future Creator 计划用户服务协议 */
export const PRIVACY_POLICY_KEY_FUTURE_CREATOR = "web.futureCreatorUserServiceAgreement";
interface PrivacyPolicyItem {
id: number;

Loading…
Cancel
Save