This commit is contained in:
Mike Cao
2026-03-22 09:55:00 -07:00
8 changed files with 101 additions and 25 deletions
@@ -1,12 +1,13 @@
'use client';
import { Button, Column, Icon, 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 { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { Panel } from '@/components/common/Panel';
import { useMessages, useSubscription } from '@/components/hooks';
import { useMessages, useSubscription, useWebsite } from '@/components/hooks';
import { Video } from '@/components/icons';
import { getItem, setItem } from '@/lib/storage';
import { Button, Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { type Key, useState } from 'react';
import { ReplayModal } from './ReplayModal';
import { ReplaysDataTable } from './ReplaysDataTable';
import { SavedReplaysDataTable } from './SavedReplaysDataTable';
@@ -15,8 +16,9 @@ const KEY_NAME = 'umami.replays.tab';
export function ReplaysPage({ websiteId }: { websiteId: string }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'replays');
const website = useWebsite();
const { t, labels, messages } = useMessages();
const { hasFeature, cloudMode } = useSubscription();
const { hasFeature, cloudMode } = useSubscription(website?.teamId);
const handleSelect = (value: Key) => {
setItem(KEY_NAME, value);
@@ -27,18 +29,18 @@ export function ReplaysPage({ websiteId }: { websiteId: string }) {
return (
<Column gap="3">
<Panel>
<Column gap="4" alignItems="center" padding="10">
<Icon size="xl">
<Video />
</Icon>
<Text className="py-4">{t(messages.upgradeRequired, { plan: 'Business' })}</Text>
<EmptyPlaceholder
icon={<Video />}
title={t(messages.upgradeRequired, { plan: 'Business' })}
description="Watch real user sessions to see exactly how visitors interact with your site."
>
<Button
variant="primary"
onPress={() => window.open(`${process.env.cloudUrl}/settings/billing`, '_blank')}
>
{t(labels.upgrade)}
</Button>
</Column>
</EmptyPlaceholder>
</Panel>
</Column>
);
@@ -7,11 +7,12 @@ import {
Select,
Slider,
Switch,
Text,
TextField,
} from '@umami/react-zen';
import { useState } from 'react';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { useMessages, useSubscription, useUpdateQuery, useWebsite } from '@/components/hooks';
import { Video } from '@/components/icons';
interface ReplayConfig {
sampleRate?: number;
@@ -23,7 +24,7 @@ interface ReplayConfig {
export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
const website = useWebsite();
const { t, labels, messages } = useMessages();
const { hasFeature, cloudMode } = useSubscription();
const { hasFeature, cloudMode } = useSubscription(website?.teamId);
const { mutateAsync, touch, toast, isPending } = useUpdateQuery(`/websites/${websiteId}`);
const [enabled, setEnabled] = useState(website?.replayEnabled ?? false);
@@ -81,12 +82,18 @@ export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
return (
<Column gap="4">
<Label>{t(labels.replays)}</Label>
<Column gap="4" alignItems="center" padding="10">
<Text>{t(messages.upgradeRequired, { plan: 'Business' })}</Text>
<Button variant="primary" onPress={() => window.open(`${process.env.cloudUrl}/settings/billing`, '_blank')}>
<EmptyPlaceholder
icon={<Video />}
title={t(messages.upgradeRequired, { plan: 'Business' })}
description="Watch real user sessions to see exactly how visitors interact with your site."
>
<Button
variant="primary"
onPress={() => window.open(`${process.env.cloudUrl}/settings/billing`, '_blank')}
>
{t(labels.upgrade)}
</Button>
</Column>
</EmptyPlaceholder>
</Column>
);
}
+21 -1
View File
@@ -1,4 +1,4 @@
import { fetchAccount } from '@/lib/load';
import { fetchAccount, fetchTeam } from '@/lib/load';
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { getAllUserTeams } from '@/queries/prisma';
@@ -20,9 +20,29 @@ export async function POST(request: Request) {
user.subscription = {
isPro: account.isPro || false,
isBusiness: account.isBusiness || false,
isNoBilling: account.isNoBilling || false,
hasSubscription: account.hasSubscription || false,
};
}
const teamsWithSubscription = await Promise.all(
teams.map(async (team: any) => {
const teamAccount = await fetchTeam(team.id);
return {
...team,
subscription: teamAccount
? {
isPro: teamAccount.isPro || false,
isBusiness: teamAccount.isBusiness || false,
isNoBilling: teamAccount.isNoBilling || false,
hasSubscription: teamAccount.hasSubscription || false,
}
: null,
};
}),
);
return json({ ...user, teams: teamsWithSubscription });
}
return json({ ...user, teams });
+7 -3
View File
@@ -4,7 +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 { fetchAccount, fetchTeam } from '@/lib/load';
import { parseRequest } from '@/lib/request';
import { badRequest, forbidden, json, serverError } from '@/lib/response';
import { getWebsite } from '@/queries/prisma';
@@ -62,9 +62,13 @@ export async function POST(request: Request) {
}
if (process.env.CLOUD_MODE) {
const account = await fetchAccount(website.userId);
const account = website.teamId
? await fetchTeam(website.teamId)
: website.userId
? await fetchAccount(website.userId)
: null;
if (!account?.isBusiness) {
if (!account?.isBusiness && !account?.isNoBilling) {
return forbidden({ message: 'Business subscription required.' });
}
}
+25 -2
View File
@@ -1,6 +1,8 @@
import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { fetchAccount } from '@/lib/load';
import redis from '@/lib/redis';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
@@ -43,14 +45,35 @@ export async function POST(request: Request) {
const { name, ownerId } = body;
const teamId = uuid();
const teamOwnerId = ownerId ?? auth.user.id;
const team = await createTeam(
{
id: uuid(),
id: teamId,
name,
accessCode: `team_${getRandomChars(16)}`,
},
ownerId ?? auth.user.id,
teamOwnerId,
);
if (process.env.CLOUD_MODE && redis.enabled) {
const account = await fetchAccount(teamOwnerId);
if (account) {
await redis.client.set(
`team:${teamId}`,
{
teamOwnerId,
isPro: account.isPro || false,
isBusiness: account.isBusiness || false,
isNoBilling: account.isNoBilling || false,
hasSubscription: account.hasSubscription || false,
},
60 * 60 * 24 * 90,
);
}
}
return json(team);
}
+10 -3
View File
@@ -4,6 +4,7 @@ import { useConfig } from './useConfig';
export interface Subscription {
isPro: boolean;
isBusiness: boolean;
isNoBilling: boolean;
hasSubscription: boolean;
}
@@ -16,18 +17,24 @@ export type FeatureName = keyof typeof FEATURES;
const defaultSubscription: Subscription = {
isPro: false,
isBusiness: false,
isNoBilling: false,
hasSubscription: false,
};
export function useSubscription() {
export function useSubscription(teamId?: string | null) {
const { user } = useApp();
const config = useConfig();
const subscription: Subscription = user?.subscription || defaultSubscription;
const ownSubscription: Subscription = user?.subscription || defaultSubscription;
const teamSubscription: Subscription | null = teamId
? user?.teams?.find((t: any) => t.id === teamId)?.subscription ?? null
: null;
const subscription: Subscription = teamSubscription || ownSubscription;
const cloudMode = config?.cloudMode || false;
function hasFeature(feature: FeatureName): boolean {
if (!cloudMode) {
if (!cloudMode || subscription.isNoBilling) {
return true;
}
+6
View File
@@ -44,3 +44,9 @@ export async function fetchAccount(userId: string) {
return account;
}
export async function fetchTeam(teamId: string) {
const team = await redis.client.get(`team:${teamId}`);
return team;
}
+7
View File
@@ -100,6 +100,13 @@ export async function getAllUserTeams(userId: string) {
});
}
export async function getTeamOwner(teamId: string) {
return prisma.client.teamUser.findFirst({
where: { teamId, role: ROLES.teamOwner },
select: { userId: true },
});
}
export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise<any> {
const { id } = data;
const { client, transaction } = prisma;