mirror of
https://github.com/sqlchat/sqlchat.git
synced 2025-09-28 18:43:53 +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")
|
@@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 {
|
model Subscription {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId String @default("") @map("user_id")
|
userId String @default("") @map("user_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
// Denormalize to avoid join with the user table
|
||||||
status SubscriptionStatus @default(ACTIVE)
|
email String @default("") @map("email")
|
||||||
startAt DateTime @default(now()) @map("start_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
expireAt DateTime @default(now()) @map("expire_at")
|
status SubscriptionStatus @default(ACTIVE)
|
||||||
paymentId String @default("") @map("payment_id")
|
startAt DateTime @default(now()) @map("start_at")
|
||||||
customerId String @default("") @map("customer_id")
|
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])
|
@@unique([paymentId])
|
||||||
|
@@index([userId], map: "subscription_user_id_idx")
|
||||||
|
@@index([email], map: "subscription_email_idx")
|
||||||
@@map("subscription")
|
@@map("subscription")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +83,10 @@ enum SubscriptionStatus {
|
|||||||
CANCELED
|
CANCELED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SubscriptionPlan {
|
||||||
|
PRO
|
||||||
|
}
|
||||||
|
|
||||||
// NextAuth Prisma Schema Begin
|
// NextAuth Prisma Schema Begin
|
||||||
// Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow
|
// Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow
|
||||||
// requires this. https://authjs.dev/reference/adapter/prisma
|
// 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;
|
OPENAI_API_KEY: string;
|
||||||
// Optional. OpenAI API endpoint. Defaults to https://api.openai.com.
|
// Optional. OpenAI API endpoint. Defaults to https://api.openai.com.
|
||||||
OPENAI_API_ENDPOINT: string;
|
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.
|
// Optional. NextAuth.js URL. Defaults to the current domain.
|
||||||
NEXTAUTH_URL: string;
|
NEXTAUTH_URL: string;
|
||||||
// Optional. NextAuth.js secret. Defaults to a randomly generated string.
|
// Optional. NextAuth.js secret. Defaults to a randomly generated string.
|
||||||
@ -33,7 +30,7 @@ declare namespace NodeJS {
|
|||||||
STRIPE_API_KEY: string;
|
STRIPE_API_KEY: string;
|
||||||
// Optional. Stripe webhook secret.
|
// Optional. Stripe webhook secret.
|
||||||
STRIPE_WEBHOOK_SECRET: string;
|
STRIPE_WEBHOOK_SECRET: string;
|
||||||
// Optional. Stripe annual license price id.
|
// Optional. Stripe price id for Pro plan 1 year subscription.
|
||||||
STRIPE_ANNUAL_LICENSE_PRICE_ID: string;
|
STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { signIn, useSession } from "next-auth/react";
|
import { signIn, useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import SubscriptionHistoryTable from "./SubscriptionHistoryTable";
|
||||||
|
import { getDateString } from "@/utils";
|
||||||
|
|
||||||
const AccountView = () => {
|
const AccountView = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: session, status } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -17,21 +19,51 @@ const AccountView = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{session?.user && (
|
{session?.user && (
|
||||||
<div className="flex items-center space-x-2">
|
<div>
|
||||||
{session.user.image && (
|
<div className="flex-col space-y-4 sm:space-y-0 sm:flex sm:flex-row justify-between">
|
||||||
<img
|
<div className="flex items-center space-x-2">
|
||||||
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 cursor-pointer"
|
{session.user.image && (
|
||||||
src={session.user.image}
|
<img
|
||||||
alt=""
|
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"
|
<span>{session.user.email ?? session.user.name}</span>
|
||||||
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"
|
<Link
|
||||||
>
|
href="/api/auth/signout"
|
||||||
{t("common.sign-out")}
|
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"
|
||||||
</Link>
|
>
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
useConversationStore,
|
useConversationStore,
|
||||||
useLayoutStore,
|
useLayoutStore,
|
||||||
ResponsiveWidth,
|
ResponsiveWidth,
|
||||||
|
useSettingStore,
|
||||||
} from "@/store";
|
} from "@/store";
|
||||||
import { Table } from "@/types";
|
import { Table } from "@/types";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "@/hooks/useLoading";
|
||||||
@ -14,14 +15,16 @@ import Icon from "./Icon";
|
|||||||
import DarkModeSwitch from "./DarkModeSwitch";
|
import DarkModeSwitch from "./DarkModeSwitch";
|
||||||
import ConversationList from "./Sidebar/ConversationList";
|
import ConversationList from "./Sidebar/ConversationList";
|
||||||
import ConnectionList from "./Sidebar/ConnectionList";
|
import ConnectionList from "./Sidebar/ConnectionList";
|
||||||
import QuotaWidget from "./QuotaWidget";
|
import QuotaView from "./QuotaView";
|
||||||
import { HasFeature } from "../utils";
|
import { hasFeature } from "../utils";
|
||||||
import MultipleSelect from "./kit/MultipleSelect";
|
import MultipleSelect from "./kit/MultipleSelect";
|
||||||
|
import SettingAvatarIcon from "./SettingAvatarIcon";
|
||||||
|
|
||||||
interface State {}
|
interface State {}
|
||||||
|
|
||||||
const ConnectionSidebar = () => {
|
const ConnectionSidebar = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
const conversationStore = useConversationStore();
|
const conversationStore = useConversationStore();
|
||||||
@ -142,8 +145,9 @@ const ConnectionSidebar = () => {
|
|||||||
<div className="w-full flex flex-col justify-start items-start">
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<ConnectionList />
|
<ConnectionList />
|
||||||
</div>
|
</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 />
|
<DarkModeSwitch />
|
||||||
|
<SettingAvatarIcon />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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 />
|
<ConversationList />
|
||||||
</div>
|
</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">
|
<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") && (
|
{!settingStore.setting.openAIApiConfig?.key &&
|
||||||
<div className="mb-4">
|
hasFeature("quota") && (
|
||||||
<QuotaWidget />
|
<div className="mb-4">
|
||||||
</div>
|
<QuotaView />
|
||||||
)}
|
</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>
|
|
||||||
<a
|
<a
|
||||||
className="dark:hidden"
|
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"
|
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 Icon from "../Icon";
|
||||||
import GitHubStarBadge from "../GitHubStarBadge";
|
import GitHubStarBadge from "../GitHubStarBadge";
|
||||||
import SettingAvatarIcon from "../SettingAvatarIcon";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -60,7 +59,6 @@ const Header = (props: Props) => {
|
|||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<SettingAvatarIcon />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -17,6 +17,7 @@ import Icon from "../Icon";
|
|||||||
import { CodeBlock } from "../CodeBlock";
|
import { CodeBlock } from "../CodeBlock";
|
||||||
import EngineIcon from "../EngineIcon";
|
import EngineIcon from "../EngineIcon";
|
||||||
import ThreeDotsLoader from "./ThreeDotsLoader";
|
import ThreeDotsLoader from "./ThreeDotsLoader";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message;
|
message: Message;
|
||||||
@ -24,6 +25,7 @@ interface Props {
|
|||||||
|
|
||||||
const MessageView = (props: Props) => {
|
const MessageView = (props: Props) => {
|
||||||
const message = props.message;
|
const message = props.message;
|
||||||
|
const { data: session } = useSession();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const settingStore = useSettingStore();
|
const settingStore = useSettingStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@ -84,8 +86,24 @@ const MessageView = (props: Props) => {
|
|||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="w-10 h-10 border dark:border-zinc-700 rounded-full flex justify-center items-center ml-2 shrink-0">
|
||||||
<Icon.AiOutlineUser className="w-6 h-6" />
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
@ -2,7 +2,6 @@ import axios from "axios";
|
|||||||
import { head, last } from "lodash-es";
|
import { head, last } from "lodash-es";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { API_KEY } from "@/env";
|
|
||||||
import {
|
import {
|
||||||
getAssistantById,
|
getAssistantById,
|
||||||
getPromptGeneratorOfAssistant,
|
getPromptGeneratorOfAssistant,
|
||||||
@ -22,12 +21,15 @@ import ClearConversationButton from "../ClearConversationButton";
|
|||||||
import MessageTextarea from "./MessageTextarea";
|
import MessageTextarea from "./MessageTextarea";
|
||||||
import DataStorageBanner from "../DataStorageBanner";
|
import DataStorageBanner from "../DataStorageBanner";
|
||||||
import QuotaOverflowBanner from "../QuotaOverflowBanner";
|
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.
|
// 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
|
// reference: https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens
|
||||||
const MAX_TOKENS = 4000;
|
const MAX_TOKENS = 4000;
|
||||||
|
|
||||||
const ConversationView = () => {
|
const ConversationView = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const settingStore = useSettingStore();
|
const settingStore = useSettingStore();
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
@ -182,10 +184,12 @@ const ConversationView = () => {
|
|||||||
|
|
||||||
const tableList: string[] = [];
|
const tableList: string[] = [];
|
||||||
if (currentConversation.selectedTablesName) {
|
if (currentConversation.selectedTablesName) {
|
||||||
currentConversation.selectedTablesName.forEach((tableName) => {
|
currentConversation.selectedTablesName.forEach(
|
||||||
const table = tables.find((table) => table.name === tableName);
|
(tableName: string) => {
|
||||||
tableList.push(table!.structure);
|
const table = tables.find((table) => table.name === tableName);
|
||||||
});
|
tableList.push(table!.structure);
|
||||||
|
}
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
tableList.push(table!.structure);
|
tableList.push(table!.structure);
|
||||||
@ -235,14 +239,21 @@ const ConversationView = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const requestHeaders: any = {};
|
const requestHeaders: any = {};
|
||||||
if (API_KEY) {
|
if (session?.user.id) {
|
||||||
requestHeaders["Authorization"] = `Bearer ${API_KEY}`;
|
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", {
|
const rawRes = await fetch("/api/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: formatedMessageList,
|
messages: formatedMessageList,
|
||||||
openAIApiConfig: settingStore.setting.openAIApiConfig,
|
|
||||||
}),
|
}),
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
});
|
});
|
||||||
@ -307,6 +318,9 @@ const ConversationView = () => {
|
|||||||
status: "DONE",
|
status: "DONE",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit usage update event so quota widget can update.
|
||||||
|
getEventEmitter().emit("usage.update");
|
||||||
|
|
||||||
// Collect system prompt
|
// Collect system prompt
|
||||||
// We only collect the db prompt for the system prompt. We do not collect the intermediate
|
// 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.
|
// exchange to save space since those can be derived from the previous record.
|
||||||
@ -324,10 +338,18 @@ const ConversationView = () => {
|
|||||||
usageMessageList.push(assistantMessage);
|
usageMessageList.push(assistantMessage);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<string[]>("/api/usage", {
|
.post<string[]>(
|
||||||
conversation: currentConversation,
|
"/api/collect",
|
||||||
messages: usageMessageList,
|
{
|
||||||
})
|
conversation: currentConversation,
|
||||||
|
messages: usageMessageList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: session?.user.id
|
||||||
|
? { Authorization: `Bearer ${session?.user.id}` }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// do nth
|
// 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`}
|
} 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">
|
<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 />
|
<DataStorageBanner />
|
||||||
<Header className={showHeaderShadow ? "shadow" : ""} />
|
<Header className={showHeaderShadow ? "shadow" : ""} />
|
||||||
</div>
|
</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 { useSettingStore } from "@/store";
|
||||||
import { OpenAIApiConfig } from "@/types";
|
import { OpenAIApiConfig } from "@/types";
|
||||||
import TextField from "./kit/TextField";
|
import TextField from "./kit/TextField";
|
||||||
|
import { set } from "lodash-es";
|
||||||
|
|
||||||
const OpenAIApiConfigView = () => {
|
const OpenAIApiConfigView = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -11,6 +12,17 @@ const OpenAIApiConfigView = () => {
|
|||||||
const [openAIApiConfig, setOpenAIApiConfig] = useState(
|
const [openAIApiConfig, setOpenAIApiConfig] = useState(
|
||||||
settingStore.setting.openAIApiConfig
|
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(
|
useDebounce(
|
||||||
() => {
|
() => {
|
||||||
@ -25,6 +37,7 @@ const OpenAIApiConfigView = () => {
|
|||||||
...openAIApiConfig,
|
...openAIApiConfig,
|
||||||
...config,
|
...config,
|
||||||
});
|
});
|
||||||
|
setMaskKey(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -34,7 +47,9 @@ const OpenAIApiConfigView = () => {
|
|||||||
<label className="mb-1">OpenAI API Key</label>
|
<label className="mb-1">OpenAI API Key</label>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="OpenAI API Key"
|
placeholder="OpenAI API Key"
|
||||||
value={openAIApiConfig.key}
|
value={
|
||||||
|
maskKey ? maskedKey(openAIApiConfig.key) : openAIApiConfig.key
|
||||||
|
}
|
||||||
onChange={(value) => handleSetOpenAIApiConfig({ key: value })}
|
onChange={(value) => handleSetOpenAIApiConfig({ key: value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,8 +4,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import getStripe from "../utils/get-stripejs";
|
import getStripe from "../utils/get-stripejs";
|
||||||
import { fetchPostJSON } from "../utils/api-helpers";
|
import { fetchPostJSON } from "../utils/api-helpers";
|
||||||
|
|
||||||
const includedFeatures = ["Private forum access", "Member resources", "Entry to annual conference", "Official member t-shirt"];
|
|
||||||
|
|
||||||
const checkout = async () => {
|
const checkout = async () => {
|
||||||
// Create a Checkout Session.
|
// Create a Checkout Session.
|
||||||
const response = await fetchPostJSON("/api/checkout_sessions", {});
|
const response = await fetchPostJSON("/api/checkout_sessions", {});
|
||||||
@ -35,14 +33,23 @@ const PricingView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-zinc-800">
|
<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">
|
<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">
|
<div className="mt-10 flex items-center gap-x-6 lg:mt-0 lg:flex-shrink-0">
|
||||||
<button
|
<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"
|
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())}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 Link from "next/link";
|
||||||
import Tooltip from "./kit/Tooltip";
|
import Tooltip from "./kit/Tooltip";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { HasFeature } from "../utils";
|
import { hasFeature } from "../utils";
|
||||||
|
|
||||||
interface Props {}
|
interface Props {}
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ const SettingAvatarIcon = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Tooltip title={t("common.setting")} side="right">
|
<Tooltip title={t("common.setting")} side="right">
|
||||||
<div>
|
<div>
|
||||||
{(!HasFeature("account") || !session) && (
|
{(!hasFeature("account") || !session) && (
|
||||||
<Link
|
<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"
|
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")}
|
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" />
|
<Icon.IoMdSettings className="text-gray-600 dark:text-gray-300 w-6 h-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{HasFeature("account") && session?.user && (
|
{hasFeature("account") && session?.user && (
|
||||||
<Link href="/setting">
|
<Link href="/setting">
|
||||||
{session.user.image ? (
|
{session.user.image ? (
|
||||||
<img
|
<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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { HasFeature } from "../utils";
|
import { useSession } from "next-auth/react";
|
||||||
|
import { hasFeature } from "../utils";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import AccountView from "./AccountView";
|
import AccountView from "./AccountView";
|
||||||
|
import DebugView from "./DebugView";
|
||||||
import PricingView from "./PricingView";
|
import PricingView from "./PricingView";
|
||||||
import WeChatQRCodeView from "./WeChatQRCodeView";
|
import WeChatQRCodeView from "./WeChatQRCodeView";
|
||||||
import ClearDataButton from "./ClearDataButton";
|
import ClearDataButton from "./ClearDataButton";
|
||||||
@ -12,6 +14,7 @@ import OpenAIApiConfigView from "./OpenAIApiConfigView";
|
|||||||
|
|
||||||
const SettingView = () => {
|
const SettingView = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4 dark:bg-zinc-800">
|
<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 />
|
<WeChatQRCodeView />
|
||||||
</div>
|
</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">
|
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
|
||||||
<AccountView />
|
<AccountView />
|
||||||
</div>
|
</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">
|
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
|
||||||
<PricingView />
|
<PricingView />
|
||||||
</div>
|
</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-in": "Sign in",
|
||||||
"sign-out": "Sign out",
|
"sign-out": "Sign out",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"quota": "Quota"
|
"quota": "Monthly Quota",
|
||||||
|
"date": "Date",
|
||||||
|
"description": "Description",
|
||||||
|
"amount": "Amount"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"new-chat": "New Chat",
|
"new-chat": "New Chat",
|
||||||
@ -58,15 +61,19 @@
|
|||||||
"self": "Setting",
|
"self": "Setting",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"plan": {
|
"plan": {
|
||||||
|
"guest": "Guest",
|
||||||
"free": "Free",
|
"free": "Free",
|
||||||
"pro": "Pro",
|
"pro": "Pro",
|
||||||
"signup-for-more": "Sign up for more",
|
"signup-for-more": "Sign up for more",
|
||||||
"upgrade": "Upgrade",
|
"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"
|
"early-bird-checkout": "Early bird discount, 50% off for 1 year"
|
||||||
},
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"self": "Billing"
|
"self": "Billing",
|
||||||
|
"view-receipt": "View receipt"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"self": "Account"
|
"self": "Account"
|
||||||
@ -102,6 +109,6 @@
|
|||||||
},
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"self": "Payment",
|
"self": "Payment",
|
||||||
"login-to-buy": "Login to buy"
|
"sign-in-to-buy": "Sign in to buy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,11 +9,14 @@
|
|||||||
"setting": "Configuración",
|
"setting": "Configuración",
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"delete": "Borrar",
|
"delete": "Borrar",
|
||||||
|
"execute": "Ejecutar",
|
||||||
"sign-in": "Iniciar sesión",
|
"sign-in": "Iniciar sesión",
|
||||||
"sign-out": "Desconectar",
|
"sign-out": "Desconectar",
|
||||||
"back": "Devolver",
|
"back": "Devolver",
|
||||||
"quota": "Cuota",
|
"quota": "Cuota Mensual",
|
||||||
"execute": "Ejecutar"
|
"date": "Fecha",
|
||||||
|
"description": "Descripción",
|
||||||
|
"amount": "Cantidad"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"new-chat": "Nuevo Chat",
|
"new-chat": "Nuevo Chat",
|
||||||
@ -56,13 +59,19 @@
|
|||||||
"self": "Configuración",
|
"self": "Configuración",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"plan": {
|
"plan": {
|
||||||
|
"guest": "Invitada",
|
||||||
"free": "Gratis",
|
"free": "Gratis",
|
||||||
"pro": "Pro",
|
"pro": "Pro",
|
||||||
"signup-for-more": "Regístrese para obtener más",
|
"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": {
|
"billing": {
|
||||||
"self": "Facturación"
|
"self": "Facturación",
|
||||||
|
"view-receipt": "Ver recibo"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"self": "Cuenta"
|
"self": "Cuenta"
|
||||||
@ -92,11 +101,12 @@
|
|||||||
"banner": {
|
"banner": {
|
||||||
"data-storage": "Las configuraciones de conexión y las consultas se almacenan en tu navegador.",
|
"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.",
|
"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": {
|
"payment": {
|
||||||
"self": "Pago",
|
"self": "Pago",
|
||||||
"quota-overflow": "Cuota excedida. Proporcione su propia clave API de OpenAI.",
|
"sign-in-to-buy": "Inicia sesión para comprar"
|
||||||
"use-my-key": "Usar mi propia clave"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,10 @@
|
|||||||
"sign-in": "登录",
|
"sign-in": "登录",
|
||||||
"sign-out": "登出",
|
"sign-out": "登出",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"quota": "额度"
|
"quota": "每月额度",
|
||||||
|
"date": "日期",
|
||||||
|
"description": "描述",
|
||||||
|
"amount": "金额"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"new-chat": "新建会话",
|
"new-chat": "新建会话",
|
||||||
@ -58,15 +61,19 @@
|
|||||||
"self": "设置",
|
"self": "设置",
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
"plan": {
|
"plan": {
|
||||||
|
"guest": "访客",
|
||||||
"free": "免费版",
|
"free": "免费版",
|
||||||
"pro": "专业版",
|
"pro": "专业版",
|
||||||
"signup-for-more": "注册提升额度",
|
"signup-for-more": "注册获得额度",
|
||||||
"upgrade": "升级",
|
"upgrade": "升级",
|
||||||
"pro-question-per-month": "1000 次提问 / 月",
|
"renew": "续费",
|
||||||
|
"expired": "已过期",
|
||||||
|
"n-question-per-month": "{{count}} 次提问 / 月",
|
||||||
"early-bird-checkout": "早鸟优惠,5 折购买 1 年"
|
"early-bird-checkout": "早鸟优惠,5 折购买 1 年"
|
||||||
},
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"self": "计费"
|
"self": "计费",
|
||||||
|
"view-receipt": "查看收据"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"self": "账户"
|
"self": "账户"
|
||||||
@ -102,6 +109,6 @@
|
|||||||
},
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"self": "支付",
|
"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 { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||||
import EmailProvider from "next-auth/providers/email";
|
import EmailProvider from "next-auth/providers/email";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { getSubscriptionByEmail } from "../utils/subscription";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -27,6 +28,25 @@ export const authOptions: NextAuthOptions = {
|
|||||||
clientSecret: process.env.GITHUB_SECRET,
|
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: {
|
theme: {
|
||||||
brandColor: "#4F46E5",
|
brandColor: "#4F46E5",
|
||||||
logo: "/chat-logo.webp",
|
logo: "/chat-logo.webp",
|
||||||
|
@ -1,16 +1,38 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "./[...nextauth]";
|
import { authOptions } from "./[...nextauth]";
|
||||||
import requestIp from "request-ip";
|
import requestIp from "request-ip";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// Returns the login user email or the client IP address
|
// Returns the login user email or the client IP address
|
||||||
export const getEndUser = async (
|
export const getEndUser = async (
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse
|
res: NextApiResponse
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const session = await getServerSession(req, res, authOptions);
|
// Get from server session if available
|
||||||
if (session?.user?.email) {
|
const serverSession = await getServerSession(req, res, authOptions);
|
||||||
return session.user.email;
|
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) || "";
|
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,
|
ReconnectInterval,
|
||||||
} from "eventsource-parser";
|
} from "eventsource-parser";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { API_KEY } from "@/env";
|
import { openAIApiEndpoint, openAIApiKey, gpt35, hasFeature } from "@/utils";
|
||||||
import { openAIApiEndpoint, openAIApiKey, gpt35 } from "@/utils";
|
|
||||||
|
|
||||||
|
// Needs Edge for streaming response.
|
||||||
export const config = {
|
export const config = {
|
||||||
runtime: "edge",
|
runtime: "edge",
|
||||||
};
|
};
|
||||||
@ -18,28 +18,8 @@ const getApiEndpoint = (apiEndpoint: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handler = async (req: NextRequest) => {
|
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 reqBody = await req.json();
|
||||||
const openAIApiConfig = reqBody.openAIApiConfig;
|
const apiKey = req.headers.get("x-openai-key") || openAIApiKey;
|
||||||
const apiKey = openAIApiConfig?.key || openAIApiKey;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return new Response(
|
return new Response(
|
||||||
@ -58,10 +38,69 @@ const handler = async (req: NextRequest) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiEndpoint = getApiEndpoint(
|
const useServerKey = !req.headers.get("x-openai-key");
|
||||||
openAIApiConfig?.endpoint || openAIApiEndpoint
|
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: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
@ -78,10 +117,10 @@ const handler = async (req: NextRequest) => {
|
|||||||
user: req.ip,
|
user: req.ip,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!remoteRes.ok) {
|
||||||
return new Response(res.body, {
|
return new Response(remoteRes.body, {
|
||||||
status: res.status,
|
status: remoteRes.status,
|
||||||
statusText: res.statusText,
|
statusText: remoteRes.statusText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,11 +146,20 @@ const handler = async (req: NextRequest) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const parser = createParser(streamParser);
|
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));
|
parser.feed(decoder.decode(chunk));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (useServerKey) {
|
||||||
|
// Increment usage count
|
||||||
|
await fetch(usageUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: requestHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(stream);
|
return new Response(stream);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next";
|
|||||||
import { authOptions } from "../auth/[...nextauth]";
|
import { authOptions } from "../auth/[...nextauth]";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
import { PlanType } from "@/types";
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
||||||
// https://github.com/stripe/stripe-node#configuration
|
// https://github.com/stripe/stripe-node#configuration
|
||||||
@ -44,7 +45,7 @@ export default async function handler(
|
|||||||
],
|
],
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: process.env.STRIPE_ANNUAL_LICENSE_PRICE_ID,
|
price: process.env.STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -58,6 +59,8 @@ export default async function handler(
|
|||||||
// setup_future_usage: "off_session",
|
// setup_future_usage: "off_session",
|
||||||
metadata: {
|
metadata: {
|
||||||
email: session?.user?.email!,
|
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.
|
// 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 { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { Conversation, Message } from "@/types";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { gpt35 } from "@/utils";
|
import { authOptions } from "./auth/[...nextauth]";
|
||||||
|
import { getSubscriptionByEmail } from "./utils/subscription";
|
||||||
|
import { addUsage, getCurrentMonthUsage } from "./utils/usage";
|
||||||
import { getEndUser } from "./auth/end-user";
|
import { getEndUser } from "./auth/end-user";
|
||||||
|
import { Quota } from "@/types";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "GET" && req.method !== "POST") {
|
||||||
export default async function handler(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse
|
|
||||||
) {
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).json([]);
|
return res.status(405).json([]);
|
||||||
}
|
}
|
||||||
const conversation = req.body.conversation as Conversation;
|
|
||||||
const messages = req.body.messages as Message[];
|
const endUser = await getEndUser(req, res);
|
||||||
try {
|
|
||||||
const chat = await prisma.chat.findUnique({
|
// Get from server session if available
|
||||||
where: {
|
const serverSession = await getServerSession(req, res, authOptions);
|
||||||
id: conversation.id,
|
let subscripion = serverSession?.user?.subscription;
|
||||||
},
|
if (!subscripion) {
|
||||||
});
|
subscripion = await getSubscriptionByEmail(endUser);
|
||||||
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);
|
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 { buffer } from "micro";
|
||||||
import Cors from "micro-cors";
|
import Cors from "micro-cors";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { PrismaClient, Prisma } from "@prisma/client";
|
import { PrismaClient, Prisma, SubscriptionPlan } from "@prisma/client";
|
||||||
|
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
import { PlanType } from "@/types";
|
||||||
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
||||||
// https://github.com/stripe/stripe-node#configuration
|
// https://github.com/stripe/stripe-node#configuration
|
||||||
apiVersion: "2022-11-15",
|
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));
|
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 = {
|
const subscription: Prisma.SubscriptionUncheckedCreateInput = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
email: paymentIntent.metadata.email,
|
||||||
createdAt: new Date(paymentIntent.created * 1000),
|
createdAt: new Date(paymentIntent.created * 1000),
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
startAt: today,
|
startAt: today,
|
||||||
expireAt: new Date(today.setFullYear(today.getFullYear() + 1)),
|
expireAt: yearFromNow,
|
||||||
paymentId: paymentIntent.id,
|
paymentId: paymentIntent.id,
|
||||||
customerId: customerId || "",
|
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 });
|
await prisma.subscription.create({ data: subscription });
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
} else if (event.type === "payment_intent.payment_failed") {
|
||||||
|
@ -4,7 +4,13 @@ import { Fragment, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
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 SettingView from "../../components/SettingView";
|
||||||
import StripeCheckPaymentBanner from "../../components/StripeCheckPaymentBanner";
|
import StripeCheckPaymentBanner from "../../components/StripeCheckPaymentBanner";
|
||||||
|
|
||||||
@ -45,7 +51,11 @@ const SettingPage: NextPage = () => {
|
|||||||
|
|
||||||
<div className="dark:bg-zinc-800">
|
<div className="dark:bg-zinc-800">
|
||||||
<Transition.Root show={sidebarOpen} as={Fragment}>
|
<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
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition-opacity ease-linear duration-300"
|
enter="transition-opacity ease-linear duration-300"
|
||||||
@ -79,9 +89,16 @@ const SettingPage: NextPage = () => {
|
|||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="absolute left-full top-0 flex w-16 justify-center pt-5">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
@ -99,13 +116,17 @@ const SettingPage: NextPage = () => {
|
|||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={classNames(
|
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"
|
"group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className={classNames(
|
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"
|
"h-6 w-6 shrink-0"
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -149,7 +170,9 @@ const SettingPage: NextPage = () => {
|
|||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className={classNames(
|
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"
|
"h-6 w-6 shrink-0 dark:text-gray-400"
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -166,14 +189,22 @@ const SettingPage: NextPage = () => {
|
|||||||
</div>
|
</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">
|
<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>
|
<span className="sr-only">Open sidebar</span>
|
||||||
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
|
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="lg:pl-72">
|
<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">
|
<div className="px-4 sm:px-6 lg:px-8">
|
||||||
<SettingView />
|
<SettingView />
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,3 +6,4 @@ export * from "./message";
|
|||||||
export * from "./layout";
|
export * from "./layout";
|
||||||
export * from "./query";
|
export * from "./query";
|
||||||
export * from "./setting";
|
export * from "./setting";
|
||||||
|
export * from "../utils/event-emitter";
|
||||||
|
@ -9,3 +9,4 @@ export * from "./api";
|
|||||||
export * from "./connector";
|
export * from "./connector";
|
||||||
export * from "./assistant";
|
export * from "./assistant";
|
||||||
export * from "./quota";
|
export * from "./quota";
|
||||||
|
export * from "./subscription";
|
||||||
|
@ -2,8 +2,3 @@ export interface Quota {
|
|||||||
current: number;
|
current: number;
|
||||||
limit: 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) => {
|
const matrix: { [key: string]: { [feature: string]: boolean } } = {
|
||||||
if (process.env.NODE_ENV === "development") {
|
development: {
|
||||||
return true;
|
debug: true,
|
||||||
}
|
account: true,
|
||||||
return false;
|
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 "./execution";
|
||||||
export * from "./model";
|
export * from "./model";
|
||||||
export * from "./feature";
|
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/*"]
|
"@/*": ["./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"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user