feat(tip): add coffee tipping page

This commit is contained in:
2026-07-08 19:21:42 +08:00
parent c1aba64573
commit cb7791dd8d
14 changed files with 1224 additions and 4 deletions
+243
View File
@@ -0,0 +1,243 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
getPaymentUrl,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
} from "@/lib/payment/payment_launch";
import { ROUTES } from "@/router/routes";
import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import { AppEnvUtil, Logger } from "@/utils";
import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog";
import dialogStyles from "../subscription/components/stripe-payment-dialog.module.css";
import styles from "./tip-screen.module.css";
const log = new Logger("TipCheckoutButton");
export interface TipCheckoutButtonProps {
disabled?: boolean;
isAuthLoading?: boolean;
onOrder: () => void;
}
export function TipCheckoutButton({
disabled = false,
isAuthLoading = false,
onOrder,
}: TipCheckoutButtonProps) {
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null
>(null);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return;
}
launchedNonceRef.current = payment.launchNonce;
const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) return;
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
const isEzpay = isEzpayPayment(payment.payParams);
if (!AppEnvUtil.isProduction() && isEzpay) {
log.debug("[tip-checkout] ezpay confirmation required", {
orderId: payment.currentOrderId,
paymentUrl,
});
return;
}
if (isEzpay) {
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType: "tip",
onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
});
return;
}
window.location.href = paymentUrl;
return;
}
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage:
"Payment parameters did not include a supported URL or Stripe client secret.",
});
}, [
payment.currentOrderId,
payment.launchNonce,
payment.payParams,
paymentDispatch,
]);
const isLoading =
isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder;
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const shouldShowStripeDialog =
stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid;
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
const shouldShowEzpayConfirmDialog =
!AppEnvUtil.isProduction() &&
payment.payParams &&
isEzpayPayment(payment.payParams) &&
ezpayPaymentUrl &&
payment.currentOrderId
? true
: false;
const label = payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: payment.isPaid
? "Thanks for the coffee"
: "Order and Buy";
const handleStripeClose = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
if (payment.orderStatus !== "paid") {
paymentDispatch({ type: "PaymentReset" });
}
};
const handleStripeConfirmed = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
};
const handleEzpayConfirm = () => {
if (!payment.currentOrderId || !ezpayPaymentUrl) return;
setIsConfirmingEzpay(true);
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType: "tip",
onFailed: (errorMessage) => {
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
});
};
const handleEzpayCancel = () => {
log.debug("[tip-checkout] ezpay confirmation cancelled", {
orderId: payment.currentOrderId,
});
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentReset" });
};
return (
<>
<button
type="button"
className={styles.checkoutButton}
disabled={disabled || isLoading}
onClick={onOrder}
>
{label}
</button>
{payment.errorMessage ? (
<p className={styles.checkoutError} role="alert">
{payment.errorMessage}
</p>
) : null}
{shouldShowEzpayConfirmDialog ? (
<EzpayRedirectConfirmDialog
orderId={payment.currentOrderId ?? ""}
isConfirming={isConfirmingEzpay}
onCancel={handleEzpayCancel}
onConfirm={handleEzpayConfirm}
/>
) : null}
{shouldShowStripeDialog ? (
<StripePaymentDialog
clientSecret={stripeClientSecret}
returnPath={ROUTES.tip}
onClose={handleStripeClose}
onConfirmed={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"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
>
{isConfirming ? "Opening..." : "Continue"}
</button>
</div>
</div>
</div>
);
}