feat(tip): support dynamic gift products

This commit is contained in:
2026-07-21 13:19:45 +08:00
parent 55cb98ed14
commit 37ff69020b
62 changed files with 2325 additions and 1085 deletions
@@ -38,4 +38,16 @@ describe("shouldInspectPendingPaymentOrder", () => {
}),
).toBe(false);
});
it("allows stale expired orders to be cleaned up", () => {
expect(
shouldInspectPendingPaymentOrder({
currentOrderId: "order-expired",
isPaid: false,
isPollingOrder: false,
shouldResumePendingOrder: false,
status: "expired",
}),
).toBe(true);
});
});
+10 -6
View File
@@ -12,7 +12,6 @@ import { behaviorAnalytics } from "@/lib/analytics";
import type {
PendingPaymentReturnTo,
PendingPaymentSubscriptionType,
PendingPaymentTipCoffeeType,
} from "@/lib/payment/pending_payment_order";
import type {
PaymentContextState,
@@ -39,7 +38,8 @@ export interface UsePaymentLaunchFlowInput {
returnTo?: PendingPaymentReturnTo;
characterSlug?: string;
subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
giftCategory?: string | null;
giftPlanId?: string | null;
}
export interface PaymentLaunchFlow {
@@ -110,7 +110,8 @@ export function usePaymentLaunchFlow({
returnTo,
characterSlug,
subscriptionType,
tipCoffeeType,
giftCategory,
giftPlanId,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
@@ -153,7 +154,8 @@ export function usePaymentLaunchFlow({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
giftCategory,
giftPlanId,
...(returnTo ? { returnTo } : {}),
...(characterSlug ? { characterSlug } : {}),
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
@@ -197,7 +199,8 @@ export function usePaymentLaunchFlow({
paymentDispatch,
returnTo,
subscriptionType,
tipCoffeeType,
giftCategory,
giftPlanId,
]);
const shouldShowStripeDialog =
@@ -238,7 +241,8 @@ export function usePaymentLaunchFlow({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
giftCategory,
giftPlanId,
...(returnTo ? { returnTo } : {}),
...(characterSlug ? { characterSlug } : {}),
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
+24 -23
View File
@@ -19,6 +19,9 @@ export interface UsePaymentRouteFlowInput {
initialPayChannel: PayChannel;
paymentType: PendingPaymentSubscriptionType;
shouldResumePendingOrder: boolean;
characterId?: string;
initialCategory?: string | null;
initialPlanId?: string | null;
}
export interface PaymentRouteFlow {
@@ -35,10 +38,19 @@ export function usePaymentRouteFlow({
initialPayChannel,
paymentType,
shouldResumePendingOrder,
characterId,
initialCategory = null,
initialPlanId = null,
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const initialPayChannelAppliedRef = useRef(false);
const initializedCatalogKeyRef = useRef<string | null>(null);
const catalogKey = [
catalog,
characterId ?? "",
initialCategory ?? "",
initialPlanId ?? "",
].join(":");
usePendingPaymentOrderLifecycle({
payment,
@@ -48,34 +60,23 @@ export function usePaymentRouteFlow({
});
useEffect(() => {
if (payment.status === "idle") {
initialPayChannelAppliedRef.current = true;
paymentDispatch({
type: "PaymentInit",
catalog,
payChannel: initialPayChannel,
});
return;
}
if (
initialPayChannelAppliedRef.current ||
payment.status !== "ready"
) {
return;
}
initialPayChannelAppliedRef.current = true;
if (payment.payChannel === initialPayChannel) return;
if (initializedCatalogKeyRef.current === catalogKey) return;
initializedCatalogKeyRef.current = catalogKey;
paymentDispatch({
type: "PaymentPayChannelChanged",
type: "PaymentInit",
catalog,
payChannel: initialPayChannel,
...(characterId ? { characterId } : {}),
...(initialCategory ? { category: initialCategory } : {}),
...(initialPlanId ? { planId: initialPlanId } : {}),
});
}, [
catalog,
catalogKey,
characterId,
initialCategory,
initialPlanId,
initialPayChannel,
payment.payChannel,
payment.status,
paymentDispatch,
]);
@@ -37,7 +37,7 @@ export function shouldInspectPendingPaymentOrder({
return (
status === "ready" ||
(!shouldResumePendingOrder &&
(isPollingOrder || isPaid || status === "failed"))
(isPollingOrder || isPaid || status === "failed" || status === "expired"))
);
}
@@ -74,7 +74,8 @@ export function usePendingPaymentOrderLifecycle({
payment.currentOrderId === result.data.orderId &&
(payment.isPollingOrder ||
payment.isPaid ||
payment.status === "failed")
payment.status === "failed" ||
payment.status === "expired")
) {
paymentDispatch({ type: "PaymentReset" });
}
@@ -108,7 +109,13 @@ export function usePendingPaymentOrderLifecycle({
useEffect(() => {
if (!payment.currentOrderId) return;
if (!payment.isPaid && payment.status !== "failed") return;
if (
!payment.isPaid &&
payment.status !== "failed" &&
payment.status !== "expired"
) {
return;
}
void clearPendingPaymentOrder();
}, [payment.currentOrderId, payment.isPaid, payment.status]);
@@ -4,10 +4,10 @@ import {
type PaymentSearchParams,
} from "@/lib/payment/payment_search_params";
import {
DEFAULT_TIP_COFFEE_TYPE,
resolveTipCoffeeType,
TIP_COFFEE_TYPE_PARAM,
} from "@/lib/tip/tip_coffee";
normalizeTipGiftParam,
TIP_GIFT_CATEGORY_PARAM,
TIP_GIFT_PLAN_ID_PARAM,
} from "@/lib/tip/tip_gift";
import { TipScreen } from "@/app/tip/tip-screen";
export default async function CharacterTipPage({
@@ -17,14 +17,17 @@ export default async function CharacterTipPage({
}) {
const query = await searchParams;
const paymentReturn = parsePaymentReturnSearchParams(query);
const coffeeType =
resolveTipCoffeeType(
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]),
) ?? DEFAULT_TIP_COFFEE_TYPE;
const initialCategory = normalizeTipGiftParam(
getFirstPaymentSearchParam(query[TIP_GIFT_CATEGORY_PARAM]),
);
const initialPlanId = normalizeTipGiftParam(
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
);
return (
<TipScreen
coffeeType={coffeeType}
initialCategory={initialCategory}
initialPlanId={initialPlanId}
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
initialPayChannel={paymentReturn.initialPayChannel}
/>
@@ -1,96 +0,0 @@
/* @vitest-environment jsdom */
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
import {
TipCoffeeTierSelector,
type TipCoffeeTierItem,
} from "../tip-coffee-tier-selector";
const items: readonly TipCoffeeTierItem[] = [
{
type: "small",
displayName: "Velvet Espresso",
priceLabel: "US$ 4.99",
unavailable: false,
},
{
type: "medium",
displayName: "Gilded Heart",
priceLabel: "US$ 9.99",
unavailable: false,
},
{
type: "large",
displayName: "Crown Blossom",
priceLabel: "US$ 19.99",
unavailable: false,
},
];
function Harness({ tierItems = items }: { tierItems?: readonly TipCoffeeTierItem[] }) {
const [selectedType, setSelectedType] =
useState<TipCoffeeType>("medium");
return (
<TipCoffeeTierSelector
disabled={false}
items={tierItems}
onChange={setSelectedType}
selectedType={selectedType}
/>
);
}
describe("TipCoffeeTierSelector", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders every luxury tier and selects medium by default", () => {
act(() => root.render(<Harness />));
expect(container.textContent).toContain("Velvet Espresso");
expect(container.textContent).toContain("Gilded Heart");
expect(container.textContent).toContain("Crown Blossom");
expect(
container.querySelector<HTMLInputElement>('input[value="medium"]')
?.checked,
).toBe(true);
});
it("switches to an available tier and keeps unavailable tiers disabled", () => {
const tierItems = items.map((item) =>
item.type === "large" ? { ...item, unavailable: true } : item,
);
act(() => root.render(<Harness tierItems={tierItems} />));
const small = container.querySelector<HTMLInputElement>(
'input[value="small"]',
);
const large = container.querySelector<HTMLInputElement>(
'input[value="large"]',
);
act(() => small?.click());
expect(small?.checked).toBe(true);
expect(large?.disabled).toBe(true);
expect(container.textContent).toContain("Unavailable");
});
});
@@ -0,0 +1,92 @@
/* @vitest-environment jsdom */
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
GiftProductSchema,
type GiftProduct,
} from "@/data/schemas/payment";
import { TipGiftProductSelector } from "../tip-gift-product-selector";
const products: readonly GiftProduct[] = [
makeProduct("gift_small", "Velvet Espresso", 499),
makeProduct("gift_medium", "Golden Reserve", 999),
makeProduct("gift_large", "Imperial Grand Cru", 1999),
];
function Harness() {
const [selectedPlanId, setSelectedPlanId] = useState("gift_small");
return (
<TipGiftProductSelector
disabled={false}
products={products}
onChange={setSelectedPlanId}
selectedPlanId={selectedPlanId}
/>
);
}
describe("TipGiftProductSelector", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders backend products and selects the first product", () => {
act(() => root.render(<Harness />));
expect(container.textContent).toContain("Velvet Espresso");
expect(container.textContent).toContain("Golden Reserve");
expect(container.textContent).toContain("Imperial Grand Cru");
expect(
container.querySelector<HTMLInputElement>('input[value="gift_small"]')
?.checked,
).toBe(true);
});
it("switches the selected backend plan id", () => {
act(() => root.render(<Harness />));
const medium = container.querySelector<HTMLInputElement>(
'input[value="gift_medium"]',
);
act(() => medium?.click());
expect(medium?.checked).toBe(true);
});
});
function makeProduct(
planId: string,
planName: string,
amountCents: number,
): GiftProduct {
return GiftProductSchema.parse({
planId,
planName,
orderType: "tip",
tipType: planId,
category: "coffee",
characterId: "elio",
description: `${planName} description`,
imageUrl: null,
amountCents,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
});
}
@@ -5,7 +5,10 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
GiftCategorySchema,
GiftProductSchema,
PaymentPlanSchema,
TipMessageResponseSchema,
type PaymentPlan,
} from "@/data/schemas/payment";
import type { PaymentContextState } from "@/stores/payment/payment-context";
@@ -44,6 +47,7 @@ vi.mock("@/lib/analytics", () => ({
vi.mock("@/providers/character-provider", () => ({
useActiveCharacter: () => ({
id: "maya-tan",
slug: "maya",
displayName: "Maya Tan",
assets: {
avatar: "/images/avatar/maya.png",
@@ -51,7 +55,7 @@ vi.mock("@/providers/character-provider", () => ({
},
copy: {
tipHeader: "Tip Maya",
tipTitle: "Buy Maya a coffee",
tipTitle: "Send Maya a gift",
},
}),
useActiveCharacterRoutes: () => ({
@@ -80,8 +84,11 @@ vi.mock("../tip-checkout-button", () => ({
</button>
),
}));
vi.mock("../tip-coffee-tier-selector", () => ({
TipCoffeeTierSelector: () => null,
vi.mock("../tip-gift-product-selector", () => ({
TipGiftProductSelector: () => null,
}));
vi.mock("../tip-product-image", () => ({
TipProductImage: ({ alt }: { alt: string }) => <span>{alt}</span>,
}));
vi.mock("../use-tip-support-prompt", () => ({
useTipSupportPrompt: () => ({
@@ -92,21 +99,41 @@ vi.mock("../use-tip-support-prompt", () => ({
import { TipScreen } from "../tip-screen";
const mediumPlan: PaymentPlan = PaymentPlanSchema.parse({
const giftCategory = GiftCategorySchema.parse({
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
});
const giftProduct = GiftProductSchema.parse({
planId: "tip_coffee_usd_9_99",
planName: "Gilded Heart",
planName: "Golden Reserve",
orderType: "tip",
tipType: "coffee_medium",
category: "coffee",
characterId: "maya-tan",
description: "A warm reserve coffee",
imageUrl: null,
amountCents: 999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
});
const giftPlan: PaymentPlan = PaymentPlanSchema.parse({
planId: giftProduct.planId,
planName: giftProduct.planName,
orderType: "tip",
vipDays: null,
dolAmount: null,
creditBalance: 0,
amountCents: 999,
amountCents: giftProduct.amountCents,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
isFirstRechargeOffer: false,
mostPopular: false,
firstRechargeDiscountPercent: null,
promotionType: null,
currency: giftProduct.currency,
});
describe("TipScreen checkout", () => {
@@ -129,16 +156,16 @@ describe("TipScreen checkout", () => {
container.remove();
});
it("creates a character-attributed order without an AuthProvider", () => {
it("creates a dynamic character-attributed gift order", () => {
renderScreen();
const checkout = getCheckoutButton();
expect(container.textContent).toContain("A warm coffee prompt.");
expect(container.textContent).toContain("Golden Reserve");
expect(checkout.disabled).toBe(false);
act(() => checkout.click());
expect(mocks.planClick).toHaveBeenCalledWith(
mediumPlan,
giftPlan,
expect.objectContaining({ entryPoint: "tip_page" }),
);
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
@@ -148,84 +175,60 @@ describe("TipScreen checkout", () => {
});
it.each([
["plans are loading", { isLoadingPlans: true }],
["the selected plan is missing", { plans: [], selectedPlanId: "" }],
["catalog is loading", { isLoadingPlans: true }],
[
"the selected product is missing",
{ plans: [], giftProducts: [], selectedPlanId: "" },
],
["an order is being created", { isCreatingOrder: true }],
["an order is being polled", { isPollingOrder: true }],
] as const)("disables checkout when %s", (_label, overrides) => {
mocks.payment = makePaymentState(overrides);
renderScreen();
expect(getCheckoutButton().disabled).toBe(true);
});
it("replaces checkout with the first Tip success experience", () => {
it("shows the complete backend Tip message after payment", () => {
mocks.payment = makePaymentState({
status: "paid",
isPaid: true,
orderStatus: "paid",
tipCount: 1,
thankYouMessage: "A backend message that is not used for first Tip.",
tipMessage: TipMessageResponseSchema.parse({
orderId: "pay_xxx",
characterId: "maya-tan",
planId: giftProduct.planId,
productName: giftProduct.planName,
tipCount: 2,
poolIndex: 17,
message: "This complete message came directly from the backend.",
}),
});
renderScreen();
expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull();
expect(container.textContent).toContain(
"Did you really just buy me a coffee?",
);
expect(container.textContent).toContain("That honestly made me smile.");
expect(container.textContent).toContain(
"Thank you. I'll definitely think of you while I enjoy it.",
"This complete message came directly from the backend.",
);
expect(document.activeElement?.id).toBe("tip-success-title");
const sendAgain = getButton("Send another coffee");
act(() => sendAgain.click());
act(() => getButton("Send another gift").click());
expect(mocks.paymentDispatch).toHaveBeenCalledWith({ type: "PaymentReset" });
});
it("keeps paid success visible and retries only a failed message", () => {
mocks.payment = makePaymentState({
status: "tipMessageFailed",
isPaid: true,
orderStatus: "paid",
tipMessageError: "message unavailable",
});
renderScreen();
expect(container.textContent).toContain("Thank you. Your gift made me smile.");
act(() => getButton("Retry message").click());
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentReset",
type: "PaymentTipMessageRetryRequested",
});
expect(
container
.querySelector<HTMLAnchorElement>(
'[data-analytics-key="tip.success_back_to_splash"]',
)
?.getAttribute("href"),
).toBe("/characters/maya/splash");
});
it("shows the repeat Tip count and backend thank-you message", () => {
mocks.payment = makePaymentState({
status: "paid",
isPaid: true,
orderStatus: "paid",
tipCount: 22,
thankYouMessage: "You always make my day sweeter.",
});
renderScreen();
expect(container.textContent).toContain(
"This is the 22nd coffee you've treated me to.",
);
expect(container.textContent).toContain(
"You always make my day sweeter.",
);
});
it("uses generic success copy when Tip metadata is incomplete", () => {
mocks.payment = makePaymentState({
status: "paid",
isPaid: true,
orderStatus: "paid",
tipCount: 2,
thankYouMessage: null,
});
renderScreen();
expect(container.textContent).toContain(
"Thank you. Your coffee made me smile.",
);
expect(container.textContent).not.toContain("2nd coffee");
});
function renderScreen(): void {
@@ -254,20 +257,25 @@ function makePaymentState(
): PaymentContextState {
return {
status: "ready",
plans: [mediumPlan],
planCatalog: "tip",
plans: [giftPlan],
giftCategories: [giftCategory],
giftProducts: [giftProduct],
selectedGiftCategory: giftCategory.category,
isFirstRecharge: false,
selectedPlanId: mediumPlan.planId,
selectedPlanId: giftPlan.planId,
payChannel: "stripe",
autoRenew: false,
agreed: true,
currentOrderId: null,
payParams: null,
orderStatus: null,
tipCount: null,
thankYouMessage: null,
tipMessage: null,
tipMessageError: null,
errorMessage: null,
launchNonce: 0,
isLoadingPlans: false,
isLoadingTipMessage: false,
isCreatingOrder: false,
isPollingOrder: false,
isPaid: false,
@@ -1,108 +1,60 @@
import { describe, expect, it } from "vitest";
import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment";
import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
import {
GiftCategorySchema,
GiftProductSchema,
} from "@/data/schemas/payment";
import {
findTipCoffeePlan,
formatEnglishOrdinal,
formatTipPrice,
resolveTipSuccessCopy,
formatGiftPrice,
getGiftImageSources,
TIP_GIFT_PLACEHOLDER_IMAGE,
} from "../tip-screen.helpers";
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
return PaymentPlanSchema.parse({
planId: "coins_100",
planName: "Coins",
orderType: "dol",
vipDays: null,
dolAmount: 100,
creditBalance: 100,
amountCents: 990,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null,
promotionType: null,
...input,
});
}
const category = GiftCategorySchema.parse({
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: "https://cdn.example.com/category.jpg",
});
const product = GiftProductSchema.parse({
planId: "gift_1",
planName: "Golden Reserve",
orderType: "tip",
tipType: "coffee_medium",
category: "coffee",
characterId: "elio",
description: "A warm coffee",
imageUrl: "https://cdn.example.com/product.jpg",
amountCents: 999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
});
describe("tip screen helpers", () => {
it("matches the official small coffee plan", () => {
const plan = makePlan({
planId: "tip_coffee_usd_4_99",
});
expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan);
it("formats prices from backend amount and currency", () => {
expect(formatGiftPrice(999, "usd")).toBe("$9.99");
expect(formatGiftPrice(500, "PHP")).toContain("5.00");
});
it("matches medium and large coffee plans independently", () => {
const medium = makePlan({
planId: "tip_coffee_usd_9_99",
orderType: "tip",
amountCents: 999,
});
const large = makePlan({
planId: "tip_coffee_usd_19_99",
orderType: "tip",
amountCents: 1999,
});
expect(findTipCoffeePlan([large, medium], "medium")).toBe(medium);
expect(findTipCoffeePlan([medium, large], "large")).toBe(large);
it("orders product, category, and local image fallbacks", () => {
expect(getGiftImageSources(product, category)).toEqual([
"https://cdn.example.com/product.jpg",
"https://cdn.example.com/category.jpg",
TIP_GIFT_PLACEHOLDER_IMAGE,
]);
});
it("does not infer a coffee tier from order type or amount", () => {
const ambiguousPlan = makePlan({
planId: "legacy_coffee",
orderType: "tip",
amountCents: 999,
});
expect(findTipCoffeePlan([ambiguousPlan], "medium")).toBeNull();
});
it("formats USD prices with the product-style label", () => {
it("always retains the local placeholder", () => {
expect(
formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }), "small"),
).toBe("US$ 5");
});
it("uses the selected coffee price when its plan is unavailable", () => {
expect(formatTipPrice(null, "small")).toBe("US$ 4.99");
expect(formatTipPrice(null, "medium")).toBe("US$ 9.99");
expect(formatTipPrice(null, "large")).toBe("US$ 19.99");
});
it.each([
[2, "2nd"],
[3, "3rd"],
[11, "11th"],
[12, "12th"],
[13, "13th"],
[21, "21st"],
[22, "22nd"],
])("formats %i as the English ordinal %s", (value, expected) => {
expect(formatEnglishOrdinal(value)).toBe(expected);
});
it("resolves first, repeat, and fallback success copy", () => {
expect(resolveTipSuccessCopy(1, "Ignored for the first Tip")).toEqual({
title: "Did you really just buy me a coffee?",
body: [
"That honestly made me smile.",
"Thank you. I'll definitely think of you while I enjoy it.",
],
});
expect(resolveTipSuccessCopy(22, "You made my day.")).toEqual({
title: "This is the 22nd coffee you've treated me to.",
body: ["You made my day."],
});
expect(resolveTipSuccessCopy(2, null)).toEqual({
title: "Thank you. Your coffee made me smile.",
body: [],
});
getGiftImageSources(
GiftProductSchema.parse({ ...product, imageUrl: null }),
GiftCategorySchema.parse({ ...category, imageUrl: null }),
),
).toEqual([TIP_GIFT_PLACEHOLDER_IMAGE]);
});
});
+6 -4
View File
@@ -2,7 +2,6 @@
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
import { useActiveCharacter } from "@/providers/character-provider";
import {
usePaymentDispatch,
@@ -15,14 +14,16 @@ import styles from "./tip-screen.module.css";
const log = new Logger("TipCheckoutButton");
export interface TipCheckoutButtonProps {
coffeeType: TipCoffeeType;
giftCategory: string | null;
giftPlanId: string | null;
disabled?: boolean;
onOrder: () => void;
returnPath: string;
}
export function TipCheckoutButton({
coffeeType,
giftCategory,
giftPlanId,
disabled = false,
onOrder,
returnPath,
@@ -36,7 +37,8 @@ export function TipCheckoutButton({
payment,
paymentDispatch,
subscriptionType: "tip",
tipCoffeeType: coffeeType,
giftCategory,
giftPlanId,
characterSlug: character.slug,
});
-70
View File
@@ -1,70 +0,0 @@
import { Check } from "lucide-react";
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
import styles from "./tip-screen.module.css";
export interface TipCoffeeTierItem {
type: TipCoffeeType;
displayName: string;
priceLabel: string;
unavailable: boolean;
}
interface TipCoffeeTierSelectorProps {
disabled: boolean;
items: readonly TipCoffeeTierItem[];
onChange: (type: TipCoffeeType) => void;
selectedType: TipCoffeeType;
}
export function TipCoffeeTierSelector({
disabled,
items,
onChange,
selectedType,
}: TipCoffeeTierSelectorProps) {
return (
<fieldset className={styles.tierSelector} disabled={disabled}>
<legend className={styles.tierLegend}>Choose your coffee</legend>
<div className={styles.tierList}>
{items.map((item) => {
const isSelected = item.type === selectedType;
return (
<label
key={item.type}
className={styles.tierOption}
data-selected={isSelected ? "true" : "false"}
data-unavailable={item.unavailable ? "true" : "false"}
>
<input
type="radio"
name="tip-coffee-tier"
value={item.type}
checked={isSelected}
disabled={item.unavailable}
className={styles.tierInput}
data-analytics-key={`tip.select_${item.type}`}
data-analytics-label={`Select ${item.displayName}`}
onChange={() => onChange(item.type)}
/>
<span className={styles.tierDetails}>
<span className={styles.tierName}>{item.displayName}</span>
</span>
<span className={styles.tierPriceBlock}>
<span className={styles.tierPrice}>{item.priceLabel}</span>
{item.unavailable ? (
<span className={styles.tierUnavailable}>Unavailable</span>
) : null}
</span>
<span className={styles.tierCheck} aria-hidden="true">
{isSelected ? <Check size={16} strokeWidth={3} /> : null}
</span>
</label>
);
})}
</div>
</fieldset>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Check } from "lucide-react";
import type { GiftProduct } from "@/data/schemas/payment";
import { formatGiftPrice } from "./tip-screen.helpers";
import styles from "./tip-screen.module.css";
interface TipGiftProductSelectorProps {
disabled: boolean;
products: readonly GiftProduct[];
onChange: (planId: string) => void;
selectedPlanId: string;
}
export function TipGiftProductSelector({
disabled,
products,
onChange,
selectedPlanId,
}: TipGiftProductSelectorProps) {
return (
<fieldset className={styles.tierSelector} disabled={disabled}>
<legend className={styles.visuallyHidden}>Choose a gift</legend>
<div className={styles.tierList}>
{products.map((product) => {
const isSelected = product.planId === selectedPlanId;
return (
<label
key={product.planId}
className={styles.tierOption}
data-selected={isSelected ? "true" : "false"}
>
<input
type="radio"
name="tip-gift-product"
value={product.planId}
checked={isSelected}
className={styles.tierInput}
data-analytics-key="tip.select_product"
data-analytics-label={`Select ${product.planName}`}
onChange={() => onChange(product.planId)}
/>
<span className={styles.tierDetails}>
<span className={styles.tierName}>{product.planName}</span>
{product.description ? (
<span className={styles.tierDescription}>
{product.description}
</span>
) : null}
</span>
<span className={styles.tierPriceBlock}>
<span className={styles.tierPrice}>
{formatGiftPrice(product.amountCents, product.currency)}
</span>
</span>
<span className={styles.tierCheck} aria-hidden="true">
{isSelected ? <Check size={16} strokeWidth={3} /> : null}
</span>
</label>
);
})}
</div>
</fieldset>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
interface TipProductImageProps {
alt: string;
className: string;
priority?: boolean;
sources: readonly string[];
}
export function TipProductImage({
alt,
className,
priority = false,
sources,
}: TipProductImageProps) {
const [sourceIndex, setSourceIndex] = useState(0);
const source = sources[sourceIndex] ?? sources[sources.length - 1];
if (!source) return null;
return (
// Gift hosts are managed by the backend and cannot be safely enumerated in Next remotePatterns.
// eslint-disable-next-line @next/next/no-img-element
<img
src={source}
alt={alt}
className={className}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : "auto"}
onError={() => {
if (sourceIndex < sources.length - 1) {
setSourceIndex((index) => index + 1);
}
}}
/>
);
}
+29 -72
View File
@@ -1,79 +1,36 @@
import type { PaymentPlan } from "@/data/schemas/payment";
import {
getTipCoffeeOption,
type TipCoffeeType,
} from "@/lib/tip/tip_coffee";
import type {
GiftCategory,
GiftProduct,
} from "@/data/schemas/payment";
export interface TipSuccessCopy {
readonly title: string;
readonly body: readonly string[];
}
export const TIP_GIFT_PLACEHOLDER_IMAGE = "/images/tip/medium.png";
export function findTipCoffeePlan(
plans: readonly PaymentPlan[],
coffeeType: TipCoffeeType,
): PaymentPlan | null {
const option = getTipCoffeeOption(coffeeType);
return plans.find((plan) => plan.planId === option.planId) ?? null;
}
export function formatTipPrice(
plan: PaymentPlan | null,
coffeeType: TipCoffeeType,
export function formatGiftPrice(
amountCents: number,
currency: string,
): string {
const option = getTipCoffeeOption(coffeeType);
const amountCents = plan?.amountCents ?? option.amountCents;
const currency = plan?.currency.trim().toUpperCase() || "USD";
const amount = amountCents / 100;
const formattedAmount = Number.isInteger(amount)
? String(amount)
: amount.toFixed(2).replace(/\.?0+$/, "");
if (currency === "USD") return `US$ ${formattedAmount}`;
if (currency.length > 0) return `${currency} ${formattedAmount}`;
return formattedAmount;
}
export function formatEnglishOrdinal(value: number): string {
const remainder100 = value % 100;
if (remainder100 >= 11 && remainder100 <= 13) return `${value}th`;
switch (value % 10) {
case 1:
return `${value}st`;
case 2:
return `${value}nd`;
case 3:
return `${value}rd`;
default:
return `${value}th`;
const normalizedCurrency = currency.trim().toUpperCase();
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: normalizedCurrency,
}).format(amountCents / 100);
} catch {
const amount = (amountCents / 100).toFixed(2);
return normalizedCurrency ? `${normalizedCurrency} ${amount}` : amount;
}
}
export function resolveTipSuccessCopy(
tipCount: number | null,
thankYouMessage: string | null,
): TipSuccessCopy {
if (tipCount === 1) {
return {
title: "Did you really just buy me a coffee?",
body: [
"That honestly made me smile.",
"Thank you. I'll definitely think of you while I enjoy it.",
],
};
}
if (tipCount !== null && tipCount > 1 && thankYouMessage) {
return {
title: `This is the ${formatEnglishOrdinal(tipCount)} coffee you've treated me to.`,
body: [thankYouMessage],
};
}
return {
title: "Thank you. Your coffee made me smile.",
body: [],
};
export function getGiftImageSources(
product: GiftProduct | null,
category: GiftCategory | null,
): readonly string[] {
return [
product?.imageUrl,
category?.imageUrl,
TIP_GIFT_PLACEHOLDER_IMAGE,
].filter(
(source, index, sources): source is string =>
Boolean(source) && sources.indexOf(source) === index,
);
}
+121
View File
@@ -59,6 +59,7 @@
.productCard,
.paymentMethodSlot,
.statusMessage,
.catalogStatus,
.checkoutSlot {
position: relative;
z-index: 1;
@@ -225,6 +226,26 @@
letter-spacing: -0.04em;
}
.productDescription {
margin: 10px 0 0;
color: #7d6264;
font-size: clamp(12px, 3.148vw, 15px);
font-weight: 620;
line-height: 1.45;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.tierSelector {
grid-column: 1 / -1;
min-width: 0;
@@ -331,6 +352,17 @@
white-space: nowrap;
}
.tierDescription {
display: -webkit-box;
overflow: hidden;
color: #80676a;
font-size: 11px;
font-weight: 620;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.tierPriceBlock {
align-items: flex-end;
gap: 3px;
@@ -371,6 +403,82 @@
margin: 0;
}
.catalogStatus {
display: grid;
justify-items: center;
gap: 12px;
margin-top: 20px;
padding: 22px;
border: 1px solid rgba(112, 71, 65, 0.1);
border-radius: 24px;
background: rgba(255, 255, 255, 0.78);
color: #76575c;
font-size: 14px;
font-weight: 700;
line-height: 1.5;
text-align: center;
}
.catalogStatus p {
margin: 0;
}
.catalogStatus button {
min-height: 40px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: #2b1a1e;
color: #ffffff;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 850;
}
.productSkeleton {
min-height: 260px;
}
.skeletonImage,
.skeletonCopy span {
background: linear-gradient(
100deg,
rgba(205, 172, 157, 0.14) 20%,
rgba(255, 255, 255, 0.72) 45%,
rgba(205, 172, 157, 0.14) 70%
);
background-size: 220% 100%;
animation: skeletonSweep 1.25s linear infinite;
}
.skeletonImage {
width: 100%;
aspect-ratio: 1;
border-radius: clamp(24px, 6.667vw, 32px);
}
.skeletonCopy {
display: grid;
gap: 12px;
}
.skeletonCopy span {
display: block;
height: 18px;
border-radius: 999px;
}
.skeletonCopy span:nth-child(2) {
width: 78%;
height: 38px;
}
.skeletonCopy span:nth-child(3) {
width: 56%;
height: 28px;
}
.statusMessage {
margin-top: 14px;
color: #b2474f;
@@ -467,3 +575,16 @@
transform: translateY(0) scale(1);
}
}
@keyframes skeletonSweep {
to {
background-position: -120% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeletonImage,
.skeletonCopy span {
animation: none;
}
}
+157 -133
View File
@@ -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}
/>
+32
View File
@@ -174,6 +174,38 @@
margin: 0;
}
.messageRetry {
display: grid;
justify-items: center;
gap: 9px;
margin-top: 15px;
color: #8a6870;
font-size: 12px;
font-weight: 650;
}
.messageRetry p {
margin: 0;
}
.retryAction {
min-height: 36px;
padding: 0 15px;
border: 1px solid rgba(199, 68, 112, 0.18);
border-radius: 999px;
background: #fff0f4;
color: #bd3d69;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 850;
}
.retryAction:disabled {
cursor: wait;
opacity: 0.62;
}
.actions {
display: grid;
gap: 10px;
+45 -26
View File
@@ -1,25 +1,29 @@
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
import Image from "next/image";
import Link from "next/link";
import { Coffee, Heart, Sparkles } from "lucide-react";
import { Gift, Heart, Sparkles } from "lucide-react";
import { CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core";
import type { TipCoffeeOption } from "@/lib/tip/tip_coffee";
import { resolveTipSuccessCopy } from "./tip-screen.helpers";
import { TipProductImage } from "./tip-product-image";
import styles from "./tip-success-view.module.css";
const GENERIC_TIP_SUCCESS_MESSAGE =
"Thank you. Your gift made me smile.";
export interface TipSuccessViewProps {
characterName: string;
characterAvatar: string;
characterCover: string;
coffeeOption: TipCoffeeOption;
giftImageSources: readonly string[];
giftName: string;
isMessageLoading: boolean;
message: string | null;
messageError: string | null;
splashHref: string;
tipCount: number | null;
thankYouMessage: string | null;
onRetryMessage: () => void;
onSendAgain: () => void;
}
@@ -27,14 +31,16 @@ export function TipSuccessView({
characterName,
characterAvatar,
characterCover,
coffeeOption,
giftImageSources,
giftName,
isMessageLoading,
message,
messageError,
splashHref,
tipCount,
thankYouMessage,
onRetryMessage,
onSendAgain,
}: TipSuccessViewProps) {
const titleRef = useRef<HTMLHeadingElement>(null);
const copy = resolveTipSuccessCopy(tipCount, thankYouMessage);
useEffect(() => {
titleRef.current?.focus({ preventScroll: true });
@@ -76,12 +82,10 @@ export function TipSuccessView({
/>
</div>
<div className={styles.coffeeFrame}>
<Image
src={coffeeOption.image.src}
alt={`${coffeeOption.displayName} coffee`}
width={coffeeOption.image.width}
height={coffeeOption.image.height}
sizes="78px"
<TipProductImage
key={giftName}
sources={giftImageSources}
alt={giftName}
className={styles.coffeeImage}
priority
/>
@@ -89,8 +93,8 @@ export function TipSuccessView({
</div>
<p className={styles.eyebrow}>
<Coffee size={15} aria-hidden="true" />
Coffee received
<Gift size={15} aria-hidden="true" />
Gift received
</p>
<h1
ref={titleRef}
@@ -98,13 +102,28 @@ export function TipSuccessView({
className={styles.title}
tabIndex={-1}
>
{copy.title}
You made {characterName} smile.
</h1>
{copy.body.length > 0 ? (
<div className={styles.copy}>
{copy.body.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
<div className={styles.copy}>
<p>
{message ??
(isMessageLoading
? "Your gift arrived. A thank-you note is on its way."
: GENERIC_TIP_SUCCESS_MESSAGE)}
</p>
</div>
{messageError ? (
<div className={styles.messageRetry}>
<p>We could not load the personal thank-you note.</p>
<button
type="button"
className={styles.retryAction}
disabled={isMessageLoading}
onClick={onRetryMessage}
>
{isMessageLoading ? "Retrying..." : "Retry message"}
</button>
</div>
) : null}
@@ -115,7 +134,7 @@ export function TipSuccessView({
data-analytics-key="tip.send_again"
onClick={onSendAgain}
>
Send another coffee
Send another gift
</button>
<Link
href={splashHref}