refactor(router): introduce app navigation manager
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { Result, SessionAsyncUtil } from "@/utils";
|
||||
|
||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
||||
export type PendingChatUnlockStage = "auth" | "payment";
|
||||
|
||||
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"]),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
const PendingChatImageReturnSchema = z.object({
|
||||
reason: z.literal("image_paywall"),
|
||||
messageId: z.string().min(1),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||
"returnUrl must be an internal route",
|
||||
),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
||||
export type PendingChatImageReturn = z.output<
|
||||
typeof PendingChatImageReturnSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* NavigationStorage owns short-lived cross-route sessions.
|
||||
*
|
||||
* UI / router code should not touch the raw session storage utility directly.
|
||||
* Keeping these payloads here makes auth, payment, and chat return flows
|
||||
* testable without spreading storage keys across screens.
|
||||
*/
|
||||
export class NavigationStorage {
|
||||
private constructor() {}
|
||||
|
||||
static async savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): Promise<void> {
|
||||
const payload: PendingChatUnlock = {
|
||||
reason: "single_message_unlock",
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
returnUrl: input.returnUrl,
|
||||
stage: input.stage,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
payload,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
}
|
||||
|
||||
static async consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatUnlock(result.data);
|
||||
}
|
||||
|
||||
static async peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
if (Result.isErr(result)) return null;
|
||||
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
||||
if (!value) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static async hasPendingChatUnlock(): Promise<boolean> {
|
||||
return (await NavigationStorage.peekPendingChatUnlock()) !== null;
|
||||
}
|
||||
|
||||
static async clearPendingChatUnlock(): Promise<void> {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
|
||||
static async savePendingChatImageReturn(input: {
|
||||
messageId: string;
|
||||
returnUrl: string;
|
||||
}): Promise<void> {
|
||||
const payload: PendingChatImageReturn = {
|
||||
reason: "image_paywall",
|
||||
messageId: input.messageId,
|
||||
returnUrl: input.returnUrl,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatImageReturn,
|
||||
payload,
|
||||
PendingChatImageReturnSchema,
|
||||
);
|
||||
}
|
||||
|
||||
static async consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatImageReturn,
|
||||
PendingChatImageReturnSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatImageReturn(result.data);
|
||||
}
|
||||
|
||||
private static parsePendingChatUnlock(
|
||||
value: PendingChatUnlock | null,
|
||||
): PendingChatUnlock | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static parsePendingChatImageReturn(
|
||||
value: PendingChatImageReturn | null,
|
||||
): PendingChatImageReturn | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user