338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
import Image from "next/image";
|
|
import { Heart, Sparkles } from "lucide-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 {
|
|
buildTipCoffeePath,
|
|
DEFAULT_TIP_COFFEE_TYPE,
|
|
getTipCoffeeOption,
|
|
TIP_COFFEE_OPTIONS,
|
|
type TipCoffeeType,
|
|
} from "@/lib/tip/tip_coffee";
|
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
|
import {
|
|
useActiveCharacter,
|
|
useActiveCharacterRoutes,
|
|
} from "@/providers/character-provider";
|
|
import { useAuthState } from "@/stores/auth/auth-context";
|
|
import { useUserState } from "@/stores/user/user-context";
|
|
|
|
import { TipCheckoutButton } from "./tip-checkout-button";
|
|
import {
|
|
TipCoffeeTierSelector,
|
|
type TipCoffeeTierItem,
|
|
} from "./tip-coffee-tier-selector";
|
|
import {
|
|
findTipCoffeePlan,
|
|
formatTipPrice,
|
|
isRealLoginStatus,
|
|
} from "./tip-screen.helpers";
|
|
import styles from "./tip-screen.module.css";
|
|
|
|
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
|
entryPoint: "tip_page",
|
|
triggerReason: "ad_landing",
|
|
};
|
|
|
|
export interface TipScreenProps {
|
|
coffeeType?: TipCoffeeType;
|
|
shouldResumePendingOrder?: boolean;
|
|
initialPayChannel?: PayChannel | null;
|
|
}
|
|
|
|
export function TipScreen({
|
|
coffeeType = DEFAULT_TIP_COFFEE_TYPE,
|
|
shouldResumePendingOrder = false,
|
|
initialPayChannel = null,
|
|
}: TipScreenProps) {
|
|
const navigator = useAppNavigator();
|
|
const character = useActiveCharacter();
|
|
const characterRoutes = useActiveCharacterRoutes();
|
|
const authState = useAuthState();
|
|
const userState = useUserState();
|
|
const paymentMethodConfig = getPaymentMethodConfig({
|
|
countryCode: userState.currentUser?.countryCode,
|
|
requestedPayChannel: initialPayChannel,
|
|
});
|
|
const [selectedCoffeeType, setSelectedCoffeeType] =
|
|
useState<TipCoffeeType>(coffeeType);
|
|
const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
|
|
const returnPath = buildTipCoffeePath(
|
|
selectedCoffeeType,
|
|
characterRoutes.tip,
|
|
);
|
|
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
|
catalog: "tip",
|
|
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
|
paymentType: "tip",
|
|
shouldResumePendingOrder,
|
|
});
|
|
|
|
const coffeeTiers = useMemo(
|
|
() =>
|
|
TIP_COFFEE_OPTIONS.map((option) => ({
|
|
option,
|
|
plan: findTipCoffeePlan(payment.plans, option.type),
|
|
})),
|
|
[payment.plans],
|
|
);
|
|
const coffeePlan =
|
|
coffeeTiers.find(({ option }) => option.type === selectedCoffeeType)?.plan ??
|
|
null;
|
|
const priceLabel = formatTipPrice(coffeePlan, selectedCoffeeType);
|
|
const availableCoffeePlans = useMemo(
|
|
() => coffeeTiers.flatMap(({ plan }) => (plan ? [plan] : [])),
|
|
[coffeeTiers],
|
|
);
|
|
const tierItems = useMemo<readonly TipCoffeeTierItem[]>(
|
|
() =>
|
|
coffeeTiers.map(({ option, plan }) => ({
|
|
type: option.type,
|
|
displayName: option.displayName,
|
|
priceLabel: formatTipPrice(plan, option.type),
|
|
unavailable:
|
|
payment.status === "ready" &&
|
|
!payment.isLoadingPlans &&
|
|
plan === null,
|
|
})),
|
|
[coffeeTiers, payment.isLoadingPlans, payment.status],
|
|
);
|
|
usePaymentPlanAnalytics(availableCoffeePlans, TIP_ANALYTICS_CONTEXT);
|
|
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 isTierSelectionDisabled =
|
|
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy;
|
|
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
|
|
|
|
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
|
paymentDispatch({
|
|
type: "PaymentPayChannelChanged",
|
|
payChannel,
|
|
});
|
|
};
|
|
|
|
usePaymentMethodSelection({
|
|
config: paymentMethodConfig,
|
|
currentPayChannel: payment.payChannel,
|
|
isPaymentBusy,
|
|
requestedPayChannel: initialPayChannel,
|
|
onChange: handlePaymentMethodChange,
|
|
});
|
|
|
|
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,
|
|
]);
|
|
|
|
const handleOrder = () => {
|
|
if (isAuthLoading) return;
|
|
if (coffeePlan) {
|
|
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
|
|
}
|
|
if (!isRealLoginStatus(authState.loginStatus)) {
|
|
navigator.openAuth(returnPath);
|
|
return;
|
|
}
|
|
if (!canCreateOrder) return;
|
|
|
|
paymentDispatch({
|
|
type: "PaymentCreateOrderSubmitted",
|
|
recipientCharacterId: character.id,
|
|
});
|
|
};
|
|
|
|
const handleCoffeeTypeChange = (type: TipCoffeeType) => {
|
|
if (isTierSelectionDisabled) return;
|
|
const nextPlan = findTipCoffeePlan(payment.plans, type);
|
|
if (!nextPlan) return;
|
|
|
|
setSelectedCoffeeType(type);
|
|
if (payment.selectedPlanId !== nextPlan.planId) {
|
|
paymentDispatch({
|
|
type: "PaymentPlanSelected",
|
|
planId: nextPlan.planId,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleResetPaidState = () => {
|
|
paymentDispatch({ type: "PaymentReset" });
|
|
};
|
|
|
|
return (
|
|
<MobileShell background="#fff5ed">
|
|
<main
|
|
className={styles.shell}
|
|
style={
|
|
{
|
|
"--tip-cover-image": `url("${character.assets.cover}")`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<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}>
|
|
<BackButton
|
|
href={characterRoutes.splash}
|
|
variant="soft"
|
|
ariaLabel="Back to character home"
|
|
analyticsKey="tip.back_to_splash"
|
|
/>
|
|
<span className={styles.headerPill}>{character.copy.tipHeader}</span>
|
|
</header>
|
|
|
|
<section className={styles.hero} aria-labelledby="tip-title">
|
|
<div className={styles.avatarRing}>
|
|
<CharacterAvatar
|
|
src={character.assets.avatar}
|
|
alt={character.displayName}
|
|
size="100%"
|
|
imageSize={88}
|
|
priority
|
|
/>
|
|
</div>
|
|
<p className={styles.eyebrow}>A little sweetness for today</p>
|
|
<h1 id="tip-title" className={styles.title}>
|
|
{character.copy.tipTitle}
|
|
</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}>
|
|
<Image
|
|
src={coffeeOption.image.src}
|
|
alt={`${coffeeOption.displayName} coffee`}
|
|
width={coffeeOption.image.width}
|
|
height={coffeeOption.image.height}
|
|
sizes="(max-width: 380px) 220px, 211px"
|
|
className={styles.coffeeImage}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.productCopy}>
|
|
<span className={styles.productBadge}>
|
|
<Sparkles size={14} aria-hidden="true" />
|
|
Coffee Gift
|
|
</span>
|
|
<h2 className={styles.productName}>
|
|
{coffeeOption.displayName}
|
|
</h2>
|
|
<p className={styles.productPrice}>{priceLabel}</p>
|
|
</div>
|
|
|
|
<TipCoffeeTierSelector
|
|
disabled={isTierSelectionDisabled}
|
|
items={tierItems}
|
|
onChange={handleCoffeeTypeChange}
|
|
selectedType={selectedCoffeeType}
|
|
/>
|
|
</section>
|
|
|
|
<PaymentMethodSelector
|
|
config={paymentMethodConfig}
|
|
value={payment.payChannel}
|
|
disabled={isPaymentBusy}
|
|
caption="GCash by default in the Philippines"
|
|
className={styles.paymentMethodSlot}
|
|
analyticsKey="tip.payment_method"
|
|
onChange={handlePaymentMethodChange}
|
|
/>
|
|
|
|
{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
|
|
coffeeType={selectedCoffeeType}
|
|
disabled={
|
|
showMissingPlan ||
|
|
(!canCreateOrder && isRealLoginStatus(authState.loginStatus))
|
|
}
|
|
isAuthLoading={isAuthLoading}
|
|
onOrder={handleOrder}
|
|
returnPath={returnPath}
|
|
/>
|
|
</div>
|
|
</main>
|
|
</MobileShell>
|
|
);
|
|
}
|