feats: configurable plan quota

This commit is contained in:
oiov
2025-06-11 15:06:36 +08:00
parent 1640f263b1
commit 468a6f5645
32 changed files with 1781 additions and 199 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import DomainList from "./domain-list";
import DomainList from "../system/domain-list";
export const metadata = constructMetadata({
title: "Domains - WR.DO",
@@ -306,7 +306,9 @@ export default function DomainList({ user, action }: DomainListProps) {
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">{t("Edit")}</p>
<p className="hidden text-nowrap sm:block">
{t("Edit")}
</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
</TableCell>
+12
View File
@@ -0,0 +1,12 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="System Settings" text="" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import DomainList from "./domain-list";
import PlanList from "./plan-list";
export const metadata = constructMetadata({
title: "System Settings - WR.DO",
description: "",
});
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
return (
<>
<DashboardHeader heading="System Settings" text="" />
<DomainList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
team: user.team,
}}
action="/api/admin/domain"
/>
<PlanList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
team: user.team,
}}
action="/api/admin/plan"
/>
</>
);
}
+269
View File
@@ -0,0 +1,269 @@
"use client";
import { useState } from "react";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import useSWR, { useSWRConfig } from "swr";
import { PlanQuotaFormData } from "@/lib/dto/plan";
import { fetcher, nFormatter } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PlanForm } from "@/components/forms/plan-form";
import { FormType } from "@/components/forms/record-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination";
import { TimeAgoIntl } from "@/components/shared/time-ago";
export interface PlanListProps {
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
action: string;
}
function TableColumnSekleton() {
return (
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-8">
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-32" />
</TableCell>
</TableRow>
);
}
export default function PlanList({ user, action }: PlanListProps) {
const { isMobile } = useMediaQuery();
const t = useTranslations("List");
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditPlan, setCurrentEditPlan] =
useState<PlanQuotaFormData | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchParams, setSearchParams] = useState({
slug: "",
target: "",
userName: "",
});
const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR<{
total: number;
list: PlanQuotaFormData[];
}>(
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
fetcher,
);
const handleRefresh = () => {
mutate(
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
undefined,
);
};
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center gap-2">
<div className="flex items-center gap-1 text-lg font-bold">
<span className="text-nowrap">{t("Quota Settings")}</span>
</div>
<div className="ml-auto flex items-center justify-end gap-3">
<Button
variant={"outline"}
onClick={() => handleRefresh()}
disabled={isLoading}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
</Button>
<Button
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditPlan(null);
setShowForm(false);
setFormType("add");
setShowForm(!isShowForm);
}}
>
<Icons.add className="size-4" />
<span className="hidden sm:inline">{t("Add Plan")}</span>
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-8">
<TableHead className="col-span-1 flex items-center font-bold">
{t("Plan Name")}
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
{t("Short Limit")}
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
{t("Email Limit")}
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
{t("Send Limit")}
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
{t("Record Limit")}
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
{t("Active")}
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
{t("Updated")}
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
{t("Actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : data && data.list && data.list.length ? (
data.list.map((plan) => (
<div className="border-b" key={plan.id}>
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-8">
<TableCell className="col-span-1 flex items-center gap-1">
{plan.name}
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
{nFormatter(plan.slNewLinks)}
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
{nFormatter(plan.emEmailAddresses)}
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
{nFormatter(plan.emSendEmails)}
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
{nFormatter(plan.rcNewRecords)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Switch
disabled
defaultChecked={plan.isActive}
// onCheckedChange={(value) =>
// handleChangeStatus(value, "active", domain)
// }
/>
</TableCell>
<TableCell className="col-span-1 flex items-center truncate">
<TimeAgoIntl date={plan.updatedAt as Date} />
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditPlan(plan);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden text-nowrap sm:block">
{t("Edit")}
</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
</TableCell>
</TableRow>
</div>
))
) : (
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="settings" />
<EmptyPlaceholder.Title>
{t("No Plans")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any plans yet. Start creating one.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
{/* form */}
<Modal
className="max-h-[90vh] overflow-y-auto md:max-w-2xl"
showModal={isShowForm}
setShowModal={setShowForm}
>
<PlanForm
user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type={formType}
initData={currentEditPlan}
action={action}
onRefresh={handleRefresh}
/>
</Modal>
</>
);
}
+15 -13
View File
@@ -2,9 +2,9 @@ import { Suspense } from "react";
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { TeamPlanQuota } from "@/config/team";
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
import { getAllUserEmailsCount } from "@/lib/dto/email";
import { getPlanQuota, PlanQuota } from "@/lib/dto/plan";
import { getUserShortUrlCount } from "@/lib/dto/short-urls";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
@@ -25,10 +25,10 @@ export const metadata = constructMetadata({
async function EmailHeroCardSection({
userId,
team,
plan,
}: {
userId: string;
team: string;
plan: PlanQuota;
}) {
const email_count = await getAllUserEmailsCount(userId);
@@ -36,17 +36,17 @@ async function EmailHeroCardSection({
<HeroCard
total={email_count.total}
monthTotal={email_count.month_total}
limit={TeamPlanQuota[team].EM_EmailAddresses}
limit={plan.emEmailAddresses}
/>
);
}
async function ShortUrlsCardSection({
userId,
team,
plan,
}: {
userId: string;
team: string;
plan: PlanQuota;
}) {
const url_count = await getUserShortUrlCount(userId);
@@ -56,7 +56,7 @@ async function ShortUrlsCardSection({
title="Short URLs"
total={url_count.total}
monthTotal={url_count.month_total}
limit={TeamPlanQuota[team].SL_NewLinks}
limit={plan.slNewLinks}
link="/dashboard/urls"
icon="link"
/>
@@ -65,10 +65,10 @@ async function ShortUrlsCardSection({
async function DnsRecordsCardSection({
userId,
team,
plan,
}: {
userId: string;
team: string;
plan: PlanQuota;
}) {
const record_count = await getUserRecordCount(userId);
@@ -78,7 +78,7 @@ async function DnsRecordsCardSection({
title="DNS Records"
total={record_count.total}
monthTotal={record_count.month_total}
limit={TeamPlanQuota[team].RC_NewRecords}
limit={plan.rcNewRecords}
link="/dashboard/records"
icon="globeLock"
/>
@@ -140,6 +140,8 @@ export default async function DashboardPage() {
if (!user?.id) redirect("/login");
const plan = await getPlanQuota(user.team);
return (
<>
<div className="flex flex-col gap-5">
@@ -150,7 +152,7 @@ export default async function DashboardPage() {
<Suspense
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
>
<EmailHeroCardSection userId={user.id} team={user.team} />
<EmailHeroCardSection userId={user.id} plan={plan} />
</Suspense>
</ErrorBoundary>
<ErrorBoundary
@@ -159,7 +161,7 @@ export default async function DashboardPage() {
<Suspense
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
>
<ShortUrlsCardSection userId={user.id} team={user.team} />
<ShortUrlsCardSection userId={user.id} plan={plan} />
</Suspense>
</ErrorBoundary>
<ErrorBoundary
@@ -168,7 +170,7 @@ export default async function DashboardPage() {
<Suspense
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
>
<DnsRecordsCardSection userId={user.id} team={user.team} />
<DnsRecordsCardSection userId={user.id} plan={plan} />
</Suspense>
</ErrorBoundary>
</div>
+40 -36
View File
@@ -9,8 +9,8 @@ import { TopoJSONMap } from "@unovis/ts";
import { WorldMapTopoJSON } from "@unovis/ts/maps";
import { useTranslations } from "next-intl";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import useSWR from "swr";
import { TeamPlanQuota } from "@/config/team";
import {
getBotName,
getCountryName,
@@ -20,7 +20,7 @@ import {
getRegionName,
} from "@/lib/contries";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { isLink, removeUrlSuffix } from "@/lib/utils";
import { fetcher, isLink, removeUrlSuffix } from "@/lib/utils";
import { useElementSize } from "@/hooks/use-element-size";
import { Button } from "@/components/ui/button";
import {
@@ -179,6 +179,11 @@ export function DailyPVUVChart({
const t = useTranslations("Components");
const { data: plan } = useSWR<{ slAnalyticsRetention: number }>(
`/api/plan?team=${user.team}`,
fetcher,
);
const processedData = processUrlMeta(data).map((entry) => ({
date: entry.date,
pv: entry.clicks,
@@ -254,40 +259,39 @@ export function DailyPVUVChart({
<CardDescription>{lastVisitorInfo}</CardDescription>
</div>
<div className="flex items-center">
<Select
onValueChange={(value: string) => {
setTimeRange(value);
}}
name="time range"
defaultValue={timeRange}
>
<SelectTrigger className="mx-4 w-full shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
value={e.value}
>
<span className="flex items-center gap-1">
{t(e.label)}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
{plan && (
<Select
onValueChange={(value: string) => {
setTimeRange(value);
}}
name="time range"
defaultValue={timeRange}
>
<SelectTrigger className="mx-4 w-full min-w-28 shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem
disabled={e.key > plan.slAnalyticsRetention}
value={e.value}
>
<span className="flex items-center gap-1">
{t(e.label)}
{e.key > plan.slAnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
)}
{["pv", "uv"].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
+38 -35
View File
@@ -5,7 +5,6 @@ import { UrlMeta, User } from "@prisma/client";
import { useTranslations } from "next-intl";
import useSWR from "swr";
import { TeamPlanQuota } from "@/config/team";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { fetcher } from "@/lib/utils";
import {
@@ -37,6 +36,11 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
{ focusThrottleInterval: 30000 }, // 30 seconds,
);
const { data: plan } = useSWR<{ slAnalyticsRetention: number }>(
`/api/plan?team=${user.team}`,
fetcher,
);
if (isLoading)
return (
<div className="space-y-2">
@@ -55,40 +59,39 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
"",
)}
.
<Select
onValueChange={(value: string) => {
setTimeRange(value);
}}
name="time range"
defaultValue={timeRange}
>
<SelectTrigger className="mt-4 w-full shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
value={e.value}
>
<span className="flex items-center gap-1">
{t(e.label)}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
{plan && (
<Select
onValueChange={(value: string) => {
setTimeRange(value);
}}
name="time range"
defaultValue={timeRange}
>
<SelectTrigger className="mt-4 w-full shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem
disabled={e.key > plan.slAnalyticsRetention}
value={e.value}
>
<span className="flex items-center gap-1">
{t(e.label)}
{e.key > plan.slAnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
)}
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
);
+1 -1
View File
@@ -410,7 +410,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">{t("Edit")}</p>
<p className="hidden text-nowrap sm:block">{t("Edit")}</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
<Button
+137
View File
@@ -0,0 +1,137 @@
import { NextRequest } from "next/server";
import {
createPlan,
deletePlan,
getAllPlans,
updatePlanQuota,
} from "@/lib/dto/plan";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
// const url = new URL(req.url);
// const page = url.searchParams.get("page");
// const size = url.searchParams.get("size");
// const target = url.searchParams.get("target") || "";
const data = await getAllPlans();
return Response.json(data, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { plan } = await req.json();
const data = await createPlan({
name: plan.name,
slTrackedClicks: plan.slTrackedClicks,
slNewLinks: plan.slNewLinks,
slAnalyticsRetention: plan.slAnalyticsRetention,
slDomains: plan.slDomains,
slAdvancedAnalytics: plan.slAdvancedAnalytics,
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
rcNewRecords: plan.rcNewRecords,
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
appSupport: plan.appSupport.toUpperCase() as any,
appApiAccess: plan.appApiAccess,
});
if (data) {
return Response.json(data, { status: 200 });
}
return Response.json(null, { status: 400 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { plan } = await req.json();
if (!plan) {
return Response.json("Invalid request body", { status: 400 });
}
const res = await updatePlanQuota({
id: plan.id,
name: plan.name,
slTrackedClicks: plan.slTrackedClicks,
slNewLinks: plan.slNewLinks,
slAnalyticsRetention: plan.slAnalyticsRetention,
slDomains: plan.slDomains,
slAdvancedAnalytics: plan.slAdvancedAnalytics,
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
rcNewRecords: plan.rcNewRecords,
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
appSupport: plan.appSupport.toUpperCase() as any,
appApiAccess: plan.appApiAccess,
isActive: plan.isActive,
});
if (res) {
return Response.json(res, { status: 200 });
}
return Response.json(null, { status: 400 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { id } = await req.json();
if (!id) {
return Response.json("id is required", { status: 400 });
}
const data = await deletePlan(id);
if (data) {
return Response.json(data, { status: 200 });
}
return Response.json(null, { status: 400 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
+4 -2
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { TeamPlanQuota } from "@/config/team";
import { createUserEmail, getAllUserEmails } from "@/lib/dto/email";
import { getPlanQuota } from "@/lib/dto/plan";
import { checkUserStatus } from "@/lib/dto/user";
import { reservedAddressSuffix } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
@@ -43,11 +43,13 @@ export async function POST(req: NextRequest) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const plan = await getPlanQuota(user.team);
// check limit
const limit = await restrictByTimeRange({
model: "userEmail",
userId: user.id,
limit: TeamPlanQuota[user.team].EM_EmailAddresses,
limit: plan.emEmailAddresses,
rangeType: "month",
});
if (limit)
+4 -2
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { TeamPlanQuota } from "@/config/team";
import { checkDomainIsConfiguratedResend } from "@/lib/dto/domains";
import { getUserSendEmailCount, saveUserSendEmail } from "@/lib/dto/email";
import { getPlanQuota } from "@/lib/dto/plan";
import { checkUserStatus } from "@/lib/dto/user";
import { resend } from "@/lib/email";
import { getCurrentUser } from "@/lib/session";
@@ -14,11 +14,13 @@ export async function POST(req: NextRequest) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const plan = await getPlanQuota(user.team);
// check limit
const limit = await restrictByTimeRange({
model: "userSendEmail",
userId: user.id,
limit: TeamPlanQuota[user.team].EM_SendEmails,
limit: plan.emSendEmails,
rangeType: "month",
});
if (limit)
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest } from "next/server";
import { getPlanNames } from "@/lib/dto/plan";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
// export const dynamic = "force-dynamic";
// Get plan names for frontend
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const res = await getPlanNames();
if (!res) {
return Response.json("Plans not found", { status: 400 });
}
return Response.json(res, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest } from "next/server";
import { getAllPlans, getPlanQuota } from "@/lib/dto/plan";
// export const dynamic = "force-dynamic";
// Get one plan by plan name for frontend
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const isAll = url.searchParams.get("all") || "0";
const team = url.searchParams.get("team") || "free";
if (isAll === "1") {
const res = await getAllPlans();
if (res) {
return Response.json(res, { status: 200 });
}
} else {
const res = await getPlanQuota(team);
if (res) {
return Response.json(res, { status: 200 });
}
}
return Response.json("Plan not found", { status: 400 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
+4 -2
View File
@@ -1,6 +1,5 @@
import { env } from "@/env.mjs";
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
createUserRecord,
@@ -8,6 +7,7 @@ import {
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { getPlanQuota } from "@/lib/dto/plan";
import { checkUserStatus, getFirstAdminUser } from "@/lib/dto/user";
import { applyRecordEmailHtml, resend } from "@/lib/email";
import { reservedDomains } from "@/lib/enums";
@@ -27,8 +27,10 @@ export async function POST(req: Request) {
});
}
const plan = await getPlanQuota(user.team);
const { total } = await getUserRecordCount(user.id);
if (total >= TeamPlanQuota[user.team].RC_NewRecords) {
if (total >= plan.rcNewRecords) {
return Response.json("Your records have reached the free limit.", {
status: 409,
});
+4 -2
View File
@@ -1,4 +1,3 @@
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
createUserRecord,
@@ -6,6 +5,7 @@ import {
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { getPlanQuota } from "@/lib/dto/plan";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
@@ -39,8 +39,10 @@ export async function POST(req: Request) {
});
}
const plan = await getPlanQuota(user.team);
const { total } = await getUserRecordCount(target_user.id);
if (total >= TeamPlanQuota[target_user.team!].RC_NewRecords) {
if (total >= plan.rcNewRecords) {
return Response.json("Your records have reached the free limit.", {
status: 409,
});
+3 -2
View File
@@ -1,5 +1,5 @@
import { TeamPlanQuota } from "@/config/team";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { getPlanQuota } from "@/lib/dto/plan";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -11,11 +11,12 @@ export async function POST(req: Request) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const plan = await getPlanQuota(user.team);
// check limit
const limit = await restrictByTimeRange({
model: "userUrl",
userId: user.id,
limit: TeamPlanQuota[user.team].SL_NewLinks,
limit: plan.slNewLinks,
rangeType: "month",
});
if (limit) return Response.json(limit.statusText, { status: limit.status });
+4 -3
View File
@@ -1,10 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { checkApiKey } from "@/lib/dto/api-key";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserEmail, deleteUserEmailByAddress } from "@/lib/dto/email";
import { getPlanQuota } from "@/lib/dto/plan";
import { reservedAddressSuffix } from "@/lib/enums";
import { restrictByTimeRange } from "@/lib/team";
@@ -32,11 +31,13 @@ export async function POST(req: NextRequest) {
});
}
const plan = await getPlanQuota(user.team!);
// check limit
const limit = await restrictByTimeRange({
model: "userEmail",
userId: user.id,
limit: TeamPlanQuota[user.team!].EM_EmailAddresses,
limit: plan.emEmailAddresses,
rangeType: "month",
});
if (limit)
+4 -2
View File
@@ -1,6 +1,6 @@
import { TeamPlanQuota } from "@/config/team";
import { checkApiKey } from "@/lib/dto/api-key";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { getPlanQuota } from "@/lib/dto/plan";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { restrictByTimeRange } from "@/lib/team";
import { createUrlSchema } from "@/lib/validations/url";
@@ -23,11 +23,13 @@ export async function POST(req: Request) {
);
}
const plan = await getPlanQuota(user.team!);
// check limit
const limit = await restrictByTimeRange({
model: "userUrl",
userId: user.id,
limit: TeamPlanQuota[user.team!].SL_NewLinks,
limit: plan.slNewLinks,
rangeType: "month",
});
if (limit) return Response.json(limit.statusText, { status: limit.status });
+342
View File
@@ -0,0 +1,342 @@
"use client";
import { Dispatch, SetStateAction, useState, useTransition } from "react";
import Link from "next/link";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { create } from "lodash";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { PlanQuotaFormData } from "@/lib/dto/plan";
import { createPlanSchema } from "@/lib/validations/plan";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Icons } from "@/components/shared/icons";
import { FormSectionColumns } from "../dashboard/form-section-columns";
import { Switch } from "../ui/switch";
export type FormData = PlanQuotaFormData;
export type FormType = "add" | "edit";
export interface PlanFormProps {
user: Pick<User, "id" | "name">;
isShowForm: boolean;
setShowForm: Dispatch<SetStateAction<boolean>>;
type: FormType;
initData?: FormData | null;
action: string;
onRefresh: () => void;
}
export function PlanForm({
setShowForm,
type,
initData,
action,
onRefresh,
}: PlanFormProps) {
const t = useTranslations("List");
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const {
handleSubmit,
register,
formState: { errors },
setValue,
} = useForm<FormData>({
resolver: zodResolver(createPlanSchema),
defaultValues: {
id: initData?.id || "",
name: initData?.name || "",
slTrackedClicks: initData?.slTrackedClicks || 10000,
slNewLinks: initData?.slNewLinks || 100,
slAnalyticsRetention: initData?.slAnalyticsRetention || 180,
slDomains: initData?.slDomains || 1,
slAdvancedAnalytics: initData?.slAdvancedAnalytics || false,
slCustomQrCodeLogo: initData?.slCustomQrCodeLogo || false,
rcNewRecords: initData?.rcNewRecords || 1,
emEmailAddresses: initData?.emEmailAddresses || 100,
emDomains: initData?.emDomains || 1,
emSendEmails: initData?.emSendEmails || 100,
appSupport: initData?.appSupport || "BASIC",
appApiAccess: initData?.appApiAccess || false,
isActive: initData?.isActive || false,
},
});
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreatePlan(data);
} else if (type === "edit") {
handleUpdatePlan(data);
}
});
const handleCreatePlan = async (data: FormData) => {
startTransition(async () => {
const response = await fetch(`${action}`, {
method: "POST",
body: JSON.stringify({
plan: data,
}),
});
if (!response.ok || response.status !== 200) {
toast.error("Created Failed!", {
description: await response.text(),
});
} else {
// const res = await response.json();
toast.success(`Created successfully!`);
setShowForm(false);
onRefresh();
}
});
};
const handleUpdatePlan = async (data: FormData) => {
startTransition(async () => {
if (type === "edit") {
const response = await fetch(`${action}`, {
method: "PUT",
body: JSON.stringify({ plan: data }),
});
if (!response.ok || response.status !== 200) {
toast.error("Update Failed", {
description: await response.text(),
});
} else {
await response.json();
toast.success(`Update successfully!`);
setShowForm(false);
onRefresh();
}
}
});
};
const handleDeletePlan = async () => {
if (type === "edit") {
startDeleteTransition(async () => {
const response = await fetch(`${action}`, {
method: "DELETE",
body: JSON.stringify({
id: initData?.id,
}),
});
if (!response.ok || response.status !== 200) {
toast.error("Delete Failed", {
description: await response.text(),
});
} else {
await response.json();
toast.success(`Success`);
setShowForm(false);
onRefresh();
}
});
}
};
return (
<div>
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
{type === "add" ? t("Create Plan") : t("Edit Plan")}
</div>
<form className="p-4" onSubmit={onSubmit}>
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title={t("Plan Name")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Plan-Name">
{t("Plan Name")}
</Label>
<Input
id="name"
className="flex-1 shadow-inner"
size={32}
{...register("name")}
/>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.name ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.name.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{t("Required")}. Plan name must be unique
</p>
)}
</div>
</FormSectionColumns>
</div>
<div className="items-center justify-start gap-4 md:flex">
{/* Short Limit - slNewLinks */}
<FormSectionColumns title={t("Short Limit")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Short-Limit">
{t("Short Limit")}
</Label>
<Input
id="short-limit"
className="flex-1 shadow-inner"
size={32}
type="number"
{...register("slNewLinks", { valueAsNumber: true })}
/>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.slNewLinks ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.slNewLinks.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{t("Monthly limit of short links created")}.
</p>
)}
</div>
</FormSectionColumns>
{/* Email Limit - emEmailAddresses */}
<FormSectionColumns title={t("Email Limit")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Email-Limit">
{t("Email Limit")}
</Label>
<Input
id="email-limit"
className="flex-1 shadow-inner"
size={32}
type="number"
{...register("emEmailAddresses", { valueAsNumber: true })}
/>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.emEmailAddresses ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.emEmailAddresses.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{t("Monthly limit of email addresses created")}.
</p>
)}
</div>
</FormSectionColumns>
</div>
<div className="items-center justify-start gap-4 md:flex">
{/* "Send Limit" - emSendEmails */}
<FormSectionColumns title={t("Send Limit")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Send-Limit">
{t("Send Limit")}
</Label>
<Input
id="send-limit"
className="flex-1 shadow-inner"
size={32}
type="number"
{...register("emSendEmails", { valueAsNumber: true })}
/>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.emSendEmails ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.emSendEmails.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{t("Monthly limit of emails sent")}.
</p>
)}
</div>
</FormSectionColumns>
{/* Record Limit - rcNewRecords */}
<FormSectionColumns title={t("Record Limit")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Record-Limit">
{t("Record Limit")}
</Label>
<Input
id="record-limit"
className="flex-1 shadow-inner"
size={32}
type="number"
{...register("rcNewRecords", { valueAsNumber: true })}
/>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.rcNewRecords ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.rcNewRecords.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{t("Monthly limit of subdomains created")}.
</p>
)}
</div>
</FormSectionColumns>
</div>
<FormSectionColumns title={t("Active")} required>
<Label className="sr-only" htmlFor="active">
{t("Active")}:
</Label>
<Switch
id="active"
{...register("isActive")}
defaultChecked={initData?.isActive ?? true}
onCheckedChange={(value) => setValue("isActive", value)}
/>
</FormSectionColumns>
{/* Action buttons */}
<div className="mt-3 flex justify-end gap-3">
{type === "edit" && initData?.name !== "free" && (
<Button
type="button"
variant="destructive"
className="mr-auto w-[80px] px-0"
onClick={() => handleDeletePlan()}
disabled={isDeleting}
>
{isDeleting ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{t("Delete")}</p>
)}
</Button>
)}
<Button
type="reset"
variant="outline"
className="w-[80px] px-0"
onClick={() => setShowForm(false)}
>
{t("Cancel")}
</Button>
<Button
type="submit"
variant="blue"
disabled={isPending}
className="w-[80px] shrink-0 px-0"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{type === "edit" ? t("Update") : t("Save")}</p>
)}
</Button>
</div>
</form>
</div>
);
}
+37 -18
View File
@@ -5,8 +5,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { User, UserRole } from "@prisma/client";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
import { ROLE_ENUM } from "@/lib/enums";
import { fetcher } from "@/lib/utils";
import { updateUserSchema } from "@/lib/validations/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -21,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Skeleton } from "../ui/skeleton";
import { Switch } from "../ui/switch";
export type FormData = User;
@@ -64,6 +67,15 @@ export function UserForm({
},
});
const { data: plans, isLoading } = useSWR<string[]>(
"/api/plan/names",
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000,
},
);
const onSubmit = handleSubmit((data) => {
if (type === "edit") {
handleUpdate(data);
@@ -175,24 +187,31 @@ export function UserForm({
</Select>
</FormSectionColumns>
<FormSectionColumns title="Plan">
<Select
onValueChange={(value: string) => {
setValue("team", value);
}}
name="team"
defaultValue={`${initData?.team}` || "free"}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
<SelectContent>
{["free", "premium", "business"].map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading ? (
<Skeleton className="h-9 w-full rounded-r-none border-r-0 shadow-inner" />
) : (
plans &&
plans.length > 0 && (
<Select
onValueChange={(value: string) => {
setValue("team", value);
}}
name="plan"
defaultValue={`${initData?.team}` || "free"}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
<SelectContent>
{plans.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
)
)}
</FormSectionColumns>
</div>
<div className="items-center justify-start gap-4 md:flex">
+2 -2
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import Link from "next/link";
import { version } from "package.json";
import pkg from "package.json";
import { footerLinks, siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
@@ -76,7 +76,7 @@ export function SiteFooter({ className }: React.HTMLAttributes<HTMLElement>) {
rel="noreferrer"
className="font-thin underline-offset-2 hover:underline"
>
v{version}
v{pkg.version}
</Link>
</div>
+49 -52
View File
@@ -6,68 +6,74 @@ import Link from "next/link";
import { motion } from "framer-motion";
import { X } from "lucide-react";
import { useTranslations } from "next-intl";
import useSWR from "swr";
import { TeamPlanQuota } from "@/config/team";
import { cn, nFormatter } from "@/lib/utils";
import { PlanQuotaFormData } from "@/lib/dto/plan";
import { cn, fetcher, nFormatter } from "@/lib/utils";
import { Icons } from "../shared/icons";
import { Button } from "../ui/button";
const getBenefits = (plan) => [
const getBenefits = (plan: PlanQuotaFormData) => [
{
text: `${nFormatter(plan.SL_TrackedClicks)} tracked clicks/mo`,
text: `${nFormatter(plan.slTrackedClicks)} tracked clicks/mo`,
checked: true,
icon: <Icons.mousePointerClick className="size-4" />,
},
{
text: `${nFormatter(plan.SL_NewLinks)} new links/mo`,
text: `${nFormatter(plan.slNewLinks)} new links/mo`,
checked: true,
icon: <Icons.link className="size-4" />,
},
{
text: `${plan.SL_AnalyticsRetention}-day analytics retention`,
text: `${plan.slAnalyticsRetention}-day analytics retention`,
checked: true,
icon: <Icons.calendar className="size-4" />,
},
{
text: `Customize short link QR code`,
checked: plan.SL_CustomQrCodeLogo,
checked: plan.slCustomQrCodeLogo,
icon: <Icons.qrcode className="size-4" />,
},
{
text: `${nFormatter(plan.EM_EmailAddresses)} email addresses/mo`,
text: `${nFormatter(plan.emEmailAddresses)} email addresses/mo`,
checked: true,
icon: <Icons.mail className="size-4" />,
},
{
text: `${nFormatter(plan.EM_SendEmails)} send emails/mo`,
text: `${nFormatter(plan.emSendEmails)} send emails/mo`,
checked: true,
icon: <Icons.send className="size-4" />,
},
{
text: `${plan.SL_Domains === 1 ? "One" : plan.SL_Domains} domain${plan.SL_Domains > 1 ? "s" : ""}`,
text: `${plan.slDomains === 1 ? "One" : plan.slDomains} domain${plan.slDomains > 1 ? "s" : ""}`,
checked: true,
icon: <Icons.globe className="size-4" />,
},
{
text: "Advanced analytics",
checked: plan.SL_AdvancedAnalytics,
checked: plan.slAdvancedAnalytics,
icon: <Icons.lineChart className="size-4" />,
},
{
text: `${plan.APP_Support.charAt(0).toUpperCase() + plan.APP_Support.slice(1)} support`,
text: `${plan.appSupport.charAt(0).toUpperCase() + plan.appSupport.slice(1)} support`,
checked: true,
icon: <Icons.help className="size-4" />,
},
{
text: "Open API Access",
checked: plan.APP_ApiAccess,
checked: plan.appApiAccess,
icon: <Icons.unplug className="size-4" />,
},
];
export const PricingSection = () => {
const t = useTranslations("Landing");
const { data: plan } = useSWR<{
total: number;
list: PlanQuotaFormData[];
}>(`/api/plan?all=1`, fetcher);
return (
<section
id="pricing"
@@ -85,45 +91,36 @@ export const PricingSection = () => {
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<PriceCard
tier={t("freeTier")}
price={t("freePrice")}
bestFor={t("freeBestFor")}
CTA={
<Link href={"/dashboard"}>
<Button className="w-full" variant={"default"}>
{t("getStartedFree")}
</Button>
</Link>
}
benefits={getBenefits(TeamPlanQuota.free)}
/>
{/* <PriceCard
tier={t("premiumTier")}
price={t("premiumPrice")}
bestFor={t("premiumBestFor")}
CTA={
<Link href={"/pricing"}>
<Button className="w-full bg-zinc-800 text-zinc-50 hover:bg-zinc-700 hover:text-zinc-50 dark:bg-zinc-50 dark:text-zinc-950 dark:hover:bg-zinc-200 dark:hover:text-zinc-900">
{t("getFreeTrial")}
</Button>
</Link>
}
benefits={getBenefits(TeamPlanQuota.premium)}
/> */}
<PriceCard
tier={t("enterpriseTier")}
price={t("enterprisePrice")}
bestFor={t("enterpriseBestFor")}
CTA={
<Link href={"mailto:support@wr.do"}>
<Button className="w-full" variant="outline">
{t("contactUs")}
</Button>
</Link>
}
benefits={getBenefits(TeamPlanQuota.business)}
/>
{plan && (
<PriceCard
tier={t("freeTier")}
price={t("freePrice")}
bestFor={t("freeBestFor")}
CTA={
<Link href={"/dashboard"}>
<Button className="w-full" variant={"default"}>
{t("getStartedFree")}
</Button>
</Link>
}
benefits={getBenefits(plan.list[0])}
/>
)}
{plan && (
<PriceCard
tier={t("enterpriseTier")}
price={t("enterprisePrice")}
bestFor={t("enterpriseBestFor")}
CTA={
<Link href={"mailto:support@wr.do"}>
<Button className="w-full" variant="outline">
{t("contactUs")}
</Button>
</Link>
}
benefits={getBenefits(plan.list[plan.list.length - 1])}
/>
)}
</div>
</div>
</section>
+6 -6
View File
@@ -54,12 +54,6 @@ export const sidebarLinks: SidebarNavItem[] = [
title: "Admin Panel",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/domains",
icon: "globeLock",
title: "Domains",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/users",
icon: "users",
@@ -78,6 +72,12 @@ export const sidebarLinks: SidebarNavItem[] = [
title: "Records",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/system",
icon: "settings",
title: "System Settings",
authorizeOnly: UserRole.ADMIN,
},
],
},
{
+159
View File
@@ -0,0 +1,159 @@
import { prisma } from "../db";
export interface PlanQuota {
name: string;
slTrackedClicks: number;
slNewLinks: number;
slAnalyticsRetention: number;
slDomains: number;
slAdvancedAnalytics: boolean;
slCustomQrCodeLogo: boolean;
rcNewRecords: number;
emEmailAddresses: number;
emDomains: number;
emSendEmails: number;
appSupport: string;
appApiAccess: boolean;
isActive: boolean;
}
export interface PlanQuotaFormData extends PlanQuota {
id?: string;
createdAt?: Date;
updatedAt?: Date;
}
// 获取计划配额
export async function getPlanQuota(planName: string) {
const plan = await prisma.plan.findUnique({
where: { name: planName },
});
if (!plan) {
return {
name: planName,
slTrackedClicks: 0,
slNewLinks: 0,
slAnalyticsRetention: 0,
slDomains: 0,
slAdvancedAnalytics: false,
slCustomQrCodeLogo: false,
rcNewRecords: 0,
emEmailAddresses: 0,
emDomains: 0,
emSendEmails: 0,
appSupport: "BASIC",
appApiAccess: true,
isActive: true,
};
}
return {
name: planName,
slTrackedClicks: plan.slTrackedClicks,
slNewLinks: plan.slNewLinks,
slAnalyticsRetention: plan.slAnalyticsRetention,
slDomains: plan.slDomains,
slAdvancedAnalytics: plan.slAdvancedAnalytics,
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
rcNewRecords: plan.rcNewRecords,
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
appSupport: plan.appSupport.toLowerCase(),
appApiAccess: plan.appApiAccess,
isActive: plan.isActive,
};
}
// 获取所有计划
export async function getAllPlans(page = 1, size = 10, target: string = "") {
let option: any;
if (target) {
option = {
name: {
contains: target,
},
};
}
const [total, list] = await prisma.$transaction([
prisma.plan.count({
where: option,
}),
prisma.plan.findMany({
where: option,
skip: (page - 1) * size,
take: size,
orderBy: {
slTrackedClicks: "asc",
},
}),
]);
return { list, total };
}
// 获取计划所有名称
export async function getPlanNames() {
const data = await prisma.plan.findMany({
where: { isActive: true },
select: { name: true },
orderBy: { name: "asc" },
});
return data.map((item) => item.name);
}
// 更新计划配额
export async function updatePlanQuota(plan: PlanQuotaFormData) {
return await prisma.plan.update({
where: { id: plan.id },
data: {
// name: plan.name,
slTrackedClicks: plan.slTrackedClicks,
slNewLinks: plan.slNewLinks,
slAnalyticsRetention: plan.slAnalyticsRetention,
slDomains: plan.slDomains,
slAdvancedAnalytics: plan.slAdvancedAnalytics,
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
rcNewRecords: plan.rcNewRecords,
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
appSupport: plan.appSupport.toUpperCase() as any,
appApiAccess: plan.appApiAccess,
isActive: plan.isActive,
updatedAt: new Date(),
},
});
}
// 创建新计划
export async function createPlan(plan: PlanQuota) {
return await prisma.plan.create({
data: {
name: plan.name,
slTrackedClicks: plan.slTrackedClicks,
slNewLinks: plan.slNewLinks,
slAnalyticsRetention: plan.slAnalyticsRetention,
slDomains: plan.slDomains,
slAdvancedAnalytics: plan.slAdvancedAnalytics,
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
rcNewRecords: plan.rcNewRecords,
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
appSupport: plan.appSupport.toUpperCase() as any,
appApiAccess: plan.appApiAccess,
isActive: true,
},
});
}
// 删除计划(软删除)
export async function deletePlan(id: string) {
return await prisma.plan.delete({
where: { id },
});
}
+301
View File
@@ -0,0 +1,301 @@
import { prisma } from "../db";
export type ConfigType = "BOOLEAN" | "STRING" | "NUMBER" | "OBJECT";
export interface SystemConfigData {
key: string;
value: any; // 解析后的实际值
type: ConfigType;
description?: string;
version?: string;
}
export interface CreateSystemConfigData {
key: string;
value: any;
type: ConfigType;
description?: string;
version?: string;
}
export interface UpdateSystemConfigData {
value?: any;
type?: ConfigType;
description?: string;
version?: string;
}
// 解析配置值
function parseConfigValue(value: string, type: ConfigType): any {
switch (type) {
case "BOOLEAN":
return value === "true";
case "NUMBER":
return Number(value);
case "OBJECT":
try {
return JSON.parse(value);
} catch {
return {};
}
case "STRING":
default:
// 如果是 JSON 字符串格式的字符串,需要解析
if (value.startsWith('"') && value.endsWith('"')) {
return JSON.parse(value);
}
return value;
}
}
// 序列化配置值
function serializeConfigValue(value: any, type: ConfigType): string {
switch (type) {
case "BOOLEAN":
return String(Boolean(value));
case "NUMBER":
return String(Number(value));
case "OBJECT":
return JSON.stringify(value);
case "STRING":
default:
return JSON.stringify(value);
}
}
// 获取单个配置
export async function getSystemConfig(
key: string,
): Promise<SystemConfigData | null> {
const config = await prisma.systemConfig.findUnique({
where: { key },
});
if (!config) {
return null;
}
return {
key: config.key,
value: parseConfigValue(config.value, config.type as ConfigType),
type: config.type as ConfigType,
description: config.description || undefined,
version: config.version,
};
}
// 获取配置值(简化版本,直接返回解析后的值)
export async function getConfigValue<T = any>(key: string): Promise<T | null> {
const config = await getSystemConfig(key);
return config ? config.value : null;
}
// 获取所有配置
export async function getAllSystemConfigs(): Promise<SystemConfigData[]> {
const configs = await prisma.systemConfig.findMany({
orderBy: { key: "asc" },
});
return configs.map((config) => ({
key: config.key,
value: parseConfigValue(config.value, config.type as ConfigType),
type: config.type as ConfigType,
description: config.description || undefined,
version: config.version,
}));
}
// 获取配置的原始数据(包含元数据)
export async function getSystemConfigRaw(key: string) {
return await prisma.systemConfig.findUnique({
where: { key },
});
}
// 创建配置
export async function createSystemConfig(data: CreateSystemConfigData) {
const serializedValue = serializeConfigValue(data.value, data.type);
return await prisma.systemConfig.create({
data: {
key: data.key,
value: serializedValue,
type: data.type,
description: data.description,
version: data.version || "0.5.0",
},
});
}
// 更新配置
export async function updateSystemConfig(
key: string,
data: UpdateSystemConfigData,
) {
const updateData: any = {};
if (data.value !== undefined && data.type) {
updateData.value = serializeConfigValue(data.value, data.type);
}
if (data.type !== undefined) {
updateData.type = data.type;
}
if (data.description !== undefined) {
updateData.description = data.description;
}
if (data.version !== undefined) {
updateData.version = data.version;
}
return await prisma.systemConfig.update({
where: { key },
data: updateData,
});
}
// 设置配置值(upsert操作)
export async function setSystemConfig(
key: string,
value: any,
type: ConfigType,
description?: string,
) {
const serializedValue = serializeConfigValue(value, type);
return await prisma.systemConfig.upsert({
where: { key },
update: {
value: serializedValue,
type,
description,
},
create: {
key,
value: serializedValue,
type,
description,
},
});
}
// 删除配置
export async function deleteSystemConfig(key: string) {
return await prisma.systemConfig.delete({
where: { key },
});
}
// 批量获取配置
export async function getMultipleConfigs(
keys: string[],
): Promise<Record<string, any>> {
const configs = await prisma.systemConfig.findMany({
where: {
key: {
in: keys,
},
},
});
const result: Record<string, any> = {};
configs.forEach((config) => {
result[config.key] = parseConfigValue(
config.value,
config.type as ConfigType,
);
});
return result;
}
// 按类型获取配置
export async function getConfigsByType(
type: ConfigType,
): Promise<SystemConfigData[]> {
const configs = await prisma.systemConfig.findMany({
where: { type },
orderBy: { key: "asc" },
});
return configs.map((config) => ({
key: config.key,
value: parseConfigValue(config.value, config.type as ConfigType),
type: config.type as ConfigType,
description: config.description || undefined,
version: config.version,
}));
}
// 搜索配置
export async function searchConfigs(
searchTerm: string,
): Promise<SystemConfigData[]> {
const configs = await prisma.systemConfig.findMany({
where: {
OR: [
{ key: { contains: searchTerm, mode: "insensitive" } },
{ description: { contains: searchTerm, mode: "insensitive" } },
],
},
orderBy: { key: "asc" },
});
return configs.map((config) => ({
key: config.key,
value: parseConfigValue(config.value, config.type as ConfigType),
type: config.type as ConfigType,
description: config.description || undefined,
version: config.version,
}));
}
// 配置是否存在
export async function configExists(key: string): Promise<boolean> {
const count = await prisma.systemConfig.count({
where: { key },
});
return count > 0;
}
// 获取配置统计
export async function getConfigStats() {
const total = await prisma.systemConfig.count();
const byType = await prisma.systemConfig.groupBy({
by: ["type"],
_count: {
type: true,
},
});
return {
total,
byType: byType.reduce(
(acc, item) => {
acc[item.type] = item._count.type;
return acc;
},
{} as Record<string, number>,
),
};
}
/** Usage Example */
/**
// 取单个配置值
const appName = await getConfigValue<string>('app_name');
// 设置配置
await setSystemConfig('maintenance_mode', true, 'BOOLEAN', 'Enable maintenance mode');
// 批量获取配置
const configs = await getMultipleConfigs(['app_name', 'maintenance_mode', 'api_rate_limit']);
// 搜索配置
const emailConfigs = await searchConfigs('email');
// 获取统计信息
const stats = await getConfigStats();
*/
+19
View File
@@ -0,0 +1,19 @@
import * as z from "zod";
export const createPlanSchema = z.object({
id: z.string().optional(),
name: z.string().min(3).max(32),
slTrackedClicks: z.number().optional().default(0),
slNewLinks: z.number().optional().default(0),
slAnalyticsRetention: z.number().optional().default(0),
slDomains: z.number().optional().default(0),
slAdvancedAnalytics: z.boolean().optional().default(true),
slCustomQrCodeLogo: z.boolean().optional().default(false),
rcNewRecords: z.number().optional().default(0),
emEmailAddresses: z.number().optional().default(0),
emDomains: z.number().optional().default(0),
emSendEmails: z.number().optional().default(0),
appSupport: z.string().optional().default("BASIC"),
appApiAccess: z.boolean().optional().default(true),
isActive: z.boolean().optional().default(true),
});
+20 -3
View File
@@ -139,7 +139,22 @@
"send email service": "send email service",
"How to get resend api key?": "How to get resend api key?",
"Analytics": "Analytics",
"Edit URL": "Edit URL"
"Edit URL": "Edit URL",
"Plan Name": "Plan",
"Quota Settings": "Quota Settings",
"Short Limit": "Short Limit",
"Record Limit": "Record Limit",
"Email Limit": "Email Limit",
"Send Limit": "Send Limit",
"Domain Limit": "Domain Limit",
"Add Plan": "Add Plan",
"No Plans": "No Plans",
"Create Plan": "Create Plan",
"Edit Plan": "Edit Plan",
"Monthly limit of short links created": "Monthly limit of short links created",
"Monthly limit of subdomains created": "Monthly limit of subdomains created",
"Monthly limit of emails sent": "Monthly limit of emails sent",
"Monthly limit of email addresses created": "Monthly limit of email addresses created"
},
"Components": {
"Dashboard": "Dashboard",
@@ -254,7 +269,8 @@
"Last 1 Year": "Last 1 Year",
"All the time": "All the time",
"No Visits": "No Visits",
"You don't have any visits yet in": "You don't have any visits yet in"
"You don't have any visits yet in": "You don't have any visits yet in",
"System Settings": "System Settings"
},
"Landing": {
"settings": "Settings",
@@ -339,7 +355,8 @@
"System": "System",
"Admin": "Admin",
"Sign in": "Sign in",
"Log out": "Log out"
"Log out": "Log out",
"System Settings": "System Settings"
},
"Email": {
"Search emails": "Search emails",
+21 -4
View File
@@ -110,7 +110,7 @@
"Time To Live": "生效时间",
"Proxy": "代理记录",
"Proxy status": "DNS 响应被 Cloudflare Anycast IP 替代",
"Total Domains": "总计",
"Total Domains": "域名管理",
"Add Domain": "添加域名",
"Domain Name": "域名",
"Shorten Service": "短链服务",
@@ -139,7 +139,22 @@
"send email service": "用于发送邮件服务",
"How to get resend api key?": "如何获取 Resend API 密钥?",
"Analytics": "访客分析",
"Edit URL": "编辑短链"
"Edit URL": "编辑短链",
"Plan Name": "计划名称",
"Quota Settings": "配额设置",
"Short Limit": "短链限制",
"Record Limit": "子域名限制",
"Email Limit": "邮箱限制",
"Send Limit": "发件限制",
"Domain Limit": "域名限制",
"Add Plan": "添加计划",
"No Plans": "暂无计划",
"Create Plan": "创建计划",
"Edit Plan": "编辑计划",
"Monthly limit of short links created": "每月创建短链数量限制",
"Monthly limit of subdomains created": "每月创建子域名数量限制",
"Monthly limit of emails sent": "每月发送邮件数量限制",
"Monthly limit of email addresses created": "每月接收邮件数量限制"
},
"Components": {
"Dashboard": "用户面板",
@@ -254,7 +269,8 @@
"Last 1 Year": "最近 1 年",
"All the time": "所有时间",
"No Visits": "无访问记录",
"You don't have any visits yet in": "您还没有任何访问记录于"
"You don't have any visits yet in": "您还没有任何访问记录于",
"System Settings": "系统设置"
},
"Landing": {
"settings": "设置",
@@ -339,7 +355,8 @@
"System": "跟随系统",
"Admin": "管理面板",
"Sign in": "登录",
"Log out": "退出登录"
"Log out": "退出登录",
"System Settings": "系统设置"
},
"Email": {
"Search emails": "搜索邮箱...",
@@ -0,0 +1,136 @@
CREATE TABLE "system_configs"
(
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"type" TEXT NOT NULL,
"description" TEXT,
"version" TEXT NOT NULL DEFAULT '0.5.0',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 1. 是否开启注册配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
'enable_user_registration',
'true',
'BOOLEAN',
'是否允许新用户注册'
);
-- 2. 系统通知配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
'system_notification',
'',
'STRING',
'系统全局通知消息'
);
-- 创建计划表
CREATE TABLE "plans"
(
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"slTrackedClicks" INTEGER NOT NULL,
"slNewLinks" INTEGER NOT NULL,
"slAnalyticsRetention" INTEGER NOT NULL,
"slDomains" INTEGER NOT NULL,
"slAdvancedAnalytics" BOOLEAN NOT NULL,
"slCustomQrCodeLogo" BOOLEAN NOT NULL,
"rcNewRecords" INTEGER NOT NULL,
"emEmailAddresses" INTEGER NOT NULL,
"emDomains" INTEGER NOT NULL,
"emSendEmails" INTEGER NOT NULL,
"appSupport" TEXT NOT NULL,
"appApiAccess" BOOLEAN NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 创建唯一索引
CREATE UNIQUE INDEX "plans_name_key" ON "plans"("name");
-- 插入初始数据
INSERT INTO "plans"
(
"id",
"name",
"slTrackedClicks",
"slNewLinks",
"slAnalyticsRetention",
"slDomains",
"slAdvancedAnalytics",
"slCustomQrCodeLogo",
"rcNewRecords",
"emEmailAddresses",
"emDomains",
"emSendEmails",
"appSupport",
"appApiAccess"
)
VALUES
(
'45fc1184-f7e7-4768-b28d-3f6e73d5a766',
'free',
100000,
1000,
180,
2,
true,
false,
3,
1000,
2,
200,
'BASIC',
true
),
(
'45fc1184-f7e7-4768-b28f-3e6e73d5a769',
'premium',
1000000,
5000,
365,
2,
true,
true,
2,
5000,
2,
1000,
'LIVE',
true
),
(
'45fc1184-f7e7-4768-b28d-3f6e73d5a678',
'business',
10000000,
10000,
1000,
2,
true,
true,
10,
10000,
2,
2000,
'LIVE',
true
);
+43 -11
View File
@@ -274,15 +274,47 @@ model Domain {
@@map("domains")
}
// model SystemConfig {
// id String @id @default(uuid())
// key String @unique
// value String // JSON String
// type String // BOOLEAN, STRING, NUMBER, OBJECT
// description String?
// version String @default("0.5.0")
// createdAt DateTime @default(now())
// updatedAt DateTime @default(now())
model SystemConfig {
id String @id @default(uuid())
key String @unique
value String // JSON String
type String // BOOLEAN, STRING, NUMBER, OBJECT
description String?
version String @default("0.5.0")
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
// @@map("system_configs")
// }
@@map("system_configs")
}
model Plan {
id String @id @default(uuid())
name String @unique // "free", "premium", "business"
// Short Link (SL) related quotas
slTrackedClicks Int
slNewLinks Int
slAnalyticsRetention Int // days
slDomains Int
slAdvancedAnalytics Boolean
slCustomQrCodeLogo Boolean
// Record (RC) related quotas
rcNewRecords Int
// Email (EM) related quotas
emEmailAddresses Int
emDomains Int
emSendEmails Int
// App (APP) related settings
appSupport String // "BASIC", "LIVE"
appApiAccess Boolean
// Metadata
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
@@map("plans")
}
+1 -1
View File
File diff suppressed because one or more lines are too long