mirror of
https://github.com/sqlchat/sqlchat.git
synced 2026-03-13 07:59:44 +08:00
feat: implement subscription (#92)
This commit is contained in:
15
auth.d.ts
vendored
Normal file
15
auth.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Subscription } from "@/types";
|
||||
import "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface User {
|
||||
id: string;
|
||||
usage: number;
|
||||
stripeId: number;
|
||||
subscription: Subscription;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
user: User;
|
||||
}
|
||||
}
|
||||
38
prisma/migrations/20230517161946_subscription/migration.sql
Normal file
38
prisma/migrations/20230517161946_subscription/migration.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `plan` to the `subscription` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SubscriptionPlan" AS ENUM ('PRO');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscription" ADD COLUMN "amount" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "currency" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "description" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "email" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "plan" "SubscriptionPlan" NOT NULL,
|
||||
ADD COLUMN "receipt" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "usage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"end_user" TEXT NOT NULL DEFAULT '',
|
||||
"count" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "usage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "usage_created_at_idx" ON "usage"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "usage_end_user_idx" ON "usage"("end_user");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscription_user_id_idx" ON "subscription"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscription_email_idx" ON "subscription"("email");
|
||||
@@ -43,18 +43,38 @@ model Message {
|
||||
@@map("message")
|
||||
}
|
||||
|
||||
model Usage {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
endUser String @default("") @map("end_user")
|
||||
count Int @default(0)
|
||||
|
||||
@@index([createdAt], map: "usage_created_at_idx")
|
||||
@@index([endUser], map: "usage_end_user_idx")
|
||||
@@map("usage")
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @default("") @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
startAt DateTime @default(now()) @map("start_at")
|
||||
expireAt DateTime @default(now()) @map("expire_at")
|
||||
paymentId String @default("") @map("payment_id")
|
||||
customerId String @default("") @map("customer_id")
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @default("") @map("user_id")
|
||||
// Denormalize to avoid join with the user table
|
||||
email String @default("") @map("email")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
startAt DateTime @default(now()) @map("start_at")
|
||||
expireAt DateTime @default(now()) @map("expire_at")
|
||||
paymentId String @default("") @map("payment_id")
|
||||
customerId String @default("") @map("customer_id")
|
||||
plan SubscriptionPlan
|
||||
description String @default("")
|
||||
amount Int @default(0)
|
||||
currency String @default("")
|
||||
receipt String @default("")
|
||||
|
||||
@@unique([paymentId])
|
||||
@@index([userId], map: "subscription_user_id_idx")
|
||||
@@index([email], map: "subscription_email_idx")
|
||||
@@map("subscription")
|
||||
}
|
||||
|
||||
@@ -63,6 +83,10 @@ enum SubscriptionStatus {
|
||||
CANCELED
|
||||
}
|
||||
|
||||
enum SubscriptionPlan {
|
||||
PRO
|
||||
}
|
||||
|
||||
// NextAuth Prisma Schema Begin
|
||||
// Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow
|
||||
// requires this. https://authjs.dev/reference/adapter/prisma
|
||||
|
||||
7
process.d.ts
vendored
7
process.d.ts
vendored
@@ -9,9 +9,6 @@ declare namespace NodeJS {
|
||||
OPENAI_API_KEY: string;
|
||||
// Optional. OpenAI API endpoint. Defaults to https://api.openai.com.
|
||||
OPENAI_API_ENDPOINT: string;
|
||||
// Optional. API key to protect the backend API endpoint.
|
||||
// This needs to be exposed to the frontend and must be prefixed with NEXT_PUBLIC_.
|
||||
NEXT_PUBLIC_API_KEY: string;
|
||||
// Optional. NextAuth.js URL. Defaults to the current domain.
|
||||
NEXTAUTH_URL: string;
|
||||
// Optional. NextAuth.js secret. Defaults to a randomly generated string.
|
||||
@@ -33,7 +30,7 @@ declare namespace NodeJS {
|
||||
STRIPE_API_KEY: string;
|
||||
// Optional. Stripe webhook secret.
|
||||
STRIPE_WEBHOOK_SECRET: string;
|
||||
// Optional. Stripe annual license price id.
|
||||
STRIPE_ANNUAL_LICENSE_PRICE_ID: string;
|
||||
// Optional. Stripe price id for Pro plan 1 year subscription.
|
||||
STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SubscriptionHistoryTable from "./SubscriptionHistoryTable";
|
||||
import { getDateString } from "@/utils";
|
||||
|
||||
const AccountView = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: session, status } = useSession();
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -17,21 +19,51 @@ const AccountView = () => {
|
||||
</button>
|
||||
)}
|
||||
{session?.user && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{session.user.image && (
|
||||
<img
|
||||
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 cursor-pointer"
|
||||
src={session.user.image}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<span>{session.user.email ?? session.user.name}</span>
|
||||
<Link
|
||||
href="/api/auth/signout"
|
||||
className="whitespace-nowrap rounded bg-indigo-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
>
|
||||
{t("common.sign-out")}
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex-col space-y-4 sm:space-y-0 sm:flex sm:flex-row justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{session.user.image && (
|
||||
<img
|
||||
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 cursor-pointer"
|
||||
src={session.user.image}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<span>{session.user.email ?? session.user.name}</span>
|
||||
<Link
|
||||
href="/api/auth/signout"
|
||||
className="whitespace-nowrap rounded bg-indigo-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
>
|
||||
{t("common.sign-out")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex text-base font-semibold tracking-tight items-center">
|
||||
<span
|
||||
className={`${
|
||||
session?.user.subscription.plan == "PRO"
|
||||
? "ring-green-600/2 bg-green-50 text-green-700"
|
||||
: "ring-gray-600/2 bg-gray-50 text-gray-700"
|
||||
} rounded-full px-3 py-1 ring-1 ring-inset`}
|
||||
>
|
||||
{t(
|
||||
`setting.plan.${session.user.subscription.plan.toLowerCase()}`
|
||||
)}
|
||||
</span>
|
||||
<div className="ml-4">
|
||||
{t("setting.plan.n-question-per-month", {
|
||||
count: session.user.subscription.quota,
|
||||
})}
|
||||
</div>
|
||||
{session.user.subscription.plan === "PRO" && (
|
||||
<div className="ml-8">
|
||||
{getDateString(session.user.subscription.startAt)}
|
||||
{` - `}
|
||||
{getDateString(session.user.subscription.expireAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SubscriptionHistoryTable />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useConversationStore,
|
||||
useLayoutStore,
|
||||
ResponsiveWidth,
|
||||
useSettingStore,
|
||||
} from "@/store";
|
||||
import { Table } from "@/types";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
@@ -14,14 +15,16 @@ import Icon from "./Icon";
|
||||
import DarkModeSwitch from "./DarkModeSwitch";
|
||||
import ConversationList from "./Sidebar/ConversationList";
|
||||
import ConnectionList from "./Sidebar/ConnectionList";
|
||||
import QuotaWidget from "./QuotaWidget";
|
||||
import { HasFeature } from "../utils";
|
||||
import QuotaView from "./QuotaView";
|
||||
import { hasFeature } from "../utils";
|
||||
import MultipleSelect from "./kit/MultipleSelect";
|
||||
import SettingAvatarIcon from "./SettingAvatarIcon";
|
||||
|
||||
interface State {}
|
||||
|
||||
const ConnectionSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const settingStore = useSettingStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const conversationStore = useConversationStore();
|
||||
@@ -142,8 +145,9 @@ const ConnectionSidebar = () => {
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<ConnectionList />
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-end items-center">
|
||||
<div className="w-full flex flex-col space-y-2 justify-end items-center">
|
||||
<DarkModeSwitch />
|
||||
<SettingAvatarIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative p-4 pb-0 w-64 h-full overflow-y-auto flex flex-col justify-start items-start bg-gray-100 dark:bg-zinc-700">
|
||||
@@ -224,19 +228,12 @@ const ConnectionSidebar = () => {
|
||||
<ConversationList />
|
||||
</div>
|
||||
<div className="sticky bottom-0 w-full flex flex-col justify-center bg-gray-100 dark:bg-zinc-700 backdrop-blur bg-opacity-60 pb-4 py-2">
|
||||
{HasFeature("quota") && (
|
||||
<div className="mb-4">
|
||||
<QuotaWidget />
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href="https://discord.gg/z6kakemDjm"
|
||||
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium flex flex-row justify-center items-center mb-2 hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon.BsDiscord className="w-4 h-auto mr-1" />
|
||||
{t("social.join-discord-channel")}
|
||||
</a>
|
||||
{!settingStore.setting.openAIApiConfig?.key &&
|
||||
hasFeature("quota") && (
|
||||
<div className="mb-4">
|
||||
<QuotaView />
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
className="dark:hidden"
|
||||
href="https://www.producthunt.com/posts/sql-chat-2?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-sql-chat-2"
|
||||
|
||||
@@ -5,7 +5,6 @@ import useDarkMode from "@/hooks/useDarkmode";
|
||||
|
||||
import Icon from "../Icon";
|
||||
import GitHubStarBadge from "../GitHubStarBadge";
|
||||
import SettingAvatarIcon from "../SettingAvatarIcon";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -60,7 +59,6 @@ const Header = (props: Props) => {
|
||||
alt=""
|
||||
/>
|
||||
</a>
|
||||
<SettingAvatarIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import Icon from "../Icon";
|
||||
import { CodeBlock } from "../CodeBlock";
|
||||
import EngineIcon from "../EngineIcon";
|
||||
import ThreeDotsLoader from "./ThreeDotsLoader";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
@@ -24,6 +25,7 @@ interface Props {
|
||||
|
||||
const MessageView = (props: Props) => {
|
||||
const message = props.message;
|
||||
const { data: session } = useSession();
|
||||
const { t } = useTranslation();
|
||||
const settingStore = useSettingStore();
|
||||
const userStore = useUserStore();
|
||||
@@ -84,8 +86,24 @@ const MessageView = (props: Props) => {
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 p-1 border dark:border-zinc-700 rounded-full flex justify-center items-center ml-2 shrink-0">
|
||||
<Icon.AiOutlineUser className="w-6 h-6" />
|
||||
<div className="w-10 h-10 border dark:border-zinc-700 rounded-full flex justify-center items-center ml-2 shrink-0">
|
||||
{session?.user ? (
|
||||
session.user.image ? (
|
||||
<img
|
||||
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700"
|
||||
src={session.user.image}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-indigo-100 px-3 py-1 rounded-full text-indigo-600 hover:bg-indigo-200 uppercase">
|
||||
{session.user.name
|
||||
? session.user.name.charAt(0)
|
||||
: session.user.email?.charAt(0)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Icon.AiOutlineUser className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,6 @@ import axios from "axios";
|
||||
import { head, last } from "lodash-es";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { API_KEY } from "@/env";
|
||||
import {
|
||||
getAssistantById,
|
||||
getPromptGeneratorOfAssistant,
|
||||
@@ -22,12 +21,15 @@ import ClearConversationButton from "../ClearConversationButton";
|
||||
import MessageTextarea from "./MessageTextarea";
|
||||
import DataStorageBanner from "../DataStorageBanner";
|
||||
import QuotaOverflowBanner from "../QuotaOverflowBanner";
|
||||
import { useSession } from "next-auth/react";
|
||||
import getEventEmitter from "@/utils/event-emitter";
|
||||
|
||||
// The maximum number of tokens that can be sent to the OpenAI API.
|
||||
// reference: https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens
|
||||
const MAX_TOKENS = 4000;
|
||||
|
||||
const ConversationView = () => {
|
||||
const { data: session } = useSession();
|
||||
const settingStore = useSettingStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -182,10 +184,12 @@ const ConversationView = () => {
|
||||
|
||||
const tableList: string[] = [];
|
||||
if (currentConversation.selectedTablesName) {
|
||||
currentConversation.selectedTablesName.forEach((tableName) => {
|
||||
const table = tables.find((table) => table.name === tableName);
|
||||
tableList.push(table!.structure);
|
||||
});
|
||||
currentConversation.selectedTablesName.forEach(
|
||||
(tableName: string) => {
|
||||
const table = tables.find((table) => table.name === tableName);
|
||||
tableList.push(table!.structure);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
for (const table of tables) {
|
||||
tableList.push(table!.structure);
|
||||
@@ -235,14 +239,21 @@ const ConversationView = () => {
|
||||
});
|
||||
|
||||
const requestHeaders: any = {};
|
||||
if (API_KEY) {
|
||||
requestHeaders["Authorization"] = `Bearer ${API_KEY}`;
|
||||
if (session?.user.id) {
|
||||
requestHeaders["Authorization"] = `Bearer ${session?.user.id}`;
|
||||
}
|
||||
if (settingStore.setting.openAIApiConfig?.key) {
|
||||
requestHeaders["x-openai-key"] =
|
||||
settingStore.setting.openAIApiConfig?.key;
|
||||
}
|
||||
if (settingStore.setting.openAIApiConfig?.endpoint) {
|
||||
requestHeaders["x-openai-endpoint"] =
|
||||
settingStore.setting.openAIApiConfig?.endpoint;
|
||||
}
|
||||
const rawRes = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messages: formatedMessageList,
|
||||
openAIApiConfig: settingStore.setting.openAIApiConfig,
|
||||
}),
|
||||
headers: requestHeaders,
|
||||
});
|
||||
@@ -307,6 +318,9 @@ const ConversationView = () => {
|
||||
status: "DONE",
|
||||
});
|
||||
|
||||
// Emit usage update event so quota widget can update.
|
||||
getEventEmitter().emit("usage.update");
|
||||
|
||||
// Collect system prompt
|
||||
// We only collect the db prompt for the system prompt. We do not collect the intermediate
|
||||
// exchange to save space since those can be derived from the previous record.
|
||||
@@ -324,10 +338,18 @@ const ConversationView = () => {
|
||||
usageMessageList.push(assistantMessage);
|
||||
|
||||
axios
|
||||
.post<string[]>("/api/usage", {
|
||||
conversation: currentConversation,
|
||||
messages: usageMessageList,
|
||||
})
|
||||
.post<string[]>(
|
||||
"/api/collect",
|
||||
{
|
||||
conversation: currentConversation,
|
||||
messages: usageMessageList,
|
||||
},
|
||||
{
|
||||
headers: session?.user.id
|
||||
? { Authorization: `Bearer ${session?.user.id}` }
|
||||
: undefined,
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
// do nth
|
||||
});
|
||||
@@ -341,7 +363,7 @@ const ConversationView = () => {
|
||||
} relative w-full h-full max-h-full flex flex-col justify-start items-start overflow-y-auto bg-white dark:bg-zinc-800`}
|
||||
>
|
||||
<div className="sticky top-0 z-1 bg-white dark:bg-zinc-800 w-full flex flex-col justify-start items-start">
|
||||
<QuotaOverflowBanner />
|
||||
{!settingStore.setting.openAIApiConfig?.key && <QuotaOverflowBanner />}
|
||||
<DataStorageBanner />
|
||||
<Header className={showHeaderShadow ? "shadow" : ""} />
|
||||
</div>
|
||||
|
||||
17
src/components/DebugView.tsx
Normal file
17
src/components/DebugView.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const PricingView = () => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-800">
|
||||
<span>Debug View</span>
|
||||
<div className="pt-2 text-sm">Session Info</div>
|
||||
<div className="whitespace-pre-wrap mx-auto max-w-7xl lg:flex lg:items-center lg:justify-between">
|
||||
{JSON.stringify(session, null, "\t")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingView;
|
||||
@@ -4,6 +4,7 @@ import { useDebounce } from "react-use";
|
||||
import { useSettingStore } from "@/store";
|
||||
import { OpenAIApiConfig } from "@/types";
|
||||
import TextField from "./kit/TextField";
|
||||
import { set } from "lodash-es";
|
||||
|
||||
const OpenAIApiConfigView = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -11,6 +12,17 @@ const OpenAIApiConfigView = () => {
|
||||
const [openAIApiConfig, setOpenAIApiConfig] = useState(
|
||||
settingStore.setting.openAIApiConfig
|
||||
);
|
||||
const [maskKey, setMaskKey] = useState(true);
|
||||
|
||||
const maskedKey = (str: string) => {
|
||||
if (str.length < 7) {
|
||||
return str;
|
||||
}
|
||||
const firstThree = str.slice(0, 3);
|
||||
const lastFour = str.slice(-4);
|
||||
const middle = ".".repeat(str.length - 7);
|
||||
return `${firstThree}${middle}${lastFour}`;
|
||||
};
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
@@ -25,6 +37,7 @@ const OpenAIApiConfigView = () => {
|
||||
...openAIApiConfig,
|
||||
...config,
|
||||
});
|
||||
setMaskKey(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -34,7 +47,9 @@ const OpenAIApiConfigView = () => {
|
||||
<label className="mb-1">OpenAI API Key</label>
|
||||
<TextField
|
||||
placeholder="OpenAI API Key"
|
||||
value={openAIApiConfig.key}
|
||||
value={
|
||||
maskKey ? maskedKey(openAIApiConfig.key) : openAIApiConfig.key
|
||||
}
|
||||
onChange={(value) => handleSetOpenAIApiConfig({ key: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import getStripe from "../utils/get-stripejs";
|
||||
import { fetchPostJSON } from "../utils/api-helpers";
|
||||
|
||||
const includedFeatures = ["Private forum access", "Member resources", "Entry to annual conference", "Official member t-shirt"];
|
||||
|
||||
const checkout = async () => {
|
||||
// Create a Checkout Session.
|
||||
const response = await fetchPostJSON("/api/checkout_sessions", {});
|
||||
@@ -35,14 +33,23 @@ const PricingView = () => {
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-800">
|
||||
<span className="rounded-full bg-green-50 px-4 py-1.5 text-xl font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
|
||||
{"🎈 "} {t(`setting.plan.pro`)}
|
||||
</span>
|
||||
<div className="mx-auto max-w-7xl p-6 lg:flex lg:items-center lg:justify-between lg:px-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">{t("setting.plan.pro-question-per-month")}</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{t("setting.plan.n-question-per-month", {
|
||||
count: 1000,
|
||||
})}
|
||||
</h2>
|
||||
<div className="mt-10 flex items-center gap-x-6 lg:mt-0 lg:flex-shrink-0">
|
||||
<button
|
||||
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-xl font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
onClick={() => (session?.user?.email ? checkout() : signIn())}
|
||||
>
|
||||
{session?.user?.email ? t("setting.plan.early-bird-checkout") : t("payment.login-to-buy")}
|
||||
{session?.user?.email
|
||||
? t("setting.plan.early-bird-checkout")
|
||||
: t("payment.sign-in-to-buy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
102
src/components/QuotaView.tsx
Normal file
102
src/components/QuotaView.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Quota } from "@/types";
|
||||
import getEventEmitter from "@/utils/event-emitter";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuotaView = (props: Props) => {
|
||||
const [quota, setQuota] = useState<Quota>({ current: 0, limit: 0 });
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const showSupplyOwnKey = !session || quota.current >= quota.limit;
|
||||
const expired =
|
||||
session?.user?.subscription?.expireAt &&
|
||||
session?.user?.subscription?.expireAt < Date.now();
|
||||
const showActionButton =
|
||||
!session || session.user.subscription.plan === "FREE" || expired;
|
||||
|
||||
const refreshQuota = async (userId: string) => {
|
||||
let quota: Quota = { current: 0, limit: 0 };
|
||||
try {
|
||||
const { data } = await axios.get("/api/usage", {
|
||||
headers: { Authorization: `Bearer ${userId}` },
|
||||
});
|
||||
quota = data;
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
setQuota(quota);
|
||||
};
|
||||
|
||||
getEventEmitter().on("usage.update", () => {
|
||||
if (session?.user.id) {
|
||||
refreshQuota(session.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user.id) {
|
||||
refreshQuota(session.user.id);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-2 rounded-lg border border-indigo-400 flex flex-col dark:text-gray-300 hover:bg-white dark:hover:bg-zinc-800 bg-white dark:bg-zinc-800">
|
||||
<div className="flex justify-between">
|
||||
<span className="rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
|
||||
{session
|
||||
? t(`setting.plan.${session.user.subscription.plan.toLowerCase()}`)
|
||||
: t("setting.plan.guest")}
|
||||
</span>
|
||||
{!!expired && (
|
||||
<span className="rounded-full bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-600/20">
|
||||
{t("setting.plan.expired")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between pt-1">
|
||||
<div>{t("common.quota")}</div>
|
||||
<div
|
||||
className={
|
||||
quota.current >= quota.limit ? "text-red-600" : "text-black"
|
||||
}
|
||||
>
|
||||
{quota.current}/{quota.limit}
|
||||
</div>
|
||||
</div>
|
||||
{!!showActionButton &&
|
||||
(session ? (
|
||||
<Link
|
||||
href="/setting"
|
||||
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
>
|
||||
{expired ? t("setting.plan.renew") : t("setting.plan.upgrade")}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
onClick={() => signIn()}
|
||||
>
|
||||
{t("setting.plan.signup-for-more")}
|
||||
</button>
|
||||
))}
|
||||
{!!showSupplyOwnKey && (
|
||||
<Link
|
||||
className="text-center rounded-full underline hover:opacity-80 px-2 py-0.5 text-xs font-medium text-gray-700"
|
||||
href="/setting"
|
||||
>
|
||||
{t("banner.use-my-key")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaView;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Quota } from "@/types";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuotaWidget = (props: Props) => {
|
||||
const [quota, setQuota] = useState<Quota>({ current: 0, limit: 0 });
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshQuota = async () => {
|
||||
let quota: Quota = { current: 0, limit: 0 };
|
||||
try {
|
||||
const { data } = await axios.get("/api/quota", {});
|
||||
quota = data;
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
setQuota(quota);
|
||||
};
|
||||
|
||||
refreshQuota();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-2 rounded-lg border border-indigo-400 flex flex-col dark:text-gray-300 hover:bg-white dark:hover:bg-zinc-800 bg-white dark:bg-zinc-800">
|
||||
<div className="flex justify-start">
|
||||
<span className="rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
|
||||
{t("setting.plan.free")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div>{t("common.quota")}</div>
|
||||
<div>
|
||||
{quota.current}/{quota.limit}
|
||||
</div>
|
||||
</div>
|
||||
{session ? (
|
||||
<Link
|
||||
href="/setting"
|
||||
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
>
|
||||
{t("setting.plan.upgrade")}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
onClick={() => signIn()}
|
||||
>
|
||||
{t("setting.plan.signup-for-more")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaWidget;
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import Tooltip from "./kit/Tooltip";
|
||||
import Icon from "./Icon";
|
||||
import { HasFeature } from "../utils";
|
||||
import { hasFeature } from "../utils";
|
||||
|
||||
interface Props {}
|
||||
|
||||
@@ -14,7 +14,7 @@ const SettingAvatarIcon = (props: Props) => {
|
||||
return (
|
||||
<Tooltip title={t("common.setting")} side="right">
|
||||
<div>
|
||||
{(!HasFeature("account") || !session) && (
|
||||
{(!hasFeature("account") || !session) && (
|
||||
<Link
|
||||
className=" w-10 h-10 p-1 rounded-full flex flex-row justify-center items-center hover:bg-gray-100 dark:hover:bg-zinc-700"
|
||||
data-tip={t("common.setting")}
|
||||
@@ -23,7 +23,7 @@ const SettingAvatarIcon = (props: Props) => {
|
||||
<Icon.IoMdSettings className="text-gray-600 dark:text-gray-300 w-6 h-auto" />
|
||||
</Link>
|
||||
)}
|
||||
{HasFeature("account") && session?.user && (
|
||||
{hasFeature("account") && session?.user && (
|
||||
<Link href="/setting">
|
||||
{session.user.image ? (
|
||||
<img
|
||||
@@ -33,7 +33,9 @@ const SettingAvatarIcon = (props: Props) => {
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-indigo-100 px-3 py-1 rounded-full text-indigo-600 hover:bg-indigo-200 uppercase cursor-pointer">
|
||||
{session.user.name?.charAt(0)}
|
||||
{session.user.name
|
||||
? session.user.name.charAt(0)
|
||||
: session.user.email?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HasFeature } from "../utils";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { hasFeature } from "../utils";
|
||||
import Icon from "./Icon";
|
||||
import AccountView from "./AccountView";
|
||||
import DebugView from "./DebugView";
|
||||
import PricingView from "./PricingView";
|
||||
import WeChatQRCodeView from "./WeChatQRCodeView";
|
||||
import ClearDataButton from "./ClearDataButton";
|
||||
@@ -12,6 +14,7 @@ import OpenAIApiConfigView from "./OpenAIApiConfigView";
|
||||
|
||||
const SettingView = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4 dark:bg-zinc-800">
|
||||
@@ -27,13 +30,19 @@ const SettingView = () => {
|
||||
<WeChatQRCodeView />
|
||||
</div>
|
||||
|
||||
{HasFeature("account") && (
|
||||
{hasFeature("debug") && (
|
||||
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
|
||||
<DebugView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFeature("account") && (
|
||||
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
|
||||
<AccountView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{HasFeature("payment") && (
|
||||
{hasFeature("payment") && session?.user?.subscription.plan != "PRO" && (
|
||||
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
|
||||
<PricingView />
|
||||
</div>
|
||||
|
||||
98
src/components/SubscriptionHistoryTable.tsx
Normal file
98
src/components/SubscriptionHistoryTable.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { SubscriptionPurchase } from "@/types";
|
||||
import { getCurrencySymbol, getDateString } from "@/utils";
|
||||
import { t } from "i18next";
|
||||
|
||||
const SubscriptionHistoryTable = () => {
|
||||
const [list, setList] = useState<SubscriptionPurchase[]>([]);
|
||||
const { data: session } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshSubscriptionList = async (userId: string) => {
|
||||
let list: SubscriptionPurchase[] = [];
|
||||
try {
|
||||
const { data } = await axios.get("/api/subscription", {
|
||||
headers: { Authorization: `Bearer ${userId}` },
|
||||
});
|
||||
list = data;
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
setList(list);
|
||||
};
|
||||
|
||||
if (session?.user.id) {
|
||||
refreshSubscriptionList(session.user.id);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="mt-8 flow-root">
|
||||
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0"
|
||||
>
|
||||
{t("common.date")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
{t("common.description")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
{t("common.amount")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="relative py-3.5 pl-3 pr-4 sm:pr-0"
|
||||
></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{list.map((subscription) => (
|
||||
<tr key={subscription.id}>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||
{getDateString(subscription.createdAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{subscription.description}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{getCurrencySymbol(
|
||||
subscription.currency.toLocaleUpperCase()
|
||||
)}
|
||||
{subscription.amount / 100}
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<a
|
||||
href={subscription.receipt}
|
||||
target="_blank"
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
{t("setting.billing.view-receipt")}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionHistoryTable;
|
||||
@@ -1,2 +0,0 @@
|
||||
// API_KEY is using to limit those authorized to use the API and protect the API endpoint.
|
||||
export const API_KEY = process.env.NEXT_PUBLIC_API_KEY || "";
|
||||
@@ -13,7 +13,10 @@
|
||||
"sign-in": "Sign in",
|
||||
"sign-out": "Sign out",
|
||||
"back": "Back",
|
||||
"quota": "Quota"
|
||||
"quota": "Monthly Quota",
|
||||
"date": "Date",
|
||||
"description": "Description",
|
||||
"amount": "Amount"
|
||||
},
|
||||
"conversation": {
|
||||
"new-chat": "New Chat",
|
||||
@@ -58,15 +61,19 @@
|
||||
"self": "Setting",
|
||||
"general": "General",
|
||||
"plan": {
|
||||
"guest": "Guest",
|
||||
"free": "Free",
|
||||
"pro": "Pro",
|
||||
"signup-for-more": "Sign up for more",
|
||||
"upgrade": "Upgrade",
|
||||
"pro-question-per-month": "1000 questions / month",
|
||||
"renew": "Renew",
|
||||
"expired": "Expired",
|
||||
"n-question-per-month": "{{count}} questions / month",
|
||||
"early-bird-checkout": "Early bird discount, 50% off for 1 year"
|
||||
},
|
||||
"billing": {
|
||||
"self": "Billing"
|
||||
"self": "Billing",
|
||||
"view-receipt": "View receipt"
|
||||
},
|
||||
"account": {
|
||||
"self": "Account"
|
||||
@@ -102,6 +109,6 @@
|
||||
},
|
||||
"payment": {
|
||||
"self": "Payment",
|
||||
"login-to-buy": "Login to buy"
|
||||
"sign-in-to-buy": "Sign in to buy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
"setting": "Configuración",
|
||||
"copy": "Copiar",
|
||||
"delete": "Borrar",
|
||||
"execute": "Ejecutar",
|
||||
"sign-in": "Iniciar sesión",
|
||||
"sign-out": "Desconectar",
|
||||
"back": "Devolver",
|
||||
"quota": "Cuota",
|
||||
"execute": "Ejecutar"
|
||||
"quota": "Cuota Mensual",
|
||||
"date": "Fecha",
|
||||
"description": "Descripción",
|
||||
"amount": "Cantidad"
|
||||
},
|
||||
"conversation": {
|
||||
"new-chat": "Nuevo Chat",
|
||||
@@ -56,13 +59,19 @@
|
||||
"self": "Configuración",
|
||||
"general": "General",
|
||||
"plan": {
|
||||
"guest": "Invitada",
|
||||
"free": "Gratis",
|
||||
"pro": "Pro",
|
||||
"signup-for-more": "Regístrese para obtener más",
|
||||
"upgrade": "Mejora"
|
||||
"upgrade": "Mejora",
|
||||
"renew": "Renovar",
|
||||
"expired": "Expirado",
|
||||
"n-question-per-month": "{{count}} preguntas / mes",
|
||||
"early-bird-checkout": "Descuento por reserva anticipada, 50 % de descuento durante 1 año"
|
||||
},
|
||||
"billing": {
|
||||
"self": "Facturación"
|
||||
"self": "Facturación",
|
||||
"view-receipt": "Ver recibo"
|
||||
},
|
||||
"account": {
|
||||
"self": "Cuenta"
|
||||
@@ -92,11 +101,12 @@
|
||||
"banner": {
|
||||
"data-storage": "Las configuraciones de conexión y las consultas se almacenan en tu navegador.",
|
||||
"non-select-sql-warning": "La declaración actual puede no ser SELECT SQL, lo que dará como resultado un esquema de base de datos o un cambio de datos. Asegúrate de saber lo que estás haciendo.",
|
||||
"product-hunt": "🚀🚀🚀 Acabamos de lanzar en Product Hunt, ¡por favor, vota por nosotros! 🚀🚀🚀"
|
||||
"product-hunt": "🚀🚀🚀 Acabamos de lanzar en Product Hunt, ¡por favor, vota por nosotros! 🚀🚀🚀",
|
||||
"quota-overflow": "Cuota excedida. Proporcione su propia clave API de OpenAI.",
|
||||
"use-my-key": "Usar mi propia clave"
|
||||
},
|
||||
"payment": {
|
||||
"self": "Pago",
|
||||
"quota-overflow": "Cuota excedida. Proporcione su propia clave API de OpenAI.",
|
||||
"use-my-key": "Usar mi propia clave"
|
||||
"sign-in-to-buy": "Inicia sesión para comprar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
"sign-in": "登录",
|
||||
"sign-out": "登出",
|
||||
"back": "返回",
|
||||
"quota": "额度"
|
||||
"quota": "每月额度",
|
||||
"date": "日期",
|
||||
"description": "描述",
|
||||
"amount": "金额"
|
||||
},
|
||||
"conversation": {
|
||||
"new-chat": "新建会话",
|
||||
@@ -58,15 +61,19 @@
|
||||
"self": "设置",
|
||||
"general": "通用",
|
||||
"plan": {
|
||||
"guest": "访客",
|
||||
"free": "免费版",
|
||||
"pro": "专业版",
|
||||
"signup-for-more": "注册提升额度",
|
||||
"signup-for-more": "注册获得额度",
|
||||
"upgrade": "升级",
|
||||
"pro-question-per-month": "1000 次提问 / 月",
|
||||
"renew": "续费",
|
||||
"expired": "已过期",
|
||||
"n-question-per-month": "{{count}} 次提问 / 月",
|
||||
"early-bird-checkout": "早鸟优惠,5 折购买 1 年"
|
||||
},
|
||||
"billing": {
|
||||
"self": "计费"
|
||||
"self": "计费",
|
||||
"view-receipt": "查看收据"
|
||||
},
|
||||
"account": {
|
||||
"self": "账户"
|
||||
@@ -102,6 +109,6 @@
|
||||
},
|
||||
"payment": {
|
||||
"self": "支付",
|
||||
"login-to-buy": "登陆后购买"
|
||||
"sign-in-to-buy": "登陆后购买"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { getSubscriptionByEmail } from "../utils/subscription";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -27,6 +28,25 @@ export const authOptions: NextAuthOptions = {
|
||||
clientSecret: process.env.GITHUB_SECRET,
|
||||
}),
|
||||
],
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
callbacks: {
|
||||
async session({ session, user }) {
|
||||
session.user.id = user.id;
|
||||
session.user.subscription = await getSubscriptionByEmail(user.email);
|
||||
session.user.stripeId = user.stripeId;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
brandColor: "#4F46E5",
|
||||
logo: "/chat-logo.webp",
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "./[...nextauth]";
|
||||
import requestIp from "request-ip";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Returns the login user email or the client IP address
|
||||
export const getEndUser = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
): Promise<string> => {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (session?.user?.email) {
|
||||
return session.user.email;
|
||||
// Get from server session if available
|
||||
const serverSession = await getServerSession(req, res, authOptions);
|
||||
if (serverSession?.user?.email) {
|
||||
return serverSession.user.email;
|
||||
}
|
||||
|
||||
// Get from session token if available
|
||||
const token = req.headers.authorization?.substring(7);
|
||||
if (token) {
|
||||
const sessionInDb = await prisma.session.findUnique({
|
||||
where: { sessionToken: token },
|
||||
});
|
||||
if (sessionInDb?.userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionInDb.userId },
|
||||
});
|
||||
if (user?.email) {
|
||||
return user.email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get from client IP address
|
||||
return requestIp.getClientIp(req) || "";
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "./[...nextauth]";
|
||||
import { getEndUser } from "./end-user";
|
||||
import { Quota, GUEST_QUOTA, FREE_QUOTA } from "@/types";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const checkQuota = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
): Promise<boolean> => {
|
||||
const quota = await getQuota(req, res);
|
||||
return quota.current <= quota.limit;
|
||||
};
|
||||
|
||||
export const getQuota = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
): Promise<Quota> => {
|
||||
const endUser = await getEndUser(req, res);
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
return {
|
||||
current: await prisma.message.count({
|
||||
where: {
|
||||
endUser: endUser,
|
||||
role: "user",
|
||||
},
|
||||
}),
|
||||
limit: session ? FREE_QUOTA : GUEST_QUOTA,
|
||||
};
|
||||
};
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
ReconnectInterval,
|
||||
} from "eventsource-parser";
|
||||
import { NextRequest } from "next/server";
|
||||
import { API_KEY } from "@/env";
|
||||
import { openAIApiEndpoint, openAIApiKey, gpt35 } from "@/utils";
|
||||
import { openAIApiEndpoint, openAIApiKey, gpt35, hasFeature } from "@/utils";
|
||||
|
||||
// Needs Edge for streaming response.
|
||||
export const config = {
|
||||
runtime: "edge",
|
||||
};
|
||||
@@ -18,28 +18,8 @@ const getApiEndpoint = (apiEndpoint: string) => {
|
||||
};
|
||||
|
||||
const handler = async (req: NextRequest) => {
|
||||
if (API_KEY) {
|
||||
const auth = req.headers.get("Authorization");
|
||||
if (!auth || auth !== `Bearer ${API_KEY}`) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Unauthorized.",
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: 401,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reqBody = await req.json();
|
||||
const openAIApiConfig = reqBody.openAIApiConfig;
|
||||
const apiKey = openAIApiConfig?.key || openAIApiKey;
|
||||
const apiKey = req.headers.get("x-openai-key") || openAIApiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
@@ -58,10 +38,69 @@ const handler = async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const apiEndpoint = getApiEndpoint(
|
||||
openAIApiConfig?.endpoint || openAIApiEndpoint
|
||||
const useServerKey = !req.headers.get("x-openai-key");
|
||||
const sessionToken = req.cookies.get("next-auth.session-token")?.value;
|
||||
const currentUrl = new URL(req.url);
|
||||
const usageUrl = new URL(
|
||||
currentUrl.protocol + "//" + currentUrl.host + "/api/usage"
|
||||
);
|
||||
const res = await fetch(apiEndpoint, {
|
||||
const requestHeaders: any = {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
};
|
||||
|
||||
if (useServerKey) {
|
||||
if (hasFeature("account") && !sessionToken) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"Please sign up to get free quota or supply your own OpenAI key.",
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: 401,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (hasFeature("quota")) {
|
||||
const usageRes = await fetch(usageUrl, {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
if (!usageRes.ok) {
|
||||
return new Response(usageRes.body, {
|
||||
status: 500,
|
||||
statusText: usageRes.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
const usage = await usageRes.json();
|
||||
if (usage.current >= usage.limit) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `You have reached your monthly quota: ${usage.current}/${usage.limit}.`,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: 401,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const apiEndpoint = getApiEndpoint(
|
||||
req.headers.get("x-openai-endpoint") || openAIApiEndpoint
|
||||
);
|
||||
const remoteRes = await fetch(apiEndpoint, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
@@ -78,10 +117,10 @@ const handler = async (req: NextRequest) => {
|
||||
user: req.ip,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
if (!remoteRes.ok) {
|
||||
return new Response(remoteRes.body, {
|
||||
status: remoteRes.status,
|
||||
statusText: remoteRes.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,11 +146,20 @@ const handler = async (req: NextRequest) => {
|
||||
}
|
||||
};
|
||||
const parser = createParser(streamParser);
|
||||
for await (const chunk of res.body as any) {
|
||||
for await (const chunk of remoteRes.body as any) {
|
||||
parser.feed(decoder.decode(chunk));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (useServerKey) {
|
||||
// Increment usage count
|
||||
await fetch(usageUrl, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(stream);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { PlanType } from "@/types";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
@@ -44,7 +45,7 @@ export default async function handler(
|
||||
],
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_ANNUAL_LICENSE_PRICE_ID,
|
||||
price: process.env.STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
@@ -58,6 +59,8 @@ export default async function handler(
|
||||
// setup_future_usage: "off_session",
|
||||
metadata: {
|
||||
email: session?.user?.email!,
|
||||
plan: "PRO",
|
||||
description: "Pro 1 Year License (Early Bird)",
|
||||
},
|
||||
},
|
||||
// Link customer if present otherwise pass email and let Stripe create a new customer.
|
||||
|
||||
62
src/pages/api/collect.ts
Normal file
62
src/pages/api/collect.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Conversation, Message } from "@/types";
|
||||
import { gpt35 } from "@/utils";
|
||||
import { getEndUser } from "./auth/end-user";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json([]);
|
||||
}
|
||||
const conversation = req.body.conversation as Conversation;
|
||||
const messages = req.body.messages as Message[];
|
||||
try {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: {
|
||||
id: conversation.id,
|
||||
},
|
||||
});
|
||||
const endUser = await getEndUser(req, res);
|
||||
if (chat) {
|
||||
await prisma.message.createMany({
|
||||
data: messages.map((message) => ({
|
||||
chatId: chat.id,
|
||||
createdAt: new Date(message.createdAt),
|
||||
endUser: endUser,
|
||||
role: message.creatorRole,
|
||||
content: message.content,
|
||||
upvote: false,
|
||||
downvote: false,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
await prisma.chat.create({
|
||||
data: {
|
||||
id: conversation.id,
|
||||
createdAt: new Date(conversation.createdAt),
|
||||
model: gpt35,
|
||||
ctx: {},
|
||||
messages: {
|
||||
create: messages.map((message) => ({
|
||||
createdAt: new Date(message.createdAt),
|
||||
endUser: endUser,
|
||||
role: message.creatorRole,
|
||||
content: message.content,
|
||||
upvote: false,
|
||||
downvote: false,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
res.status(200).json(true);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getQuota } from "./auth/quota";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json([]);
|
||||
}
|
||||
res.status(200).json(await getQuota(req, res));
|
||||
};
|
||||
|
||||
export default handler;
|
||||
16
src/pages/api/subscription.ts
Normal file
16
src/pages/api/subscription.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getEndUser } from "./auth/end-user";
|
||||
import { getSubscriptionListByEmail } from "./utils/subscription";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json([]);
|
||||
}
|
||||
|
||||
const endUser = await getEndUser(req, res);
|
||||
const subscriptionList = await getSubscriptionListByEmail(endUser);
|
||||
|
||||
res.status(200).json(subscriptionList);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -1,62 +1,38 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Conversation, Message } from "@/types";
|
||||
import { gpt35 } from "@/utils";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "./auth/[...nextauth]";
|
||||
import { getSubscriptionByEmail } from "./utils/subscription";
|
||||
import { addUsage, getCurrentMonthUsage } from "./utils/usage";
|
||||
import { getEndUser } from "./auth/end-user";
|
||||
import { Quota } from "@/types";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET" && req.method !== "POST") {
|
||||
return res.status(405).json([]);
|
||||
}
|
||||
const conversation = req.body.conversation as Conversation;
|
||||
const messages = req.body.messages as Message[];
|
||||
try {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: {
|
||||
id: conversation.id,
|
||||
},
|
||||
});
|
||||
const endUser = await getEndUser(req, res);
|
||||
if (chat) {
|
||||
await prisma.message.createMany({
|
||||
data: messages.map((message) => ({
|
||||
chatId: chat.id,
|
||||
createdAt: new Date(message.createdAt),
|
||||
endUser: endUser,
|
||||
role: message.creatorRole,
|
||||
content: message.content,
|
||||
upvote: false,
|
||||
downvote: false,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
await prisma.chat.create({
|
||||
data: {
|
||||
id: conversation.id,
|
||||
createdAt: new Date(conversation.createdAt),
|
||||
model: gpt35,
|
||||
ctx: {},
|
||||
messages: {
|
||||
create: messages.map((message) => ({
|
||||
createdAt: new Date(message.createdAt),
|
||||
endUser: endUser,
|
||||
role: message.creatorRole,
|
||||
content: message.content,
|
||||
upvote: false,
|
||||
downvote: false,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
const endUser = await getEndUser(req, res);
|
||||
|
||||
// Get from server session if available
|
||||
const serverSession = await getServerSession(req, res, authOptions);
|
||||
let subscripion = serverSession?.user?.subscription;
|
||||
if (!subscripion) {
|
||||
subscripion = await getSubscriptionByEmail(endUser);
|
||||
}
|
||||
|
||||
res.status(200).json(true);
|
||||
}
|
||||
let usage = 0;
|
||||
if (req.method === "GET") {
|
||||
usage = await getCurrentMonthUsage(endUser);
|
||||
} else if (req.method === "POST") {
|
||||
usage = await addUsage(endUser);
|
||||
}
|
||||
|
||||
const quota: Quota = {
|
||||
current: usage,
|
||||
limit: subscripion.quota,
|
||||
};
|
||||
|
||||
res.status(200).json(quota);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
85
src/pages/api/utils/subscription.ts
Normal file
85
src/pages/api/utils/subscription.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
PlanConfig,
|
||||
PlanType,
|
||||
Subscription,
|
||||
SubscriptionPurchase,
|
||||
} from "@/types";
|
||||
import { PrismaClient, SubscriptionStatus } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const getSubscriptionByEmail = async (
|
||||
email: string
|
||||
): Promise<Subscription> => {
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: { email: email },
|
||||
orderBy: { expireAt: "desc" },
|
||||
});
|
||||
|
||||
// Return the latest ACTIVE, not-expired subscription if exists.
|
||||
for (const subscription of subscriptions) {
|
||||
const result = {
|
||||
plan: subscription.plan as PlanType,
|
||||
quota: PlanConfig[subscription.plan].quota,
|
||||
status: subscription.status,
|
||||
startAt: subscription.startAt.getTime(),
|
||||
expireAt: subscription.expireAt.getTime(),
|
||||
};
|
||||
if (
|
||||
subscription.status === SubscriptionStatus.ACTIVE &&
|
||||
subscription.expireAt.getTime() > Date.now()
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the latest ACTIVE, expired subscripion if exists.
|
||||
for (const subscription of subscriptions) {
|
||||
const result = {
|
||||
plan: subscription.plan as PlanType,
|
||||
quota: PlanConfig["FREE"].quota,
|
||||
status: subscription.status,
|
||||
startAt: subscription.startAt.getTime(),
|
||||
expireAt: subscription.expireAt.getTime(),
|
||||
};
|
||||
if (subscription.status === SubscriptionStatus.ACTIVE) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Return a FREE subscription.
|
||||
return {
|
||||
plan: "FREE",
|
||||
quota: PlanConfig["FREE"].quota,
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
startAt: 0,
|
||||
expireAt: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSubscriptionListByEmail = async (
|
||||
email: string
|
||||
): Promise<SubscriptionPurchase[]> => {
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: { email: email },
|
||||
orderBy: { expireAt: "desc" },
|
||||
});
|
||||
|
||||
// Return the latest ACTIVE, not-expired subscription if exists.
|
||||
const result: SubscriptionPurchase[] = [];
|
||||
for (const subscription of subscriptions) {
|
||||
result.push({
|
||||
id: subscription.id,
|
||||
email: subscription.email,
|
||||
amount: subscription.amount,
|
||||
currency: subscription.currency,
|
||||
receipt: subscription.receipt,
|
||||
plan: subscription.plan as PlanType,
|
||||
description: subscription.description,
|
||||
createdAt: subscription.createdAt.getTime(),
|
||||
startAt: subscription.startAt.getTime(),
|
||||
expireAt: subscription.expireAt.getTime(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
63
src/pages/api/utils/usage.ts
Normal file
63
src/pages/api/utils/usage.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const getCurrentMonthUsage = async (
|
||||
endUser: string
|
||||
): Promise<number> => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const aggregations = await prisma.usage.aggregate({
|
||||
_sum: {
|
||||
count: true,
|
||||
},
|
||||
where: {
|
||||
endUser: endUser,
|
||||
createdAt: {
|
||||
gte: start,
|
||||
lt: end,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return aggregations._sum.count || 0;
|
||||
};
|
||||
|
||||
// We coerce individual usage to the begining of the day to reduce the usage records.
|
||||
export const addUsage = async (endUser: string): Promise<number> => {
|
||||
const now = new Date();
|
||||
const today = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
);
|
||||
const usage = await prisma.usage.findFirst({
|
||||
where: {
|
||||
endUser: endUser,
|
||||
createdAt: today,
|
||||
},
|
||||
});
|
||||
|
||||
let newUsage = 0;
|
||||
if (usage) {
|
||||
newUsage = usage.count + 1;
|
||||
await prisma.usage.update({
|
||||
where: {
|
||||
id: usage.id,
|
||||
},
|
||||
data: {
|
||||
count: newUsage,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
newUsage = 1;
|
||||
await prisma.usage.create({
|
||||
data: {
|
||||
endUser: endUser,
|
||||
createdAt: today,
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return newUsage;
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { buffer } from "micro";
|
||||
import Cors from "micro-cors";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import { PrismaClient, Prisma, SubscriptionPlan } from "@prisma/client";
|
||||
|
||||
import Stripe from "stripe";
|
||||
import { PlanType } from "@/types";
|
||||
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: "2022-11-15",
|
||||
@@ -66,14 +67,26 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
});
|
||||
|
||||
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
// Subtract 1 second from the year from now to make it 23:59:59
|
||||
const yearFromNow = new Date(
|
||||
new Date(new Date().setHours(0, 0, 0, 0)).setFullYear(
|
||||
today.getFullYear() + 1
|
||||
) - 1000
|
||||
);
|
||||
const subscription: Prisma.SubscriptionUncheckedCreateInput = {
|
||||
userId: user.id,
|
||||
email: paymentIntent.metadata.email,
|
||||
createdAt: new Date(paymentIntent.created * 1000),
|
||||
status: "ACTIVE",
|
||||
startAt: today,
|
||||
expireAt: new Date(today.setFullYear(today.getFullYear() + 1)),
|
||||
expireAt: yearFromNow,
|
||||
paymentId: paymentIntent.id,
|
||||
customerId: customerId || "",
|
||||
plan: paymentIntent.metadata.plan as SubscriptionPlan,
|
||||
description: paymentIntent.metadata.description,
|
||||
amount: paymentIntent.amount,
|
||||
currency: paymentIntent.currency,
|
||||
receipt: paymentIntent.charges.data[0].receipt_url,
|
||||
};
|
||||
await prisma.subscription.create({ data: subscription });
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
|
||||
@@ -4,7 +4,13 @@ import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { ArrowUturnLeftIcon, Bars3Icon, Cog6ToothIcon, XMarkIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ArrowUturnLeftIcon,
|
||||
Bars3Icon,
|
||||
Cog6ToothIcon,
|
||||
XMarkIcon,
|
||||
CreditCardIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import SettingView from "../../components/SettingView";
|
||||
import StripeCheckPaymentBanner from "../../components/StripeCheckPaymentBanner";
|
||||
|
||||
@@ -45,7 +51,11 @@ const SettingPage: NextPage = () => {
|
||||
|
||||
<div className="dark:bg-zinc-800">
|
||||
<Transition.Root show={sidebarOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50 lg:hidden" onClose={setSidebarOpen}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50 lg:hidden"
|
||||
onClose={setSidebarOpen}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
@@ -79,9 +89,16 @@ const SettingPage: NextPage = () => {
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute left-full top-0 flex w-16 justify-center pt-5">
|
||||
<button type="button" className="-m-2.5 p-2.5" onClick={() => setSidebarOpen(false)}>
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
<XMarkIcon
|
||||
className="h-6 w-6 text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
@@ -99,13 +116,17 @@ const SettingPage: NextPage = () => {
|
||||
<Link
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current ? "bg-gray-50 text-indigo-600" : "text-gray-700 hover:text-indigo-600 hover:bg-gray-50",
|
||||
item.current
|
||||
? "bg-gray-50 text-indigo-600"
|
||||
: "text-gray-700 hover:text-indigo-600 hover:bg-gray-50",
|
||||
"group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current ? "text-indigo-600" : "text-gray-400 group-hover:text-indigo-600",
|
||||
item.current
|
||||
? "text-indigo-600"
|
||||
: "text-gray-400 group-hover:text-indigo-600",
|
||||
"h-6 w-6 shrink-0"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
@@ -149,7 +170,9 @@ const SettingPage: NextPage = () => {
|
||||
>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current ? "!text-indigo-600" : "text-gray-400 group-hover:text-indigo-600",
|
||||
item.current
|
||||
? "!text-indigo-600"
|
||||
: "text-gray-400 group-hover:text-indigo-600",
|
||||
"h-6 w-6 shrink-0 dark:text-gray-400"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
@@ -166,14 +189,22 @@ const SettingPage: NextPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 z-40 flex items-center gap-x-6 bg-white dark:bg-zinc-700 px-4 py-4 shadow-sm sm:px-6 lg:hidden">
|
||||
<button type="button" className="-m-2.5 p-2.5 text-gray-700 dark:text-gray-400 lg:hidden" onClick={() => setSidebarOpen(true)}>
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-gray-700 dark:text-gray-400 lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main className="lg:pl-72">
|
||||
{router.query.session_id && <StripeCheckPaymentBanner sessionId={router.query.session_id as string} />}
|
||||
{router.query.session_id && (
|
||||
<StripeCheckPaymentBanner
|
||||
sessionId={router.query.session_id as string}
|
||||
/>
|
||||
)}
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<SettingView />
|
||||
</div>
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from "./message";
|
||||
export * from "./layout";
|
||||
export * from "./query";
|
||||
export * from "./setting";
|
||||
export * from "../utils/event-emitter";
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from "./api";
|
||||
export * from "./connector";
|
||||
export * from "./assistant";
|
||||
export * from "./quota";
|
||||
export * from "./subscription";
|
||||
|
||||
@@ -2,8 +2,3 @@ export interface Quota {
|
||||
current: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// By month
|
||||
export const GUEST_QUOTA = 10;
|
||||
export const FREE_QUOTA = 25;
|
||||
export const PRO_QUOTA = 1000;
|
||||
|
||||
41
src/types/subscription.ts
Normal file
41
src/types/subscription.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { SubscriptionPlan, SubscriptionStatus } from "@prisma/client";
|
||||
|
||||
export type PlanType = SubscriptionPlan | "GUEST" | "FREE";
|
||||
|
||||
// Quota is by month
|
||||
export const PlanConfig: {
|
||||
[key: string]: {
|
||||
quota: number;
|
||||
};
|
||||
} = {
|
||||
GUEST: {
|
||||
quota: 0,
|
||||
},
|
||||
FREE: {
|
||||
quota: 20,
|
||||
},
|
||||
PRO: {
|
||||
quota: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
export interface Subscription {
|
||||
plan: PlanType;
|
||||
quota: number;
|
||||
status: SubscriptionStatus;
|
||||
startAt: number;
|
||||
expireAt: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionPurchase {
|
||||
id: string;
|
||||
email: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
receipt: string;
|
||||
plan: PlanType;
|
||||
description: string;
|
||||
createdAt: number;
|
||||
startAt: number;
|
||||
expireAt: number;
|
||||
}
|
||||
37
src/utils/event-emitter.ts
Normal file
37
src/utils/event-emitter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type EventType = "usage.update";
|
||||
|
||||
type Callback = () => void;
|
||||
|
||||
class EventEmitter {
|
||||
private static instance: EventEmitter;
|
||||
private events: { [eventName: string]: Callback[] } = {};
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): EventEmitter {
|
||||
if (!EventEmitter.instance) {
|
||||
EventEmitter.instance = new EventEmitter();
|
||||
}
|
||||
return EventEmitter.instance;
|
||||
}
|
||||
|
||||
on(eventName: EventType, callback: Callback) {
|
||||
if (!this.events[eventName]) {
|
||||
this.events[eventName] = [];
|
||||
}
|
||||
this.events[eventName].push(callback);
|
||||
}
|
||||
|
||||
emit(eventName: EventType) {
|
||||
const callbacks = this.events[eventName];
|
||||
if (callbacks) {
|
||||
callbacks.forEach((callback) => callback());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getEventEmitter = () => {
|
||||
return EventEmitter.getInstance();
|
||||
};
|
||||
|
||||
export default getEventEmitter;
|
||||
@@ -1,8 +1,20 @@
|
||||
type FeatureType = "account" | "payment" | "quota";
|
||||
type FeatureType = "debug" | "account" | "payment" | "quota";
|
||||
|
||||
export const HasFeature = (feature: FeatureType) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
const matrix: { [key: string]: { [feature: string]: boolean } } = {
|
||||
development: {
|
||||
debug: true,
|
||||
account: true,
|
||||
payment: true,
|
||||
quota: true,
|
||||
},
|
||||
production: {
|
||||
debug: true,
|
||||
account: true,
|
||||
payment: true,
|
||||
quota: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const hasFeature = (feature: FeatureType) => {
|
||||
return matrix[process.env.NODE_ENV][feature];
|
||||
};
|
||||
|
||||
@@ -4,4 +4,5 @@ export * from "./sql";
|
||||
export * from "./execution";
|
||||
export * from "./model";
|
||||
export * from "./feature";
|
||||
export * from "./tidb";
|
||||
export * from "./tidb";
|
||||
export * from "./misc";
|
||||
|
||||
24
src/utils/misc.ts
Normal file
24
src/utils/misc.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { timeStamp } from "console";
|
||||
|
||||
export const getCurrencySymbol = (currencyCode: string): string => {
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currencyCode,
|
||||
})
|
||||
.formatToParts(1)
|
||||
.find((part) => part.type === "currency").value;
|
||||
} catch (error) {
|
||||
console.error(`Invalid currency code: ${currencyCode}`);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const getDateString = (timeStamp = Date.now()): string => {
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
return new Date(timeStamp).toLocaleDateString(undefined, dateOptions);
|
||||
};
|
||||
@@ -18,6 +18,12 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "process.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"process.d.ts",
|
||||
"auth.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user