feat(chat): add promotional external entry flow

This commit is contained in:
2026-07-13 12:43:18 +08:00
parent e5ee9940ca
commit 3752b3b729
44 changed files with 1623 additions and 190 deletions
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
consumePendingChatPromotion,
peekPendingChatUnlock,
} from "@/lib/navigation/chat_unlock_session";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { Logger } from "@/utils";
const log = new Logger("UseChatPromotionBootstrap");
export function useChatPromotionBootstrap(): boolean {
const chatDispatch = useChatDispatch();
const startedRef = useRef(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
void (async () => {
try {
const [entryPromotion, pendingUnlock] = await Promise.all([
consumePendingChatPromotion(),
peekPendingChatUnlock(),
]);
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
if (promotion) {
chatDispatch({
type: "ChatPromotionInjected",
promotion,
messageId: pendingUnlock?.promotion
? pendingUnlock.messageId
: undefined,
});
} else {
chatDispatch({ type: "ChatPromotionCleared" });
}
} catch (error) {
log.warn("Failed to restore chat promotion", error);
chatDispatch({ type: "ChatPromotionCleared" });
} finally {
setIsReady(true);
}
})();
}, [chatDispatch]);
return isReady;
}
@@ -2,11 +2,13 @@
import { useEffect } from "react";
import type { ChatLockType } from "@/data/schemas/chat";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
type PendingChatUnlock,
type PendingChatUnlockKind,
type PendingChatPromotion,
} from "@/lib/navigation/chat_unlock_session";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
@@ -15,11 +17,21 @@ import { useUserState } from "@/stores/user/user-context";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
type PromotionScope = "any" | "only" | "exclude";
export interface MessageUnlockOptions {
remoteMessageId?: string;
lockType?: ChatLockType;
clientLockId?: string;
promotion?: PendingChatPromotion;
}
export interface UseChatUnlockNavigationFlowInput {
returnUrl: string;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
ignoredKind?: PendingChatUnlockKind;
promotionScope?: PromotionScope;
enabled?: boolean;
}
@@ -28,6 +40,7 @@ export interface UseChatUnlockNavigationFlowOutput {
requestMessageUnlock: (
messageId: string,
kind: PendingChatUnlockKind,
options?: MessageUnlockOptions,
) => void;
closeInsufficientCreditsDialog: () => void;
confirmInsufficientCreditsDialog: () => void;
@@ -38,6 +51,7 @@ export function useChatUnlockNavigationFlow({
expectedKind,
expectedMessageId,
ignoredKind,
promotionScope = "any",
enabled = true,
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const navigator = useAppNavigator();
@@ -49,6 +63,7 @@ export function useChatUnlockNavigationFlow({
expectedKind,
expectedMessageId,
ignoredKind,
promotionScope,
enabled,
});
@@ -68,6 +83,7 @@ export function useChatUnlockNavigationFlow({
pending,
expectedKind,
expectedMessageId,
promotionScope,
})
) {
return;
@@ -78,8 +94,11 @@ export function useChatUnlockNavigationFlow({
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
messageId: consumed.displayMessageId,
remoteMessageId: consumed.messageId,
kind: consumed.kind,
lockType: consumed.lockType,
clientLockId: consumed.clientLockId,
});
};
@@ -93,24 +112,34 @@ export function useChatUnlockNavigationFlow({
enabled,
expectedKind,
expectedMessageId,
ignoredKind,
navigator.isAuthenticatedUser,
promotionScope,
returnUrl,
]);
function requestMessageUnlock(
messageId: string,
kind: PendingChatUnlockKind,
options: MessageUnlockOptions = {},
): void {
const remoteMessageId =
options.remoteMessageId ?? (options.lockType ? undefined : messageId);
navigator.startMessageUnlock({
messageId,
displayMessageId: messageId,
messageId: remoteMessageId,
kind,
lockType: options.lockType,
clientLockId: options.clientLockId,
promotion: options.promotion,
returnUrl,
onAuthenticated: () => {
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
remoteMessageId,
kind,
lockType: options.lockType,
clientLockId: options.clientLockId,
});
},
});
@@ -125,8 +154,12 @@ export function useChatUnlockNavigationFlow({
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
navigator.openSubscriptionForPendingUnlock({
displayMessageId: unlockPaywallRequest.displayMessageId,
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
lockType: unlockPaywallRequest.lockType,
clientLockId: unlockPaywallRequest.clientLockId,
promotion: unlockPaywallRequest.promotion,
returnUrl,
type: getInsufficientCreditsSubscriptionType(userState.isVip),
});
@@ -145,6 +178,7 @@ function getScopedUnlockPaywallRequest(input: {
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
ignoredKind?: PendingChatUnlockKind;
promotionScope?: PromotionScope;
enabled?: boolean;
}): ChatUnlockPaywallRequest | null {
const {
@@ -152,13 +186,21 @@ function getScopedUnlockPaywallRequest(input: {
expectedKind,
expectedMessageId,
ignoredKind,
promotionScope = "any",
enabled = true,
} = input;
if (!enabled) return null;
if (!request) return null;
if (!enabled || !request) return null;
if (promotionScope === "only" && !request.promotion) return null;
if (promotionScope === "exclude" && request.promotion) return null;
if (ignoredKind && request.kind === ignoredKind) return null;
if (expectedKind && request.kind !== expectedKind) return null;
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
if (
expectedMessageId &&
request.displayMessageId !== expectedMessageId &&
request.messageId !== expectedMessageId
) {
return null;
}
return request;
}
@@ -166,10 +208,22 @@ function matchesPendingUnlockScope(input: {
pending: PendingChatUnlock;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
promotionScope?: PromotionScope;
}): boolean {
const { pending, expectedKind, expectedMessageId } = input;
const {
pending,
expectedKind,
expectedMessageId,
promotionScope = "any",
} = input;
if (promotionScope === "only" && !pending.promotion) return false;
if (promotionScope === "exclude" && pending.promotion) return false;
if (expectedKind && pending.kind !== expectedKind) return false;
if (expectedMessageId && pending.messageId !== expectedMessageId) {
if (
expectedMessageId &&
pending.displayMessageId !== expectedMessageId &&
pending.messageId !== expectedMessageId
) {
return false;
}
return true;