feat(tip): add coffee tipping page
This commit is contained in:
@@ -27,12 +27,14 @@ const stripePromise = stripePublishableKey
|
|||||||
|
|
||||||
export interface StripePaymentDialogProps {
|
export interface StripePaymentDialogProps {
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
|
returnPath?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmed?: () => void;
|
onConfirmed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StripePaymentDialog({
|
export function StripePaymentDialog({
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
returnPath = ROUTES.subscription + "/success",
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmed,
|
onConfirmed,
|
||||||
}: StripePaymentDialogProps) {
|
}: StripePaymentDialogProps) {
|
||||||
@@ -91,7 +93,11 @@ export function StripePaymentDialog({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StripePaymentForm onClose={onClose} onConfirmed={onConfirmed} />
|
<StripePaymentForm
|
||||||
|
returnPath={returnPath}
|
||||||
|
onClose={onClose}
|
||||||
|
onConfirmed={onConfirmed}
|
||||||
|
/>
|
||||||
</Elements>
|
</Elements>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,9 +105,13 @@ export function StripePaymentDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StripePaymentForm({
|
function StripePaymentForm({
|
||||||
|
returnPath,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmed,
|
onConfirmed,
|
||||||
}: Pick<StripePaymentDialogProps, "onClose" | "onConfirmed">) {
|
}: Pick<
|
||||||
|
StripePaymentDialogProps,
|
||||||
|
"returnPath" | "onClose" | "onConfirmed"
|
||||||
|
>) {
|
||||||
const stripe = useStripe();
|
const stripe = useStripe();
|
||||||
const elements = useElements();
|
const elements = useElements();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
@@ -141,7 +151,7 @@ function StripePaymentForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = new URL(
|
const returnUrl = new URL(
|
||||||
ROUTES.subscription + "/success",
|
returnPath ?? ROUTES.subscription + "/success",
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const { error } = await stripe.confirmPayment({
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
const PendingPaymentOrderSchema = z.object({
|
||||||
orderId: z.string().min(1),
|
orderId: z.string().min(1),
|
||||||
payChannel: z.literal("ezpay"),
|
payChannel: z.literal("ezpay"),
|
||||||
subscriptionType: z.enum(["vip", "topup"]),
|
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
||||||
returnTo: z.enum(["chat"]).optional(),
|
returnTo: z.enum(["chat"]).optional(),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,4 +12,13 @@ describe("pending payment order helpers", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat");
|
).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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export function buildPendingPaymentSubscriptionUrl(
|
|||||||
"payChannel" | "returnTo" | "subscriptionType"
|
"payChannel" | "returnTo" | "subscriptionType"
|
||||||
>,
|
>,
|
||||||
): string {
|
): string {
|
||||||
|
if (order.subscriptionType === "tip") {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
payChannel: order.payChannel,
|
||||||
|
paymentReturn: "1",
|
||||||
|
});
|
||||||
|
return `${ROUTES.tip}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
type: order.subscriptionType,
|
type: order.subscriptionType,
|
||||||
payChannel: order.payChannel,
|
payChannel: order.payChannel,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ describe("route meta", () => {
|
|||||||
expect(getRouteAccess(ROUTES.auth)).toBe("authOnly");
|
expect(getRouteAccess(ROUTES.auth)).toBe("authOnly");
|
||||||
expect(getRouteAccess(ROUTES.chat)).toBe("guestEntry");
|
expect(getRouteAccess(ROUTES.chat)).toBe("guestEntry");
|
||||||
expect(getRouteAccess(ROUTES.privateRoom)).toBe("guestEntry");
|
expect(getRouteAccess(ROUTES.privateRoom)).toBe("guestEntry");
|
||||||
|
expect(getRouteAccess(ROUTES.tip)).toBe("public");
|
||||||
expect(getRouteAccess(ROUTES.sidebar)).toBe("session");
|
expect(getRouteAccess(ROUTES.sidebar)).toBe("session");
|
||||||
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
|
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
|
||||||
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
|
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
|
||||||
@@ -23,6 +24,10 @@ describe("route meta", () => {
|
|||||||
expect(ALL_STATIC_ROUTES).toContain(ROUTES.privateRoom);
|
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", () => {
|
it("treats unknown routes as public by default", () => {
|
||||||
expect(getRouteAccess("/unknown")).toBe("public");
|
expect(getRouteAccess("/unknown")).toBe("public");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
|
|||||||
[ROUTES.auth]: "authOnly",
|
[ROUTES.auth]: "authOnly",
|
||||||
[ROUTES.chat]: "guestEntry",
|
[ROUTES.chat]: "guestEntry",
|
||||||
[ROUTES.privateRoom]: "guestEntry",
|
[ROUTES.privateRoom]: "guestEntry",
|
||||||
|
[ROUTES.tip]: "public",
|
||||||
[ROUTES.sidebar]: "session",
|
[ROUTES.sidebar]: "session",
|
||||||
[ROUTES.subscription]: "realUser",
|
[ROUTES.subscription]: "realUser",
|
||||||
[ROUTES.coinsRules]: "public",
|
[ROUTES.coinsRules]: "public",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const ROUTES = {
|
|||||||
splash: "/splash",
|
splash: "/splash",
|
||||||
chat: "/chat",
|
chat: "/chat",
|
||||||
privateRoom: "/private-room",
|
privateRoom: "/private-room",
|
||||||
|
tip: "/tip",
|
||||||
auth: "/auth",
|
auth: "/auth",
|
||||||
sidebar: "/sidebar",
|
sidebar: "/sidebar",
|
||||||
subscription: "/subscription",
|
subscription: "/subscription",
|
||||||
@@ -54,6 +55,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
|||||||
ROUTES.splash,
|
ROUTES.splash,
|
||||||
ROUTES.chat,
|
ROUTES.chat,
|
||||||
ROUTES.privateRoom,
|
ROUTES.privateRoom,
|
||||||
|
ROUTES.tip,
|
||||||
ROUTES.auth,
|
ROUTES.auth,
|
||||||
ROUTES.sidebar,
|
ROUTES.sidebar,
|
||||||
ROUTES.coinsRules,
|
ROUTES.coinsRules,
|
||||||
|
|||||||
Reference in New Issue
Block a user