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
@@ -27,12 +27,14 @@ const stripePromise = stripePublishableKey
export interface StripePaymentDialogProps {
clientSecret: string;
returnPath?: string;
onClose: () => void;
onConfirmed?: () => void;
}
export function StripePaymentDialog({
clientSecret,
returnPath = ROUTES.subscription + "/success",
onClose,
onConfirmed,
}: StripePaymentDialogProps) {
@@ -91,7 +93,11 @@ export function StripePaymentDialog({
},
}}
>
<StripePaymentForm onClose={onClose} onConfirmed={onConfirmed} />
<StripePaymentForm
returnPath={returnPath}
onClose={onClose}
onConfirmed={onConfirmed}
/>
</Elements>
</div>
</div>
@@ -99,9 +105,13 @@ export function StripePaymentDialog({
}
function StripePaymentForm({
returnPath,
onClose,
onConfirmed,
}: Pick<StripePaymentDialogProps, "onClose" | "onConfirmed">) {
}: Pick<
StripePaymentDialogProps,
"returnPath" | "onClose" | "onConfirmed"
>) {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -141,7 +151,7 @@ function StripePaymentForm({
}
const returnUrl = new URL(
ROUTES.subscription + "/success",
returnPath ?? ROUTES.subscription + "/success",
window.location.origin,
);
const { error } = await stripe.confirmPayment({
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { PaymentPlan } from "@/data/dto/payment";
import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
import {
findTipCoffeePlan,
formatTipPrice,
isRealLoginStatus,
} from "../tip-screen.helpers";
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
return PaymentPlan.from({
planId: "coins_100",
planName: "Coins",
orderType: "dol",
vipDays: null,
dolAmount: 100,
creditBalance: 100,
amountCents: 990,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null,
promotionType: null,
...input,
});
}
describe("tip screen helpers", () => {
it("prefers the exact tip coffee plan id", () => {
const byOrderType = makePlan({
planId: "legacy_coffee",
planName: "Legacy coffee",
orderType: "tip_coffee",
});
const byPlanId = makePlan({
planId: "tip_coffee",
planName: "Coffee",
orderType: "custom_tip",
});
expect(findTipCoffeePlan([byOrderType, byPlanId])).toBe(byPlanId);
});
it("falls back to the tip coffee order type", () => {
const plan = makePlan({
planId: "legacy_coffee",
orderType: "tip_coffee",
});
expect(findTipCoffeePlan([makePlan({}), plan])).toBe(plan);
});
it("formats USD prices with the product-style label", () => {
expect(formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }))).toBe(
"US$ 5",
);
});
it("treats guest and notLoggedIn as non-real login states", () => {
expect(isRealLoginStatus("guest")).toBe(false);
expect(isRealLoginStatus("notLoggedIn")).toBe(false);
expect(isRealLoginStatus("facebook")).toBe(true);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { Suspense } from "react";
import { MobileShell } from "@/app/_components/core";
import { TipPageClient } from "./tip-page-client";
export default function TipPage() {
return (
<Suspense fallback={<TipFallback />}>
<TipPageClient />
</Suspense>
);
}
function TipFallback() {
return (
<MobileShell background="#fff6ed">
<div
style={{
minHeight: "60vh",
display: "grid",
placeItems: "center",
padding: "2rem",
color: "#8b6265",
}}
>
Loading tip...
</div>
</MobileShell>
);
}
+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>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { useSearchParams } from "next/navigation";
import type { PayChannel } from "@/data/dto/payment";
import { TipScreen } from "./tip-screen";
function toPayChannel(value: string | null): PayChannel | null {
if (value === "ezpay" || value === "stripe") return value;
return null;
}
export function TipPageClient() {
const searchParams = useSearchParams();
return (
<TipScreen
shouldResumePendingOrder={searchParams.get("paymentReturn") === "1"}
initialPayChannel={toPayChannel(searchParams.get("payChannel"))}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { LoginStatus } from "@/data/dto/auth";
import type { PaymentPlan } from "@/data/dto/payment";
const TIP_COFFEE_PLAN_ID = "tip_coffee";
const TIP_COFFEE_ORDER_TYPE = "tip_coffee";
export function findTipCoffeePlan(
plans: readonly PaymentPlan[],
): PaymentPlan | null {
return (
plans.find((plan) => plan.planId === TIP_COFFEE_PLAN_ID) ??
plans.find((plan) => plan.orderType === TIP_COFFEE_ORDER_TYPE) ??
null
);
}
export function formatTipPrice(plan: PaymentPlan | null): string {
if (!plan) return "US$ 5";
const amount = plan.amountCents / 100;
const formattedAmount = Number.isInteger(amount)
? String(amount)
: amount.toFixed(2).replace(/\.?0+$/, "");
const currency = plan.currency.trim().toUpperCase();
if (currency === "USD") return `US$ ${formattedAmount}`;
if (currency.length > 0) return `${currency} ${formattedAmount}`;
return formattedAmount;
}
export function isRealLoginStatus(loginStatus: LoginStatus): boolean {
return loginStatus !== "notLoggedIn" && loginStatus !== "guest";
}
+463
View File
@@ -0,0 +1,463 @@
.shell {
position: relative;
min-height: var(--app-viewport-height, 100dvh);
padding:
calc(var(--app-safe-top, 0px) + clamp(18px, 4.444vw, 24px))
calc(var(--app-safe-right, 0px) + clamp(20px, 5.556vw, 30px))
calc(var(--app-safe-bottom, 0px) + clamp(24px, 6.667vw, 36px))
calc(var(--app-safe-left, 0px) + clamp(20px, 5.556vw, 30px));
overflow: hidden auto;
background:
radial-gradient(circle at 18% 12%, rgba(255, 181, 105, 0.34), transparent 32%),
radial-gradient(circle at 86% 24%, rgba(255, 85, 139, 0.2), transparent 30%),
linear-gradient(180deg, #fff2e8 0%, #fff8f2 46%, #ffffff 100%);
color: #25191b;
}
.bgImage,
.bgGlowOne,
.bgGlowTwo {
position: absolute;
pointer-events: none;
}
.bgImage {
inset: 0 0 auto;
height: 330px;
background:
linear-gradient(180deg, rgba(255, 242, 232, 0.3), #fff2e8 96%),
url("/images/splash/pic-bg-home.png") center 18% / cover no-repeat;
opacity: 0.36;
filter: saturate(0.95) blur(0.2px);
}
.bgGlowOne,
.bgGlowTwo {
z-index: 0;
border-radius: 999px;
filter: blur(10px);
}
.bgGlowOne {
top: 192px;
right: -86px;
width: 190px;
height: 190px;
background: rgba(255, 92, 142, 0.16);
}
.bgGlowTwo {
bottom: 108px;
left: -98px;
width: 210px;
height: 210px;
background: rgba(255, 181, 104, 0.18);
}
.header,
.hero,
.productCard,
.summaryCard,
.statusMessage,
.successCard,
.checkoutSlot {
position: relative;
z-index: 1;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.backButton {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #382429;
box-shadow: 0 12px 26px rgba(112, 67, 66, 0.12);
cursor: pointer;
backdrop-filter: blur(18px);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.backButton:active {
transform: scale(0.96);
}
.headerPill {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 14px;
border: 1px solid rgba(255, 116, 151, 0.22);
border-radius: 999px;
background: rgba(255, 255, 255, 0.66);
color: #9b5360;
font-size: clamp(12px, 2.963vw, 14px);
font-weight: 850;
letter-spacing: 0.03em;
backdrop-filter: blur(18px);
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
margin-top: clamp(18px, 5.185vw, 28px);
text-align: center;
animation: floatIn 0.5s ease both;
}
.avatarRing {
display: grid;
width: clamp(88px, 22.222vw, 112px);
height: clamp(88px, 22.222vw, 112px);
place-items: center;
border: 4px solid rgba(255, 255, 255, 0.9);
border-radius: 30px;
background: linear-gradient(145deg, #ffbd7c, #ff5d91);
box-shadow:
0 20px 44px rgba(255, 98, 133, 0.24),
inset 0 0 0 1px rgba(255, 255, 255, 0.32);
overflow: hidden;
}
.avatarRing img {
width: 100%;
height: 100%;
object-fit: cover;
}
.eyebrow {
margin: 16px 0 0;
color: #b35d63;
font-size: clamp(12px, 3.148vw, 15px);
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.title {
max-width: 320px;
margin: 8px 0 0;
color: #231518;
font-size: clamp(34px, 9.259vw, 48px);
font-weight: 950;
letter-spacing: -0.055em;
line-height: 0.92;
}
.subtitle {
max-width: 330px;
margin: 14px 0 0;
color: #7c6062;
font-size: clamp(14px, 3.704vw, 18px);
font-weight: 620;
line-height: 1.52;
}
.productCard {
display: grid;
grid-template-columns: minmax(112px, 0.85fr) 1fr;
align-items: center;
gap: clamp(16px, 4.444vw, 24px);
margin-top: clamp(24px, 7.407vw, 38px);
padding: clamp(18px, 5.185vw, 28px);
border: 1px solid rgba(118, 61, 55, 0.08);
border-radius: clamp(30px, 8.148vw, 44px);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(255, 237, 223, 0.78)),
#ffffff;
box-shadow:
0 26px 62px rgba(119, 72, 67, 0.13),
inset 0 1px 0 rgba(255, 255, 255, 0.86);
animation: floatIn 0.5s 0.08s ease both;
}
.coffeeStage {
position: relative;
display: grid;
min-height: 172px;
place-items: center;
border-radius: 30px;
background:
radial-gradient(circle at 52% 36%, rgba(255, 197, 123, 0.32), transparent 28%),
linear-gradient(145deg, #fff8ed, #ffe1cc);
box-shadow: inset 0 0 0 1px rgba(126, 66, 57, 0.06);
}
.cup {
position: relative;
width: 92px;
height: 76px;
margin-top: 28px;
border-radius: 0 0 30px 30px;
background: linear-gradient(145deg, #ffffff, #ffe8dd);
box-shadow:
0 18px 30px rgba(108, 63, 56, 0.15),
inset -10px -12px 22px rgba(255, 137, 105, 0.12);
}
.coffeeSurface {
position: absolute;
top: -13px;
left: 8px;
width: 76px;
height: 28px;
border: 7px solid #fff8f3;
border-radius: 999px;
background: radial-gradient(circle at 35% 40%, #d4905d, #7f4b32 72%);
}
.cupHandle {
position: absolute;
top: 18px;
right: -25px;
width: 34px;
height: 38px;
border: 8px solid #ffe8dd;
border-left: 0;
border-radius: 0 999px 999px 0;
}
.saucer {
position: absolute;
bottom: 34px;
width: 118px;
height: 20px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.74);
box-shadow: 0 12px 22px rgba(105, 59, 48, 0.12);
}
.steamOne,
.steamTwo,
.steamThree {
position: absolute;
top: 34px;
width: 10px;
height: 44px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 137, 128, 0), rgba(255, 137, 128, 0.42));
filter: blur(0.2px);
animation: steam 2.4s ease-in-out infinite;
}
.steamOne {
left: 48%;
}
.steamTwo {
left: 38%;
animation-delay: 0.28s;
}
.steamThree {
left: 58%;
animation-delay: 0.56s;
}
.productCopy {
min-width: 0;
}
.productBadge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 11px;
border-radius: 999px;
background: rgba(255, 91, 142, 0.1);
color: #b84d65;
font-size: clamp(11px, 2.778vw, 13px);
font-weight: 900;
}
.productName {
margin: 15px 0 0;
color: #231719;
font-size: clamp(28px, 7.407vw, 40px);
font-weight: 950;
letter-spacing: -0.045em;
line-height: 1;
}
.productPrice {
margin: 10px 0 0;
color: #ff4f86;
font-size: clamp(25px, 6.667vw, 36px);
font-weight: 950;
letter-spacing: -0.04em;
}
.summaryCard,
.successCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 16px;
padding: 16px 18px;
border: 1px solid rgba(118, 61, 55, 0.08);
border-radius: 24px;
background: rgba(255, 255, 255, 0.76);
box-shadow: 0 16px 38px rgba(119, 72, 67, 0.09);
backdrop-filter: blur(16px);
}
.summaryLabel,
.summaryValue,
.statusMessage,
.successTitle,
.successText {
margin: 0;
}
.summaryLabel {
color: #a18181;
font-size: 12px;
font-weight: 780;
}
.summaryValue {
margin-top: 4px;
color: #2a1b1e;
font-size: clamp(14px, 3.519vw, 17px);
font-weight: 880;
}
.statusMessage {
margin-top: 14px;
color: #b2474f;
font-size: 14px;
font-weight: 720;
line-height: 1.45;
text-align: center;
}
.successCard {
justify-content: flex-start;
border-color: rgba(255, 84, 135, 0.16);
background: rgba(255, 246, 249, 0.86);
color: #ff4f86;
}
.successTitle {
color: #25191b;
font-size: 15px;
font-weight: 900;
}
.successText {
margin-top: 2px;
color: #8a686b;
font-size: 13px;
font-weight: 620;
}
.successButton {
margin-left: auto;
border: 0;
border-radius: 999px;
padding: 9px 12px;
background: #231719;
color: #ffffff;
font: inherit;
font-size: 12px;
font-weight: 850;
cursor: pointer;
}
.checkoutSlot {
margin-top: clamp(18px, 5.185vw, 28px);
}
.checkoutButton {
width: 100%;
min-height: 58px;
border: 0;
border-radius: 999px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.2), transparent 34%),
linear-gradient(135deg, #1f1719 0%, #6d3f49 48%, #ff548a 100%);
color: #ffffff;
cursor: pointer;
font: inherit;
font-size: clamp(16px, 4.074vw, 19px);
font-weight: 940;
letter-spacing: -0.01em;
box-shadow:
0 18px 40px rgba(255, 83, 137, 0.28),
0 8px 18px rgba(37, 23, 27, 0.18);
transition: transform 0.18s ease, filter 0.18s ease, box-shadow 0.18s ease;
}
.checkoutButton:disabled {
cursor: not-allowed;
filter: grayscale(0.25);
opacity: 0.62;
box-shadow: 0 10px 24px rgba(77, 52, 55, 0.12);
}
.checkoutButton:not(:disabled):active {
transform: translateY(1px) scale(0.985);
}
.checkoutError {
margin: 10px 0 0;
color: #b2474f;
font-size: 14px;
font-weight: 720;
line-height: 1.45;
text-align: center;
}
@media (max-width: 380px) {
.productCard {
grid-template-columns: 1fr;
text-align: center;
}
.coffeeStage {
min-height: 152px;
}
}
@media (hover: hover) {
.backButton:hover,
.checkoutButton:not(:disabled):hover {
transform: translateY(-1px);
}
}
@keyframes floatIn {
from {
opacity: 0;
transform: translateY(18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes steam {
0%,
100% {
opacity: 0.34;
transform: translateY(8px) scaleY(0.82);
}
50% {
opacity: 0.76;
transform: translateY(-8px) scaleY(1.08);
}
}
+325
View File
@@ -0,0 +1,325 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import Image from "next/image";
import { ArrowLeft, Heart, Sparkles } from "lucide-react";
import { MobileShell } from "@/app/_components/core";
import type { PayChannel } from "@/data/dto/payment";
import {
clearPendingPaymentOrder,
getPendingPaymentOrderForType,
} from "@/lib/payment/pending_payment_order";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useAuthState } from "@/stores/auth/auth-context";
import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import { TipCheckoutButton } from "./tip-checkout-button";
import {
findTipCoffeePlan,
formatTipPrice,
isRealLoginStatus,
} from "./tip-screen.helpers";
import styles from "./tip-screen.module.css";
export interface TipScreenProps {
shouldResumePendingOrder?: boolean;
initialPayChannel?: PayChannel | null;
}
export function TipScreen({
shouldResumePendingOrder = false,
initialPayChannel = null,
}: TipScreenProps) {
const navigator = useAppNavigator();
const authState = useAuthState();
const userState = useUserState();
const userDispatch = useUserDispatch();
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const initialPayChannelAppliedRef = useRef(false);
const resumedPendingOrderRef = useRef<string | null>(null);
const refreshedPaidOrderRef = useRef<string | null>(null);
const resolvedInitialPayChannel =
initialPayChannel ?? navigator.getDefaultPayChannel();
const coffeePlan = useMemo(
() => findTipCoffeePlan(payment.plans),
[payment.plans],
);
const priceLabel = formatTipPrice(coffeePlan);
const isPaymentBusy =
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
const canCreateOrder =
coffeePlan !== null &&
payment.selectedPlanId === coffeePlan.planId &&
payment.agreed &&
!payment.autoRenew &&
!payment.isLoadingPlans &&
!isPaymentBusy;
const showMissingPlan =
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null;
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
useEffect(() => {
if (payment.status === "idle") {
initialPayChannelAppliedRef.current = true;
paymentDispatch({
type: "PaymentInit",
payChannel: resolvedInitialPayChannel,
});
return;
}
if (!initialPayChannelAppliedRef.current && payment.status === "ready") {
initialPayChannelAppliedRef.current = true;
if (payment.payChannel === resolvedInitialPayChannel) return;
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel: resolvedInitialPayChannel,
});
}
}, [
payment.payChannel,
payment.status,
paymentDispatch,
resolvedInitialPayChannel,
]);
useEffect(() => {
if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) {
return;
}
if (!coffeePlan) return;
if (payment.selectedPlanId !== coffeePlan.planId) {
paymentDispatch({
type: "PaymentPlanSelected",
planId: coffeePlan.planId,
});
return;
}
if (payment.autoRenew) {
paymentDispatch({ type: "PaymentAutoRenewChanged", autoRenew: false });
return;
}
if (!payment.agreed) {
paymentDispatch({ type: "PaymentAgreementChanged", agreed: true });
}
}, [
coffeePlan,
payment.agreed,
payment.autoRenew,
payment.isCreatingOrder,
payment.isLoadingPlans,
payment.isPollingOrder,
payment.selectedPlanId,
paymentDispatch,
]);
useEffect(() => {
const canInspectPendingOrder =
payment.status === "ready" ||
(!shouldResumePendingOrder &&
(payment.isPollingOrder ||
payment.isPaid ||
payment.status === "failed"));
if (!canInspectPendingOrder) return;
let cancelled = false;
const handlePendingOrder = async () => {
const result = await getPendingPaymentOrderForType("tip");
if (cancelled || !result.success || result.data === null) return;
if (!shouldResumePendingOrder) {
await clearPendingPaymentOrder();
if (
payment.currentOrderId === result.data.orderId &&
(payment.isPollingOrder ||
payment.isPaid ||
payment.status === "failed")
) {
paymentDispatch({ type: "PaymentReset" });
}
return;
}
if (payment.currentOrderId === result.data.orderId) return;
if (resumedPendingOrderRef.current === result.data.orderId) return;
resumedPendingOrderRef.current = result.data.orderId;
paymentDispatch({
type: "PaymentReturned",
orderId: result.data.orderId,
createdAt: result.data.createdAt,
});
};
void handlePendingOrder();
return () => {
cancelled = true;
};
}, [
payment.currentOrderId,
payment.isPaid,
payment.isPollingOrder,
payment.status,
paymentDispatch,
shouldResumePendingOrder,
]);
useEffect(() => {
if (!payment.currentOrderId) return;
if (!payment.isPaid && payment.status !== "failed") return;
void clearPendingPaymentOrder();
}, [payment.currentOrderId, payment.isPaid, payment.status]);
useEffect(() => {
if (!payment.isPaid || !payment.currentOrderId) return;
if (refreshedPaidOrderRef.current === payment.currentOrderId) return;
refreshedPaidOrderRef.current = payment.currentOrderId;
userDispatch({ type: "UserFetch" });
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
const handleOrder = () => {
if (isAuthLoading) return;
if (!isRealLoginStatus(authState.loginStatus)) {
navigator.openAuth(ROUTES.tip);
return;
}
if (!canCreateOrder) return;
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
};
const handleBack = () => {
navigator.back();
};
const handleResetPaidState = () => {
paymentDispatch({ type: "PaymentReset" });
};
return (
<MobileShell background="#fff5ed">
<main className={styles.shell}>
<div className={styles.bgImage} aria-hidden="true" />
<div className={styles.bgGlowOne} aria-hidden="true" />
<div className={styles.bgGlowTwo} aria-hidden="true" />
<header className={styles.header}>
<button
type="button"
className={styles.backButton}
aria-label="Go back"
onClick={handleBack}
>
<ArrowLeft size={19} aria-hidden="true" />
</button>
<span className={styles.headerPill}>Tip Elio</span>
</header>
<section className={styles.hero} aria-labelledby="tip-title">
<div className={styles.avatarRing}>
<Image
src="/images/chat/pic-chat-elio.png"
alt="Elio Silvestri"
width={88}
height={88}
priority
/>
</div>
<p className={styles.eyebrow}>A little sweetness for today</p>
<h1 id="tip-title" className={styles.title}>
Buy Elio a coffee
</h1>
<p className={styles.subtitle}>
Send a warm coffee tip and keep the private moments glowing.
</p>
</section>
<section className={styles.productCard} aria-label="Coffee tip product">
<div className={styles.coffeeStage} aria-hidden="true">
<div className={styles.steamOne} />
<div className={styles.steamTwo} />
<div className={styles.steamThree} />
<div className={styles.cup}>
<div className={styles.coffeeSurface} />
<div className={styles.cupHandle} />
</div>
<div className={styles.saucer} />
</div>
<div className={styles.productCopy}>
<span className={styles.productBadge}>
<Sparkles size={14} aria-hidden="true" />
Coffee Gift
</span>
<h2 className={styles.productName}>
{coffeePlan?.planName || "拿铁"}
</h2>
<p className={styles.productPrice}>{priceLabel}</p>
</div>
</section>
<section className={styles.summaryCard} aria-label="Order summary">
<div>
<p className={styles.summaryLabel}>For</p>
<p className={styles.summaryValue}>Elio Silvestri</p>
</div>
<div>
<p className={styles.summaryLabel}>Balance</p>
<p className={styles.summaryValue}>
{userState.creditBalance} credits
</p>
</div>
</section>
{showMissingPlan ? (
<p className={styles.statusMessage} role="alert">
Coffee tip is not available yet. Please try again later.
</p>
) : null}
{payment.isPaid ? (
<section className={styles.successCard} aria-live="polite">
<Heart size={18} aria-hidden="true" />
<div>
<p className={styles.successTitle}>Coffee sent</p>
<p className={styles.successText}>
Thank you. Your payment has been confirmed.
</p>
</div>
<button
type="button"
className={styles.successButton}
onClick={handleResetPaidState}
>
Send again
</button>
</section>
) : null}
<div className={styles.checkoutSlot}>
<TipCheckoutButton
disabled={
showMissingPlan ||
(!canCreateOrder && isRealLoginStatus(authState.loginStatus))
}
isAuthLoading={isAuthLoading}
onOrder={handleOrder}
/>
</div>
</main>
</MobileShell>
);
}
@@ -9,7 +9,7 @@ import { StorageKeys } from "../storage_keys";
const PendingPaymentOrderSchema = z.object({
orderId: z.string().min(1),
payChannel: z.literal("ezpay"),
subscriptionType: z.enum(["vip", "topup"]),
subscriptionType: z.enum(["vip", "topup", "tip"]),
returnTo: z.enum(["chat"]).optional(),
createdAt: z.number(),
});
@@ -12,4 +12,13 @@ describe("pending payment order helpers", () => {
}),
).toBe("/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat");
});
it("routes tip payments back to the tip page", () => {
expect(
buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay",
subscriptionType: "tip",
}),
).toBe("/tip?payChannel=ezpay&paymentReturn=1");
});
});
+8
View File
@@ -48,6 +48,14 @@ export function buildPendingPaymentSubscriptionUrl(
"payChannel" | "returnTo" | "subscriptionType"
>,
): string {
if (order.subscriptionType === "tip") {
const params = new URLSearchParams({
payChannel: order.payChannel,
paymentReturn: "1",
});
return `${ROUTES.tip}?${params.toString()}`;
}
const params = new URLSearchParams({
type: order.subscriptionType,
payChannel: order.payChannel,
+5
View File
@@ -9,6 +9,7 @@ describe("route meta", () => {
expect(getRouteAccess(ROUTES.auth)).toBe("authOnly");
expect(getRouteAccess(ROUTES.chat)).toBe("guestEntry");
expect(getRouteAccess(ROUTES.privateRoom)).toBe("guestEntry");
expect(getRouteAccess(ROUTES.tip)).toBe("public");
expect(getRouteAccess(ROUTES.sidebar)).toBe("session");
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
@@ -23,6 +24,10 @@ describe("route meta", () => {
expect(ALL_STATIC_ROUTES).toContain(ROUTES.privateRoom);
});
it("includes tip in static routes", () => {
expect(ALL_STATIC_ROUTES).toContain(ROUTES.tip);
});
it("treats unknown routes as public by default", () => {
expect(getRouteAccess("/unknown")).toBe("public");
});
+1
View File
@@ -13,6 +13,7 @@ const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.auth]: "authOnly",
[ROUTES.chat]: "guestEntry",
[ROUTES.privateRoom]: "guestEntry",
[ROUTES.tip]: "public",
[ROUTES.sidebar]: "session",
[ROUTES.subscription]: "realUser",
[ROUTES.coinsRules]: "public",
+2
View File
@@ -22,6 +22,7 @@ export const ROUTES = {
splash: "/splash",
chat: "/chat",
privateRoom: "/private-room",
tip: "/tip",
auth: "/auth",
sidebar: "/sidebar",
subscription: "/subscription",
@@ -54,6 +55,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
ROUTES.splash,
ROUTES.chat,
ROUTES.privateRoom,
ROUTES.tip,
ROUTES.auth,
ROUTES.sidebar,
ROUTES.coinsRules,