Merge branch 'dev' into test
This commit is contained in:
@@ -19,6 +19,8 @@ import {
|
|||||||
import styles from "./components/sidebar-screen.module.css";
|
import styles from "./components/sidebar-screen.module.css";
|
||||||
|
|
||||||
const FALLBACK_USERNAME = "User name";
|
const FALLBACK_USERNAME = "User name";
|
||||||
|
const VIP_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=vip`;
|
||||||
|
const VOICE_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=voice`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 侧边栏主屏幕
|
* 侧边栏主屏幕
|
||||||
@@ -76,7 +78,7 @@ export function SidebarScreen() {
|
|||||||
<section className={styles.cardSlot}>
|
<section className={styles.cardSlot}>
|
||||||
<VipBenefitsCard
|
<VipBenefitsCard
|
||||||
state={sidebarState}
|
state={sidebarState}
|
||||||
onActivate={() => router.push(ROUTES.subscription)}
|
onActivate={() => router.push(VIP_SUBSCRIPTION_URL)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ export function SidebarScreen() {
|
|||||||
<VoicePackageCard
|
<VoicePackageCard
|
||||||
usedMinutes={0}
|
usedMinutes={0}
|
||||||
totalMinutes={0}
|
totalMinutes={0}
|
||||||
onBuyPackage={() => router.push(ROUTES.subscription)}
|
onBuyPackage={() => router.push(VOICE_SUBSCRIPTION_URL)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -93,11 +95,6 @@ export function SidebarScreen() {
|
|||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Other"
|
title="Other"
|
||||||
items={[
|
items={[
|
||||||
// {
|
|
||||||
// id: "membership",
|
|
||||||
// title: "Membership",
|
|
||||||
// onClick: () => router.push(ROUTES.subscription),
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: "logout",
|
id: "logout",
|
||||||
title: "Log out",
|
title: "Log out",
|
||||||
|
|||||||
@@ -11,15 +11,19 @@ import styles from "./subscription-banner.module.css";
|
|||||||
|
|
||||||
export interface SubscriptionBannerProps {
|
export interface SubscriptionBannerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionBanner({ className }: SubscriptionBannerProps) {
|
export function SubscriptionBanner({
|
||||||
|
className,
|
||||||
|
title = "Subscribe to become the VIP Member",
|
||||||
|
}: SubscriptionBannerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[styles.banner, className].filter(Boolean).join(" ")}
|
className={[styles.banner, className].filter(Boolean).join(" ")}
|
||||||
role="note"
|
role="note"
|
||||||
>
|
>
|
||||||
<p className={styles.text}>Subscribe to become the VIP Member</p>
|
<p className={styles.text}>{title}</p>
|
||||||
<span className={styles.pointer} aria-hidden="true" />
|
<span className={styles.pointer} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export function SubscriptionCheckoutButton({
|
|||||||
? "Processing payment..."
|
? "Processing payment..."
|
||||||
: payment.isCreatingOrder
|
: payment.isCreatingOrder
|
||||||
? "Creating order..."
|
? "Creating order..."
|
||||||
|
: payment.agreed
|
||||||
|
? "Pay and Activiate"
|
||||||
: "Confirm the agreement and Activate";
|
: "Confirm the agreement and Activate";
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
padding: var(--spacing-sm) var(--spacing-xs) 0;
|
padding: var(--spacing-sm) var(--spacing-xs) 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 13px;
|
border-radius: 20px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
box-shadow: 0 5px 7px 0 rgba(247, 89, 168, 0.1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -31,11 +32,7 @@
|
|||||||
.selected {
|
.selected {
|
||||||
border-color: #f657a0;
|
border-color: #f657a0;
|
||||||
border-width: 3px;
|
border-width: 3px;
|
||||||
box-shadow: 0 3px 5px rgba(247, 89, 168, 0.1);
|
border-radius: 20px;
|
||||||
}
|
|
||||||
|
|
||||||
.firstSelected {
|
|
||||||
padding-top: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
@@ -81,7 +78,7 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
margin: auto calc(-1 * var(--spacing-xs)) 0;
|
margin: auto calc(-1 * var(--spacing-xs)) 0;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
border-radius: 0 0 13px 13px;
|
border-radius: 0;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
margin-top: var(--spacing-lg);
|
margin-top: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.benefitsPlaceholder {
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
.ctaSlot {
|
.ctaSlot {
|
||||||
margin-top: var(--spacing-xl);
|
margin-top: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
@@ -64,6 +68,10 @@
|
|||||||
padding: 0 var(--spacing-xs);
|
padding: 0 var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agreementLabel {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
.agreementLink {
|
.agreementLink {
|
||||||
color: var(--color-auth-text-primary);
|
color: var(--color-auth-text-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { SubscriptionScreen } from "@/app/subscription/subscription-screen";
|
import { SubscriptionScreen } from "@/app/subscription/subscription-screen";
|
||||||
|
|
||||||
export default function SubscriptionPage() {
|
import type { SubscriptionType } from "./subscription-screen";
|
||||||
return <SubscriptionScreen />;
|
|
||||||
|
interface SubscriptionPageProps {
|
||||||
|
searchParams: Promise<{
|
||||||
|
type?: string | string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSubscriptionType(value: string | string[] | undefined): SubscriptionType {
|
||||||
|
const type = Array.isArray(value) ? value[0] : value;
|
||||||
|
return type === "voice" ? "voice" : "vip";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SubscriptionPage({
|
||||||
|
searchParams,
|
||||||
|
}: SubscriptionPageProps) {
|
||||||
|
const params = await searchParams;
|
||||||
|
return <SubscriptionScreen subscriptionType={toSubscriptionType(params.type)} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,38 @@ import styles from "./components/subscription-screen.module.css";
|
|||||||
import type { SubscriptionPlanView } from "./components/subscription-plan-card";
|
import type { SubscriptionPlanView } from "./components/subscription-plan-card";
|
||||||
|
|
||||||
const FALLBACK_USERNAME = "User name";
|
const FALLBACK_USERNAME = "User name";
|
||||||
|
export type SubscriptionType = "vip" | "voice";
|
||||||
|
|
||||||
|
const SUBSCRIPTION_BANNER_TITLES: Record<SubscriptionType, string> = {
|
||||||
|
vip: "Subscribe to become the VIP Member",
|
||||||
|
voice: "Buy Voice Message Package",
|
||||||
|
};
|
||||||
|
|
||||||
function isVipPlan(plan: PaymentPlan): boolean {
|
function isVipPlan(plan: PaymentPlan): boolean {
|
||||||
return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_");
|
return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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_") ||
|
||||||
|
orderType.includes("voice") ||
|
||||||
|
planId.includes("voice")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlanForType(
|
||||||
|
plan: PaymentPlan,
|
||||||
|
subscriptionType: SubscriptionType,
|
||||||
|
): boolean {
|
||||||
|
return subscriptionType === "voice"
|
||||||
|
? isVoicePackagePlan(plan)
|
||||||
|
: isVipPlan(plan);
|
||||||
|
}
|
||||||
|
|
||||||
function currencySymbol(currency: string): string {
|
function currencySymbol(currency: string): string {
|
||||||
if (currency === "CNY") return "¥";
|
if (currency === "CNY") return "¥";
|
||||||
if (currency === "USD") return "$";
|
if (currency === "USD") return "$";
|
||||||
@@ -54,24 +81,41 @@ function formatAmount(amountCents: number): string {
|
|||||||
return (amountCents / 100).toFixed(2).replace(/\.00$/, "");
|
return (amountCents / 100).toFixed(2).replace(/\.00$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function planCaption(plan: PaymentPlan): string {
|
function planCaption(
|
||||||
|
plan: PaymentPlan,
|
||||||
|
subscriptionType: SubscriptionType,
|
||||||
|
): string {
|
||||||
|
if (subscriptionType === "voice") {
|
||||||
|
return plan.dolAmount === null
|
||||||
|
? "Voice package"
|
||||||
|
: `${plan.dolAmount} voice messages`;
|
||||||
|
}
|
||||||
if (plan.vipDays === null) return "Lifetime";
|
if (plan.vipDays === null) return "Lifetime";
|
||||||
const perDay = plan.amountCents / 100 / Math.max(plan.vipDays, 1);
|
const perDay = plan.amountCents / 100 / Math.max(plan.vipDays, 1);
|
||||||
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
|
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPlanView(plan: PaymentPlan): SubscriptionPlanView {
|
function toPlanView(
|
||||||
|
plan: PaymentPlan,
|
||||||
|
subscriptionType: SubscriptionType,
|
||||||
|
): SubscriptionPlanView {
|
||||||
return {
|
return {
|
||||||
id: plan.planId,
|
id: plan.planId,
|
||||||
name: plan.planName,
|
name: plan.planName,
|
||||||
price: formatAmount(plan.amountCents),
|
price: formatAmount(plan.amountCents),
|
||||||
originalPrice: null,
|
originalPrice: null,
|
||||||
perDay: planCaption(plan),
|
perDay: planCaption(plan, subscriptionType),
|
||||||
currencySymbol: currencySymbol(plan.currency),
|
currencySymbol: currencySymbol(plan.currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionScreen() {
|
export interface SubscriptionScreenProps {
|
||||||
|
subscriptionType?: SubscriptionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionScreen({
|
||||||
|
subscriptionType = "vip",
|
||||||
|
}: SubscriptionScreenProps) {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
const userDispatch = useUserDispatch();
|
const userDispatch = useUserDispatch();
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
@@ -91,16 +135,16 @@ export function SubscriptionScreen() {
|
|||||||
userDispatch({ type: "UserFetch" });
|
userDispatch({ type: "UserFetch" });
|
||||||
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
|
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
|
||||||
|
|
||||||
const vipPlans = useMemo(
|
const plans = useMemo(
|
||||||
() =>
|
() =>
|
||||||
payment.plans
|
payment.plans
|
||||||
.filter(isVipPlan)
|
.filter((plan) => isPlanForType(plan, subscriptionType))
|
||||||
.map((plan) => toPlanView(plan)),
|
.map((plan) => toPlanView(plan, subscriptionType)),
|
||||||
[payment.plans],
|
[payment.plans, subscriptionType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedPlan =
|
const selectedPlan =
|
||||||
vipPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
|
plans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
|
||||||
const isPaymentBusy =
|
const isPaymentBusy =
|
||||||
payment.isCreatingOrder ||
|
payment.isCreatingOrder ||
|
||||||
payment.isPollingOrder ||
|
payment.isPollingOrder ||
|
||||||
@@ -108,15 +152,45 @@ export function SubscriptionScreen() {
|
|||||||
const canActivate =
|
const canActivate =
|
||||||
selectedPlan !== null && payment.agreed && !isPaymentBusy;
|
selectedPlan !== null && payment.agreed && !isPaymentBusy;
|
||||||
const isVip =
|
const isVip =
|
||||||
user.currentUser?.isVip === true ||
|
subscriptionType === "vip" &&
|
||||||
|
(user.currentUser?.isVip === true ||
|
||||||
payment.vipStatus?.isVip === true ||
|
payment.vipStatus?.isVip === true ||
|
||||||
payment.isPaid;
|
payment.isPaid);
|
||||||
|
|
||||||
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
||||||
const avatarUrl = user.currentUser?.avatarUrl ?? null;
|
const avatarUrl = user.currentUser?.avatarUrl ?? null;
|
||||||
const autoRenewCaption = payment.autoRenew
|
const autoRenewCaption = payment.autoRenew
|
||||||
? "It will automatically renew upon expiration, and can be canceled at any time"
|
? "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";
|
: "This plan is a one-time purchase and will not automatically renew";
|
||||||
|
const plansStatusMessage =
|
||||||
|
subscriptionType === "voice"
|
||||||
|
? "Voice packages are unavailable."
|
||||||
|
: "Membership plans are unavailable.";
|
||||||
|
const plansAriaLabel =
|
||||||
|
subscriptionType === "voice"
|
||||||
|
? "Choose a voice package"
|
||||||
|
: "Choose a subscription plan";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (payment.isLoadingPlans || payment.plans.length === 0) return;
|
||||||
|
if (selectedPlan !== null) return;
|
||||||
|
|
||||||
|
const nextPlan = payment.plans.find((plan) =>
|
||||||
|
isPlanForType(plan, subscriptionType),
|
||||||
|
);
|
||||||
|
if (!nextPlan) return;
|
||||||
|
|
||||||
|
paymentDispatch({
|
||||||
|
type: "PaymentPlanSelected",
|
||||||
|
planId: nextPlan.planId,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
payment.isLoadingPlans,
|
||||||
|
payment.plans,
|
||||||
|
paymentDispatch,
|
||||||
|
selectedPlan,
|
||||||
|
subscriptionType,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
@@ -130,15 +204,17 @@ export function SubscriptionScreen() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.bannerSlot}>
|
<section className={styles.bannerSlot}>
|
||||||
<SubscriptionBanner />
|
<SubscriptionBanner
|
||||||
|
title={SUBSCRIPTION_BANNER_TITLES[subscriptionType]}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
className={styles.plansRow}
|
className={styles.plansRow}
|
||||||
aria-label="Choose a subscription plan"
|
aria-label={plansAriaLabel}
|
||||||
>
|
>
|
||||||
{vipPlans.length > 0 ? (
|
{plans.length > 0 ? (
|
||||||
vipPlans.map((plan, index) => (
|
plans.map((plan, index) => (
|
||||||
<SubscriptionPlanCard
|
<SubscriptionPlanCard
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
@@ -156,7 +232,7 @@ export function SubscriptionScreen() {
|
|||||||
<p className={styles.plansStatus}>
|
<p className={styles.plansStatus}>
|
||||||
{payment.isLoadingPlans
|
{payment.isLoadingPlans
|
||||||
? "Loading membership plans..."
|
? "Loading membership plans..."
|
||||||
: payment.errorMessage ?? "Membership plans are unavailable."}
|
: payment.errorMessage ?? plansStatusMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -164,10 +240,14 @@ export function SubscriptionScreen() {
|
|||||||
<p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
|
<p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
|
||||||
|
|
||||||
<section className={styles.benefitsSlot}>
|
<section className={styles.benefitsSlot}>
|
||||||
|
{subscriptionType === "voice" ? (
|
||||||
|
<div className={styles.benefitsPlaceholder} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
<SubscriptionBenefitsCard
|
<SubscriptionBenefitsCard
|
||||||
title="VIP Membership Benefits"
|
title="VIP Membership Benefits"
|
||||||
items={VIP_BENEFITS}
|
items={VIP_BENEFITS}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className={styles.ctaSlot}>
|
<div className={styles.ctaSlot}>
|
||||||
@@ -192,7 +272,7 @@ export function SubscriptionScreen() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<>
|
<span className={styles.agreementLabel}>
|
||||||
I agree to the{" "}
|
I agree to the{" "}
|
||||||
<a
|
<a
|
||||||
href={AppConstants.privacyPolicyUrl}
|
href={AppConstants.privacyPolicyUrl}
|
||||||
@@ -211,7 +291,7 @@ export function SubscriptionScreen() {
|
|||||||
>
|
>
|
||||||
Automatic renewal Agreement
|
Automatic renewal Agreement
|
||||||
</a>
|
</a>
|
||||||
</>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user