diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json
index 1d8f3be30..fc29387ec 100644
--- a/public/intl/messages/en-US.json
+++ b/public/intl/messages/en-US.json
@@ -348,6 +348,7 @@
"saved": "Saved",
"unknown": "Unknown",
"untitled": "Untitled",
+ "upgrade": "Upgrade",
"update": "Update",
"url": "URL",
"user": "User",
@@ -426,6 +427,7 @@
"unauthorized": "Unauthorized",
"user-deleted": "User deleted.",
"viewed-page": "Viewed page",
+ "upgrade-required": "This feature requires a {plan} plan subscription.",
"visitor-log": "Visitor from {country} using {browser} on {os} {device}"
}
}
diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx
index 6c473b592..adfde5bac 100644
--- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx
@@ -1,10 +1,10 @@
'use client';
-import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { Button, Column, Tab, TabList, TabPanel, Tabs, Text } from '@umami/react-zen';
import { type Key, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
-import { useMessages } from '@/components/hooks';
+import { useMessages, useSubscription } from '@/components/hooks';
import { getItem, setItem } from '@/lib/storage';
import { ReplayModal } from './ReplayModal';
import { ReplaysDataTable } from './ReplaysDataTable';
@@ -14,13 +14,29 @@ const KEY_NAME = 'umami.replays.tab';
export function ReplaysPage({ websiteId }: { websiteId: string }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'replays');
- const { t, labels } = useMessages();
+ const { t, labels, messages } = useMessages();
+ const { hasFeature, cloudMode } = useSubscription();
const handleSelect = (value: Key) => {
setItem(KEY_NAME, value);
setTab(value);
};
+ if (cloudMode && !hasFeature('replays')) {
+ return (
+
+
+
+ {t(messages.upgradeRequired, { plan: 'Business' })}
+
+
+
+
+ );
+ }
+
return (
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx
index d88923d78..b84fb8cd7 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx
@@ -7,10 +7,11 @@ import {
Select,
Slider,
Switch,
+ Text,
TextField,
} from '@umami/react-zen';
import { useState } from 'react';
-import { useMessages, useUpdateQuery, useWebsite } from '@/components/hooks';
+import { useMessages, useSubscription, useUpdateQuery, useWebsite } from '@/components/hooks';
interface ReplayConfig {
sampleRate?: number;
@@ -22,6 +23,7 @@ interface ReplayConfig {
export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
const website = useWebsite();
const { t, labels, messages } = useMessages();
+ const { hasFeature, cloudMode } = useSubscription();
const { mutateAsync, touch, toast, isPending } = useUpdateQuery(`/websites/${websiteId}`);
const [enabled, setEnabled] = useState(website?.replayEnabled ?? false);
@@ -75,6 +77,20 @@ export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
);
};
+ if (cloudMode && !hasFeature('replays')) {
+ return (
+
+
+
+ {t(messages.upgradeRequired, { plan: 'Business' })}
+
+
+
+ );
+ }
+
return (
diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts
index b308b7b69..1c611e6f3 100644
--- a/src/app/api/auth/verify/route.ts
+++ b/src/app/api/auth/verify/route.ts
@@ -1,3 +1,4 @@
+import { fetchAccount } from '@/lib/load';
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { getAllUserTeams } from '@/queries/prisma';
@@ -9,7 +10,20 @@ export async function POST(request: Request) {
return error();
}
- const teams = await getAllUserTeams(auth.user.id);
+ const user = { ...auth.user };
+ const teams = await getAllUserTeams(user.id);
- return json({ ...auth.user, teams });
+ if (process.env.CLOUD_MODE) {
+ const account = await fetchAccount(user.id);
+
+ if (account) {
+ user.subscription = {
+ isPro: account.isPro || false,
+ isBusiness: account.isBusiness || false,
+ hasSubscription: account.hasSubscription || false,
+ };
+ }
+ }
+
+ return json({ ...user, teams });
}
diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts
index 390b25b49..ed70db078 100644
--- a/src/app/api/record/route.ts
+++ b/src/app/api/record/route.ts
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { secret } from '@/lib/crypto';
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { parseToken } from '@/lib/jwt';
+import { fetchAccount } from '@/lib/load';
import { parseRequest } from '@/lib/request';
import { badRequest, forbidden, json, serverError } from '@/lib/response';
import { getWebsite } from '@/queries/prisma';
@@ -60,6 +61,14 @@ export async function POST(request: Request) {
return json({ ok: false, reason: 'replay_disabled' });
}
+ if (process.env.CLOUD_MODE) {
+ const account = await fetchAccount(website.userId);
+
+ if (!account?.isBusiness) {
+ return forbidden({ message: 'Business subscription required.' });
+ }
+ }
+
// Client info for bot/IP checks
const { ip, userAgent } = await getClientInfo(request, {});
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index d683ce403..604a87a5f 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -96,5 +96,6 @@ export * from './usePageParameters';
export * from './useRegionNames';
export * from './useSlug';
export * from './useSticky';
+export * from './useSubscription';
export * from './useTimezone';
export * from './useWebsiteNavItems';
diff --git a/src/components/hooks/useSubscription.ts b/src/components/hooks/useSubscription.ts
new file mode 100644
index 000000000..a57edb4f8
--- /dev/null
+++ b/src/components/hooks/useSubscription.ts
@@ -0,0 +1,43 @@
+import { useApp } from '@/store/app';
+import { useConfig } from './useConfig';
+
+export interface Subscription {
+ isPro: boolean;
+ isBusiness: boolean;
+ hasSubscription: boolean;
+}
+
+const FEATURES = {
+ replays: 'isBusiness',
+} as const;
+
+export type FeatureName = keyof typeof FEATURES;
+
+const defaultSubscription: Subscription = {
+ isPro: false,
+ isBusiness: false,
+ hasSubscription: false,
+};
+
+export function useSubscription() {
+ const { user } = useApp();
+ const config = useConfig();
+
+ const subscription: Subscription = user?.subscription || defaultSubscription;
+ const cloudMode = config?.cloudMode || false;
+
+ function hasFeature(feature: FeatureName): boolean {
+ if (!cloudMode) {
+ return true;
+ }
+
+ const requiredFlag = FEATURES[feature];
+ return subscription[requiredFlag] || false;
+ }
+
+ return {
+ ...subscription,
+ cloudMode,
+ hasFeature,
+ };
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index f5a3d6bca..e91390bf8 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -378,6 +378,7 @@ export const labels: Record = {
retentionDays: 'label.retention-days',
duration: 'label.duration',
recorded: 'label.recorded',
+ upgrade: 'label.upgrade',
};
export const messages: Record = {
@@ -427,4 +428,5 @@ export const messages: Record = {
forbidden: 'message.forbidden',
notFound: 'message.not-found',
serverError: 'message.sever-error',
+ upgradeRequired: 'message.upgrade-required',
};