diff --git a/src/app/subscription/components/stripe-payment-dialog.tsx b/src/app/subscription/components/stripe-payment-dialog.tsx index 68c969b7..16e36de2 100644 --- a/src/app/subscription/components/stripe-payment-dialog.tsx +++ b/src/app/subscription/components/stripe-payment-dialog.tsx @@ -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({ }, }} > - + @@ -99,9 +105,13 @@ export function StripePaymentDialog({ } function StripePaymentForm({ + returnPath, onClose, onConfirmed, -}: Pick) { +}: Pick< + StripePaymentDialogProps, + "returnPath" | "onClose" | "onConfirmed" +>) { const stripe = useStripe(); const elements = useElements(); const [errorMessage, setErrorMessage] = useState(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({ diff --git a/src/app/tip/__tests__/tip-screen.helpers.test.ts b/src/app/tip/__tests__/tip-screen.helpers.test.ts new file mode 100644 index 00000000..f903c489 --- /dev/null +++ b/src/app/tip/__tests__/tip-screen.helpers.test.ts @@ -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): 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); + }); +}); diff --git a/src/app/tip/page.tsx b/src/app/tip/page.tsx new file mode 100644 index 00000000..13e86e18 --- /dev/null +++ b/src/app/tip/page.tsx @@ -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 ( + }> + + + ); +} + +function TipFallback() { + return ( + +
+ Loading tip... +
+
+ ); +} diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx new file mode 100644 index 00000000..507307d2 --- /dev/null +++ b/src/app/tip/tip-checkout-button.tsx @@ -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 ( + <> + + {payment.errorMessage ? ( +

+ {payment.errorMessage} +

+ ) : null} + {shouldShowEzpayConfirmDialog ? ( + + ) : null} + {shouldShowStripeDialog ? ( + + ) : null} + + ); +} + +interface EzpayRedirectConfirmDialogProps { + orderId: string; + isConfirming: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +function EzpayRedirectConfirmDialog({ + orderId, + isConfirming, + onCancel, + onConfirm, +}: EzpayRedirectConfirmDialogProps) { + return ( +
+
+
+

+ Continue to GCash? +

+

+ Your coffee order is ready. Continue to GCash to finish the + payment. +

+

Order No. {orderId}

+
+
+ + +
+
+
+ ); +} diff --git a/src/app/tip/tip-page-client.tsx b/src/app/tip/tip-page-client.tsx new file mode 100644 index 00000000..8c10f383 --- /dev/null +++ b/src/app/tip/tip-page-client.tsx @@ -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 ( + + ); +} diff --git a/src/app/tip/tip-screen.helpers.ts b/src/app/tip/tip-screen.helpers.ts new file mode 100644 index 00000000..4a0dd70e --- /dev/null +++ b/src/app/tip/tip-screen.helpers.ts @@ -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"; +} diff --git a/src/app/tip/tip-screen.module.css b/src/app/tip/tip-screen.module.css new file mode 100644 index 00000000..a3fed616 --- /dev/null +++ b/src/app/tip/tip-screen.module.css @@ -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); + } +} diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx new file mode 100644 index 00000000..0949ee4b --- /dev/null +++ b/src/app/tip/tip-screen.tsx @@ -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(null); + const refreshedPaidOrderRef = useRef(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 ( + +
+
+
+ ); +} diff --git a/src/data/storage/payment/pending_payment_order_storage.ts b/src/data/storage/payment/pending_payment_order_storage.ts index 678be66c..259dbcde 100644 --- a/src/data/storage/payment/pending_payment_order_storage.ts +++ b/src/data/storage/payment/pending_payment_order_storage.ts @@ -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(), }); diff --git a/src/lib/payment/__tests__/pending_payment_order.test.ts b/src/lib/payment/__tests__/pending_payment_order.test.ts index 81ed7627..029b9037 100644 --- a/src/lib/payment/__tests__/pending_payment_order.test.ts +++ b/src/lib/payment/__tests__/pending_payment_order.test.ts @@ -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"); + }); }); diff --git a/src/lib/payment/pending_payment_order.ts b/src/lib/payment/pending_payment_order.ts index 6e7cd943..b35d3606 100644 --- a/src/lib/payment/pending_payment_order.ts +++ b/src/lib/payment/pending_payment_order.ts @@ -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, diff --git a/src/router/__tests__/route-meta.test.ts b/src/router/__tests__/route-meta.test.ts index 01bafd4e..ca7f9670 100644 --- a/src/router/__tests__/route-meta.test.ts +++ b/src/router/__tests__/route-meta.test.ts @@ -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"); }); diff --git a/src/router/route-meta.ts b/src/router/route-meta.ts index a3548414..5da06844 100644 --- a/src/router/route-meta.ts +++ b/src/router/route-meta.ts @@ -13,6 +13,7 @@ const STATIC_ROUTE_ACCESS: Partial> = { [ROUTES.auth]: "authOnly", [ROUTES.chat]: "guestEntry", [ROUTES.privateRoom]: "guestEntry", + [ROUTES.tip]: "public", [ROUTES.sidebar]: "session", [ROUTES.subscription]: "realUser", [ROUTES.coinsRules]: "public", diff --git a/src/router/routes.ts b/src/router/routes.ts index 4752e52b..59a95472 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -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,