feat(chat): unlock paid messages before payment
This commit is contained in:
@@ -3,23 +3,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import { consumePendingChatImageReturn } from "../chat_image_return_session";
|
||||
import {
|
||||
clearPendingChatUnlock,
|
||||
peekPendingChatUnlock,
|
||||
} from "../chat_unlock_session";
|
||||
import {
|
||||
consumeSubscriptionExitUrl,
|
||||
consumeSubscriptionExplicitExitUrl,
|
||||
getSubscriptionFallbackExitUrl,
|
||||
peekSubscriptionExplicitExitUrl,
|
||||
} from "../subscription_exit";
|
||||
|
||||
vi.mock("../chat_image_return_session", () => ({
|
||||
consumePendingChatImageReturn: vi.fn(),
|
||||
}));
|
||||
vi.mock("../chat_unlock_session", () => ({
|
||||
clearPendingChatUnlock: vi.fn(),
|
||||
peekPendingChatUnlock: vi.fn(),
|
||||
}));
|
||||
|
||||
const consumePendingChatImageReturnMock = vi.mocked(
|
||||
consumePendingChatImageReturn,
|
||||
);
|
||||
const clearPendingChatUnlockMock = vi.mocked(clearPendingChatUnlock);
|
||||
const peekPendingChatUnlockMock = vi.mocked(peekPendingChatUnlock);
|
||||
|
||||
describe("subscription exit helpers", () => {
|
||||
beforeEach(() => {
|
||||
consumePendingChatImageReturnMock.mockReset();
|
||||
clearPendingChatUnlockMock.mockReset();
|
||||
peekPendingChatUnlockMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses explicit chat image return urls first", () => {
|
||||
@@ -35,14 +48,45 @@ describe("subscription exit helpers", () => {
|
||||
|
||||
it("falls back to chat when requested", () => {
|
||||
consumePendingChatImageReturnMock.mockReturnValue(null);
|
||||
peekPendingChatUnlockMock.mockReturnValue(null);
|
||||
|
||||
expect(consumeSubscriptionExitUrl("chat")).toBe(ROUTES.chat);
|
||||
});
|
||||
|
||||
it("falls back to sidebar by default", () => {
|
||||
consumePendingChatImageReturnMock.mockReturnValue(null);
|
||||
peekPendingChatUnlockMock.mockReturnValue(null);
|
||||
|
||||
expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
|
||||
expect(consumeSubscriptionExitUrl(null)).toBe(ROUTES.sidebar);
|
||||
});
|
||||
|
||||
it("peeks chat unlock return urls without clearing them", () => {
|
||||
peekPendingChatUnlockMock.mockReturnValue({
|
||||
reason: "single_message_unlock",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
stage: "payment",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
expect(peekSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1");
|
||||
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears chat unlock return urls when consumed", () => {
|
||||
consumePendingChatImageReturnMock.mockReturnValue(null);
|
||||
peekPendingChatUnlockMock.mockReturnValue({
|
||||
reason: "single_message_unlock",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
stage: "payment",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1");
|
||||
expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
const STORAGE_KEY = "cozsweet.chat.pendingUnlock";
|
||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
||||
export type PendingChatUnlockStage = "auth" | "payment";
|
||||
|
||||
export interface PendingChatUnlock {
|
||||
reason: "single_message_unlock";
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const payload: PendingChatUnlock = {
|
||||
reason: "single_message_unlock",
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
returnUrl: input.returnUrl,
|
||||
stage: input.stage,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function consumePendingChatUnlock(): PendingChatUnlock | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
||||
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
return parsePendingChatUnlock(raw);
|
||||
}
|
||||
|
||||
export function peekPendingChatUnlock(): PendingChatUnlock | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const value = parsePendingChatUnlock(raw);
|
||||
if (!value) {
|
||||
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function hasPendingChatUnlock(): boolean {
|
||||
return peekPendingChatUnlock() !== null;
|
||||
}
|
||||
|
||||
export function clearPendingChatUnlock(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function parsePendingChatUnlock(raw: string): PendingChatUnlock | null {
|
||||
try {
|
||||
const value = JSON.parse(raw) as Partial<PendingChatUnlock>;
|
||||
if (value.reason !== "single_message_unlock") return null;
|
||||
if (typeof value.messageId !== "string" || value.messageId.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!isPendingChatUnlockKind(value.kind)) return null;
|
||||
if (!isPendingChatUnlockStage(value.stage)) return null;
|
||||
if (typeof value.returnUrl !== "string" || value.returnUrl.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!value.returnUrl.startsWith("/") || value.returnUrl.startsWith("//")) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value.createdAt !== "number") return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
|
||||
return {
|
||||
reason: value.reason,
|
||||
messageId: value.messageId,
|
||||
kind: value.kind,
|
||||
returnUrl: value.returnUrl,
|
||||
stage: value.stage,
|
||||
createdAt: value.createdAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPendingChatUnlockKind(
|
||||
value: unknown,
|
||||
): value is PendingChatUnlockKind {
|
||||
return value === "private" || value === "voice" || value === "image";
|
||||
}
|
||||
|
||||
function isPendingChatUnlockStage(
|
||||
value: unknown,
|
||||
): value is PendingChatUnlockStage {
|
||||
return value === "auth" || value === "payment";
|
||||
}
|
||||
@@ -3,12 +3,27 @@
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import { consumePendingChatImageReturn } from "./chat_image_return_session";
|
||||
import {
|
||||
clearPendingChatUnlock,
|
||||
peekPendingChatUnlock,
|
||||
} from "./chat_unlock_session";
|
||||
|
||||
export type SubscriptionReturnTo = "chat" | null;
|
||||
|
||||
export function consumeSubscriptionExplicitExitUrl(): string | null {
|
||||
const pendingImageReturn = consumePendingChatImageReturn();
|
||||
if (pendingImageReturn) return pendingImageReturn.returnUrl;
|
||||
const pendingChatUnlock = peekPendingChatUnlock();
|
||||
if (pendingChatUnlock) {
|
||||
clearPendingChatUnlock();
|
||||
return pendingChatUnlock.returnUrl;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function peekSubscriptionExplicitExitUrl(): string | null {
|
||||
const pendingChatUnlock = peekPendingChatUnlock();
|
||||
if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user