fix(payment): inherit chat recipient without selector
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,12 +105,14 @@ describe("payment order flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("binds a default VIP or credit order to the selected support character", async () => {
|
||||
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: "maya-tan" });
|
||||
actor.send({ type: "PaymentInit", characterId });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
@@ -120,10 +122,11 @@ describe("payment order flow", () => {
|
||||
planId: "vip_monthly",
|
||||
payChannel: "stripe",
|
||||
autoRenew: true,
|
||||
recipientCharacterId: "maya-tan",
|
||||
recipientCharacterId: characterId,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("carries the originating chat action into order creation", async () => {
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
|
||||
Reference in New Issue
Block a user