fix(payment): inherit chat recipient without selector
Docker Image / Build and Push Docker Image (push) Successful in 2m30s
Docker Image / Build and Push Docker Image (push) Successful in 2m30s
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);
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
await prepareIndonesiaUser(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 expect(page.getByText("Choose who you want to support")).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Elio", exact: true }).click();
|
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||||
await expect(page.getByText("Supporting Elio")).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Elio", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"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 }) => {
|
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||||
const payment = await registerIndonesiaPaymentMocks(page);
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
await prepareIndonesiaUser(page);
|
await prepareIndonesiaUser(page);
|
||||||
await page.goto("/subscription?type=topup");
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
await page.getByRole("button", { name: "Elio", exact: true }).click();
|
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||||
await expectCheckoutButtonLayout(page);
|
await expectCheckoutButtonLayout(page);
|
||||||
|
|
||||||
const orderId = await expectQrisOrder(
|
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({
|
const page = await SubscriptionPage({
|
||||||
searchParams: Promise.resolve({}),
|
searchParams: Promise.resolve({}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ describe("SubscriptionScreen payment selection flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("confirms VIP selection before enabling the separate checkout button", () => {
|
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>(
|
const checkout = container.querySelector<HTMLButtonElement>(
|
||||||
'[data-testid="checkout"]',
|
'[data-testid="checkout"]',
|
||||||
);
|
);
|
||||||
@@ -250,7 +250,7 @@ describe("SubscriptionScreen payment selection flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the original selection when VIP confirmation is cancelled", () => {
|
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"));
|
act(() => clickButton(container, "vip_quarterly"));
|
||||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
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", () => {
|
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, "vip_monthly"));
|
||||||
act(() => clickButton(container, "Confirm"));
|
act(() => clickButton(container, "Confirm"));
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
act(() => root.render(<SubscriptionScreen />));
|
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||||
act(() => clickButton(container, "vip_monthly"));
|
act(() => clickButton(container, "vip_monthly"));
|
||||||
|
|
||||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects a coin package without showing the renewal dialog", () => {
|
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"));
|
act(() => clickButton(container, "coin_1000"));
|
||||||
|
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
@@ -319,7 +319,7 @@ describe("SubscriptionScreen payment selection flow", () => {
|
|||||||
promotionType: "first_recharge_half_price",
|
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("First Recharge Offer");
|
||||||
expect(container.textContent).not.toContain(
|
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";
|
mocks.payment.selectedPlanId = "coin_1000";
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
@@ -342,19 +373,17 @@ describe("SubscriptionScreen payment selection flow", () => {
|
|||||||
const checkout = container.querySelector<HTMLButtonElement>(
|
const checkout = container.querySelector<HTMLButtonElement>(
|
||||||
'[data-testid="checkout"]',
|
'[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(checkout?.disabled).toBe(true);
|
||||||
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ sourceCharacterSlug: null }),
|
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);
|
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 {
|
.characterSupportBanner {
|
||||||
margin-top: var(--page-section-gap, 14px);
|
margin-top: var(--page-section-gap, 14px);
|
||||||
padding: 14px 16px;
|
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 { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
import {
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
DEFAULT_CHARACTER_SLUG,
|
|
||||||
getCharacterBySlug,
|
|
||||||
} from "@/data/constants/character";
|
|
||||||
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||||
import {
|
import {
|
||||||
behaviorAnalytics,
|
behaviorAnalytics,
|
||||||
@@ -65,7 +62,7 @@ export function SubscriptionScreen({
|
|||||||
returnTo = null,
|
returnTo = null,
|
||||||
initialPayChannel = null,
|
initialPayChannel = null,
|
||||||
analyticsContext: providedAnalyticsContext,
|
analyticsContext: providedAnalyticsContext,
|
||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = null,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
initialAutoRenew = null,
|
initialAutoRenew = null,
|
||||||
commercialOfferId = null,
|
commercialOfferId = null,
|
||||||
@@ -81,14 +78,8 @@ export function SubscriptionScreen({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const characterCatalog = useCharacterCatalog();
|
const characterCatalog = useCharacterCatalog();
|
||||||
const [selectedSupportCharacterSlug, setSelectedSupportCharacterSlug] =
|
|
||||||
useState<string | null>(() =>
|
|
||||||
getCharacterBySlug(sourceCharacterSlug)?.slug ?? null,
|
|
||||||
);
|
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
const sourceCharacter = characterCatalog.getBySlug(
|
const sourceCharacter = characterCatalog.getBySlug(sourceCharacterSlug);
|
||||||
selectedSupportCharacterSlug,
|
|
||||||
);
|
|
||||||
const hasHydrated = useHasHydrated();
|
const hasHydrated = useHasHydrated();
|
||||||
const countryCode = userState.currentUser?.countryCode;
|
const countryCode = userState.currentUser?.countryCode;
|
||||||
const paymentMethodConfig = getPaymentMethodConfig({
|
const paymentMethodConfig = getPaymentMethodConfig({
|
||||||
@@ -278,35 +269,6 @@ export function SubscriptionScreen({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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 ? (
|
{chatActionId && sourceCharacter ? (
|
||||||
<section
|
<section
|
||||||
className={styles.characterSupportBanner}
|
className={styles.characterSupportBanner}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
|||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CHARACTER,
|
DEFAULT_CHARACTER,
|
||||||
DEFAULT_CHARACTER_SLUG,
|
|
||||||
getCharacterBySlug,
|
getCharacterBySlug,
|
||||||
} from "@/data/constants/character";
|
} from "@/data/constants/character";
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +35,7 @@ export function useSubscriptionPaymentFlow({
|
|||||||
shouldResumePendingOrder,
|
shouldResumePendingOrder,
|
||||||
returnTo,
|
returnTo,
|
||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = null,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
initialAutoRenew = null,
|
initialAutoRenew = null,
|
||||||
commercialOfferId = null,
|
commercialOfferId = null,
|
||||||
|
|||||||
@@ -105,12 +105,14 @@ describe("payment order flow", () => {
|
|||||||
actor.stop();
|
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 createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||||
const actor = createActor(
|
const actor = createActor(
|
||||||
createTestPaymentMachine({ createOrderSpy }),
|
createTestPaymentMachine({ createOrderSpy }),
|
||||||
).start();
|
).start();
|
||||||
actor.send({ type: "PaymentInit", characterId: "maya-tan" });
|
actor.send({ type: "PaymentInit", characterId });
|
||||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||||
|
|
||||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||||
@@ -120,10 +122,11 @@ describe("payment order flow", () => {
|
|||||||
planId: "vip_monthly",
|
planId: "vip_monthly",
|
||||||
payChannel: "stripe",
|
payChannel: "stripe",
|
||||||
autoRenew: true,
|
autoRenew: true,
|
||||||
recipientCharacterId: "maya-tan",
|
recipientCharacterId: characterId,
|
||||||
});
|
});
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("carries the originating chat action into order creation", async () => {
|
it("carries the originating chat action into order creation", async () => {
|
||||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||||
|
|||||||
Reference in New Issue
Block a user