Compare commits
31 Commits
pre
...
38ae06fe04
| Author | SHA1 | Date | |
|---|---|---|---|
| 38ae06fe04 | |||
| 995fdbda57 | |||
| ed822c159f | |||
| d5b7a1f36c | |||
| 9f829bbc0e | |||
| d01441b8c7 | |||
| ddacd03601 | |||
| 1b121a8ef8 | |||
| b7221f2f70 | |||
| 30f88d3a20 | |||
| 7b09a7f850 | |||
| 38a4645b9c | |||
| 76bffc1a0d | |||
| 19b8fc51d6 | |||
| 9fbf180df6 | |||
| 6721b6eb43 | |||
| 64ba720121 | |||
| b04ef58855 | |||
| 606e6d60ff | |||
| c659c5fc3f | |||
| 537a0a2c36 | |||
| b1f52c68e8 | |||
| 3c7b0c30e0 | |||
| 469512df18 | |||
| 318e4991be | |||
| 2d432e5367 | |||
| d6f104ee3f | |||
| 9ca56c2a04 | |||
| 4639acf232 | |||
| bdd53a6ea1 | |||
| fb9e30cfd1 |
@@ -26,7 +26,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({
|
||||
);
|
||||
await expect(page).toHaveURL(defaultCharacterChatUrl);
|
||||
|
||||
const unlockButton = page.getByRole("button", {
|
||||
const promotedImageCard = page
|
||||
.getByRole("group", { name: "Locked private image" })
|
||||
.last();
|
||||
const unlockButton = promotedImageCard.getByRole("button", {
|
||||
name: "Unlock private image",
|
||||
});
|
||||
await expect(unlockButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
|
||||
|
||||
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||
import type { PaymentAnalyticsTriggerReason } from "@/lib/analytics/payment_analytics_context";
|
||||
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
@@ -15,6 +16,18 @@ export interface PaymentGuidanceCardProps {
|
||||
guidance: PaymentGuidance;
|
||||
}
|
||||
|
||||
function triggerReasonForGuidance(
|
||||
scene: PaymentGuidance["scene"],
|
||||
): PaymentAnalyticsTriggerReason {
|
||||
if (scene === "supportCharacter" || scene === "vip" || scene === "purchaseOptions") {
|
||||
return "vip_cta";
|
||||
}
|
||||
if (scene === "privateMessageLocked" || scene === "historyLocked") {
|
||||
return "private_topic_limit";
|
||||
}
|
||||
return "insufficient_credits";
|
||||
}
|
||||
|
||||
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
||||
const character = useActiveCharacter();
|
||||
const navigator = useAppNavigator();
|
||||
@@ -91,7 +104,7 @@ export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
||||
chatActionId: guidance.guidanceId,
|
||||
analytics: {
|
||||
entryPoint: "chat_input",
|
||||
triggerReason: "insufficient_credits",
|
||||
triggerReason: triggerReasonForGuidance(guidance.scene),
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -170,6 +170,13 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mocks.payment.isFirstRecharge = false;
|
||||
mocks.payment.commercialOffer = null;
|
||||
for (const plan of mocks.payment.plans) {
|
||||
delete (plan as Record<string, unknown>).isFirstRechargeOffer;
|
||||
delete (plan as Record<string, unknown>).firstRechargeDiscountPercent;
|
||||
delete (plan as Record<string, unknown>).promotionType;
|
||||
}
|
||||
mocks.payment.selectedPlanId = "vip_monthly";
|
||||
mocks.paymentDispatch.mockClear();
|
||||
mocks.planClick.mockClear();
|
||||
@@ -275,6 +282,22 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
expect(guidance?.textContent).toContain("cannot continue");
|
||||
expect(guidance?.textContent).not.toContain("Elio");
|
||||
});
|
||||
|
||||
it("does not render the first recharge banner on the subscription page", () => {
|
||||
mocks.payment.isFirstRecharge = true;
|
||||
Object.assign(mocks.payment.plans[0] as Record<string, unknown>, {
|
||||
isFirstRechargeOffer: true,
|
||||
firstRechargeDiscountPercent: 50,
|
||||
promotionType: "first_recharge_half_price",
|
||||
});
|
||||
|
||||
act(() => root.render(<SubscriptionScreen />));
|
||||
|
||||
expect(container.textContent).not.toContain("First Recharge Offer");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Your first recharge price is already applied",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function clickButton(root: ParentNode, label: string): void {
|
||||
|
||||
@@ -3,8 +3,12 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Result } from "@/utils/result";
|
||||
import { ApiError } from "@/data/services/api/api_result";
|
||||
|
||||
import { SubscriptionPaymentIssueDialog } from "../subscription-payment-issue-dialog";
|
||||
import {
|
||||
getPaymentIssueSubmitErrorLogDetails,
|
||||
SubscriptionPaymentIssueDialog,
|
||||
} from "../subscription-payment-issue-dialog";
|
||||
import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -200,6 +204,28 @@ describe("subscription payment dialogs", () => {
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps HTTP status and backend error code in payment issue failure diagnostics", () => {
|
||||
const failure = Result.err(
|
||||
new ApiError("HTTP_ERROR", "Invalid feedback context", 400, {
|
||||
detail: {
|
||||
message: "Invalid feedback context",
|
||||
errorCode: "INVALID_FEEDBACK_CONTEXT",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (failure.success) throw new Error("Expected failure result");
|
||||
expect(getPaymentIssueSubmitErrorLogDetails(failure.error)).toMatchObject({
|
||||
code: "UNKNOWN",
|
||||
message: "Invalid feedback context",
|
||||
causeName: "ApiError",
|
||||
causeMessage: "Invalid feedback context",
|
||||
httpStatus: 400,
|
||||
apiCode: "HTTP_ERROR",
|
||||
serverErrorCode: "INVALID_FEEDBACK_CONTEXT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clickButton(root: ParentNode | null, label: string): void {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||
import { createFeedbackContext } from "@/app/feedback/feedback-context";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import { submitFeedback } from "@/lib/feedback";
|
||||
import { Logger } from "@/utils/logger";
|
||||
|
||||
import type { SubscriptionType } from "../subscription-screen.helpers";
|
||||
import styles from "./subscription-dialog.module.css";
|
||||
@@ -33,6 +34,21 @@ const OTHER_DETAILS_MIN_LENGTH = 10;
|
||||
const OTHER_DETAILS_MAX_LENGTH = 2000;
|
||||
const SUCCESS_MESSAGE = "Thanks. Your payment issue has been submitted.";
|
||||
const FAILURE_MESSAGE = "Unable to submit. Please try again.";
|
||||
const log = new Logger("SubscriptionPaymentIssueDialog");
|
||||
|
||||
interface ErrorRecord {
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PaymentIssueSubmitErrorLogDetails {
|
||||
code: string;
|
||||
message: string;
|
||||
causeName?: string;
|
||||
causeMessage?: string;
|
||||
httpStatus?: number;
|
||||
apiCode?: string;
|
||||
serverErrorCode?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPaymentIssueDialogProps {
|
||||
open: boolean;
|
||||
@@ -119,6 +135,19 @@ export function SubscriptionPaymentIssueDialog({
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
log.error(
|
||||
{
|
||||
...getPaymentIssueSubmitErrorLogDetails(result.error),
|
||||
paymentIssueReason: reason,
|
||||
subscriptionType,
|
||||
planId,
|
||||
orderId,
|
||||
payChannel,
|
||||
countryCode,
|
||||
characterId,
|
||||
},
|
||||
"Payment issue feedback submit failed",
|
||||
);
|
||||
setErrorMessage(FAILURE_MESSAGE);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
@@ -206,6 +235,32 @@ export function SubscriptionPaymentIssueDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function getPaymentIssueSubmitErrorLogDetails(
|
||||
error: Error,
|
||||
): PaymentIssueSubmitErrorLogDetails {
|
||||
const errorRecord = toErrorRecord(error);
|
||||
const cause = toErrorRecord(errorRecord?.cause);
|
||||
const responseDetails = toErrorRecord(cause?.details);
|
||||
const detail = toErrorRecord(responseDetails?.detail);
|
||||
const causeName = stringValue(cause?.name);
|
||||
const causeMessage = stringValue(cause?.message);
|
||||
const httpStatus = numberValue(cause?.status);
|
||||
const apiCode = stringValue(cause?.code);
|
||||
const serverErrorCode =
|
||||
stringValue(detail?.errorCode) ??
|
||||
stringValue(responseDetails?.errorCode);
|
||||
|
||||
return {
|
||||
code: stringValue(errorRecord?.code) ?? "UNKNOWN",
|
||||
message: error.message,
|
||||
...(causeName ? { causeName } : {}),
|
||||
...(causeMessage ? { causeMessage } : {}),
|
||||
...(httpStatus !== undefined ? { httpStatus } : {}),
|
||||
...(apiCode ? { apiCode } : {}),
|
||||
...(serverErrorCode ? { serverErrorCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
const token =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
@@ -213,3 +268,18 @@ function createIdempotencyKey(): string {
|
||||
: `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
return `payment_feedback_${token}`;
|
||||
}
|
||||
|
||||
function toErrorRecord(value: unknown): ErrorRecord | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
return value as ErrorRecord;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -131,14 +131,14 @@ export function SubscriptionScreen({
|
||||
});
|
||||
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
|
||||
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
|
||||
const firstRechargeOffer = useMemo(
|
||||
const hasFirstRechargeOffer = useMemo(
|
||||
() =>
|
||||
getFirstRechargeOfferView({
|
||||
isFirstRecharge: payment.isFirstRecharge,
|
||||
subscriptionType,
|
||||
vipPlans: vipOfferPlans,
|
||||
coinPlans: directCoinsPlans,
|
||||
}),
|
||||
}) !== null,
|
||||
[
|
||||
directCoinsPlans,
|
||||
payment.isFirstRecharge,
|
||||
@@ -284,31 +284,7 @@ export function SubscriptionScreen({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{firstRechargeOffer ? (
|
||||
<section
|
||||
className={styles.firstRechargeBanner}
|
||||
aria-label="First recharge offer"
|
||||
>
|
||||
<span className={styles.firstRechargeBadge}>
|
||||
{firstRechargeOffer.badgeText}
|
||||
</span>
|
||||
<div className={styles.firstRechargeCopy}>
|
||||
<h2 className={styles.firstRechargeTitle}>
|
||||
{firstRechargeOffer.title}
|
||||
</h2>
|
||||
<p className={styles.firstRechargeSubtitle}>
|
||||
{firstRechargeOffer.subtitle}
|
||||
</p>
|
||||
{firstRechargeOffer.renewalNotice ? (
|
||||
<p className={styles.firstRechargeRenewalNotice}>
|
||||
{firstRechargeOffer.renewalNotice}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{payment.commercialOffer && !firstRechargeOffer ? (
|
||||
{payment.commercialOffer && !hasFirstRechargeOffer ? (
|
||||
<section
|
||||
className={styles.firstRechargeBanner}
|
||||
aria-label={`${sourceCharacter.shortName} private offer`}
|
||||
|
||||
@@ -27,6 +27,16 @@ describe("payment guidance wire contract", () => {
|
||||
expect(PaymentGuidanceSchema.parse(guidance)).toEqual(guidance);
|
||||
});
|
||||
|
||||
it("accepts the supportCharacter guidance scene", () => {
|
||||
expect(
|
||||
PaymentGuidanceSchema.parse({
|
||||
...guidance,
|
||||
scene: "supportCharacter",
|
||||
ruleId: "payment_guidance_supportCharacter",
|
||||
}).scene,
|
||||
).toBe("supportCharacter");
|
||||
});
|
||||
|
||||
it("keeps all backend lock facts instead of dropping hint and CTA", () => {
|
||||
expect(
|
||||
ChatLockDetailSchema.parse({
|
||||
|
||||
@@ -24,6 +24,7 @@ export const PaymentGuidanceSceneSchema = z.enum([
|
||||
"privateZoneLocked",
|
||||
"unknownLock",
|
||||
"whyPay",
|
||||
"supportCharacter",
|
||||
"leavingAfterCredits",
|
||||
"paymentIssue",
|
||||
"externalRisk",
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("sendResponseToUiMessage", () => {
|
||||
copy: "Buy me a coffee?",
|
||||
ctaLabel: "View gifts",
|
||||
target: "giftCatalog",
|
||||
ruleId: "coffee_support_question",
|
||||
ruleId: "coffee_after_thanks",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -127,7 +127,7 @@ describe("sendResponseToUiMessage", () => {
|
||||
copy: "Buy me a coffee?",
|
||||
ctaLabel: "View gifts",
|
||||
target: "giftCatalog",
|
||||
ruleId: "coffee_support_question",
|
||||
ruleId: "coffee_after_thanks",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user