Files
cozsweet-frontend-nextjs/src/app/tip/tip-screen.tsx
T

319 lines
9.8 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import Image from "next/image";
import { 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 {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
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,
} from "./tip-screen.helpers";
import { TipSuccessView } from "./tip-success-view";
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 character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
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 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 (!canCreateOrder) return;
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
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" });
};
if (payment.isPaid) {
return (
<TipSuccessView
characterName={character.displayName}
characterAvatar={character.assets.avatar}
characterCover={character.assets.cover}
coffeeOption={coffeeOption}
splashHref={characterRoutes.splash}
tipCount={payment.tipCount}
thankYouMessage={payment.thankYouMessage}
onSendAgain={handleResetPaidState}
/>
);
}
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}>
❤️ Thank you for being here. If you&apos;d ever like to treat me to a
little coffee, I&apos;d really appreciate it.
</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}
<div className={styles.checkoutSlot}>
<TipCheckoutButton
coffeeType={selectedCoffeeType}
disabled={showMissingPlan || !canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
/>
</div>
</main>
</MobileShell>
);
}