feat(tip): support dynamic gift products
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user