feat(tip): support tiered coffee gifts

This commit is contained in:
2026-07-14 10:44:46 +08:00
parent 37ae152abb
commit 0fe74b5371
17 changed files with 307 additions and 41 deletions
+32 -2
View File
@@ -6,7 +6,7 @@
https://<APP_HOST>/external-entry https://<APP_HOST>/external-entry
``` ```
入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面并清理地址栏中的参数 入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面。身份参数和入口控制参数会从地址栏中移除;目标页面自身需要的状态参数会保留,例如打赏页的 `coffee_type`
如果进入时已经是 Email、Google 或 Facebook 正式登录用户,并且携带 ASID 或 PSID,入口会额外调用一次 Facebook Identity 绑定接口。普通启动、刷新、Guest 和未登录状态不会调用绑定接口,绑定失败也不会阻止目标页面跳转。 如果进入时已经是 Email、Google 或 Facebook 正式登录用户,并且携带 ASID 或 PSID,入口会额外调用一次 Facebook Identity 绑定接口。普通启动、刷新、Guest 和未登录状态不会调用绑定接口,绑定失败也不会阻止目标页面跳转。
@@ -19,6 +19,7 @@ https://<APP_HOST>/external-entry
| `asid` | 否 | string | Facebook App-scoped User ID。 | | `asid` | 否 | string | Facebook App-scoped User ID。 |
| `psid` | 否 | string | Facebook Page-scoped User ID。 | | `psid` | 否 | string | Facebook Page-scoped User ID。 |
| `avatar_url` | 否 | URL string | 用户头像地址。 | | `avatar_url` | 否 | URL string | 用户头像地址。 |
| `coffee_type` | 打赏入口可选 | `small``medium``large` | `target=tip` 时指定咖啡档位,缺失或非法时默认为 `small`。 |
| `mode` | 否 | `promotion` | 设置为 `promotion` 时尝试开启聊天促销模式。 | | `mode` | 否 | `promotion` | 设置为 `promotion` 时尝试开启聊天促销模式。 |
| `promotion_type` | 促销模式必填 | `voice``image``private` | 指定促销锁消息类型。 | | `promotion_type` | 促销模式必填 | `voice``image``private` | 指定促销锁消息类型。 |
@@ -32,18 +33,46 @@ https://<APP_HOST>/external-entry
https://<APP_HOST>/external-entry?target=chat&asid=<ASID>&psid=<PSID> https://<APP_HOST>/external-entry?target=chat&asid=<ASID>&psid=<PSID>
``` ```
进入打赏页面: 进入打赏页面(默认 Small Coffee
```text ```text
https://<APP_HOST>/external-entry?target=tip https://<APP_HOST>/external-entry?target=tip
``` ```
入口会跳转到规范地址:
```text
https://<APP_HOST>/tip?coffee_type=small
```
进入私密空间: 进入私密空间:
```text ```text
https://<APP_HOST>/external-entry?target=private-room https://<APP_HOST>/external-entry?target=private-room
``` ```
## 咖啡打赏入口
打赏入口支持三种咖啡档位:
| `coffee_type` | 展示名称 | 价格 | 后端套餐 `planId` |
| --- | --- | --- | --- |
| `small` | Small Coffee | US$4.99 | `tip_coffee_small` |
| `medium` | Medium Coffee | US$9.99 | `tip_coffee_medium` |
| `large` | Large Coffee | US$19.99 | `tip_coffee_large` |
分别进入三种咖啡打赏页面:
```text
https://<APP_HOST>/external-entry?target=tip&coffee_type=small
https://<APP_HOST>/external-entry?target=tip&coffee_type=medium
https://<APP_HOST>/external-entry?target=tip&coffee_type=large
```
外部入口会将它们规范化为 `/tip?coffee_type=<type>``coffee_type` 会在登录、Stripe 回跳和 Ezpay 回跳期间保留,确保支付流程始终使用最初选择的咖啡档位。
实际创建订单时,前端只使用对应 `planId` 的后端套餐;Small 额外兼容旧套餐 `tip_coffee`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。
## 促销入口 ## 促销入口
促销模式只在 `target=chat` 时生效,并且 `mode``promotion_type` 必须同时有效: 促销模式只在 `target=chat` 时生效,并且 `mode``promotion_type` 必须同时有效:
@@ -60,5 +89,6 @@ https://<APP_HOST>/external-entry?target=chat&mode=promotion&promotion_type=voic
- 所有参数必须使用 URL 编码,尤其是 `avatar_url` - 所有参数必须使用 URL 编码,尤其是 `avatar_url`
- 不要通过入口传递登录 token、Page Access Token 或 App Secret。 - 不要通过入口传递登录 token、Page Access Token 或 App Secret。
- `coffee_type` 仅在 `target=tip` 时生效;缺失或非法时使用 `small`
- `promotion_type` 非法时按普通聊天入口处理,不展示促销消息。 - `promotion_type` 非法时按普通聊天入口处理,不展示促销消息。
- 不支持任意 URL 跳转;无效 `target` 固定回退到聊天页。 - 不支持任意 URL 跳转;无效 `target` 固定回退到聊天页。
@@ -11,6 +11,7 @@ import {
import type { import type {
PendingPaymentReturnTo, PendingPaymentReturnTo,
PendingPaymentSubscriptionType, PendingPaymentSubscriptionType,
PendingPaymentTipCoffeeType,
} from "@/lib/payment/pending_payment_order"; } from "@/lib/payment/pending_payment_order";
import type { import type {
PaymentContextState, PaymentContextState,
@@ -35,6 +36,7 @@ export interface UsePaymentLaunchFlowInput {
paymentDispatch: Dispatch<PaymentEvent>; paymentDispatch: Dispatch<PaymentEvent>;
returnTo?: PendingPaymentReturnTo; returnTo?: PendingPaymentReturnTo;
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
} }
export interface PaymentLaunchFlow { export interface PaymentLaunchFlow {
@@ -72,6 +74,7 @@ export function usePaymentLaunchFlow({
paymentDispatch, paymentDispatch,
returnTo, returnTo,
subscriptionType, subscriptionType,
tipCoffeeType,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow { }: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0); const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
@@ -112,6 +115,7 @@ export function usePaymentLaunchFlow({
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrl, paymentUrl,
subscriptionType, subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
onFailed: (errorMessage) => onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }), paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
@@ -136,6 +140,7 @@ export function usePaymentLaunchFlow({
paymentDispatch, paymentDispatch,
returnTo, returnTo,
subscriptionType, subscriptionType,
tipCoffeeType,
]); ]);
const shouldShowStripeDialog = const shouldShowStripeDialog =
@@ -176,6 +181,7 @@ export function usePaymentLaunchFlow({
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl, paymentUrl: ezpayPaymentUrl,
subscriptionType, subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
onFailed: (errorMessage) => { onFailed: (errorMessage) => {
setIsConfirmingEzpay(false); setIsConfirmingEzpay(false);
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { import {
persistExternalEntryPayload, persistExternalEntryPayload,
resolveExternalEntryDestination,
resolveExternalEntryPromotionType, resolveExternalEntryPromotionType,
resolveExternalEntryTarget, resolveExternalEntryTarget,
shouldBindExternalFacebookIdentity, shouldBindExternalFacebookIdentity,
@@ -25,6 +26,7 @@ interface ExternalEntryPersistProps {
psid: string | null; psid: string | null;
avatarUrl: string | null; avatarUrl: string | null;
target: string | null; target: string | null;
coffeeType: string | null;
mode: string | null; mode: string | null;
promotionType: string | null; promotionType: string | null;
} }
@@ -35,6 +37,7 @@ export default function ExternalEntryPersist({
psid, psid,
avatarUrl, avatarUrl,
target, target,
coffeeType,
mode, mode,
promotionType, promotionType,
}: ExternalEntryPersistProps) { }: ExternalEntryPersistProps) {
@@ -43,7 +46,8 @@ export default function ExternalEntryPersist({
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const [hasPersisted, setHasPersisted] = useState(false); const [hasPersisted, setHasPersisted] = useState(false);
const hasNavigatedRef = useRef(false); const hasNavigatedRef = useRef(false);
const destination = resolveExternalEntryTarget({ target }); const targetRoute = resolveExternalEntryTarget({ target });
const destination = resolveExternalEntryDestination({ target, coffeeType });
const resolvedPromotionType = resolveExternalEntryPromotionType({ const resolvedPromotionType = resolveExternalEntryPromotionType({
mode, mode,
promotionType, promotionType,
@@ -60,7 +64,7 @@ export default function ExternalEntryPersist({
psid, psid,
avatarUrl, avatarUrl,
}), }),
destination === ROUTES.chat && resolvedPromotionType targetRoute === ROUTES.chat && resolvedPromotionType
? savePendingChatPromotion(resolvedPromotionType) ? savePendingChatPromotion(resolvedPromotionType)
: clearPendingChatPromotion(), : clearPendingChatPromotion(),
]); ]);
@@ -81,12 +85,14 @@ export default function ExternalEntryPersist({
}, [ }, [
avatarUrl, avatarUrl,
asid, asid,
coffeeType,
destination, destination,
deviceId, deviceId,
mode, mode,
promotionType, promotionType,
psid, psid,
resolvedPromotionType, resolvedPromotionType,
targetRoute,
]); ]);
useEffect(() => { useEffect(() => {
+2
View File
@@ -4,6 +4,7 @@
* 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如: * 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如:
* `/external-entry?target=chat&asid=xxx&psid=yyy` * `/external-entry?target=chat&asid=xxx&psid=yyy`
* `/external-entry?target=chat&mode=promotion&promotion_type=voice` * `/external-entry?target=chat&mode=promotion&promotion_type=voice`
* `/external-entry?target=tip&coffee_type=medium`
* *
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
@@ -29,6 +30,7 @@ export default async function ExternalEntryPage({
psid={pickParam(params.psid)} psid={pickParam(params.psid)}
avatarUrl={pickParam(params.avatar_url)} avatarUrl={pickParam(params.avatar_url)}
target={pickParam(params.target)} target={pickParam(params.target)}
coffeeType={pickParam(params.coffee_type)}
mode={pickParam(params.mode)} mode={pickParam(params.mode)}
promotionType={pickParam(params.promotion_type)} promotionType={pickParam(params.promotion_type)}
/> />
@@ -29,34 +29,51 @@ function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
} }
describe("tip screen helpers", () => { describe("tip screen helpers", () => {
it("prefers the exact tip coffee plan id", () => { it("uses the legacy tip coffee plan only for small", () => {
const byOrderType = makePlan({ const plan = makePlan({
planId: "legacy_coffee",
planName: "Legacy coffee",
orderType: "tip_coffee",
});
const byPlanId = makePlan({
planId: "tip_coffee", planId: "tip_coffee",
planName: "Coffee",
orderType: "custom_tip",
}); });
expect(findTipCoffeePlan([byOrderType, byPlanId])).toBe(byPlanId); expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan);
expect(findTipCoffeePlan([plan], "medium")).toBeNull();
}); });
it("falls back to the tip coffee order type", () => { it("matches medium and large coffee plans independently", () => {
const plan = makePlan({ const medium = makePlan({
planId: "legacy_coffee", planId: "tip_coffee_medium",
orderType: "tip_coffee", orderType: "tip_coffee_medium",
amountCents: 999,
});
const large = makePlan({
planId: "tip_coffee_large",
orderType: "tip_coffee_large",
amountCents: 1999,
}); });
expect(findTipCoffeePlan([makePlan({}), plan])).toBe(plan); expect(findTipCoffeePlan([large, medium], "medium")).toBe(medium);
expect(findTipCoffeePlan([medium, large], "large")).toBe(large);
});
it("does not infer a coffee tier from order type or amount", () => {
const ambiguousPlan = makePlan({
planId: "legacy_coffee",
orderType: "tip_coffee_medium",
amountCents: 999,
});
expect(findTipCoffeePlan([ambiguousPlan], "medium")).toBeNull();
}); });
it("formats USD prices with the product-style label", () => { it("formats USD prices with the product-style label", () => {
expect(formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }))).toBe( expect(
"US$ 5", 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("treats guest and notLoggedIn as non-real login states", () => { it("treats guest and notLoggedIn as non-real login states", () => {
+7 -2
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { ROUTES } from "@/router/routes"; import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
import { import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
@@ -15,15 +15,19 @@ import styles from "./tip-screen.module.css";
const log = new Logger("TipCheckoutButton"); const log = new Logger("TipCheckoutButton");
export interface TipCheckoutButtonProps { export interface TipCheckoutButtonProps {
coffeeType: TipCoffeeType;
disabled?: boolean; disabled?: boolean;
isAuthLoading?: boolean; isAuthLoading?: boolean;
onOrder: () => void; onOrder: () => void;
returnPath: string;
} }
export function TipCheckoutButton({ export function TipCheckoutButton({
coffeeType,
disabled = false, disabled = false,
isAuthLoading = false, isAuthLoading = false,
onOrder, onOrder,
returnPath,
}: TipCheckoutButtonProps) { }: TipCheckoutButtonProps) {
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
@@ -33,6 +37,7 @@ export function TipCheckoutButton({
payment, payment,
paymentDispatch, paymentDispatch,
subscriptionType: "tip", subscriptionType: "tip",
tipCoffeeType: coffeeType,
}); });
const isLoading = const isLoading =
@@ -71,7 +76,7 @@ export function TipCheckoutButton({
{paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? ( {paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? (
<StripePaymentDialog <StripePaymentDialog
clientSecret={paymentLaunch.stripeClientSecret} clientSecret={paymentLaunch.stripeClientSecret}
returnPath={ROUTES.tip} returnPath={returnPath}
onClose={paymentLaunch.handleStripeClose} onClose={paymentLaunch.handleStripeClose}
onConfirmed={paymentLaunch.handleStripeConfirmed} onConfirmed={paymentLaunch.handleStripeConfirmed}
/> />
+9
View File
@@ -3,6 +3,11 @@
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { PayChannel } from "@/data/dto/payment"; import type { PayChannel } from "@/data/dto/payment";
import {
DEFAULT_TIP_COFFEE_TYPE,
resolveTipCoffeeType,
TIP_COFFEE_TYPE_PARAM,
} from "@/lib/tip/tip_coffee";
import { TipScreen } from "./tip-screen"; import { TipScreen } from "./tip-screen";
@@ -13,9 +18,13 @@ function toPayChannel(value: string | null): PayChannel | null {
export function TipPageClient() { export function TipPageClient() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const coffeeType =
resolveTipCoffeeType(searchParams.get(TIP_COFFEE_TYPE_PARAM)) ??
DEFAULT_TIP_COFFEE_TYPE;
return ( return (
<TipScreen <TipScreen
coffeeType={coffeeType}
shouldResumePendingOrder={searchParams.get("paymentReturn") === "1"} shouldResumePendingOrder={searchParams.get("paymentReturn") === "1"}
initialPayChannel={toPayChannel(searchParams.get("payChannel"))} initialPayChannel={toPayChannel(searchParams.get("payChannel"))}
/> />
+19 -9
View File
@@ -1,27 +1,37 @@
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import type { PaymentPlan } from "@/data/dto/payment"; import type { PaymentPlan } from "@/data/dto/payment";
import {
const TIP_COFFEE_PLAN_ID = "tip_coffee"; getTipCoffeeOption,
const TIP_COFFEE_ORDER_TYPE = "tip_coffee"; type TipCoffeeType,
} from "@/lib/tip/tip_coffee";
export function findTipCoffeePlan( export function findTipCoffeePlan(
plans: readonly PaymentPlan[], plans: readonly PaymentPlan[],
coffeeType: TipCoffeeType,
): PaymentPlan | null { ): PaymentPlan | null {
const option = getTipCoffeeOption(coffeeType);
return ( return (
plans.find((plan) => plan.planId === TIP_COFFEE_PLAN_ID) ?? plans.find((plan) => plan.planId === option.planId) ??
plans.find((plan) => plan.orderType === TIP_COFFEE_ORDER_TYPE) ?? (coffeeType === "small"
? (plans.find((plan) => plan.planId === "tip_coffee") ?? null)
: null) ??
null null
); );
} }
export function formatTipPrice(plan: PaymentPlan | null): string { export function formatTipPrice(
if (!plan) return "US$ 5"; plan: PaymentPlan | null,
coffeeType: TipCoffeeType,
): string {
const option = getTipCoffeeOption(coffeeType);
const amountCents = plan?.amountCents ?? option.amountCents;
const currency = plan?.currency.trim().toUpperCase() || "USD";
const amount = plan.amountCents / 100; const amount = amountCents / 100;
const formattedAmount = Number.isInteger(amount) const formattedAmount = Number.isInteger(amount)
? String(amount) ? String(amount)
: amount.toFixed(2).replace(/\.?0+$/, ""); : amount.toFixed(2).replace(/\.?0+$/, "");
const currency = plan.currency.trim().toUpperCase();
if (currency === "USD") return `US$ ${formattedAmount}`; if (currency === "USD") return `US$ ${formattedAmount}`;
if (currency.length > 0) return `${currency} ${formattedAmount}`; if (currency.length > 0) return `${currency} ${formattedAmount}`;
+17 -6
View File
@@ -8,7 +8,12 @@ import { CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle"; import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle";
import type { PayChannel } from "@/data/dto/payment"; import type { PayChannel } from "@/data/dto/payment";
import { ROUTES } from "@/router/routes"; import {
buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE,
getTipCoffeeOption,
type TipCoffeeType,
} from "@/lib/tip/tip_coffee";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { import {
@@ -25,11 +30,13 @@ import {
import styles from "./tip-screen.module.css"; import styles from "./tip-screen.module.css";
export interface TipScreenProps { export interface TipScreenProps {
coffeeType?: TipCoffeeType;
shouldResumePendingOrder?: boolean; shouldResumePendingOrder?: boolean;
initialPayChannel?: PayChannel | null; initialPayChannel?: PayChannel | null;
} }
export function TipScreen({ export function TipScreen({
coffeeType = DEFAULT_TIP_COFFEE_TYPE,
shouldResumePendingOrder = false, shouldResumePendingOrder = false,
initialPayChannel = null, initialPayChannel = null,
}: TipScreenProps) { }: TipScreenProps) {
@@ -38,14 +45,16 @@ export function TipScreen({
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const initialPayChannelAppliedRef = useRef(false); const initialPayChannelAppliedRef = useRef(false);
const coffeeOption = getTipCoffeeOption(coffeeType);
const returnPath = buildTipCoffeePath(coffeeType);
const resolvedInitialPayChannel = const resolvedInitialPayChannel =
initialPayChannel ?? navigator.getDefaultPayChannel(); initialPayChannel ?? navigator.getDefaultPayChannel();
const coffeePlan = useMemo( const coffeePlan = useMemo(
() => findTipCoffeePlan(payment.plans), () => findTipCoffeePlan(payment.plans, coffeeType),
[payment.plans], [coffeeType, payment.plans],
); );
const priceLabel = formatTipPrice(coffeePlan); const priceLabel = formatTipPrice(coffeePlan, coffeeType);
const isPaymentBusy = const isPaymentBusy =
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid; payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
const canCreateOrder = const canCreateOrder =
@@ -127,7 +136,7 @@ export function TipScreen({
const handleOrder = () => { const handleOrder = () => {
if (isAuthLoading) return; if (isAuthLoading) return;
if (!isRealLoginStatus(authState.loginStatus)) { if (!isRealLoginStatus(authState.loginStatus)) {
navigator.openAuth(ROUTES.tip); navigator.openAuth(returnPath);
return; return;
} }
if (!canCreateOrder) return; if (!canCreateOrder) return;
@@ -198,7 +207,7 @@ export function TipScreen({
Coffee Gift Coffee Gift
</span> </span>
<h2 className={styles.productName}> <h2 className={styles.productName}>
{coffeePlan?.planName || "Latte"} {coffeePlan?.planName || coffeeOption.fallbackName}
</h2> </h2>
<p className={styles.productPrice}>{priceLabel}</p> <p className={styles.productPrice}>{priceLabel}</p>
</div> </div>
@@ -231,12 +240,14 @@ export function TipScreen({
<div className={styles.checkoutSlot}> <div className={styles.checkoutSlot}>
<TipCheckoutButton <TipCheckoutButton
coffeeType={coffeeType}
disabled={ disabled={
showMissingPlan || showMissingPlan ||
(!canCreateOrder && isRealLoginStatus(authState.loginStatus)) (!canCreateOrder && isRealLoginStatus(authState.loginStatus))
} }
isAuthLoading={isAuthLoading} isAuthLoading={isAuthLoading}
onOrder={handleOrder} onOrder={handleOrder}
returnPath={returnPath}
/> />
</div> </div>
</main> </main>
@@ -10,6 +10,7 @@ const PendingPaymentOrderSchema = z.object({
orderId: z.string().min(1), orderId: z.string().min(1),
payChannel: z.literal("ezpay"), payChannel: z.literal("ezpay"),
subscriptionType: z.enum(["vip", "topup", "tip"]), subscriptionType: z.enum(["vip", "topup", "tip"]),
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
returnTo: z.enum(["chat"]).optional(), returnTo: z.enum(["chat"]).optional(),
createdAt: z.number(), createdAt: z.number(),
}); });
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { import {
resolveExternalEntryDestination,
resolveExternalEntryPromotionType, resolveExternalEntryPromotionType,
resolveExternalEntryTarget, resolveExternalEntryTarget,
shouldBindExternalFacebookIdentity, shouldBindExternalFacebookIdentity,
@@ -30,6 +31,30 @@ describe("external entry navigation", () => {
expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat); expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat);
expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat); expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat);
}); });
it("routes tip entries to the requested coffee type", () => {
expect(
resolveExternalEntryDestination({
target: "tip",
coffeeType: "medium",
}),
).toBe("/tip?coffee_type=medium");
expect(
resolveExternalEntryDestination({
target: "tip",
coffeeType: "large",
}),
).toBe("/tip?coffee_type=large");
});
it("defaults invalid tip coffee types to small", () => {
expect(
resolveExternalEntryDestination({
target: "tip",
coffeeType: "unknown",
}),
).toBe("/tip?coffee_type=small");
});
}); });
describe("external entry facebook identity binding", () => { describe("external entry facebook identity binding", () => {
+21
View File
@@ -5,6 +5,11 @@ import { UserStorage } from "@/data/storage/user/user_storage";
import type { PendingChatPromotionType } from "@/data/storage/navigation"; import type { PendingChatPromotionType } from "@/data/storage/navigation";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import {
buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE,
resolveTipCoffeeType,
} from "@/lib/tip/tip_coffee";
export type ExternalEntryTarget = export type ExternalEntryTarget =
| typeof ROUTES.chat | typeof ROUTES.chat
@@ -22,6 +27,10 @@ export interface ExternalEntryTargetInput {
target?: string | null; target?: string | null;
} }
export interface ExternalEntryDestinationInput extends ExternalEntryTargetInput {
coffeeType?: string | null;
}
export interface ExternalEntryPromotionInput { export interface ExternalEntryPromotionInput {
mode?: string | null; mode?: string | null;
promotionType?: string | null; promotionType?: string | null;
@@ -77,6 +86,18 @@ export function resolveExternalEntryTarget({
return resolveTarget(target) ?? ROUTES.chat; return resolveTarget(target) ?? ROUTES.chat;
} }
export function resolveExternalEntryDestination({
target,
coffeeType,
}: ExternalEntryDestinationInput): string {
const resolvedTarget = resolveExternalEntryTarget({ target });
if (resolvedTarget !== ROUTES.tip) return resolvedTarget;
return buildTipCoffeePath(
resolveTipCoffeeType(coffeeType) ?? DEFAULT_TIP_COFFEE_TYPE,
);
}
export async function persistExternalEntryPayload({ export async function persistExternalEntryPayload({
deviceId, deviceId,
asid, asid,
@@ -18,7 +18,17 @@ describe("pending payment order helpers", () => {
buildPendingPaymentSubscriptionUrl({ buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay", payChannel: "ezpay",
subscriptionType: "tip", subscriptionType: "tip",
tipCoffeeType: "large",
}), }),
).toBe("/tip?payChannel=ezpay&paymentReturn=1"); ).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large");
});
it("defaults legacy tip payment returns to small coffee", () => {
expect(
buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay",
subscriptionType: "tip",
}),
).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small");
}); });
}); });
+4
View File
@@ -6,6 +6,7 @@ import {
savePendingEzpayOrder, savePendingEzpayOrder,
type PendingPaymentReturnTo, type PendingPaymentReturnTo,
type PendingPaymentSubscriptionType, type PendingPaymentSubscriptionType,
type PendingPaymentTipCoffeeType,
} from "./pending_payment_order"; } from "./pending_payment_order";
const log = new Logger("LibPaymentPaymentLaunch"); const log = new Logger("LibPaymentPaymentLaunch");
@@ -61,6 +62,7 @@ export interface LaunchEzpayRedirectInput {
orderId: string | null; orderId: string | null;
paymentUrl: string; paymentUrl: string;
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
returnTo?: PendingPaymentReturnTo; returnTo?: PendingPaymentReturnTo;
onFailed: (errorMessage: string) => void; onFailed: (errorMessage: string) => void;
} }
@@ -69,6 +71,7 @@ export async function launchEzpayRedirect({
orderId, orderId,
paymentUrl, paymentUrl,
subscriptionType, subscriptionType,
tipCoffeeType,
returnTo, returnTo,
onFailed, onFailed,
}: LaunchEzpayRedirectInput): Promise<void> { }: LaunchEzpayRedirectInput): Promise<void> {
@@ -93,6 +96,7 @@ export async function launchEzpayRedirect({
const saveResult = await savePendingEzpayOrder({ const saveResult = await savePendingEzpayOrder({
orderId, orderId,
subscriptionType, subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
}); });
if (Result.isErr(saveResult)) { if (Result.isErr(saveResult)) {
+12 -1
View File
@@ -5,15 +5,23 @@ import {
type PendingPaymentOrder, type PendingPaymentOrder,
} from "@/data/storage"; } from "@/data/storage";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import {
DEFAULT_TIP_COFFEE_TYPE,
TIP_COFFEE_TYPE_PARAM,
} from "@/lib/tip/tip_coffee";
import type { Result } from "@/utils"; import type { Result } from "@/utils";
export type PendingPaymentSubscriptionType = export type PendingPaymentSubscriptionType =
PendingPaymentOrder["subscriptionType"]; PendingPaymentOrder["subscriptionType"];
export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"]; export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"];
export type PendingPaymentTipCoffeeType = NonNullable<
PendingPaymentOrder["tipCoffeeType"]
>;
export function savePendingEzpayOrder(input: { export function savePendingEzpayOrder(input: {
orderId: string; orderId: string;
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
returnTo?: PendingPaymentReturnTo; returnTo?: PendingPaymentReturnTo;
createdAt?: number; createdAt?: number;
}): Promise<Result<void>> { }): Promise<Result<void>> {
@@ -21,6 +29,7 @@ export function savePendingEzpayOrder(input: {
orderId: input.orderId, orderId: input.orderId,
payChannel: "ezpay", payChannel: "ezpay",
subscriptionType: input.subscriptionType, subscriptionType: input.subscriptionType,
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}),
...(input.returnTo ? { returnTo: input.returnTo } : {}), ...(input.returnTo ? { returnTo: input.returnTo } : {}),
createdAt: input.createdAt ?? Date.now(), createdAt: input.createdAt ?? Date.now(),
}); });
@@ -45,13 +54,15 @@ export function clearPendingPaymentOrder(): Promise<Result<void>> {
export function buildPendingPaymentSubscriptionUrl( export function buildPendingPaymentSubscriptionUrl(
order: Pick< order: Pick<
PendingPaymentOrder, PendingPaymentOrder,
"payChannel" | "returnTo" | "subscriptionType" "payChannel" | "returnTo" | "subscriptionType" | "tipCoffeeType"
>, >,
): string { ): string {
if (order.subscriptionType === "tip") { if (order.subscriptionType === "tip") {
const params = new URLSearchParams({ const params = new URLSearchParams({
payChannel: order.payChannel, payChannel: order.payChannel,
paymentReturn: "1", paymentReturn: "1",
[TIP_COFFEE_TYPE_PARAM]:
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE,
}); });
return `${ROUTES.tip}?${params.toString()}`; return `${ROUTES.tip}?${params.toString()}`;
} }
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
buildTipCoffeePath,
getTipCoffeeOption,
resolveTipCoffeeType,
} from "../tip_coffee";
describe("tip coffee configuration", () => {
it("resolves supported types case-insensitively", () => {
expect(resolveTipCoffeeType(" Small ")).toBe("small");
expect(resolveTipCoffeeType("MEDIUM")).toBe("medium");
expect(resolveTipCoffeeType("large")).toBe("large");
});
it("rejects missing and unsupported types", () => {
expect(resolveTipCoffeeType(null)).toBeNull();
expect(resolveTipCoffeeType("espresso")).toBeNull();
});
it("provides the configured plan and fallback price for each type", () => {
expect(getTipCoffeeOption("small")).toMatchObject({
amountCents: 499,
planId: "tip_coffee_small",
});
expect(getTipCoffeeOption("medium")).toMatchObject({
amountCents: 999,
planId: "tip_coffee_medium",
});
expect(getTipCoffeeOption("large")).toMatchObject({
amountCents: 1999,
planId: "tip_coffee_large",
});
});
it("builds canonical tip paths", () => {
expect(buildTipCoffeePath("small")).toBe("/tip?coffee_type=small");
expect(buildTipCoffeePath("medium")).toBe("/tip?coffee_type=medium");
expect(buildTipCoffeePath("large")).toBe("/tip?coffee_type=large");
});
});
+57
View File
@@ -0,0 +1,57 @@
import { ROUTES } from "@/router/routes";
export const TIP_COFFEE_TYPE_PARAM = "coffee_type";
export const DEFAULT_TIP_COFFEE_TYPE = "small";
export type TipCoffeeType = "small" | "medium" | "large";
export interface TipCoffeeOption {
type: TipCoffeeType;
amountCents: number;
fallbackName: string;
planId: string;
}
const TIP_COFFEE_OPTIONS: Record<TipCoffeeType, TipCoffeeOption> = {
small: {
type: "small",
amountCents: 499,
fallbackName: "Small Coffee",
planId: "tip_coffee_small",
},
medium: {
type: "medium",
amountCents: 999,
fallbackName: "Medium Coffee",
planId: "tip_coffee_medium",
},
large: {
type: "large",
amountCents: 1999,
fallbackName: "Large Coffee",
planId: "tip_coffee_large",
},
};
export function resolveTipCoffeeType(
value: string | null | undefined,
): TipCoffeeType | null {
const normalized = value?.trim().toLowerCase();
if (
normalized === "small" ||
normalized === "medium" ||
normalized === "large"
) {
return normalized;
}
return null;
}
export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption {
return TIP_COFFEE_OPTIONS[type];
}
export function buildTipCoffeePath(type: TipCoffeeType): string {
const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type });
return `${ROUTES.tip}?${params.toString()}`;
}