mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-03 03:01:52 +02:00
♻️ Refactor poll view tracking (#1644)
This commit is contained in:
parent
6b914610d9
commit
f05f437b56
13 changed files with 288 additions and 41 deletions
33
apps/web/src/actions/track-poll-view.ts
Normal file
33
apps/web/src/actions/track-poll-view.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { getUserId } from "@/next-auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server action to track a poll view
|
||||||
|
* Records information about the view in the database
|
||||||
|
*/
|
||||||
|
export async function trackPollView(pollId: string) {
|
||||||
|
try {
|
||||||
|
const headersList = headers();
|
||||||
|
const userAgent = headersList.get("user-agent");
|
||||||
|
const ip = headersList.get("x-forwarded-for") || "unknown";
|
||||||
|
|
||||||
|
const userId = await getUserId();
|
||||||
|
await prisma.pollView.create({
|
||||||
|
data: {
|
||||||
|
pollId,
|
||||||
|
userId,
|
||||||
|
ipAddress: ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error recording poll view:", error);
|
||||||
|
return { success: false, error: "Failed to record view" };
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,9 +6,9 @@ import Discussion from "@/components/discussion";
|
||||||
import { EventCard } from "@/components/event-card";
|
import { EventCard } from "@/components/event-card";
|
||||||
import { PollFooter } from "@/components/poll/poll-footer";
|
import { PollFooter } from "@/components/poll/poll-footer";
|
||||||
import { PollHeader } from "@/components/poll/poll-header";
|
import { PollHeader } from "@/components/poll/poll-header";
|
||||||
|
import { PollViewTracker } from "@/components/poll/poll-view-tracker";
|
||||||
import { ResponsiveResults } from "@/components/poll/responsive-results";
|
import { ResponsiveResults } from "@/components/poll/responsive-results";
|
||||||
import { ScheduledEvent } from "@/components/poll/scheduled-event";
|
import { ScheduledEvent } from "@/components/poll/scheduled-event";
|
||||||
import { useTouchBeacon } from "@/components/poll/use-touch-beacon";
|
|
||||||
import { VotingForm } from "@/components/poll/voting-form";
|
import { VotingForm } from "@/components/poll/voting-form";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { useUser } from "@/components/user-provider";
|
import { useUser } from "@/components/user-provider";
|
||||||
|
@ -51,9 +51,12 @@ const GoToApp = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvitePage() {
|
export function InvitePage() {
|
||||||
useTouchBeacon();
|
const poll = usePoll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-3 p-3 lg:space-y-4 lg:px-4 lg:py-8">
|
<div className="mx-auto max-w-4xl space-y-3 p-3 lg:space-y-4 lg:px-4 lg:py-8">
|
||||||
|
{/* Track poll views */}
|
||||||
|
<PollViewTracker pollId={poll.id} />
|
||||||
<PollHeader />
|
<PollHeader />
|
||||||
<GoToApp />
|
<GoToApp />
|
||||||
<EventCard />
|
<EventCard />
|
||||||
|
|
|
@ -3,18 +3,23 @@ import Discussion from "@/components/discussion";
|
||||||
import { EventCard } from "@/components/event-card";
|
import { EventCard } from "@/components/event-card";
|
||||||
import { PollFooter } from "@/components/poll/poll-footer";
|
import { PollFooter } from "@/components/poll/poll-footer";
|
||||||
import { PollHeader } from "@/components/poll/poll-header";
|
import { PollHeader } from "@/components/poll/poll-header";
|
||||||
|
import { PollViewTracker } from "@/components/poll/poll-view-tracker";
|
||||||
import { ResponsiveResults } from "@/components/poll/responsive-results";
|
import { ResponsiveResults } from "@/components/poll/responsive-results";
|
||||||
import { ScheduledEvent } from "@/components/poll/scheduled-event";
|
import { ScheduledEvent } from "@/components/poll/scheduled-event";
|
||||||
import { useTouchBeacon } from "@/components/poll/use-touch-beacon";
|
|
||||||
import { VotingForm } from "@/components/poll/voting-form";
|
import { VotingForm } from "@/components/poll/voting-form";
|
||||||
|
import { usePoll } from "@/contexts/poll";
|
||||||
|
|
||||||
import { GuestPollAlert } from "./guest-poll-alert";
|
import { GuestPollAlert } from "./guest-poll-alert";
|
||||||
import { UnsubscribeAlert } from "./unsubscribe-alert";
|
import { UnsubscribeAlert } from "./unsubscribe-alert";
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
useTouchBeacon();
|
// Get the poll ID from the context
|
||||||
|
const poll = usePoll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 lg:space-y-4">
|
<div className="space-y-3 lg:space-y-4">
|
||||||
|
{/* Track poll views */}
|
||||||
|
<PollViewTracker pollId={poll.id} />
|
||||||
<UnsubscribeAlert />
|
<UnsubscribeAlert />
|
||||||
<PollHeader />
|
<PollHeader />
|
||||||
<GuestPollAlert />
|
<GuestPollAlert />
|
||||||
|
|
|
@ -13,21 +13,20 @@ export async function POST() {
|
||||||
const unauthorized = checkApiAuthorization();
|
const unauthorized = checkApiAuthorization();
|
||||||
if (unauthorized) return unauthorized;
|
if (unauthorized) return unauthorized;
|
||||||
|
|
||||||
|
// Define the 30-day threshold once
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Mark inactive polls as deleted in a single query
|
// Mark inactive polls as deleted in a single query
|
||||||
const { count: markedDeleted } = await prisma.poll.updateMany({
|
const { count: markedDeleted } = await prisma.poll.updateMany({
|
||||||
where: {
|
where: {
|
||||||
touchedAt: {
|
|
||||||
lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
|
|
||||||
},
|
|
||||||
deleted: false,
|
deleted: false,
|
||||||
|
// All poll dates are in the past
|
||||||
options: {
|
options: {
|
||||||
none: {
|
none: {
|
||||||
startTime: {
|
startTime: { gt: new Date() },
|
||||||
gt: new Date(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Include polls without a user or with users that don't have an active subscription
|
// User is either null or doesn't have an active subscription
|
||||||
OR: [
|
OR: [
|
||||||
{ userId: null },
|
{ userId: null },
|
||||||
{
|
{
|
||||||
|
@ -36,6 +35,13 @@ export async function POST() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// Poll is inactive (not touched AND not viewed in the last 30 days)
|
||||||
|
touchedAt: { lt: thirtyDaysAgo },
|
||||||
|
views: {
|
||||||
|
none: {
|
||||||
|
viewedAt: { gte: thirtyDaysAgo },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
deleted: true,
|
deleted: true,
|
||||||
|
|
59
apps/web/src/components/poll/poll-view-tracker.tsx
Normal file
59
apps/web/src/components/poll/poll-view-tracker.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { safeSessionStorage } from "@rallly/utils/safe-session-storage";
|
||||||
|
import { useDebounce } from "react-use";
|
||||||
|
|
||||||
|
import { trackPollView } from "@/actions/track-poll-view";
|
||||||
|
|
||||||
|
// Global tracking state to prevent duplicate tracking across re-renders and strict mode
|
||||||
|
const trackedPolls = new Set<string>();
|
||||||
|
|
||||||
|
const VIEW_DELAY = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that tracks poll views
|
||||||
|
* This should be included in poll pages to automatically record views
|
||||||
|
*/
|
||||||
|
export function PollViewTracker({ pollId }: { pollId: string }) {
|
||||||
|
// Use debounce to handle the view tracking
|
||||||
|
// This will only execute after VIEW_DELAY milliseconds
|
||||||
|
// and will automatically handle cleanup if the component unmounts
|
||||||
|
useDebounce(
|
||||||
|
() => {
|
||||||
|
// Only track views in the browser
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
// Check if this poll has already been tracked in this session
|
||||||
|
if (trackedPolls.has(pollId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as tracked immediately to prevent duplicate tracking
|
||||||
|
trackedPolls.add(pollId);
|
||||||
|
|
||||||
|
// Avoid duplicate view tracking within the same browser session
|
||||||
|
const viewKey = `poll-view-${pollId}`;
|
||||||
|
const lastView = safeSessionStorage.get(viewKey);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Only track a view if it's been more than 30 minutes since the last view
|
||||||
|
// or if this is the first view in this session
|
||||||
|
if (!lastView || now - parseInt(lastView) > 30 * 60 * 1000) {
|
||||||
|
// Record the view using server action
|
||||||
|
trackPollView(pollId)
|
||||||
|
.then(() => {
|
||||||
|
// Update the last view timestamp
|
||||||
|
safeSessionStorage.set(viewKey, now.toString());
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
trackedPolls.delete(pollId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
VIEW_DELAY,
|
||||||
|
[pollId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// This component doesn't render anything
|
||||||
|
return null;
|
||||||
|
}
|
|
@ -1,19 +0,0 @@
|
||||||
import { useMount } from "react-use";
|
|
||||||
|
|
||||||
import { usePoll } from "@/contexts/poll";
|
|
||||||
import { trpc } from "@/trpc/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Touching a poll updates a column with the current date. This information is used to
|
|
||||||
* find polls that haven't been accessed for some time so that they can be deleted by house keeping.
|
|
||||||
*/
|
|
||||||
export const useTouchBeacon = () => {
|
|
||||||
const poll = usePoll();
|
|
||||||
const touchMutation = trpc.polls.touch.useMutation({
|
|
||||||
meta: { doNotInvalidate: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
useMount(() => {
|
|
||||||
touchMutation.mutate({ pollId: poll.id });
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -219,4 +219,13 @@ const requireUser = async () => {
|
||||||
return session?.user;
|
return session?.user;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If email is not set it means the user is a guest
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getUserId = async () => {
|
||||||
|
const session = await auth();
|
||||||
|
return session?.user?.email ? session.user.id : null;
|
||||||
|
};
|
||||||
|
|
||||||
export { auth, handlers, requireUser, signIn, signOut };
|
export { auth, handlers, requireUser, signIn, signOut };
|
||||||
|
|
|
@ -158,6 +158,24 @@ test.describe("House-keeping API", () => {
|
||||||
});
|
});
|
||||||
createdPollIds.push(oldPollNoUser.id);
|
createdPollIds.push(oldPollNoUser.id);
|
||||||
|
|
||||||
|
// 6. Old poll with recent views from regular user (should NOT be marked as deleted)
|
||||||
|
const oldPollWithRecentViews = await prisma.poll.create({
|
||||||
|
data: {
|
||||||
|
id: "old-poll-with-recent-views",
|
||||||
|
title: "Old Poll With Recent Views",
|
||||||
|
participantUrlId: "old-poll-with-recent-views-participant",
|
||||||
|
adminUrlId: "old-poll-with-recent-views-admin",
|
||||||
|
userId: regularUser.id,
|
||||||
|
touchedAt: dayjs().subtract(35, "day").toDate(), // 35 days old
|
||||||
|
views: {
|
||||||
|
create: {
|
||||||
|
viewedAt: dayjs().subtract(15, "day").toDate(), // Viewed 15 days ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdPollIds.push(oldPollWithRecentViews.id);
|
||||||
|
|
||||||
// Call the delete-inactive-polls endpoint
|
// Call the delete-inactive-polls endpoint
|
||||||
const response = await request.post(
|
const response = await request.post(
|
||||||
`${baseURL}/api/house-keeping/delete-inactive-polls`,
|
`${baseURL}/api/house-keeping/delete-inactive-polls`,
|
||||||
|
@ -207,6 +225,12 @@ test.describe("House-keeping API", () => {
|
||||||
});
|
});
|
||||||
expect(updatedOldPollNoUser?.deleted).toBe(true);
|
expect(updatedOldPollNoUser?.deleted).toBe(true);
|
||||||
expect(updatedOldPollNoUser?.deletedAt).not.toBeNull();
|
expect(updatedOldPollNoUser?.deletedAt).not.toBeNull();
|
||||||
|
|
||||||
|
const updatedOldPollWithRecentViews = await prisma.poll.findUnique({
|
||||||
|
where: { id: oldPollWithRecentViews.id },
|
||||||
|
});
|
||||||
|
expect(updatedOldPollWithRecentViews?.deleted).toBe(false);
|
||||||
|
expect(updatedOldPollWithRecentViews?.deletedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should permanently remove polls that have been marked as deleted for more than 7 days", async ({
|
test("should permanently remove polls that have been marked as deleted for more than 7 days", async ({
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Page, Request } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
import { load } from "cheerio";
|
import { load } from "cheerio";
|
||||||
import type { PollPage } from "tests/poll-page";
|
import type { PollPage } from "tests/poll-page";
|
||||||
|
|
||||||
|
@ -9,26 +10,64 @@ import { NewPollPage } from "./new-poll-page";
|
||||||
test.describe(() => {
|
test.describe(() => {
|
||||||
let page: Page;
|
let page: Page;
|
||||||
let pollPage: PollPage;
|
let pollPage: PollPage;
|
||||||
let touchRequest: Promise<Request>;
|
|
||||||
let editSubmissionUrl: string;
|
let editSubmissionUrl: string;
|
||||||
|
let pollId: string;
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeAll(async ({ browser }) => {
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
touchRequest = page.waitForRequest(
|
await page.clock.install();
|
||||||
(request) =>
|
|
||||||
request.method() === "POST" &&
|
|
||||||
request.url().includes("/api/trpc/polls.touch"),
|
|
||||||
);
|
|
||||||
const newPollPage = new NewPollPage(page);
|
const newPollPage = new NewPollPage(page);
|
||||||
await newPollPage.goto();
|
await newPollPage.goto();
|
||||||
pollPage = await newPollPage.createPollAndCloseDialog({
|
pollPage = await newPollPage.createPollAndCloseDialog({
|
||||||
name: "Monthly Meetup",
|
name: "Monthly Meetup",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract the poll ID from the URL
|
||||||
|
const url = page.url();
|
||||||
|
const match = url.match(/\/poll\/([a-zA-Z0-9]+)/);
|
||||||
|
pollId = match ? match[1] : "";
|
||||||
|
expect(pollId).not.toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should call touch endpoint", async () => {
|
test("should record poll view", async () => {
|
||||||
// make sure call to touch RPC is made
|
// Fast forward time to trigger view tracking
|
||||||
expect(await touchRequest).not.toBeNull();
|
await page.clock.fastForward(5000);
|
||||||
|
|
||||||
|
let pollViews: Array<{
|
||||||
|
id: string;
|
||||||
|
pollId: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
viewedAt: Date;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Retry until we find poll views or timeout
|
||||||
|
await expect(async () => {
|
||||||
|
// Query the database for poll views
|
||||||
|
pollViews = await prisma.pollView.findMany({
|
||||||
|
where: {
|
||||||
|
pollId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
viewedAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// This will throw if the condition is not met, causing a retry
|
||||||
|
expect(pollViews.length).toBeGreaterThan(0);
|
||||||
|
}).toPass({
|
||||||
|
timeout: 5000, // 5 second timeout
|
||||||
|
intervals: [100, 200, 500, 1000], // Retry intervals in milliseconds
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now that we know we have at least one poll view, verify its properties
|
||||||
|
const latestView = pollViews[0];
|
||||||
|
expect(latestView.pollId).toBe(pollId);
|
||||||
|
expect(latestView.ipAddress).toBeDefined();
|
||||||
|
expect(latestView.userAgent).toBeDefined();
|
||||||
|
expect(latestView.viewedAt).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should be able to comment", async () => {
|
test("should be able to comment", async () => {
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "poll_views" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"poll_id" TEXT NOT NULL,
|
||||||
|
"ip_address" TEXT,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"user_agent" TEXT,
|
||||||
|
"viewed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "poll_views_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "poll_views_poll_id_idx" ON "poll_views" USING HASH ("poll_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "poll_views_user_id_idx" ON "poll_views" USING HASH ("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "poll_views_viewed_at_idx" ON "poll_views"("viewed_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "poll_views" ADD CONSTRAINT "poll_views_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "poll_views" ADD CONSTRAINT "poll_views_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -1,3 +1,3 @@
|
||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
|
@ -62,6 +62,7 @@ model User {
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
paymentMethods PaymentMethod[]
|
paymentMethods PaymentMethod[]
|
||||||
subscription Subscription? @relation("UserToSubscription")
|
subscription Subscription? @relation("UserToSubscription")
|
||||||
|
pollViews PollView[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
@ -167,6 +168,7 @@ model Poll {
|
||||||
watchers Watcher[]
|
watchers Watcher[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
|
views PollView[]
|
||||||
|
|
||||||
@@index([guestId])
|
@@index([guestId])
|
||||||
@@map("polls")
|
@@map("polls")
|
||||||
|
@ -284,6 +286,23 @@ model Comment {
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PollView {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String @map("poll_id")
|
||||||
|
ipAddress String? @map("ip_address")
|
||||||
|
userId String? @map("user_id")
|
||||||
|
userAgent String? @map("user_agent")
|
||||||
|
viewedAt DateTime @default(now()) @map("viewed_at")
|
||||||
|
|
||||||
|
poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([pollId], type: Hash)
|
||||||
|
@@index([userId], type: Hash)
|
||||||
|
@@index([viewedAt])
|
||||||
|
@@map("poll_views")
|
||||||
|
}
|
||||||
|
|
||||||
model VerificationToken {
|
model VerificationToken {
|
||||||
identifier String @db.Citext
|
identifier String @db.Citext
|
||||||
token String @unique
|
token String @unique
|
||||||
|
|
43
packages/utils/src/safe-session-storage.ts
Normal file
43
packages/utils/src/safe-session-storage.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
// Memory fallback when sessionStorage isn't available
|
||||||
|
const memoryStorage = new Map<string, string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe wrapper for sessionStorage with memory fallback
|
||||||
|
* Handles browser environments, private browsing modes, and SSR
|
||||||
|
*/
|
||||||
|
export const safeSessionStorage = {
|
||||||
|
get(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return typeof window !== "undefined"
|
||||||
|
? window.sessionStorage.getItem(key)
|
||||||
|
: memoryStorage.get(key) || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Error accessing sessionStorage:", error);
|
||||||
|
return memoryStorage.get(key) || null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.sessionStorage.setItem(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Error setting sessionStorage:", error);
|
||||||
|
}
|
||||||
|
memoryStorage.set(key, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(key: string): void {
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.sessionStorage.removeItem(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Error deleting sessionStorage:", error);
|
||||||
|
}
|
||||||
|
memoryStorage.delete(key);
|
||||||
|
},
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue