fix(payment): inherit chat recipient without selector
Docker Image / Build and Push Docker Image (push) Successful in 2m42s

This commit is contained in:
Codex
2026-07-28 19:33:59 +08:00
parent 74b7eae18b
commit 3536045794
7 changed files with 78 additions and 129 deletions
@@ -210,11 +210,13 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=vip");
await page.goto("/subscription?type=vip&character=elio");
await expect(page.getByText("Choose who you want to support")).toBeVisible();
await page.getByRole("button", { name: "Elio", exact: true }).click();
await expect(page.getByText("Supporting Elio")).toBeVisible();
await expect(page.getByText("Choose who you want to support")).toHaveCount(0);
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Elio", exact: true }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
"aria-pressed",
@@ -255,8 +257,8 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup");
await page.getByRole("button", { name: "Elio", exact: true }).click();
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
await expectCheckoutButtonLayout(page);
const orderId = await expectQrisOrder(
@@ -34,7 +34,7 @@ describe("SubscriptionPage", () => {
);
});
it("requires an explicit selection when source navigation has no character", async () => {
it("keeps the recipient empty when source navigation has no character", async () => {
const page = await SubscriptionPage({
searchParams: Promise.resolve({}),
});
@@ -219,7 +219,7 @@ describe("SubscriptionScreen payment selection flow", () => {
});
it("confirms VIP selection before enabling the separate checkout button", () => {
act(() => root.render(<SubscriptionScreen />));
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
@@ -250,7 +250,7 @@ describe("SubscriptionScreen payment selection flow", () => {
});
it("keeps the original selection when VIP confirmation is cancelled", () => {
act(() => root.render(<SubscriptionScreen />));
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_quarterly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
@@ -265,21 +265,21 @@ describe("SubscriptionScreen payment selection flow", () => {
});
it("requires VIP confirmation again after the page is remounted", () => {
act(() => root.render(<SubscriptionScreen />));
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_monthly"));
act(() => clickButton(container, "Confirm"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => root.unmount());
root = createRoot(container);
act(() => root.render(<SubscriptionScreen />));
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
});
it("selects a coin package without showing the renewal dialog", () => {
act(() => root.render(<SubscriptionScreen />));
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "coin_1000"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
@@ -319,7 +319,7 @@ describe("SubscriptionScreen payment selection flow", () => {
promotionType: "first_recharge_half_price",
});
act(() => root.render(<SubscriptionScreen />));
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
expect(container.textContent).not.toContain("First Recharge Offer");
expect(container.textContent).not.toContain(
@@ -327,7 +327,38 @@ describe("SubscriptionScreen payment selection flow", () => {
);
});
it("requires a support character selection when opened without a character", () => {
it.each(["elio", "maya", "nayeli"])(
"inherits the %s chat character without rendering a recipient selector",
(sourceCharacterSlug) => {
mocks.payment.selectedPlanId = "coin_1000";
act(() =>
root.render(
<SubscriptionScreen
subscriptionType="topup"
sourceCharacterSlug={sourceCharacterSlug}
/>,
),
);
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(container.textContent).not.toContain("Supporting ");
expect(container.textContent).not.toContain(
"Choose who you want to support",
);
expect(container.textContent).not.toContain(
"Your VIP or credit purchase supports the character you choose",
);
expect(checkout?.disabled).toBe(false);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug }),
);
},
);
it("does not invent a recipient when opened without a source character", () => {
mocks.payment.selectedPlanId = "coin_1000";
act(() =>
@@ -342,19 +373,17 @@ describe("SubscriptionScreen payment selection flow", () => {
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(container.textContent).toContain("Choose who you want to support");
expect(container.textContent).not.toContain("Supporting ");
expect(container.textContent).not.toContain(
"Choose who you want to support",
);
expect(container.textContent).not.toContain(
"Your VIP or credit purchase supports the character you choose",
);
expect(checkout?.disabled).toBe(true);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: null }),
);
act(() => clickButton(container, "Maya"));
expect(container.textContent).toContain("Supporting Maya");
expect(checkout?.disabled).toBe(false);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: "maya" }),
);
});
});
@@ -61,52 +61,6 @@
box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12);
}
.supportCharacterSelector {
margin-top: var(--page-section-gap, 14px);
padding: 14px 16px;
border: 1px solid rgba(246, 87, 160, 0.2);
border-radius: var(--responsive-card-radius-sm, 22px);
background: rgba(255, 255, 255, 0.9);
color: #4a3340;
}
.supportCharacterSelector h2 {
margin: 0;
color: #24151d;
font-size: var(--responsive-card-title, 17px);
}
.supportCharacterSelector p {
margin: 6px 0 10px;
font-size: var(--responsive-caption, 13px);
line-height: 1.4;
}
.supportCharacterOptions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.supportCharacterOption,
.supportCharacterOptionActive {
border: 1px solid rgba(246, 87, 160, 0.24);
border-radius: 999px;
padding: 7px 12px;
background: #fff;
color: #8e315e;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 800;
}
.supportCharacterOptionActive {
border-color: #f657a0;
background: #f657a0;
color: #fff;
}
.characterSupportBanner {
margin-top: var(--page-section-gap, 14px);
padding: 14px 16px;
+3 -41
View File
@@ -8,10 +8,7 @@ import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selec
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import type { PayChannel } from "@/data/schemas/payment";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import {
behaviorAnalytics,
@@ -65,7 +62,7 @@ export function SubscriptionScreen({
returnTo = null,
initialPayChannel = null,
analyticsContext: providedAnalyticsContext,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
sourceCharacterSlug = null,
initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null,
@@ -81,14 +78,8 @@ export function SubscriptionScreen({
null,
);
const characterCatalog = useCharacterCatalog();
const [selectedSupportCharacterSlug, setSelectedSupportCharacterSlug] =
useState<string | null>(() =>
getCharacterBySlug(sourceCharacterSlug)?.slug ?? null,
);
const userState = useUserState();
const sourceCharacter = characterCatalog.getBySlug(
selectedSupportCharacterSlug,
);
const sourceCharacter = characterCatalog.getBySlug(sourceCharacterSlug);
const hasHydrated = useHasHydrated();
const countryCode = userState.currentUser?.countryCode;
const paymentMethodConfig = getPaymentMethodConfig({
@@ -278,35 +269,6 @@ export function SubscriptionScreen({
</p>
) : null}
<section className={styles.supportCharacterSelector}>
<h2>
{sourceCharacter
? `Supporting ${sourceCharacter.shortName}`
: "Choose who you want to support"}
</h2>
<p>
Your VIP or credit purchase supports the character you choose, and
they will thank you after the payment and benefits are confirmed.
</p>
<div className={styles.supportCharacterOptions}>
{characterCatalog.characters.map((character) => (
<button
key={character.id}
type="button"
className={
sourceCharacter?.id === character.id
? styles.supportCharacterOptionActive
: styles.supportCharacterOption
}
aria-pressed={sourceCharacter?.id === character.id}
onClick={() => setSelectedSupportCharacterSlug(character.slug)}
>
{character.shortName}
</button>
))}
</div>
</section>
{chatActionId && sourceCharacter ? (
<section
className={styles.characterSupportBanner}
@@ -7,7 +7,6 @@ import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
import type { PayChannel } from "@/data/schemas/payment";
import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import {
@@ -36,7 +35,7 @@ export function useSubscriptionPaymentFlow({
shouldResumePendingOrder,
returnTo,
initialPayChannel,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
sourceCharacterSlug = null,
initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null,
@@ -105,25 +105,28 @@ describe("payment order flow", () => {
actor.stop();
});
it("binds a default VIP or credit order to the selected support character", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
).start();
actor.send({ type: "PaymentInit", characterId: "maya-tan" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
it.each(["elio", "maya-tan", "nayeli-cervantes"])(
"binds a default VIP or credit order to the source chat character %s",
async (characterId) => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
).start();
actor.send({ type: "PaymentInit", characterId });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
expect(createOrderSpy).toHaveBeenCalledWith({
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
recipientCharacterId: "maya-tan",
});
actor.stop();
});
expect(createOrderSpy).toHaveBeenCalledWith({
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
recipientCharacterId: characterId,
});
actor.stop();
},
);
it("carries the originating chat action into order creation", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();