Files
cozsweet-frontend-nextjs/src/app/tip/tip-screen.tsx
T
Codex 599f3e3d26
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
fix(payment): route Philippine GCash separately from QRIS
2026-07-29 19:21:07 +08:00

339 lines
11 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { BackButton, CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core";
import { PaymentMethodSelector } from "@/app/_components/payment/payment-method-selector";
import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selection";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
import type { PayChannel } from "@/data/schemas/payment";
import {
behaviorAnalytics,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
import { useHasHydrated } from "@/hooks/use-has-hydrated";
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { useUserState } from "@/stores/user/user-context";
import { TipCheckoutButton } from "./tip-checkout-button";
import { TipGiftProductSelector } from "./tip-gift-product-selector";
import { TipProductImage } from "./tip-product-image";
import { getGiftImageSources } from "./tip-screen.helpers";
import { TipSuccessView } from "./tip-success-view";
import { useTipSupportPrompt } from "./use-tip-support-prompt";
import styles from "./tip-screen.module.css";
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
entryPoint: "tip_page",
triggerReason: "ad_landing",
};
export interface TipScreenProps {
initialCategory?: string | null;
initialPlanId?: string | null;
shouldResumePendingOrder?: boolean;
initialPayChannel?: PayChannel | null;
chatActionId?: string | null;
}
export function TipScreen({
initialCategory = null,
initialPlanId = null,
shouldResumePendingOrder = false,
initialPayChannel = null,
chatActionId = null,
}: TipScreenProps) {
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const userState = useUserState();
const hasHydrated = useHasHydrated();
const supportPrompt = useTipSupportPrompt(character.displayName);
const paymentMethodConfig = getPaymentMethodConfig({
countryCode: userState.currentUser?.countryCode,
requestedPayChannel: initialPayChannel,
});
const renderedPaymentMethodConfig = hasHydrated
? paymentMethodConfig
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
const { payment, paymentDispatch } = usePaymentRouteFlow({
catalog: "tip",
characterId: character.id,
initialCategory,
initialPlanId,
initialPayChannel: paymentMethodConfig.initialPayChannel,
paymentType: "tip",
shouldResumePendingOrder,
chatActionId,
});
const selectedCategory =
payment.giftCategories.find(
(category) => category.category === payment.selectedGiftCategory,
) ?? null;
const visibleProducts = payment.selectedGiftCategory
? payment.giftProducts.filter(
(product) => product.category === payment.selectedGiftCategory,
)
: [];
const selectedProduct =
visibleProducts.find(
(product) => product.planId === payment.selectedPlanId,
) ?? null;
const selectedPlan =
payment.plans.find((plan) => plan.planId === payment.selectedPlanId) ??
null;
const returnPath = buildTipGiftPath(
{
category: payment.selectedGiftCategory,
planId: payment.selectedPlanId || null,
},
characterRoutes.tip,
);
usePaymentPlanAnalytics(payment.plans, TIP_ANALYTICS_CONTEXT);
const isPaymentBusy =
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
const canCreateOrder =
selectedProduct !== null &&
selectedPlan !== null &&
payment.agreed &&
!payment.autoRenew &&
!payment.isLoadingPlans &&
!isPaymentBusy;
const isSelectionDisabled =
!["ready", "failed", "expired"].includes(payment.status) ||
payment.isLoadingPlans ||
isPaymentBusy;
const catalogLoaded =
payment.status === "ready" && !payment.isLoadingPlans;
const showCatalogError =
catalogLoaded && payment.errorMessage !== null && visibleProducts.length === 0;
const showEmptyCatalog =
catalogLoaded && payment.errorMessage === null && visibleProducts.length === 0;
const handlePaymentMethodChange = (payChannel: PayChannel) => {
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel,
});
};
usePaymentMethodSelection({
config: paymentMethodConfig,
currentPayChannel: payment.payChannel,
isPaymentBusy,
requestedPayChannel: initialPayChannel,
onChange: handlePaymentMethodChange,
});
useEffect(() => {
if (!selectedProduct || isPaymentBusy || payment.isLoadingPlans) return;
if (payment.autoRenew) {
paymentDispatch({ type: "PaymentAutoRenewChanged", autoRenew: false });
return;
}
if (!payment.agreed) {
paymentDispatch({ type: "PaymentAgreementChanged", agreed: true });
}
}, [
isPaymentBusy,
payment.agreed,
payment.autoRenew,
payment.isLoadingPlans,
paymentDispatch,
selectedProduct,
]);
const handleOrder = () => {
if (!canCreateOrder || !selectedPlan) return;
behaviorAnalytics.planClick(selectedPlan, TIP_ANALYTICS_CONTEXT);
paymentDispatch({
type: "PaymentCreateOrderSubmitted",
recipientCharacterId: character.id,
});
};
const handleProductChange = (planId: string) => {
if (isSelectionDisabled || planId === payment.selectedPlanId) return;
if (!visibleProducts.some((product) => product.planId === planId)) return;
paymentDispatch({ type: "PaymentPlanSelected", planId });
};
if (payment.isPaid) {
const successProduct =
payment.giftProducts.find(
(product) => product.planId === payment.tipMessage?.planId,
) ?? selectedProduct;
const successCategory =
payment.giftCategories.find(
(category) => category.category === successProduct?.category,
) ?? selectedCategory;
return (
<TipSuccessView
characterName={character.displayName}
characterAvatar={character.assets.avatar}
characterCover={character.assets.cover}
giftImageSources={getGiftImageSources(
successProduct,
successCategory,
)}
giftName={
payment.tipMessage?.productName ??
successProduct?.planName ??
"Your gift"
}
isMessageLoading={payment.isLoadingTipMessage}
tipCount={payment.tipMessage?.tipCount ?? null}
message={payment.tipMessage?.message ?? null}
messageError={payment.tipMessageError}
splashHref={characterRoutes.splash}
onRetryMessage={() =>
paymentDispatch({ type: "PaymentTipMessageRetryRequested" })
}
onSendAgain={() => paymentDispatch({ type: "PaymentReset" })}
/>
);
}
return (
<MobileShell background="#fff5ed">
<main className={styles.shell}>
<div className={styles.bgGlowOne} aria-hidden="true" />
<div className={styles.bgGlowTwo} aria-hidden="true" />
<header className={styles.header}>
<BackButton
href={characterRoutes.splash}
variant="soft"
iconSize={28}
ariaLabel="Back to character home"
analyticsKey="tip.back_to_splash"
/>
</header>
<div className={styles.contentFlow}>
<div className={styles.headerIdentity}>
<div className={styles.headerAvatar}>
<CharacterAvatar
src={character.assets.avatar}
alt={character.displayName}
size="100%"
imageSize={88}
priority
/>
</div>
<h1 id="tip-title" className={styles.headerTitle}>
{character.copy.tipTitle}
</h1>
</div>
<p
className={`${styles.supportPrompt} ${
supportPrompt.isReady ? styles.supportPromptReady : ""
}`}
>
{supportPrompt.prompt}
</p>
<div className={styles.purchaseFlow}>
{payment.isLoadingPlans ? (
<section
className={`${styles.productCard} ${styles.productSkeleton}`}
aria-label="Loading gifts"
aria-busy="true"
>
<div className={styles.skeletonImage} />
<div className={styles.skeletonList}>
<span />
<span />
<span />
</div>
</section>
) : selectedProduct ? (
<>
<section
className={styles.productCard}
aria-label="Gift products"
>
<div className={styles.coffeeStage}>
<TipProductImage
key={selectedProduct.planId}
sources={getGiftImageSources(
selectedProduct,
selectedCategory,
)}
alt={selectedProduct.planName}
className={styles.coffeeImage}
priority
/>
</div>
<TipGiftProductSelector
disabled={isSelectionDisabled}
products={visibleProducts}
onChange={handleProductChange}
selectedPlanId={payment.selectedPlanId}
/>
</section>
<PaymentMethodSelector
config={renderedPaymentMethodConfig}
value={payment.payChannel}
density="compact"
disabled={payment.isCreatingOrder}
className={styles.paymentMethodSlot}
analyticsKey="tip.payment_method"
onChange={handlePaymentMethodChange}
/>
<div className={styles.checkoutSlot}>
<TipCheckoutButton
giftCategory={payment.selectedGiftCategory}
giftPlanId={payment.selectedPlanId || null}
disabled={!canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
countryCode={userState.currentUser?.countryCode}
/>
</div>
</>
) : showCatalogError || showEmptyCatalog ? (
<section className={styles.catalogStatus} role="status">
<p>
{showCatalogError
? "We could not load gifts for this character."
: "This character does not have any gifts available yet."}
</p>
{showCatalogError ? (
<button
type="button"
onClick={() =>
paymentDispatch({
type: "PaymentCatalogRetryRequested",
})
}
>
Try again
</button>
) : null}
</section>
) : null}
</div>
</div>
</main>
</MobileShell>
);
}