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
+122 -14
View File
@@ -3,28 +3,45 @@
import { z } from "zod";
import { StorageKeys } from "@/data/storage/storage_keys";
import { ChatLockTypeSchema } from "@/data/schemas/chat";
import { Result, SessionAsyncUtil } from "@/utils";
const MAX_AGE_MS = 30 * 60 * 1000;
export type PendingChatUnlockKind = "private" | "voice" | "image";
export type PendingChatUnlockStage = "auth" | "payment";
export type PendingChatPromotionType = "voice" | "image" | "private";
const PendingChatUnlockSchema = z.object({
reason: z.literal("single_message_unlock"),
messageId: z.string().min(1),
kind: z.enum(["private", "voice", "image"]),
returnUrl: z
.string()
.min(1)
.refine(
(value) => value.startsWith("/") && !value.startsWith("//"),
"returnUrl must be an internal route",
),
stage: z.enum(["auth", "payment"]),
export const PendingChatPromotionSchema = z.object({
promotionType: z.enum(["voice", "image", "private"]),
lockType: ChatLockTypeSchema,
clientLockId: z.string().min(1),
createdAt: z.number(),
});
const PendingChatUnlockSchema = z
.object({
reason: z.literal("single_message_unlock"),
displayMessageId: z.string().min(1),
messageId: z.string().min(1).optional(),
kind: z.enum(["private", "voice", "image"]),
lockType: ChatLockTypeSchema.optional(),
clientLockId: z.string().min(1).optional(),
promotion: PendingChatPromotionSchema.optional(),
returnUrl: z
.string()
.min(1)
.refine(
(value) => value.startsWith("/") && !value.startsWith("//"),
"returnUrl must be an internal route",
),
stage: z.enum(["auth", "payment"]),
createdAt: z.number(),
})
.refine((value) => value.messageId || value.lockType, {
message: "messageId or lockType is required",
});
const PendingChatImageReturnSchema = z.object({
reason: z.literal("image_paywall"),
messageId: z.string().min(1),
@@ -39,6 +56,9 @@ const PendingChatImageReturnSchema = z.object({
});
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
export type PendingChatPromotion = z.output<
typeof PendingChatPromotionSchema
>;
export type PendingChatImageReturn = z.output<
typeof PendingChatImageReturnSchema
>;
@@ -54,15 +74,30 @@ export class NavigationStorage {
private constructor() {}
static async savePendingChatUnlock(input: {
messageId: string;
displayMessageId?: string;
messageId?: string;
kind: PendingChatUnlockKind;
lockType?: PendingChatUnlock["lockType"];
clientLockId?: string;
promotion?: PendingChatPromotion;
returnUrl: string;
stage: PendingChatUnlockStage;
}): Promise<void> {
const displayMessageId =
input.displayMessageId ??
input.messageId ??
(input.clientLockId ? `promotion:${input.clientLockId}` : null);
if (!displayMessageId) {
throw new Error("displayMessageId is required");
}
const payload: PendingChatUnlock = {
reason: "single_message_unlock",
messageId: input.messageId,
displayMessageId,
...(input.messageId ? { messageId: input.messageId } : {}),
kind: input.kind,
...(input.lockType ? { lockType: input.lockType } : {}),
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
...(input.promotion ? { promotion: input.promotion } : {}),
returnUrl: input.returnUrl,
stage: input.stage,
createdAt: Date.now(),
@@ -105,6 +140,37 @@ export class NavigationStorage {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
}
static async savePendingChatPromotion(
promotionType: PendingChatPromotionType,
): Promise<PendingChatPromotion> {
const promotion: PendingChatPromotion = {
promotionType,
lockType: toPromotionLockType(promotionType),
clientLockId: `promotion_${createUuidV4()}`,
createdAt: Date.now(),
};
await SessionAsyncUtil.setJson(
StorageKeys.pendingChatPromotion,
promotion,
PendingChatPromotionSchema,
);
return promotion;
}
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatPromotion,
PendingChatPromotionSchema,
);
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
if (Result.isErr(result)) return null;
return NavigationStorage.parsePendingChatPromotion(result.data);
}
static async clearPendingChatPromotion(): Promise<void> {
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
}
static async savePendingChatImageReturn(input: {
messageId: string;
returnUrl: string;
@@ -147,4 +213,46 @@ export class NavigationStorage {
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
}
private static parsePendingChatPromotion(
value: PendingChatPromotion | null,
): PendingChatPromotion | null {
if (!value) return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
}
}
function toPromotionLockType(
promotionType: PendingChatPromotionType,
): PendingChatPromotion["lockType"] {
if (promotionType === "voice") return "voice_message";
if (promotionType === "image") return "image_paywall";
return "private_message";
}
function createUuidV4(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === "function") {
return cryptoApi.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof cryptoApi?.getRandomValues === "function") {
cryptoApi.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return [
hex.slice(0, 4).join(""),
hex.slice(4, 6).join(""),
hex.slice(6, 8).join(""),
hex.slice(8, 10).join(""),
hex.slice(10, 16).join(""),
].join("-");
}