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
+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>
);
}