feat(tip): support dynamic gift products
This commit is contained in:
+157
-133
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, type CSSProperties } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { BackButton, CharacterAvatar } from "@/app/_components";
|
||||
@@ -16,13 +15,7 @@ import {
|
||||
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 { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
useActiveCharacterRoutes,
|
||||
@@ -30,13 +23,11 @@ import {
|
||||
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 {
|
||||
TipCoffeeTierSelector,
|
||||
type TipCoffeeTierItem,
|
||||
} from "./tip-coffee-tier-selector";
|
||||
import {
|
||||
findTipCoffeePlan,
|
||||
formatTipPrice,
|
||||
formatGiftPrice,
|
||||
getGiftImageSources,
|
||||
} from "./tip-screen.helpers";
|
||||
import { TipSuccessView } from "./tip-success-view";
|
||||
import { useTipSupportPrompt } from "./use-tip-support-prompt";
|
||||
@@ -48,13 +39,15 @@ const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
||||
};
|
||||
|
||||
export interface TipScreenProps {
|
||||
coffeeType?: TipCoffeeType;
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
shouldResumePendingOrder?: boolean;
|
||||
initialPayChannel?: PayChannel | null;
|
||||
}
|
||||
|
||||
export function TipScreen({
|
||||
coffeeType = DEFAULT_TIP_COFFEE_TYPE,
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
shouldResumePendingOrder = false,
|
||||
initialPayChannel = null,
|
||||
}: TipScreenProps) {
|
||||
@@ -66,63 +59,61 @@ export function TipScreen({
|
||||
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",
|
||||
characterId: character.id,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
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 ??
|
||||
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 priceLabel = formatTipPrice(coffeePlan, selectedCoffeeType);
|
||||
const availableCoffeePlans = useMemo(
|
||||
() => coffeeTiers.flatMap(({ plan }) => (plan ? [plan] : [])),
|
||||
[coffeeTiers],
|
||||
const returnPath = buildTipGiftPath(
|
||||
{
|
||||
category: payment.selectedGiftCategory,
|
||||
planId: payment.selectedPlanId || null,
|
||||
},
|
||||
characterRoutes.tip,
|
||||
);
|
||||
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);
|
||||
|
||||
usePaymentPlanAnalytics(payment.plans, TIP_ANALYTICS_CONTEXT);
|
||||
|
||||
const isPaymentBusy =
|
||||
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
|
||||
const canCreateOrder =
|
||||
coffeePlan !== null &&
|
||||
payment.selectedPlanId === coffeePlan.planId &&
|
||||
selectedProduct !== null &&
|
||||
selectedPlan !== null &&
|
||||
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 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({
|
||||
@@ -140,18 +131,7 @@ export function TipScreen({
|
||||
});
|
||||
|
||||
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 (!selectedProduct || isPaymentBusy || payment.isLoadingPlans) return;
|
||||
|
||||
if (payment.autoRenew) {
|
||||
paymentDispatch({ type: "PaymentAutoRenewChanged", autoRenew: false });
|
||||
@@ -162,55 +142,62 @@ export function TipScreen({
|
||||
paymentDispatch({ type: "PaymentAgreementChanged", agreed: true });
|
||||
}
|
||||
}, [
|
||||
coffeePlan,
|
||||
isPaymentBusy,
|
||||
payment.agreed,
|
||||
payment.autoRenew,
|
||||
payment.isCreatingOrder,
|
||||
payment.isLoadingPlans,
|
||||
payment.isPollingOrder,
|
||||
payment.selectedPlanId,
|
||||
paymentDispatch,
|
||||
selectedProduct,
|
||||
]);
|
||||
|
||||
const handleOrder = () => {
|
||||
if (!canCreateOrder) return;
|
||||
if (!canCreateOrder || !selectedPlan) return;
|
||||
|
||||
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
|
||||
behaviorAnalytics.planClick(selectedPlan, 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" });
|
||||
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}
|
||||
coffeeOption={coffeeOption}
|
||||
giftImageSources={getGiftImageSources(
|
||||
successProduct,
|
||||
successCategory,
|
||||
)}
|
||||
giftName={
|
||||
payment.tipMessage?.productName ??
|
||||
successProduct?.planName ??
|
||||
"Your gift"
|
||||
}
|
||||
isMessageLoading={payment.isLoadingTipMessage}
|
||||
message={payment.tipMessage?.message ?? null}
|
||||
messageError={payment.tipMessageError}
|
||||
splashHref={characterRoutes.splash}
|
||||
tipCount={payment.tipCount}
|
||||
thankYouMessage={payment.thankYouMessage}
|
||||
onSendAgain={handleResetPaidState}
|
||||
onRetryMessage={() =>
|
||||
paymentDispatch({ type: "PaymentTipMessageRetryRequested" })
|
||||
}
|
||||
onSendAgain={() => paymentDispatch({ type: "PaymentReset" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -262,57 +249,94 @@ export function TipScreen({
|
||||
</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}
|
||||
{payment.isLoadingPlans ? (
|
||||
<section
|
||||
className={`${styles.productCard} ${styles.productSkeleton}`}
|
||||
aria-label="Loading gifts"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className={styles.skeletonImage} />
|
||||
<div className={styles.skeletonCopy}>
|
||||
<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>
|
||||
|
||||
<div className={styles.productCopy}>
|
||||
<span className={styles.productBadge}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{selectedCategory?.name ?? "Gift"}
|
||||
</span>
|
||||
<h2 className={styles.productName}>{selectedProduct.planName}</h2>
|
||||
<p className={styles.productPrice}>
|
||||
{formatGiftPrice(
|
||||
selectedProduct.amountCents,
|
||||
selectedProduct.currency,
|
||||
)}
|
||||
</p>
|
||||
{selectedProduct.description ? (
|
||||
<p className={styles.productDescription}>
|
||||
{selectedProduct.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TipGiftProductSelector
|
||||
disabled={isSelectionDisabled}
|
||||
products={visibleProducts}
|
||||
onChange={handleProductChange}
|
||||
selectedPlanId={payment.selectedPlanId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
{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}
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={paymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
disabled={isPaymentBusy}
|
||||
disabled={isPaymentBusy || !selectedProduct}
|
||||
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}
|
||||
giftCategory={payment.selectedGiftCategory}
|
||||
giftPlanId={payment.selectedPlanId || null}
|
||||
disabled={!canCreateOrder}
|
||||
onOrder={handleOrder}
|
||||
returnPath={returnPath}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user