338 lines
11 KiB
TypeScript
338 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();
|
|
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={isPaymentBusy}
|
|
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}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|