Compare commits

..

1 Commits

Author SHA1 Message Date
admin b8abe88d6d chore(favicon): 使用预发布环境的图标
Docker Image / Quality and Bundle Budgets (push) Successful in 3s
Docker Image / Build and Push Docker Image (push) Successful in 2m43s
2026-07-17 16:51:11 +08:00
16 changed files with 175 additions and 495 deletions
@@ -1,104 +0,0 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import {
defaultCharacterChatUrl,
dismissChatInterruptions,
enterChatFromSplash,
} from "@e2e/fixtures/test-helpers";
const preReleaseHost = "frontend-test.banlv-ai.com";
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "";
test.describe("pre-release multi-message lock types", () => {
test.setTimeout(120_000);
test.skip(
!baseURL.includes(preReleaseHost),
`Run this test only with PLAYWRIGHT_BASE_URL pointing at ${preReleaseHost}.`,
);
test("returns locked image, locked voice, then unlocked text", async ({
page,
}) => {
await enterChatFromSplash(page, {
expectedUrl: defaultCharacterChatUrl,
timeout: 20_000,
waitUntil: "domcontentloaded",
});
await dismissChatInterruptions(page);
const messageInput = page.getByRole("textbox", { name: "Message" });
await expect(messageInput).toBeEnabled({ timeout: 30_000 });
let aiMessageCount = await page
.locator('[aria-label="AI message"]')
.count();
const imageReply = await sendMessageAndWaitForAiReply(
page,
messageInput,
"发个图片",
aiMessageCount,
);
aiMessageCount += 1;
await expect(
imageReply.getByRole("button", { name: "Open image in fullscreen" }),
).toBeVisible();
await expect(
imageReply
.getByRole("button", { name: "Open image in fullscreen" })
.locator("img"),
).toHaveClass(/blur-sm/);
const voiceReply = await sendMessageAndWaitForAiReply(
page,
messageInput,
"发送语音",
aiMessageCount,
);
aiMessageCount += 1;
await expect(
voiceReply.getByRole("button", { name: "Unlock voice message" }),
).toBeVisible();
await expect(
voiceReply.getByRole("button", { name: "Play voice message" }),
).toBeDisabled();
const textReply = await sendMessageAndWaitForAiReply(
page,
messageInput,
"你好",
aiMessageCount,
);
await expect(
textReply.getByRole("button", { name: "Unlock voice message" }),
).toHaveCount(0);
await expect(
textReply.getByRole("group", { name: "Locked private image" }),
).toHaveCount(0);
await expect(textReply.getByRole("button", { name: /Unlock/ })).toHaveCount(0);
await expect(textReply).toContainText(/\S/);
});
});
async function sendMessageAndWaitForAiReply(
page: Page,
messageInput: Locator,
message: string,
previousAiMessageCount: number,
): Promise<Locator> {
const aiMessages = page.locator('[aria-label="AI message"]');
const sendRequestPromise = page.waitForRequest("**/api/chat/send");
await messageInput.fill(message);
await messageInput.press("Enter");
const sendRequest = await sendRequestPromise;
expect(sendRequest.postDataJSON()).toMatchObject({ message });
await expect
.poll(() => aiMessages.count(), { timeout: 60_000 })
.toBeGreaterThan(previousAiMessageCount);
const latestReply = aiMessages.last();
await expect(latestReply).toBeVisible();
return latestReply;
}
@@ -1,55 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PaymentMethodSelector } from "../payment-method-selector";
describe("PaymentMethodSelector", () => {
it("orders the default channel first and renders page analytics", () => {
const html = renderToStaticMarkup(
<PaymentMethodSelector
value="ezpay"
defaultChannel="ezpay"
caption="GCash by default"
analyticsKey="tip.payment_method"
onChange={() => undefined}
/>,
);
expect(html).toContain('aria-labelledby="payment-method-title"');
expect(html).toContain("Payment Method");
expect(html).toContain("GCash by default");
expect(html.indexOf('aria-label="GCash"')).toBeLessThan(
html.indexOf('aria-label="Stripe"'),
);
const selectedButton = html.match(
/<button[^>]*aria-pressed="true"[^>]*>/,
)?.[0];
expect(selectedButton).toBeDefined();
expect(selectedButton).toContain(
'data-analytics-key="tip.payment_method"',
);
expect(selectedButton).toContain("border-[#f657a0]");
expect(selectedButton).not.toContain("border-[rgba(246,87,160,0.18)]");
expect(selectedButton).toContain("bg-[#fff4f9]");
expect(selectedButton).not.toContain("bg-white");
expect(selectedButton).toContain("0_0_0_3px_rgba(217,47,127,0.18)");
expect(selectedButton).toContain("-translate-y-0.5");
expect(html).toContain("/images/subscription/gcash-logo.svg");
});
it("disables both payment methods while payment is busy", () => {
const html = renderToStaticMarkup(
<PaymentMethodSelector
value="stripe"
disabled
analyticsKey="subscription.payment_method"
onChange={() => undefined}
/>,
);
expect(html.match(/ disabled=""/g)).toHaveLength(2);
expect(html).toContain(
'data-analytics-key="subscription.payment_method"',
);
});
});
@@ -1,107 +0,0 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PayChannel } from "@/data/schemas/payment";
import type { PaymentMethodConfig } from "@/lib/payment/payment_method";
import { usePaymentMethodSelection } from "../use-payment-method-selection";
describe("usePaymentMethodSelection", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("forces Stripe when payment method selection is unavailable", () => {
const onChange = vi.fn();
renderHarness(root, {
config: { canChoosePaymentMethod: false, initialPayChannel: "stripe" },
currentPayChannel: "ezpay",
onChange,
});
expect(onChange).toHaveBeenCalledWith("stripe");
});
it("applies the default channel once when no return channel was requested", () => {
const onChange = vi.fn();
const config = {
canChoosePaymentMethod: true,
initialPayChannel: "ezpay",
} as const;
renderHarness(root, {
config,
currentPayChannel: "stripe",
onChange,
});
renderHarness(root, {
config,
currentPayChannel: "ezpay",
onChange,
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("ezpay");
});
it("preserves an explicit return channel and pauses while payment is busy", () => {
const onChange = vi.fn();
renderHarness(root, {
config: { canChoosePaymentMethod: true, initialPayChannel: "ezpay" },
currentPayChannel: "stripe",
requestedPayChannel: "stripe",
onChange,
});
renderHarness(root, {
config: { canChoosePaymentMethod: false, initialPayChannel: "stripe" },
currentPayChannel: "ezpay",
isPaymentBusy: true,
onChange,
});
expect(onChange).not.toHaveBeenCalled();
});
});
interface HarnessProps {
config: PaymentMethodConfig;
currentPayChannel: PayChannel;
requestedPayChannel?: PayChannel | null;
isPaymentBusy?: boolean;
onChange: (payChannel: PayChannel) => void;
}
function renderHarness(root: Root, props: HarnessProps): void {
act(() => {
root.render(<Harness {...props} />);
});
}
function Harness({
config,
currentPayChannel,
requestedPayChannel = null,
isPaymentBusy = false,
onChange,
}: HarnessProps) {
usePaymentMethodSelection({
config,
currentPayChannel,
requestedPayChannel,
isPaymentBusy,
onChange,
});
return null;
}
@@ -1,49 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import type { PayChannel } from "@/data/schemas/payment";
import type { PaymentMethodConfig } from "@/lib/payment/payment_method";
export interface UsePaymentMethodSelectionInput {
config: PaymentMethodConfig;
currentPayChannel: PayChannel;
isPaymentBusy: boolean;
requestedPayChannel: PayChannel | null;
onChange: (payChannel: PayChannel) => void;
}
export function usePaymentMethodSelection({
config,
currentPayChannel,
isPaymentBusy,
requestedPayChannel,
onChange,
}: UsePaymentMethodSelectionInput): void {
const appliedDefaultChannelRef = useRef<PayChannel | null>(null);
useEffect(() => {
if (isPaymentBusy) return;
if (!config.canChoosePaymentMethod) {
appliedDefaultChannelRef.current = null;
if (currentPayChannel !== "stripe") onChange("stripe");
return;
}
if (requestedPayChannel !== null) return;
if (appliedDefaultChannelRef.current === config.initialPayChannel) return;
appliedDefaultChannelRef.current = config.initialPayChannel;
if (currentPayChannel !== config.initialPayChannel) {
onChange(config.initialPayChannel);
}
}, [
config.canChoosePaymentMethod,
config.initialPayChannel,
currentPayChannel,
isPaymentBusy,
onChange,
requestedPayChannel,
]);
}
@@ -187,7 +187,6 @@ describe("chat Tailwind components", () => {
expect(guestHtml).toContain('href="/characters/elio/splash"'); expect(guestHtml).toContain('href="/characters/elio/splash"');
expect(guestHtml).toContain('aria-label="Back to home"'); expect(guestHtml).toContain('aria-label="Back to home"');
expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"'); expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(guestHtml).toContain("px-(--chat-inline-padding,16px)");
expect(guestHtml).not.toContain('aria-label="Menu"'); expect(guestHtml).not.toContain('aria-label="Menu"');
expect(memberHtml).toContain("Offer"); expect(memberHtml).toContain("Offer");
expect(memberHtml).toContain('href="/characters/elio/splash"'); expect(memberHtml).toContain('href="/characters/elio/splash"');
@@ -195,8 +194,6 @@ describe("chat Tailwind components", () => {
expect(memberHtml).toContain('aria-label="Menu"'); expect(memberHtml).toContain('aria-label="Menu"');
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"'); expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"'); expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(memberHtml).toContain("px-(--chat-inline-padding,16px)");
expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]"); expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)"); expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
expect(memberWithHintHtml).toContain( expect(memberWithHintHtml).toContain(
+2 -2
View File
@@ -53,8 +53,8 @@ export function ChatHeader({
<div <div
className={ className={
isGuest isGuest
? "flex items-center px-(--chat-inline-padding,16px) py-(--spacing-sm,8px)" ? "flex items-center px-(--spacing-md,12px) py-(--spacing-sm,8px)"
: "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-(--spacing-sm,8px) px-(--chat-inline-padding,16px) py-(--spacing-sm,8px)" : "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-(--spacing-sm,8px) px-(--spacing-md,12px) py-(--spacing-sm,8px)"
} }
> >
<BackButton <BackButton
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
PaymentPlanSchema, PaymentPlanSchema,
@@ -7,9 +7,12 @@ import {
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import { import {
canChooseSubscriptionPayChannel,
findSelectedSubscriptionPlan, findSelectedSubscriptionPlan,
getDefaultPayChannelForCountryCode,
getDefaultSubscriptionPlanId, getDefaultSubscriptionPlanId,
getFirstRechargeOfferView, getFirstRechargeOfferView,
resolveSubscriptionPayChannel,
toCoinsOfferPlanViews, toCoinsOfferPlanViews,
toVipOfferPlanViews, toVipOfferPlanViews,
} from "../subscription-screen.helpers"; } from "../subscription-screen.helpers";
@@ -31,6 +34,68 @@ function makePlan(overrides: Partial<PaymentPlanInput>): PaymentPlan {
} }
describe("subscription screen helpers", () => { describe("subscription screen helpers", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("defaults Philippines users to GCash and other countries to Stripe", () => {
expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay");
expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay");
expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe");
expect(getDefaultPayChannelForCountryCode("")).toBe("stripe");
expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe");
});
it("only allows payment method switching for Philippines users in production", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
expect(canChooseSubscriptionPayChannel("PH")).toBe(true);
expect(canChooseSubscriptionPayChannel("ph")).toBe(true);
expect(canChooseSubscriptionPayChannel("HK")).toBe(false);
expect(canChooseSubscriptionPayChannel(null)).toBe(false);
});
it("allows payment method switching in non-production", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
expect(canChooseSubscriptionPayChannel("HK")).toBe(true);
expect(canChooseSubscriptionPayChannel(null)).toBe(true);
});
it("resolves subscription pay channel by country in production", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
expect(
resolveSubscriptionPayChannel({
countryCode: "PH",
requestedPayChannel: null,
}),
).toBe("ezpay");
expect(
resolveSubscriptionPayChannel({
countryCode: "PH",
requestedPayChannel: "stripe",
}),
).toBe("stripe");
expect(
resolveSubscriptionPayChannel({
countryCode: "HK",
requestedPayChannel: "ezpay",
}),
).toBe("stripe");
});
it("keeps requested pay channel in non-production", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
expect(
resolveSubscriptionPayChannel({
countryCode: "HK",
requestedPayChannel: "ezpay",
}),
).toBe("ezpay");
});
it("maps at most three VIP plans and all coin plans", () => { it("maps at most three VIP plans and all coin plans", () => {
const plans = [ const plans = [
makePlan({ planId: "vip_monthly", planName: "Monthly" }), makePlan({ planId: "vip_monthly", planName: "Monthly" }),
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import { StripePaymentDialog } from "@/app/_components/payment/stripe-payment-dialog"; import { StripePaymentDialog } from "@/app/_components/payment/stripe-payment-dialog";
import { SubscriptionCtaButton } from "../subscription-cta-button"; import { SubscriptionCtaButton } from "../subscription-cta-button";
import { SubscriptionPaymentMethod } from "../subscription-payment-method";
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog"; import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
import { SubscriptionCoinsOfferSection } from "../subscription-coins-offer-section"; import { SubscriptionCoinsOfferSection } from "../subscription-coins-offer-section";
import { SubscriptionVipOfferSection } from "../subscription-vip-offer-section"; import { SubscriptionVipOfferSection } from "../subscription-vip-offer-section";
@@ -48,6 +49,38 @@ describe("subscription Tailwind components", () => {
expect(html).toContain("OK"); expect(html).toContain("OK");
}); });
it("renders SubscriptionPaymentMethod with default ordering and selection", () => {
const html = renderToStaticMarkup(
<SubscriptionPaymentMethod
value="ezpay"
defaultChannel="ezpay"
caption="GCash by default"
onChange={() => undefined}
/>,
);
expect(html).toContain('aria-labelledby="payment-method-title"');
expect(html).toContain("Payment Method");
expect(html).toContain("GCash by default");
expect(html.indexOf('aria-label="GCash"')).toBeLessThan(
html.indexOf('aria-label="Stripe"'),
);
const selectedButton = html.match(
/<button[^>]*aria-pressed="true"[^>]*>/,
)?.[0];
expect(selectedButton).toBeDefined();
expect(selectedButton).toContain(
'data-analytics-key="subscription.payment_method"',
);
expect(selectedButton).toContain("border-[#f657a0]");
expect(selectedButton).not.toContain("border-[rgba(246,87,160,0.18)]");
expect(selectedButton).toContain("bg-[#fff4f9]");
expect(selectedButton).not.toContain("bg-white");
expect(selectedButton).toContain("0_0_0_3px_rgba(217,47,127,0.18)");
expect(selectedButton).toContain("-translate-y-0.5");
expect(html).toContain("/images/subscription/gcash-logo.svg");
});
it("renders StripePaymentDialog fallback with Tailwind dialog utilities", () => { it("renders StripePaymentDialog fallback with Tailwind dialog utilities", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<StripePaymentDialog <StripePaymentDialog
+1
View File
@@ -5,5 +5,6 @@
export * from "./subscription-checkout-button"; export * from "./subscription-checkout-button";
export * from "./subscription-coins-offer-section"; export * from "./subscription-coins-offer-section";
export * from "./subscription-cta-button"; export * from "./subscription-cta-button";
export * from "./subscription-payment-method";
export * from "./subscription-payment-success-dialog"; export * from "./subscription-payment-success-dialog";
export * from "./subscription-vip-offer-section"; export * from "./subscription-vip-offer-section";
@@ -4,7 +4,7 @@ import Image from "next/image";
import type { PayChannel } from "@/data/schemas/payment"; import type { PayChannel } from "@/data/schemas/payment";
const PAYMENT_METHODS: readonly { const PAYMENT_METHODS: {
channel: PayChannel; channel: PayChannel;
title: string; title: string;
logoSrc?: string; logoSrc?: string;
@@ -20,23 +20,21 @@ const PAYMENT_METHODS: readonly {
}, },
]; ];
export interface PaymentMethodSelectorProps { export interface SubscriptionPaymentMethodProps {
value: PayChannel; value: PayChannel;
defaultChannel?: PayChannel; defaultChannel?: PayChannel;
disabled?: boolean; disabled?: boolean;
caption?: string; caption?: string;
analyticsKey: string;
onChange: (channel: PayChannel) => void; onChange: (channel: PayChannel) => void;
} }
export function PaymentMethodSelector({ export function SubscriptionPaymentMethod({
value, value,
defaultChannel = "stripe", defaultChannel = "stripe",
disabled = false, disabled = false,
caption = "Stripe by default", caption = "Stripe by default",
analyticsKey,
onChange, onChange,
}: PaymentMethodSelectorProps) { }: SubscriptionPaymentMethodProps) {
const paymentMethods = const paymentMethods =
defaultChannel === "ezpay" defaultChannel === "ezpay"
? [...PAYMENT_METHODS].sort((a, b) => ? [...PAYMENT_METHODS].sort((a, b) =>
@@ -77,7 +75,7 @@ export function PaymentMethodSelector({
<button <button
key={method.channel} key={method.channel}
type="button" type="button"
data-analytics-key={analyticsKey} data-analytics-key="subscription.payment_method"
data-analytics-label="Select payment method" data-analytics-label="Select payment method"
className={classes} className={classes}
disabled={disabled} disabled={disabled}
@@ -1,4 +1,7 @@
import type { PaymentPlan } from "@/data/schemas/payment"; import type { PayChannel, PaymentPlan } from "@/data/schemas/payment";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { AppEnvUtil } from "@/utils/app-env";
export { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section"; import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section";
import type { VipOfferPlanView } from "./components/subscription-vip-offer-section"; import type { VipOfferPlanView } from "./components/subscription-vip-offer-section";
@@ -12,6 +15,24 @@ export interface FirstRechargeOfferView {
renewalNotice: string | null; renewalNotice: string | null;
} }
export function canChooseSubscriptionPayChannel(
countryCode: string | null | undefined,
): boolean {
if (!AppEnvUtil.isProduction()) return true;
return countryCode?.trim().toUpperCase() === "PH";
}
export function resolveSubscriptionPayChannel(input: {
countryCode: string | null | undefined;
requestedPayChannel: PayChannel | null | undefined;
}): PayChannel {
if (!canChooseSubscriptionPayChannel(input.countryCode)) return "stripe";
return (
input.requestedPayChannel ??
getDefaultPayChannelForCountryCode(input.countryCode)
);
}
export function isVipPlan(plan: PaymentPlan): boolean { export function isVipPlan(plan: PaymentPlan): boolean {
return plan.vipDays !== null; return plan.vipDays !== null;
} }
+43 -21
View File
@@ -1,10 +1,8 @@
"use client"; "use client";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useRef } from "react";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { Checkbox, MobileShell } from "@/app/_components/core"; import { Checkbox, MobileShell } from "@/app/_components/core";
import { PaymentMethodSelector } from "@/app/_components/payment/payment-method-selector";
import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selection";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics"; import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import { AppConstants } from "@/core/app_constants"; import { AppConstants } from "@/core/app_constants";
import type { PayChannel } from "@/data/schemas/payment"; import type { PayChannel } from "@/data/schemas/payment";
@@ -15,20 +13,22 @@ import {
getDefaultPaymentAnalyticsContext, getDefaultPaymentAnalyticsContext,
type PaymentAnalyticsContext, type PaymentAnalyticsContext,
} from "@/lib/analytics"; } from "@/lib/analytics";
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
import { useUserState } from "@/stores/user/user-context"; import { useUserState } from "@/stores/user/user-context";
import { import {
SubscriptionCheckoutButton, SubscriptionCheckoutButton,
SubscriptionCoinsOfferSection, SubscriptionCoinsOfferSection,
SubscriptionPaymentMethod,
SubscriptionPaymentSuccessDialog, SubscriptionPaymentSuccessDialog,
SubscriptionVipOfferSection, SubscriptionVipOfferSection,
} from "./components"; } from "./components";
import styles from "./components/subscription-screen.module.css"; import styles from "./components/subscription-screen.module.css";
import { import {
findSelectedSubscriptionPlan, findSelectedSubscriptionPlan,
canChooseSubscriptionPayChannel,
getFirstRechargeOfferView, getFirstRechargeOfferView,
getDefaultSubscriptionPlanId, getDefaultSubscriptionPlanId,
resolveSubscriptionPayChannel,
toCoinsOfferPlanViews, toCoinsOfferPlanViews,
toVipOfferPlanViews, toVipOfferPlanViews,
type SubscriptionType, type SubscriptionType,
@@ -56,10 +56,13 @@ export function SubscriptionScreen({
}: SubscriptionScreenProps) { }: SubscriptionScreenProps) {
const userState = useUserState(); const userState = useUserState();
const countryCode = userState.currentUser?.countryCode; const countryCode = userState.currentUser?.countryCode;
const paymentMethodConfig = getPaymentMethodConfig({ const canChoosePaymentMethod =
canChooseSubscriptionPayChannel(countryCode);
const resolvedInitialPayChannel = resolveSubscriptionPayChannel({
countryCode, countryCode,
requestedPayChannel: initialPayChannel, requestedPayChannel: initialPayChannel,
}); });
const phDefaultPayChannelAppliedRef = useRef(false);
const { const {
payment, payment,
paymentDispatch, paymentDispatch,
@@ -71,7 +74,7 @@ export function SubscriptionScreen({
shouldResumePendingOrder, shouldResumePendingOrder,
returnTo, returnTo,
characterSlug, characterSlug,
initialPayChannel: paymentMethodConfig.initialPayChannel, initialPayChannel: resolvedInitialPayChannel,
}); });
const canSubscribeVip = subscriptionType === "vip"; const canSubscribeVip = subscriptionType === "vip";
const analyticsContext = const analyticsContext =
@@ -131,20 +134,35 @@ export function SubscriptionScreen({
paymentDispatch({ type: "PaymentPlanSelected", planId }); paymentDispatch({ type: "PaymentPlanSelected", planId });
}; };
const handlePaymentMethodChange = (payChannel: PayChannel) => { useEffect(() => {
if (isPaymentBusy) return;
if (!canChoosePaymentMethod) {
if (payment.payChannel === "stripe") return;
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel: "stripe",
});
return;
}
if (initialPayChannel !== null) return;
if (phDefaultPayChannelAppliedRef.current) return;
phDefaultPayChannelAppliedRef.current = true;
if (payment.payChannel === resolvedInitialPayChannel) return;
paymentDispatch({ paymentDispatch({
type: "PaymentPayChannelChanged", type: "PaymentPayChannelChanged",
payChannel, payChannel: resolvedInitialPayChannel,
}); });
}; }, [
canChoosePaymentMethod,
usePaymentMethodSelection({ initialPayChannel,
config: paymentMethodConfig,
currentPayChannel: payment.payChannel,
isPaymentBusy, isPaymentBusy,
requestedPayChannel: initialPayChannel, payment.payChannel,
onChange: handlePaymentMethodChange, paymentDispatch,
}); resolvedInitialPayChannel,
]);
useEffect(() => { useEffect(() => {
const defaultPlanId = getDefaultSubscriptionPlanId({ const defaultPlanId = getDefaultSubscriptionPlanId({
@@ -224,15 +242,19 @@ export function SubscriptionScreen({
/> />
</div> </div>
{paymentMethodConfig.canChoosePaymentMethod ? ( {canChoosePaymentMethod ? (
<section className={styles.paymentMethodSlot}> <section className={styles.paymentMethodSlot}>
<PaymentMethodSelector <SubscriptionPaymentMethod
value={payment.payChannel} value={payment.payChannel}
defaultChannel={paymentMethodConfig.initialPayChannel} defaultChannel={resolvedInitialPayChannel}
disabled={isPaymentBusy} disabled={isPaymentBusy}
caption="GCash by default in the Philippines" caption="GCash by default in the Philippines"
analyticsKey="subscription.payment_method" onChange={(payChannel) => {
onChange={handlePaymentMethodChange} paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel,
});
}}
/> />
</section> </section>
) : null} ) : null}
-5
View File
@@ -57,7 +57,6 @@
.header, .header,
.hero, .hero,
.productCard, .productCard,
.paymentMethodSlot,
.statusMessage, .statusMessage,
.successCard, .successCard,
.checkoutSlot { .checkoutSlot {
@@ -408,10 +407,6 @@
text-align: center; text-align: center;
} }
.paymentMethodSlot {
margin-top: clamp(18px, 5.185vw, 28px);
}
.successCard { .successCard {
justify-content: flex-start; justify-content: flex-start;
border-color: rgba(255, 84, 135, 0.16); border-color: rgba(255, 84, 135, 0.16);
+3 -38
View File
@@ -6,8 +6,6 @@ import { ArrowLeft, Heart, Sparkles } from "lucide-react";
import { CharacterAvatar } from "@/app/_components"; import { CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { PaymentMethodSelector } from "@/app/_components/payment/payment-method-selector";
import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selection";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics"; import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow"; import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
import type { PayChannel } from "@/data/schemas/payment"; import type { PayChannel } from "@/data/schemas/payment";
@@ -15,7 +13,6 @@ import {
behaviorAnalytics, behaviorAnalytics,
type PaymentAnalyticsContext, type PaymentAnalyticsContext,
} from "@/lib/analytics"; } from "@/lib/analytics";
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
import { import {
buildTipCoffeePath, buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE, DEFAULT_TIP_COFFEE_TYPE,
@@ -29,7 +26,6 @@ import {
useActiveCharacterRoutes, useActiveCharacterRoutes,
} from "@/providers/character-provider"; } from "@/providers/character-provider";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useUserState } from "@/stores/user/user-context";
import { TipCheckoutButton } from "./tip-checkout-button"; import { TipCheckoutButton } from "./tip-checkout-button";
import { import {
@@ -63,11 +59,6 @@ export function TipScreen({
const character = useActiveCharacter(); const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const authState = useAuthState(); const authState = useAuthState();
const userState = useUserState();
const paymentMethodConfig = getPaymentMethodConfig({
countryCode: userState.currentUser?.countryCode,
requestedPayChannel: initialPayChannel,
});
const [selectedCoffeeType, setSelectedCoffeeType] = const [selectedCoffeeType, setSelectedCoffeeType] =
useState<TipCoffeeType>(coffeeType); useState<TipCoffeeType>(coffeeType);
const coffeeOption = getTipCoffeeOption(selectedCoffeeType); const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
@@ -75,9 +66,11 @@ export function TipScreen({
selectedCoffeeType, selectedCoffeeType,
characterRoutes.tip, characterRoutes.tip,
); );
const resolvedInitialPayChannel =
initialPayChannel ?? navigator.getDefaultPayChannel();
const { payment, paymentDispatch } = usePaymentRouteFlow({ const { payment, paymentDispatch } = usePaymentRouteFlow({
catalog: "tip", catalog: "tip",
initialPayChannel: paymentMethodConfig.initialPayChannel, initialPayChannel: resolvedInitialPayChannel,
paymentType: "tip", paymentType: "tip",
shouldResumePendingOrder, shouldResumePendingOrder,
}); });
@@ -127,21 +120,6 @@ export function TipScreen({
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy;
const isAuthLoading = !authState.hasInitialized || authState.isLoading; const isAuthLoading = !authState.hasInitialized || authState.isLoading;
const handlePaymentMethodChange = (payChannel: PayChannel) => {
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel,
});
};
usePaymentMethodSelection({
config: paymentMethodConfig,
currentPayChannel: payment.payChannel,
isPaymentBusy,
requestedPayChannel: initialPayChannel,
onChange: handlePaymentMethodChange,
});
useEffect(() => { useEffect(() => {
if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) { if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) {
return; return;
@@ -292,19 +270,6 @@ export function TipScreen({
/> />
</section> </section>
{paymentMethodConfig.canChoosePaymentMethod ? (
<section className={styles.paymentMethodSlot}>
<PaymentMethodSelector
value={payment.payChannel}
defaultChannel={paymentMethodConfig.initialPayChannel}
disabled={isPaymentBusy}
caption="GCash by default in the Philippines"
analyticsKey="tip.payment_method"
onChange={handlePaymentMethodChange}
/>
</section>
) : null}
{showMissingPlan ? ( {showMissingPlan ? (
<p className={styles.statusMessage} role="alert"> <p className={styles.statusMessage} role="alert">
Coffee tip is not available yet. Please try again later. Coffee tip is not available yet. Please try again later.
@@ -1,65 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getDefaultPayChannelForCountryCode } from "../default_pay_channel";
import {
canChoosePayChannel,
getPaymentMethodConfig,
resolvePayChannel,
} from "../payment_method";
describe("payment method rules", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("defaults Philippines users to GCash and other countries to Stripe", () => {
expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay");
expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay");
expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe");
expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe");
});
it("only allows Philippines users to choose in production", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
expect(canChoosePayChannel("PH")).toBe(true);
expect(canChoosePayChannel("ph")).toBe(true);
expect(canChoosePayChannel("HK")).toBe(false);
expect(canChoosePayChannel(null)).toBe(false);
expect(
getPaymentMethodConfig({
countryCode: null,
requestedPayChannel: "ezpay",
}),
).toEqual({
canChoosePaymentMethod: false,
initialPayChannel: "stripe",
});
});
it("resolves requested and default channels for Philippines", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
expect(
resolvePayChannel({ countryCode: "PH", requestedPayChannel: null }),
).toBe("ezpay");
expect(
resolvePayChannel({
countryCode: "PH",
requestedPayChannel: "stripe",
}),
).toBe("stripe");
});
it("allows every user and keeps the requested channel outside production", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
expect(canChoosePayChannel(null)).toBe(true);
expect(
resolvePayChannel({
countryCode: "HK",
requestedPayChannel: "ezpay",
}),
).toBe("ezpay");
});
});
-37
View File
@@ -1,37 +0,0 @@
import type { PayChannel } from "@/data/schemas/payment";
import { AppEnvUtil } from "@/utils/app-env";
import { getDefaultPayChannelForCountryCode } from "./default_pay_channel";
export interface PaymentMethodConfig {
canChoosePaymentMethod: boolean;
initialPayChannel: PayChannel;
}
export function canChoosePayChannel(
countryCode: string | null | undefined,
): boolean {
if (!AppEnvUtil.isProduction()) return true;
return countryCode?.trim().toUpperCase() === "PH";
}
export function resolvePayChannel(input: {
countryCode: string | null | undefined;
requestedPayChannel: PayChannel | null | undefined;
}): PayChannel {
if (!canChoosePayChannel(input.countryCode)) return "stripe";
return (
input.requestedPayChannel ??
getDefaultPayChannelForCountryCode(input.countryCode)
);
}
export function getPaymentMethodConfig(input: {
countryCode: string | null | undefined;
requestedPayChannel: PayChannel | null | undefined;
}): PaymentMethodConfig {
return {
canChoosePaymentMethod: canChoosePayChannel(input.countryCode),
initialPayChannel: resolvePayChannel(input),
};
}