refactor(payment): share checkout dialogs

This commit is contained in:
2026-07-14 18:23:08 +08:00
parent 0033625866
commit 5bec04a970
8 changed files with 187 additions and 148 deletions
@@ -0,0 +1,52 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = {
handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(),
handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false,
shouldShowEzpayConfirmDialog: false,
shouldShowStripeDialog: false,
stripeClientSecret: null,
};
describe("PaymentLaunchDialogs", () => {
it("renders nothing when neither payment dialog is active", () => {
expect(
renderToStaticMarkup(
<PaymentLaunchDialogs
currentOrderId={null}
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Continue to payment."
launch={hiddenLaunch}
/>,
),
).toBe("");
});
it("renders the shared Ezpay confirmation content", () => {
const html = renderToStaticMarkup(
<PaymentLaunchDialogs
currentOrderId="order-123"
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Your coffee order is ready."
launch={{
...hiddenLaunch,
isConfirmingEzpay: true,
shouldShowEzpayConfirmDialog: true,
}}
/>,
);
expect(html).toContain('role="alertdialog"');
expect(html).toContain("Continue to GCash?");
expect(html).toContain("Your coffee order is ready.");
expect(html).toContain("Order No. order-123");
expect(html).toContain('data-analytics-key="tip.external_checkout"');
expect(html).toContain("Opening...");
});
});
@@ -0,0 +1,118 @@
"use client";
import { useId, type ReactNode } from "react";
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow,
| "handleEzpayCancel"
| "handleEzpayConfirm"
| "handleStripeClose"
| "handleStripeConfirmed"
| "isConfirmingEzpay"
| "shouldShowEzpayConfirmDialog"
| "shouldShowStripeDialog"
| "stripeClientSecret"
>;
export interface PaymentLaunchDialogsProps {
currentOrderId: string | null;
externalCheckoutAnalyticsKey: string;
ezpayDescription: ReactNode;
launch: PaymentLaunchDialogFlow;
stripeReturnPath?: string;
}
export function PaymentLaunchDialogs({
currentOrderId,
externalCheckoutAnalyticsKey,
ezpayDescription,
launch,
stripeReturnPath,
}: PaymentLaunchDialogsProps) {
return (
<>
{launch.shouldShowEzpayConfirmDialog ? (
<EzpayRedirectConfirmDialog
orderId={currentOrderId ?? ""}
description={ezpayDescription}
externalCheckoutAnalyticsKey={externalCheckoutAnalyticsKey}
isConfirming={launch.isConfirmingEzpay}
onCancel={launch.handleEzpayCancel}
onConfirm={launch.handleEzpayConfirm}
/>
) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
<LazyStripePaymentDialog
clientSecret={launch.stripeClientSecret}
returnPath={stripeReturnPath}
onClose={launch.handleStripeClose}
onConfirmed={launch.handleStripeConfirmed}
/>
) : null}
</>
);
}
interface EzpayRedirectConfirmDialogProps {
description: ReactNode;
externalCheckoutAnalyticsKey: string;
isConfirming: boolean;
onCancel: () => void;
onConfirm: () => void;
orderId: string;
}
function EzpayRedirectConfirmDialog({
description,
externalCheckoutAnalyticsKey,
isConfirming,
onCancel,
onConfirm,
orderId,
}: EzpayRedirectConfirmDialogProps) {
const titleId = useId();
return (
<div
className={styles.overlay}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className={styles.dialog}>
<div className={styles.header}>
<h2 id={titleId} className={styles.title}>
Continue to GCash?
</h2>
<p className={styles.content}>{description}</p>
<p className={styles.content}>Order No. {orderId}</p>
</div>
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.secondary}`}
disabled={isConfirming}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
data-analytics-key={externalCheckoutAnalyticsKey}
data-analytics-label="Continue to external checkout"
className={`${styles.button} ${styles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
>
{isConfirming ? "Opening..." : "Continue"}
</button>
</div>
</div>
</div>
);
}
@@ -1,10 +1,11 @@
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
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 { SubscriptionPaymentMethod } from "../subscription-payment-method";
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog"; import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
import { StripePaymentDialog } from "../stripe-payment-dialog";
describe("subscription Tailwind components", () => { describe("subscription Tailwind components", () => {
it("renders SubscriptionCtaButton with loading state", () => { it("renders SubscriptionCtaButton with loading state", () => {
@@ -5,14 +5,13 @@
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。 * 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
*/ */
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
import { import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
} from "@/stores/payment/payment-context"; } from "@/stores/payment/payment-context";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
import { stripePaymentDialogStyles as dialogStyles } from "./stripe-payment-dialog.styles";
import { SubscriptionCtaButton } from "./subscription-cta-button"; import { SubscriptionCtaButton } from "./subscription-cta-button";
const log = new Logger("SubscriptionCheckoutButton"); const log = new Logger("SubscriptionCheckoutButton");
@@ -88,77 +87,12 @@ export function SubscriptionCheckoutButton({
{payment.errorMessage} {payment.errorMessage}
</p> </p>
) : null} ) : null}
{paymentLaunch.shouldShowEzpayConfirmDialog ? ( <PaymentLaunchDialogs
<EzpayRedirectConfirmDialog currentOrderId={payment.currentOrderId}
orderId={payment.currentOrderId ?? ""} externalCheckoutAnalyticsKey="subscription.external_checkout"
isConfirming={paymentLaunch.isConfirmingEzpay} ezpayDescription="Your order has been created. Continue to GCash to finish the payment."
onCancel={paymentLaunch.handleEzpayCancel} launch={paymentLaunch}
onConfirm={paymentLaunch.handleEzpayConfirm} />
/>
) : null}
{paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? (
<LazyStripePaymentDialog
clientSecret={paymentLaunch.stripeClientSecret}
onClose={paymentLaunch.handleStripeClose}
onConfirmed={paymentLaunch.handleStripeConfirmed}
/>
) : null}
</> </>
); );
} }
interface EzpayRedirectConfirmDialogProps {
orderId: string;
isConfirming: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function EzpayRedirectConfirmDialog({
orderId,
isConfirming,
onCancel,
onConfirm,
}: EzpayRedirectConfirmDialogProps) {
return (
<div
className={dialogStyles.overlay}
role="alertdialog"
aria-modal="true"
aria-labelledby="ezpay-redirect-title"
>
<div className={dialogStyles.dialog}>
<div className={dialogStyles.header}>
<h2 id="ezpay-redirect-title" className={dialogStyles.title}>
Continue to GCash?
</h2>
<p className={dialogStyles.content}>
Your order has been created. Continue to GCash to finish the
payment.
</p>
<p className={dialogStyles.content}>Order No. {orderId}</p>
</div>
<div className={dialogStyles.actions}>
<button
type="button"
className={`${dialogStyles.button} ${dialogStyles.secondary}`}
disabled={isConfirming}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
data-analytics-key="subscription.external_checkout"
data-analytics-label="Continue to external checkout"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
>
{isConfirming ? "Opening..." : "Continue"}
</button>
</div>
</div>
</div>
);
}
+8 -74
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
import type { TipCoffeeType } from "@/lib/tip/tip_coffee"; import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
import { import {
usePaymentDispatch, usePaymentDispatch,
@@ -8,8 +9,6 @@ import {
} from "@/stores/payment/payment-context"; } from "@/stores/payment/payment-context";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { LazyStripePaymentDialog } from "../subscription/components/lazy-stripe-payment-dialog";
import { stripePaymentDialogStyles as dialogStyles } from "../subscription/components/stripe-payment-dialog.styles";
import styles from "./tip-screen.module.css"; import styles from "./tip-screen.module.css";
const log = new Logger("TipCheckoutButton"); const log = new Logger("TipCheckoutButton");
@@ -67,78 +66,13 @@ export function TipCheckoutButton({
{payment.errorMessage} {payment.errorMessage}
</p> </p>
) : null} ) : null}
{paymentLaunch.shouldShowEzpayConfirmDialog ? ( <PaymentLaunchDialogs
<EzpayRedirectConfirmDialog currentOrderId={payment.currentOrderId}
orderId={payment.currentOrderId ?? ""} externalCheckoutAnalyticsKey="tip.external_checkout"
isConfirming={paymentLaunch.isConfirmingEzpay} ezpayDescription="Your coffee order is ready. Continue to GCash to finish the payment."
onCancel={paymentLaunch.handleEzpayCancel} launch={paymentLaunch}
onConfirm={paymentLaunch.handleEzpayConfirm} stripeReturnPath={returnPath}
/> />
) : null}
{paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? (
<LazyStripePaymentDialog
clientSecret={paymentLaunch.stripeClientSecret}
returnPath={returnPath}
onClose={paymentLaunch.handleStripeClose}
onConfirmed={paymentLaunch.handleStripeConfirmed}
/>
) : null}
</> </>
); );
} }
interface EzpayRedirectConfirmDialogProps {
orderId: string;
isConfirming: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function EzpayRedirectConfirmDialog({
orderId,
isConfirming,
onCancel,
onConfirm,
}: EzpayRedirectConfirmDialogProps) {
return (
<div
className={dialogStyles.overlay}
role="alertdialog"
aria-modal="true"
aria-labelledby="tip-ezpay-redirect-title"
>
<div className={dialogStyles.dialog}>
<div className={dialogStyles.header}>
<h2 id="tip-ezpay-redirect-title" className={dialogStyles.title}>
Continue to GCash?
</h2>
<p className={dialogStyles.content}>
Your coffee order is ready. Continue to GCash to finish the
payment.
</p>
<p className={dialogStyles.content}>Order No. {orderId}</p>
</div>
<div className={dialogStyles.actions}>
<button
type="button"
className={`${dialogStyles.button} ${dialogStyles.secondary}`}
disabled={isConfirming}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
data-analytics-key="tip.external_checkout"
data-analytics-label="Continue to external checkout"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
>
{isConfirming ? "Opening..." : "Continue"}
</button>
</div>
</div>
</div>
);
}