feat(tip): support tiered coffee gifts
This commit is contained in:
+32
-2
@@ -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(() => {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user