From 49b67064f78324f00873c913acddecfc876d6ea9 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 26 Jun 2026 15:09:27 +0800 Subject: [PATCH] feat(subscription): load vip and credit plans from api --- src/app/subscription/components/index.ts | 2 + ...ubscription-coins-offer-section.module.css | 116 +++++++++++++ .../subscription-coins-offer-section.tsx | 54 ++++++ .../subscription-vip-offer-section.module.css | 139 ++++++++++++++++ .../subscription-vip-offer-section.tsx | 59 +++++++ .../subscription-screen.helpers.ts | 73 +++++++- src/app/subscription/subscription-screen.tsx | 156 +++++++++++++----- src/data/dto/payment/payment_plan.ts | 1 + .../responses/payment-plans-credits.json | 69 ++++++++ .../payment/responses/payment-plans-vip.json | 45 +++++ .../interfaces/ipayment_repository.ts | 7 + src/data/repositories/payment_repository.ts | 37 +++++ src/data/schemas/payment/payment_plan.ts | 3 + .../payment/__tests__/payment-machine.test.ts | 30 ++++ src/stores/payment/payment-machine.ts | 7 +- 15 files changed, 748 insertions(+), 50 deletions(-) create mode 100644 src/app/subscription/components/subscription-coins-offer-section.module.css create mode 100644 src/app/subscription/components/subscription-coins-offer-section.tsx create mode 100644 src/app/subscription/components/subscription-vip-offer-section.module.css create mode 100644 src/app/subscription/components/subscription-vip-offer-section.tsx create mode 100644 src/data/mock/payment/responses/payment-plans-credits.json create mode 100644 src/data/mock/payment/responses/payment-plans-vip.json diff --git a/src/app/subscription/components/index.ts b/src/app/subscription/components/index.ts index bc1f58c9..8b02f37f 100644 --- a/src/app/subscription/components/index.ts +++ b/src/app/subscription/components/index.ts @@ -6,9 +6,11 @@ export * from "./subscription-back-link"; export * from "./subscription-banner"; export * from "./subscription-benefits-card"; export * from "./subscription-checkout-button"; +export * from "./subscription-coins-offer-section"; export * from "./subscription-cta-button"; export * from "./subscription-payment-method"; export * from "./subscription-payment-success-dialog"; export * from "./subscription-plan-card"; export * from "./subscription-user-row"; +export * from "./subscription-vip-offer-section"; export * from "./stripe-payment-dialog"; diff --git a/src/app/subscription/components/subscription-coins-offer-section.module.css b/src/app/subscription/components/subscription-coins-offer-section.module.css new file mode 100644 index 00000000..a1982680 --- /dev/null +++ b/src/app/subscription/components/subscription-coins-offer-section.module.css @@ -0,0 +1,116 @@ +.section { + margin: 18px -30px 0; +} + +.heading { + position: relative; + padding: 14px 16px 18px; + background: #d9d9d9; +} + +.title { + margin: 0; + color: #111111; + font-size: 22px; + font-weight: 800; + line-height: 1.2; +} + +.notch { + position: absolute; + left: 50%; + bottom: -12px; + width: 24px; + height: 24px; + background: #d9d9d9; + transform: translateX(-50%) rotate(45deg); +} + +.panel { + position: relative; + z-index: 1; + margin-top: 12px; + padding: 8px 16px 8px 12px; + border: 1px solid #8d8d8d; + border-radius: 14px; + background: #d9d9d9; +} + +.row { + display: grid; + grid-template-columns: 50px minmax(0, 1fr) auto; + align-items: center; + width: 100%; + min-height: 42px; + padding: 0; + border: 0; + border-bottom: 3px solid #828282; + background: transparent; + color: #4f4f4f; + cursor: pointer; + font: inherit; + text-align: left; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.row:last-child { + border-bottom-width: 3px; +} + +.row:focus-visible { + outline: 2px solid #f657a0; + outline-offset: 2px; +} + +.selected { + color: #111111; +} + +.coinIcon { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + justify-self: center; + border-radius: 999px; + background: #ffffff; + color: #5f5f5f; + font-size: 12px; + line-height: 1; + text-transform: lowercase; +} + +.coins { + min-width: 0; + font-size: 16px; + font-weight: 600; + line-height: 1.2; +} + +.price { + font-size: 16px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +@media (max-width: 360px) { + .title { + font-size: 20px; + } + + .panel { + padding-right: 14px; + padding-left: 10px; + } + + .row { + grid-template-columns: 46px minmax(0, 1fr) auto; + } + + .coins, + .price { + font-size: 14px; + } +} diff --git a/src/app/subscription/components/subscription-coins-offer-section.tsx b/src/app/subscription/components/subscription-coins-offer-section.tsx new file mode 100644 index 00000000..662293bf --- /dev/null +++ b/src/app/subscription/components/subscription-coins-offer-section.tsx @@ -0,0 +1,54 @@ +"use client"; + +import styles from "./subscription-coins-offer-section.module.css"; + +export interface CoinsOfferPlanView { + id: string; + coins: number; + price: string; + currency: string; +} + +export interface SubscriptionCoinsOfferSectionProps { + plans: readonly CoinsOfferPlanView[]; + selectedPlanId: string | null; + onSelectPlan: (planId: string) => void; +} + +export function SubscriptionCoinsOfferSection({ + plans, + selectedPlanId, + onSelectPlan, +}: SubscriptionCoinsOfferSectionProps) { + return ( +
+
+

直接充值购买积分

+
+ +
+ {plans.map((plan) => { + const selected = selectedPlanId === plan.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/app/subscription/components/subscription-vip-offer-section.module.css b/src/app/subscription/components/subscription-vip-offer-section.module.css new file mode 100644 index 00000000..6fba0db5 --- /dev/null +++ b/src/app/subscription/components/subscription-vip-offer-section.module.css @@ -0,0 +1,139 @@ +.section { + margin: 18px -30px 0; +} + +.summary { + position: relative; + padding: 10px 10px 16px; + background: #d9d9d9; +} + +.title { + display: flex; + align-items: baseline; + gap: 10px; + margin: 0; + line-height: 1.25; +} + +.titleText { + color: #111111; + font-size: 20px; + font-weight: 800; +} + +.renewText { + color: #7d7d7d; + font-size: 14px; + font-weight: 700; +} + +.subtitle { + margin: 8px 0 0; + color: #666666; + font-size: 15px; + font-weight: 700; + line-height: 1.3; +} + +.notch { + position: absolute; + left: 50%; + bottom: -12px; + width: 24px; + height: 24px; + background: #d9d9d9; + transform: translateX(-50%) rotate(45deg); +} + +.planGrid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 14px 10px 0; +} + +.planCard { + display: flex; + min-width: 0; + min-height: 116px; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 12px 6px 10px; + border: 0; + border-radius: 14px; + background: #f4f4f4; + color: #111111; + cursor: pointer; + font: inherit; + text-align: center; + transition: background-color 0.15s ease, transform 0.05s ease; +} + +.planCard:focus-visible { + outline: 2px solid #f657a0; + outline-offset: 2px; +} + +.planCard:active { + transform: scale(0.98); +} + +.selected { + background: #d9d9d9; +} + +.planTitle { + font-size: 19px; + font-weight: 800; + line-height: 1.15; + white-space: nowrap; +} + +.priceLine { + display: inline-flex; + align-items: flex-end; + justify-content: center; + margin-top: 4px; + line-height: 1; +} + +.price { + font-size: 30px; + font-weight: 500; + letter-spacing: -0.8px; +} + +.currency { + margin-bottom: 3px; + color: #777777; + font-size: 12px; + font-weight: 700; + text-transform: lowercase; +} + +.originalPrice { + margin-top: 10px; + color: #7c7c7c; + font-size: 14px; + font-weight: 800; + line-height: 1; + text-decoration: line-through; +} + +@media (max-width: 360px) { + .planGrid { + gap: 8px; + } + + .planTitle { + font-size: 17px; + } + + .price { + font-size: 27px; + } +} diff --git a/src/app/subscription/components/subscription-vip-offer-section.tsx b/src/app/subscription/components/subscription-vip-offer-section.tsx new file mode 100644 index 00000000..18de4de1 --- /dev/null +++ b/src/app/subscription/components/subscription-vip-offer-section.tsx @@ -0,0 +1,59 @@ +"use client"; + +import styles from "./subscription-vip-offer-section.module.css"; + +export interface VipOfferPlanView { + id: string; + title: string; + price: string; + currency: string; + originalPrice: string; +} + +export interface SubscriptionVipOfferSectionProps { + plans: readonly VipOfferPlanView[]; + selectedPlanId: string | null; + onSelectPlan: (planId: string) => void; +} + +export function SubscriptionVipOfferSection({ + plans, + selectedPlanId, + onSelectPlan, +}: SubscriptionVipOfferSectionProps) { + return ( +
+
+

+ VIP会员 + (到期自动续费,可随时取消) +

+

每月送3000积分,每日早上畅聊到晚上

+
+ +
+ {plans.map((plan) => { + const selected = selectedPlanId === plan.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/app/subscription/subscription-screen.helpers.ts b/src/app/subscription/subscription-screen.helpers.ts index a0747b74..f8dcb957 100644 --- a/src/app/subscription/subscription-screen.helpers.ts +++ b/src/app/subscription/subscription-screen.helpers.ts @@ -1,6 +1,8 @@ import type { PaymentPlan } from "@/data/dto/payment"; +import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section"; import type { SubscriptionPlanView } from "./components/subscription-plan-card"; +import type { VipOfferPlanView } from "./components/subscription-vip-offer-section"; export type SubscriptionType = "vip" | "voice"; @@ -9,17 +11,29 @@ export const SUBSCRIPTION_BANNER_TITLES: Record = { voice: "Buy Voice Message Package", }; -function isVipPlan(plan: PaymentPlan): boolean { +export function isVipPlan(plan: PaymentPlan): boolean { return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_"); } +export function isCreditPlan(plan: PaymentPlan): boolean { + const orderType = plan.orderType.toLowerCase(); + const planId = plan.planId.toLowerCase(); + return ( + plan.dolAmount !== null || + orderType.startsWith("dol_") || + orderType.startsWith("credit_") || + planId.startsWith("dol_") || + planId.startsWith("credit_") || + orderType.includes("credit") || + planId.includes("credit") + ); +} + function isVoicePackagePlan(plan: PaymentPlan): boolean { const orderType = plan.orderType.toLowerCase(); const planId = plan.planId.toLowerCase(); return ( - plan.dolAmount !== null || - orderType.startsWith("dol_") || - planId.startsWith("dol_") || + isCreditPlan(plan) || orderType.includes("voice") || planId.includes("voice") ); @@ -35,8 +49,9 @@ export function isPlanForType( } function currencySymbol(currency: string): string { - if (currency === "CNY") return "¥"; - if (currency === "USD") return "$"; + const normalized = currency.toUpperCase(); + if (normalized === "CNY") return "¥"; + if (normalized === "USD") return "$"; return `${currency} `; } @@ -46,6 +61,33 @@ function formatAmount(amountCents: number | null): string | null { return (amountCents / 100).toFixed(2).replace(/\.?0+$/, ""); } +function formatOfferAmount(amountCents: number | null): string { + return (formatAmount(amountCents) ?? "").replace(".", ","); +} + +function formatOfferCurrency(currency: string): string { + const normalized = currency.toUpperCase(); + if (normalized === "USD") return "usd"; + return currency.toLowerCase(); +} + +function formatCoinCurrency(currency: string): string { + const normalized = currency.toUpperCase(); + if (normalized === "USD") return "US$"; + if (normalized === "CNY") return "¥"; + return `${currency} `; +} + +function vipOfferTitle(plan: PaymentPlan): string { + const key = `${plan.planId} ${plan.orderType} ${plan.planName}`.toLowerCase(); + if (key.includes("year")) return "连续包年"; + if (key.includes("quarter")) return "连续包季"; + if (key.includes("month")) return "连续包月"; + if (plan.vipDays !== null && plan.vipDays >= 300) return "连续包年"; + if (plan.vipDays !== null && plan.vipDays >= 80) return "连续包季"; + return plan.planName; +} + function planCaption( plan: PaymentPlan, subscriptionType: SubscriptionType, @@ -79,3 +121,22 @@ export function toPlanView( currencySymbol: currencySymbol(plan.currency), }; } + +export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView { + return { + id: plan.planId, + title: vipOfferTitle(plan), + price: formatOfferAmount(plan.amountCents), + currency: formatOfferCurrency(plan.currency), + originalPrice: formatOfferAmount(plan.originalAmountCents), + }; +} + +export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView { + return { + id: plan.planId, + coins: plan.dolAmount ?? 0, + price: formatOfferAmount(plan.amountCents), + currency: formatCoinCurrency(plan.currency), + }; +} diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 3b1b2ab7..58ff63b3 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -17,7 +17,6 @@ import { useRouter } from "next/navigation"; import { Checkbox, MobileShell } from "@/app/_components/core"; import { AppConstants } from "@/core/app_constants"; -import { VIP_BENEFITS } from "@/data/constants/vip-benefits"; import { PendingPaymentOrderStorage } from "@/data/storage"; import { usePaymentDispatch, @@ -29,18 +28,23 @@ import { ROUTES } from "@/router/routes"; import { SubscriptionBackLink, SubscriptionBanner, - SubscriptionBenefitsCard, SubscriptionCheckoutButton, + SubscriptionCoinsOfferSection, SubscriptionPaymentMethod, SubscriptionPaymentSuccessDialog, SubscriptionPlanCard, SubscriptionUserRow, + SubscriptionVipOfferSection, } from "./components"; import styles from "./components/subscription-screen.module.css"; import { SUBSCRIPTION_BANNER_TITLES, + isCreditPlan, isPlanForType, + isVipPlan, + toCoinsOfferPlanView, toPlanView, + toVipOfferPlanView, type SubscriptionType, } from "./subscription-screen.helpers"; @@ -71,6 +75,7 @@ export function SubscriptionScreen({ useState(false); const [pendingChatReturnAfterVipSync, setPendingChatReturnAfterVipSync] = useState(false); + const isVipSubscription = subscriptionType === "vip"; useEffect(() => { if (payment.status === "idle") { @@ -187,9 +192,25 @@ export function SubscriptionScreen({ .map((plan) => toPlanView(plan, subscriptionType)), [payment.plans, subscriptionType], ); + const vipOfferPlans = useMemo( + () => + payment.plans + .filter(isVipPlan) + .slice(0, 3) + .map(toVipOfferPlanView), + [payment.plans], + ); + const directCoinsPlans = useMemo( + () => payment.plans.filter(isCreditPlan).map(toCoinsOfferPlanView), + [payment.plans], + ); const selectedPlan = - plans.find((plan) => plan.id === payment.selectedPlanId) ?? null; + (isVipSubscription + ? [...vipOfferPlans, ...directCoinsPlans].find( + (plan) => plan.id === payment.selectedPlanId, + ) + : plans.find((plan) => plan.id === payment.selectedPlanId)) ?? null; const isPaymentBusy = payment.isCreatingOrder || payment.isPollingOrder || @@ -199,9 +220,6 @@ export function SubscriptionScreen({ const name = user.currentUser?.username ?? FALLBACK_USERNAME; const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null; - const autoRenewCaption = payment.autoRenew - ? "It will automatically renew upon expiration, and can be canceled at any time" - : "This plan is a one-time purchase and will not automatically renew"; const plansStatusMessage = subscriptionType === "voice" ? "Voice packages are unavailable." @@ -260,6 +278,29 @@ export function SubscriptionScreen({ }, [pendingChatReturnAfterVipSync, subscriptionType, user.currentUser?.isVip]); useEffect(() => { + if (isVipSubscription) { + const firstVipPlanId = vipOfferPlans[0]?.id ?? ""; + const hasSelectedVipPlan = vipOfferPlans.some( + (plan) => plan.id === payment.selectedPlanId, + ); + const hasSelectedCoinsPlan = directCoinsPlans.some( + (plan) => plan.id === payment.selectedPlanId, + ); + const canSelectPlan = + firstVipPlanId.length > 0 && + (payment.status === "ready" || + payment.status === "paid" || + payment.status === "failed"); + + if (!canSelectPlan || hasSelectedVipPlan || hasSelectedCoinsPlan) return; + + paymentDispatch({ + type: "PaymentPlanSelected", + planId: firstVipPlanId, + }); + return; + } + if (payment.isLoadingPlans || payment.plans.length === 0) return; if (selectedPlan !== null) return; @@ -276,8 +317,13 @@ export function SubscriptionScreen({ payment.isLoadingPlans, payment.plans, paymentDispatch, + payment.selectedPlanId, + payment.status, + directCoinsPlans, + isVipSubscription, selectedPlan, subscriptionType, + vipOfferPlans, ]); return ( @@ -287,45 +333,69 @@ export function SubscriptionScreen({ -
- -
+ {isVipSubscription ? ( + <> + + paymentDispatch({ + type: "PaymentPlanSelected", + planId, + }) + } + /> + + paymentDispatch({ + type: "PaymentPlanSelected", + planId, + }) + } + /> + + ) : ( + <> +
+ +
-
- -
- -
- {plans.length > 0 ? ( - plans.map((plan, index) => ( - - paymentDispatch({ - type: "PaymentPlanSelected", - planId: plan.id, - }) - } +
+ - )) - ) : ( -

- {payment.isLoadingPlans - ? "Loading membership plans..." - : payment.errorMessage ?? plansStatusMessage} -

- )} -
+
-

{autoRenewCaption}

+
+ {plans.length > 0 ? ( + plans.map((plan, index) => ( + + paymentDispatch({ + type: "PaymentPlanSelected", + planId: plan.id, + }) + } + /> + )) + ) : ( +

+ {payment.isLoadingPlans + ? "Loading membership plans..." + : payment.errorMessage ?? plansStatusMessage} +

+ )} +
+ + )} + + {/*

{autoRenewCaption}

{subscriptionType === "voice" ? ( @@ -336,7 +406,7 @@ export function SubscriptionScreen({ items={VIP_BENEFITS} /> )} -
+ */}
>; + /** 获取会员套餐列表。 */ + getVipPlans(): Promise>; + + /** 获取积分套餐列表。 */ + getCreditPlans(): Promise>; + /** 创建支付订单。 */ createOrder( planId: string, diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index 62d94bcf..01daba78 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -10,6 +10,7 @@ import { CreatePaymentOrderResponse, PayChannel, PaymentOrderStatusResponse, + PaymentPlan, PaymentPlansResponse, PaymentVipStatusResponse, } from "@/data/dto/payment"; @@ -25,6 +26,22 @@ export class PaymentRepository implements IPaymentRepository { return Result.wrap(() => this.api.getPlans()); } + /** 获取会员套餐列表。 */ + async getVipPlans(): Promise> { + return Result.wrap(async () => { + const response = await this.api.getPlans(); + return response.plans.filter(isVipPaymentPlan); + }); + } + + /** 获取积分套餐列表。 */ + async getCreditPlans(): Promise> { + return Result.wrap(async () => { + const response = await this.api.getPlans(); + return response.plans.filter(isCreditPaymentPlan); + }); + } + /** 创建支付订单。 */ async createOrder( planId: string, @@ -54,3 +71,23 @@ export class PaymentRepository implements IPaymentRepository { /** 全局单例。 */ export const paymentRepository = new PaymentRepository(paymentApi); + +export function isVipPaymentPlan(plan: PaymentPlan): boolean { + const orderType = plan.orderType.toLowerCase(); + const planId = plan.planId.toLowerCase(); + return orderType.startsWith("vip_") || planId.startsWith("vip_"); +} + +export function isCreditPaymentPlan(plan: PaymentPlan): boolean { + const orderType = plan.orderType.toLowerCase(); + const planId = plan.planId.toLowerCase(); + return ( + plan.dolAmount !== null || + orderType.startsWith("dol_") || + orderType.startsWith("credit_") || + planId.startsWith("dol_") || + planId.startsWith("credit_") || + orderType.includes("credit") || + planId.includes("credit") + ); +} diff --git a/src/data/schemas/payment/payment_plan.ts b/src/data/schemas/payment/payment_plan.ts index 6bbd2c72..add65792 100644 --- a/src/data/schemas/payment/payment_plan.ts +++ b/src/data/schemas/payment/payment_plan.ts @@ -11,6 +11,7 @@ const PaymentPlanWireSchema = z.object({ original_amount_cents: z.number().nullable().default(null), daily_price_cents: z.number().nullable().default(null), currency: z.string(), + pricing_tier: z.string(), vip_days: z.number().nullable(), dol_amount: z.number().nullable(), }); @@ -28,6 +29,7 @@ export const PaymentPlanSchema = z original_amount_cents: data.originalAmountCents, daily_price_cents: data.dailyPriceCents, currency: data.currency, + pricing_tier: data.pricingTier, vip_days: data.vipDays, dol_amount: data.dolAmount, }; @@ -42,6 +44,7 @@ export const PaymentPlanSchema = z originalAmountCents: data.original_amount_cents, dailyPriceCents: data.daily_price_cents, currency: data.currency, + pricingTier: data.pricing_tier, vipDays: data.vip_days, dolAmount: data.dol_amount, })); diff --git a/src/stores/payment/__tests__/payment-machine.test.ts b/src/stores/payment/__tests__/payment-machine.test.ts index 87bf9099..c944ca21 100644 --- a/src/stores/payment/__tests__/payment-machine.test.ts +++ b/src/stores/payment/__tests__/payment-machine.test.ts @@ -26,6 +26,7 @@ const monthlyPlan = { original_amount_cents: null, daily_price_cents: 33, currency: "usd", + pricing_tier: "T1", vip_days: 30, dol_amount: null, }; @@ -38,10 +39,24 @@ const lifetimePlan = { original_amount_cents: null, daily_price_cents: null, currency: "usd", + pricing_tier: "T1", vip_days: null, dol_amount: null, }; +const creditPlan = { + plan_id: "credit_1000", + plan_name: "1000 Credits", + order_type: "credit_recharge", + amount_cents: 990, + original_amount_cents: null, + daily_price_cents: null, + currency: "usd", + pricing_tier: "T1", + vip_days: null, + dol_amount: 1000, +}; + function createTestPaymentMachine( overrides: Partial<{ createOrderSpy: CreateOrderSpy; @@ -218,4 +233,19 @@ describe("paymentMachine", () => { actor.stop(); }); + + it("disables auto renew for credit recharge plans", async () => { + const actor = createActor(createTestPaymentMachine()).start(); + + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + actor.send({ type: "PaymentPlanSelected", planId: creditPlan.plan_id }); + + const context = actor.getSnapshot().context; + expect(context.selectedPlanId).toBe("credit_1000"); + expect(context.autoRenew).toBe(false); + + actor.stop(); + }); }); diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index f7718502..89835b89 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -25,7 +25,12 @@ function toErrorMessage(error: unknown): string { } function defaultAutoRenewForPlan(planId: string): boolean { - return !planId.includes("lifetime") && !planId.startsWith("dol_"); + return ( + !planId.includes("lifetime") && + !planId.startsWith("dol_") && + !planId.startsWith("points_") && + !planId.startsWith("credit_") + ); } function getDefaultPlanId(plans: readonly PaymentPlan[]): string {