Compare commits

...

2 Commits

Author SHA1 Message Date
admin fa08cc114f feat(tip): customize first gift thank-you 2026-07-21 14:39:53 +08:00
admin a990f5082b fix(tip): refine gift presentation 2026-07-21 14:24:26 +08:00
8 changed files with 101 additions and 40 deletions
+2 -2
View File
@@ -264,9 +264,9 @@ Authorization: Bearer <TOKEN>
{"orderId":"pay_xxx"} {"orderId":"pay_xxx"}
``` ```
响应包含 `orderId``characterId``planId``productName``tipCount``poolIndex` 和可直接展示的完整 `message`前端不再根据次数拼接文案,也不从订单状态读取感谢字段 响应包含 `orderId``characterId``planId``productName``tipCount``poolIndex` 和可直接展示的完整 `message``tipCount` 表示当前付款身份对同一商品的累计成功次数
Tip 成功页在文案加载期间立即确认支付成功;接口成功后按纯文本直接展示 `message`。接口失败时展示本地通用感谢语和 Retry,重试只重新请求 Tip Message,不重复轮询订单或创建订单。 Tip 成功页在文案加载期间立即确认支付成功`tipCount=1` 时展示固定首次咖啡文案并忽略后端 `message`;大于 1 时按纯文本直接展示 `message`。接口失败时展示本地通用感谢语和 Retry,重试只重新请求 Tip Message,不重复轮询订单或创建订单。
## 10. Provider 与状态边界 ## 10. Provider 与状态边界
@@ -188,7 +188,36 @@ describe("TipScreen checkout", () => {
expect(getCheckoutButton().disabled).toBe(true); expect(getCheckoutButton().disabled).toBe(true);
}); });
it("shows the complete backend Tip message after payment", () => { it("shows fixed copy for the first character Tip", () => {
mocks.payment = makePaymentState({
status: "paid",
isPaid: true,
orderStatus: "paid",
tipMessage: TipMessageResponseSchema.parse({
orderId: "pay_first",
characterId: "maya-tan",
planId: giftProduct.planId,
productName: giftProduct.planName,
tipCount: 1,
poolIndex: 4,
message: "This backend message is ignored for the first Tip.",
}),
});
renderScreen();
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(
"I'll definitely think of you while I enjoy it.",
);
expect(container.textContent).not.toContain(
"This backend message is ignored for the first Tip.",
);
});
it("shows the backend message after the first Tip", () => {
mocks.payment = makePaymentState({ mocks.payment = makePaymentState({
status: "paid", status: "paid",
isPaid: true, isPaid: true,
@@ -209,6 +238,9 @@ describe("TipScreen checkout", () => {
expect(container.textContent).toContain( expect(container.textContent).toContain(
"This complete message came directly from the backend.", "This complete message came directly from the backend.",
); );
expect(container.textContent).not.toContain(
"Did you really just buy me a coffee?",
);
expect(document.activeElement?.id).toBe("tip-success-title"); expect(document.activeElement?.id).toBe("tip-success-title");
act(() => getButton("Send another gift").click()); act(() => getButton("Send another gift").click());
@@ -57,4 +57,36 @@ describe("tip screen helpers", () => {
), ),
).toEqual([TIP_GIFT_PLACEHOLDER_IMAGE]); ).toEqual([TIP_GIFT_PLACEHOLDER_IMAGE]);
}); });
it.each([
["coffee_small", "/images/tip/small.jpg"],
["coffee_medium", "/images/tip/medium.png"],
["coffee_large", "/images/tip/large.png"],
])("uses the %s tier image as its local fallback", (tipType, expected) => {
const tierProduct = GiftProductSchema.parse({
...product,
tipType,
imageUrl: null,
});
const categoryWithoutImage = GiftCategorySchema.parse({
...category,
imageUrl: null,
});
expect(getGiftImageSources(tierProduct, categoryWithoutImage)).toEqual([
expected,
]);
});
it("uses the generic placeholder for unknown gift types", () => {
const unknownProduct = GiftProductSchema.parse({
...product,
tipType: "flowers",
imageUrl: null,
});
expect(getGiftImageSources(unknownProduct, null)).toEqual([
TIP_GIFT_PLACEHOLDER_IMAGE,
]);
});
}); });
@@ -43,11 +43,6 @@ export function TipGiftProductSelector({
/> />
<span className={styles.tierDetails}> <span className={styles.tierDetails}>
<span className={styles.tierName}>{product.planName}</span> <span className={styles.tierName}>{product.planName}</span>
{product.description ? (
<span className={styles.tierDescription}>
{product.description}
</span>
) : null}
</span> </span>
<span className={styles.tierPriceBlock}> <span className={styles.tierPriceBlock}>
<span className={styles.tierPrice}> <span className={styles.tierPrice}>
+12 -1
View File
@@ -5,6 +5,12 @@ import type {
export const TIP_GIFT_PLACEHOLDER_IMAGE = "/images/tip/medium.png"; export const TIP_GIFT_PLACEHOLDER_IMAGE = "/images/tip/medium.png";
const TIP_GIFT_PLACEHOLDER_BY_TIP_TYPE: Readonly<Record<string, string>> = {
coffee_small: "/images/tip/small.jpg",
coffee_medium: "/images/tip/medium.png",
coffee_large: "/images/tip/large.png",
};
export function formatGiftPrice( export function formatGiftPrice(
amountCents: number, amountCents: number,
currency: string, currency: string,
@@ -25,10 +31,15 @@ export function getGiftImageSources(
product: GiftProduct | null, product: GiftProduct | null,
category: GiftCategory | null, category: GiftCategory | null,
): readonly string[] { ): readonly string[] {
const placeholderImage = product
? (TIP_GIFT_PLACEHOLDER_BY_TIP_TYPE[product.tipType] ??
TIP_GIFT_PLACEHOLDER_IMAGE)
: TIP_GIFT_PLACEHOLDER_IMAGE;
return [ return [
product?.imageUrl, product?.imageUrl,
category?.imageUrl, category?.imageUrl,
TIP_GIFT_PLACEHOLDER_IMAGE, placeholderImage,
].filter( ].filter(
(source, index, sources): source is string => (source, index, sources): source is string =>
Boolean(source) && sources.indexOf(source) === index, Boolean(source) && sources.indexOf(source) === index,
-19
View File
@@ -226,14 +226,6 @@
letter-spacing: -0.04em; 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 { .visuallyHidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -352,17 +344,6 @@
white-space: nowrap; 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 { .tierPriceBlock {
align-items: flex-end; align-items: flex-end;
gap: 3px; gap: 3px;
+1 -5
View File
@@ -191,6 +191,7 @@ export function TipScreen({
"Your gift" "Your gift"
} }
isMessageLoading={payment.isLoadingTipMessage} isMessageLoading={payment.isLoadingTipMessage}
tipCount={payment.tipMessage?.tipCount ?? null}
message={payment.tipMessage?.message ?? null} message={payment.tipMessage?.message ?? null}
messageError={payment.tipMessageError} messageError={payment.tipMessageError}
splashHref={characterRoutes.splash} splashHref={characterRoutes.splash}
@@ -286,11 +287,6 @@ export function TipScreen({
selectedProduct.currency, selectedProduct.currency,
)} )}
</p> </p>
{selectedProduct.description ? (
<p className={styles.productDescription}>
{selectedProduct.description}
</p>
) : null}
</div> </div>
<TipGiftProductSelector <TipGiftProductSelector
+15 -1
View File
@@ -12,11 +12,17 @@ import styles from "./tip-success-view.module.css";
const GENERIC_TIP_SUCCESS_MESSAGE = const GENERIC_TIP_SUCCESS_MESSAGE =
"Thank you. Your gift made me smile."; "Thank you. Your gift made me smile.";
const FIRST_TIP_TITLE = "Did you really just buy me a coffee?";
const FIRST_TIP_MESSAGE = [
"That honestly made me smile.",
"I'll definitely think of you while I enjoy it.",
] as const;
export interface TipSuccessViewProps { export interface TipSuccessViewProps {
characterName: string; characterName: string;
characterAvatar: string; characterAvatar: string;
characterCover: string; characterCover: string;
tipCount: number | null;
giftImageSources: readonly string[]; giftImageSources: readonly string[];
giftName: string; giftName: string;
isMessageLoading: boolean; isMessageLoading: boolean;
@@ -31,6 +37,7 @@ export function TipSuccessView({
characterName, characterName,
characterAvatar, characterAvatar,
characterCover, characterCover,
tipCount,
giftImageSources, giftImageSources,
giftName, giftName,
isMessageLoading, isMessageLoading,
@@ -41,6 +48,7 @@ export function TipSuccessView({
onSendAgain, onSendAgain,
}: TipSuccessViewProps) { }: TipSuccessViewProps) {
const titleRef = useRef<HTMLHeadingElement>(null); const titleRef = useRef<HTMLHeadingElement>(null);
const isFirstTip = tipCount === 1;
useEffect(() => { useEffect(() => {
titleRef.current?.focus({ preventScroll: true }); titleRef.current?.focus({ preventScroll: true });
@@ -102,15 +110,21 @@ export function TipSuccessView({
className={styles.title} className={styles.title}
tabIndex={-1} tabIndex={-1}
> >
You made {characterName} smile. {isFirstTip ? FIRST_TIP_TITLE : GENERIC_TIP_SUCCESS_MESSAGE}
</h1> </h1>
<div className={styles.copy}> <div className={styles.copy}>
{isFirstTip
? FIRST_TIP_MESSAGE.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))
: (
<p> <p>
{message ?? {message ??
(isMessageLoading (isMessageLoading
? "Your gift arrived. A thank-you note is on its way." ? "Your gift arrived. A thank-you note is on its way."
: GENERIC_TIP_SUCCESS_MESSAGE)} : GENERIC_TIP_SUCCESS_MESSAGE)}
</p> </p>
)}
</div> </div>
{messageError ? ( {messageError ? (