Compare commits
3 Commits
1a58a11045
...
d6be2081c3
| Author | SHA1 | Date | |
|---|---|---|---|
| d6be2081c3 | |||
| fa08cc114f | |||
| a990f5082b |
@@ -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 与状态边界
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 35 KiB |
@@ -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}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
<p>
|
{isFirstTip
|
||||||
{message ??
|
? FIRST_TIP_MESSAGE.map((paragraph) => (
|
||||||
(isMessageLoading
|
<p key={paragraph}>{paragraph}</p>
|
||||||
? "Your gift arrived. A thank-you note is on its way."
|
))
|
||||||
: GENERIC_TIP_SUCCESS_MESSAGE)}
|
: (
|
||||||
</p>
|
<p>
|
||||||
|
{message ??
|
||||||
|
(isMessageLoading
|
||||||
|
? "Your gift arrived. A thank-you note is on its way."
|
||||||
|
: GENERIC_TIP_SUCCESS_MESSAGE)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{messageError ? (
|
{messageError ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user