feats: configurable plan quota
This commit is contained in:
@@ -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",
|
||||
|
||||
+3
-1
@@ -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>
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
*/
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user