mirror of
https://github.com/coaidev/coai.git
synced 2025-05-21 22:10:12 +09:00
update subscription page
This commit is contained in:
parent
618b0511ba
commit
b7b9c3b582
@ -16,6 +16,7 @@
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-select": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
|
50
app/pnpm-lock.yaml
generated
50
app/pnpm-lock.yaml
generated
@ -20,6 +20,9 @@ dependencies:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
@ -1675,6 +1678,12 @@ packages:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.15.0
|
||||
|
||||
/@radix-ui/number@1.0.1:
|
||||
resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.11
|
||||
dev: false
|
||||
|
||||
/@radix-ui/primitive@1.0.1:
|
||||
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
|
||||
dependencies:
|
||||
@ -2140,6 +2149,47 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-select@1.2.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.11
|
||||
'@radix-ui/number': 1.0.1
|
||||
'@radix-ui/primitive': 1.0.1
|
||||
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-focus-scope': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-popper': 1.1.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-portal': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.15)(react@18.2.0)
|
||||
'@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@types/react': 18.2.15
|
||||
'@types/react-dom': 18.2.7
|
||||
aria-hidden: 1.2.3
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-remove-scroll: 2.5.5(@types/react@18.2.15)(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-separator@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==}
|
||||
peerDependencies:
|
||||
|
@ -5,7 +5,7 @@ import { Button } from "./components/ui/button.tsx";
|
||||
import router from "./router.ts";
|
||||
import I18nProvider from "./components/I18nProvider.tsx";
|
||||
import ProjectLink from "./components/ProjectLink.tsx";
|
||||
import { Cloud, Menu } from "lucide-react";
|
||||
import {BadgeCent, Boxes, CalendarPlus, Cloud, Menu} from "lucide-react";
|
||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||
import { toggleMenu } from "./store/menu.ts";
|
||||
import store from "./store/index.ts";
|
||||
@ -30,7 +30,9 @@ import { useTranslation } from "react-i18next";
|
||||
import Quota from "./routes/Quota.tsx";
|
||||
import { openDialog as openQuotaDialog, quotaSelector } from "./store/quota.ts";
|
||||
import { openDialog as openPackageDialog } from "./store/package.ts";
|
||||
import { openDialog as openSub } from "./store/subscription.ts";
|
||||
import Package from "./routes/Package.tsx";
|
||||
import Subscription from "./routes/Subscription.tsx";
|
||||
|
||||
function Settings() {
|
||||
const { t } = useTranslation();
|
||||
@ -56,9 +58,15 @@ function Settings() {
|
||||
{quota}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => dispatch(openQuotaDialog())}>
|
||||
<BadgeCent className={`h-4 w-4 mr-1`} />
|
||||
{t("quota")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => dispatch(openSub())}>
|
||||
<CalendarPlus className={`h-4 w-4 mr-1`} />
|
||||
{t("sub.title")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => dispatch(openPackageDialog())}>
|
||||
<Boxes className={`h-4 w-4 mr-1`} />
|
||||
{t("pkg.title")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@ -126,6 +134,7 @@ function App() {
|
||||
<Toaster />
|
||||
<Quota />
|
||||
<Package />
|
||||
<Subscription />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
@ -70,3 +70,26 @@ strong {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-dialog {
|
||||
border-radius: var(--radius) !important;
|
||||
max-height: calc(100vh - 2rem) !important;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
outline: 0;
|
||||
|
||||
@media (max-width: 520px) {
|
||||
& {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transform: translate(var(--tw-translate-x), calc(var(--tw-translate-y) - 1rem)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cent {
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
@ -36,21 +36,6 @@
|
||||
|
||||
.quota-dialog {
|
||||
max-width: min(90vw, 1044px) !important;
|
||||
border-radius: var(--radius) !important;
|
||||
max-height: calc(100vh - 2rem) !important;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
|
||||
@media (max-width: 520px) {
|
||||
& {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transform: translate(var(--tw-translate-x), calc(var(--tw-translate-y) - 1rem)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.amount-container {
|
||||
@ -224,6 +209,14 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
|
109
app/src/assets/subscription.less
Normal file
109
app/src/assets/subscription.less
Normal file
@ -0,0 +1,109 @@
|
||||
.sub-dialog {
|
||||
max-width: min(90vw, 844px) !important;
|
||||
}
|
||||
|
||||
.sub-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 18px 4px !important;
|
||||
margin-bottom: 32px !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
color: hsl(45, 100%, 50%);
|
||||
padding: 8px 16px;
|
||||
margin-top: 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid hsl(var(--border));
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.plan-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 36px 64px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid hsl(var(--border));
|
||||
color: hsl(var(--text));
|
||||
min-width: 294px;
|
||||
|
||||
&.pro {
|
||||
border-color: hsl(var(--text-secondary));
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 24px 0;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-wrapper {
|
||||
margin: 24px auto 8px;
|
||||
|
||||
.price {
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.plan {
|
||||
scale: .95;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.plan-wrapper {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
119
app/src/components/ui/select.tsx
Normal file
119
app/src/components/ui/select.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "./lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
}
|
@ -18,3 +18,5 @@ export function login() {
|
||||
|
||||
axios.defaults.baseURL = rest_api;
|
||||
axios.defaults.headers.post["Content-Type"] = "application/json";
|
||||
|
||||
console.debug(`chatnio application (version: ${version})`);
|
||||
|
@ -11,6 +11,17 @@ type PackageResponse = {
|
||||
teenager: boolean;
|
||||
};
|
||||
|
||||
type SubscriptionResponse = {
|
||||
status: boolean;
|
||||
is_subscribed: boolean;
|
||||
expired: number;
|
||||
}
|
||||
|
||||
type BuySubscriptionResponse = {
|
||||
status: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function buyQuota(quota: number): Promise<QuotaResponse> {
|
||||
try {
|
||||
const resp = await axios.post(`/buy`, { quota });
|
||||
@ -24,6 +35,9 @@ export async function buyQuota(quota: number): Promise<QuotaResponse> {
|
||||
export async function getPackage(): Promise<PackageResponse> {
|
||||
try {
|
||||
const resp = await axios.get(`/package`);
|
||||
if (resp.data.status === false) {
|
||||
return { status: false, cert: false, teenager: false };
|
||||
}
|
||||
return {
|
||||
status: resp.data.status,
|
||||
cert: resp.data.data.cert,
|
||||
@ -34,3 +48,32 @@ export async function getPackage(): Promise<PackageResponse> {
|
||||
return { status: false, cert: false, teenager: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubscription(): Promise<SubscriptionResponse> {
|
||||
try {
|
||||
const resp = await axios.get(`/subscription`);
|
||||
if (resp.data.status === false) {
|
||||
return { status: false, is_subscribed: false, expired: 0 };
|
||||
}
|
||||
return {
|
||||
status: resp.data.status,
|
||||
is_subscribed: resp.data.data.is_subscribed,
|
||||
expired: resp.data.data.expired,
|
||||
};
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
return { status: false, is_subscribed: false, expired: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function buySubscription(
|
||||
month: number,
|
||||
): Promise<BuySubscriptionResponse> {
|
||||
try {
|
||||
const resp = await axios.post(`/subscription`, { month });
|
||||
return resp.data as BuySubscriptionResponse;
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
return { status: false, error: "network error" };
|
||||
}
|
||||
}
|
||||
|
@ -75,6 +75,7 @@ const resources = {
|
||||
failed: "Purchase failed",
|
||||
"failed-prompt":
|
||||
"Failed to purchase points. Please make sure you have enough balance, you will soon jump to deeptrain wallet to pay balance.",
|
||||
"gpt4-tip": "Tip: web searching feature may consume more input points",
|
||||
},
|
||||
pkg: {
|
||||
title: "Packages",
|
||||
@ -91,6 +92,43 @@ const resources = {
|
||||
false: "Not Received",
|
||||
},
|
||||
},
|
||||
sub: {
|
||||
title: "Subscription",
|
||||
"dialog-title": "Subscription Plan",
|
||||
free: "Free",
|
||||
"free-price": "Free Forever",
|
||||
pro: "Pro",
|
||||
"pro-price": "8 CNY/Month",
|
||||
"free-gpt3": "GPT-3.5 Free Forever",
|
||||
"free-dalle": "5 free quotas per day",
|
||||
"free-web": "web searching feature",
|
||||
"free-conversation": "conversation storage",
|
||||
"free-api": "API calls",
|
||||
"pro-gpt4": "GPT-4 10 requests per day",
|
||||
"pro-dalle": "50 quotas per day",
|
||||
"pro-service": "Priority Service Support",
|
||||
"pro-thread": "Concurrency Increase",
|
||||
"current": "Current Plan",
|
||||
"upgrade": "Upgrade",
|
||||
"renew": "Renew",
|
||||
"cannot-select": "Cannot Select",
|
||||
"select-time": "Select Subscription Time",
|
||||
"price": "Price {{price}} CNY",
|
||||
"expired": "Your Pro subscription will expire in {{expired}} days",
|
||||
time: {
|
||||
1: "1 Month",
|
||||
3: "3 Months",
|
||||
6: "6 Months",
|
||||
12: "1 Year",
|
||||
},
|
||||
"success": "Subscribe success",
|
||||
"success-prompt": "You have successfully subscribed to {{month}} months of Pro.",
|
||||
"failed": "Subscribe failed",
|
||||
"failed-prompt": "Failed to subscribe, please make sure you have enough balance, you will soon jump to deeptrain wallet to pay balance.",
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"percent": "{{cent}}0%",
|
||||
},
|
||||
},
|
||||
cn: {
|
||||
@ -157,6 +195,7 @@ const resources = {
|
||||
failed: "购买失败",
|
||||
"failed-prompt":
|
||||
"购买点数失败。请确保您有足够的余额,您即将跳转到 deeptrain 钱包支付余额。",
|
||||
"gpt4-tip": "提示:web 联网版功能可能会带来更多的输入点数消耗",
|
||||
},
|
||||
pkg: {
|
||||
title: "礼包",
|
||||
@ -172,6 +211,43 @@ const resources = {
|
||||
false: "无法领取",
|
||||
},
|
||||
},
|
||||
sub: {
|
||||
title: "订阅",
|
||||
"dialog-title": "订阅计划",
|
||||
free: "免费版",
|
||||
"free-price": "永久免费",
|
||||
pro: "专业版",
|
||||
"pro-price": "8 元/月",
|
||||
"free-gpt3": "GPT-3.5 永久免费",
|
||||
"free-dalle": "每日 5 次免费绘图",
|
||||
"free-web": "联网搜索功能",
|
||||
"free-conversation": "对话存储记录",
|
||||
"free-api": "API 调用",
|
||||
"pro-gpt4": "GPT-4 每日请求 10 次",
|
||||
"pro-dalle": "每日 50 次绘图",
|
||||
"pro-service": "优先服务支持",
|
||||
"pro-thread": "并发数提升",
|
||||
"current": "当前计划",
|
||||
"upgrade": "升级",
|
||||
"renew": "续费",
|
||||
"cannot-select": "无法选择",
|
||||
"select-time": "选择订阅时间",
|
||||
"price": "价格 {{price}} 元",
|
||||
"expired": "您的专业版订阅还有 {{expired}} 天到期",
|
||||
time: {
|
||||
1: "1个月",
|
||||
3: "3个月",
|
||||
6: "半年",
|
||||
12: "1年",
|
||||
},
|
||||
"success": "订阅成功",
|
||||
"success-prompt": "您已成功订阅 {{month}} 月专业版。",
|
||||
"failed": "订阅失败",
|
||||
"failed-prompt": "订阅失败,请确保您有足够的余额,您即将跳转到 deeptrain 钱包支付余额。",
|
||||
},
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"percent": "{{cent}}折",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -5,9 +5,7 @@ import "./conf.ts";
|
||||
import "./i18n.ts";
|
||||
import "./assets/main.less";
|
||||
import "./assets/globals.less";
|
||||
import {version} from "./conf.ts";
|
||||
|
||||
console.debug(`chatnio application (version: ${version})`);
|
||||
import "./conf.ts";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useToast } from "../components/ui/use-toast.ts";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { ToastAction } from "../components/ui/toast.tsx";
|
||||
import { login, tokenField } from "../conf.ts";
|
||||
import { login } from "../conf.ts";
|
||||
import { useEffect } from "react";
|
||||
import Loader from "../components/Loader.tsx";
|
||||
import "../assets/auth.less";
|
||||
@ -16,23 +16,24 @@ function Auth() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const search = new URLSearchParams(useLocation().search);
|
||||
const token = (search.get(tokenField) || "").trim();
|
||||
|
||||
if (!token.length) {
|
||||
toast({
|
||||
title: t("invalid-token"),
|
||||
description: t("invalid-token-prompt"),
|
||||
action: (
|
||||
<ToastAction altText={t("try-again")} onClick={login}>
|
||||
{t("try-again")}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
setTimeout(login, 2500);
|
||||
}
|
||||
const token = (search.get("token") || "").trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token.length) {
|
||||
toast({
|
||||
title: t("invalid-token"),
|
||||
description: t("invalid-token-prompt"),
|
||||
action: (
|
||||
<ToastAction altText={t("try-again")} onClick={login}>
|
||||
{t("try-again")}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
setTimeout(login, 2500);
|
||||
return;
|
||||
}
|
||||
|
||||
axios
|
||||
.post("/login", { token })
|
||||
.then((res) => {
|
||||
|
@ -20,7 +20,7 @@ import {
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
HardDriveDownload,
|
||||
HardDriveUpload,
|
||||
HardDriveUpload, Info,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Input } from "../components/ui/input.tsx";
|
||||
@ -88,7 +88,7 @@ function Quota() {
|
||||
open={open}
|
||||
onOpenChange={(state: boolean) => dispatch(setDialog(state))}
|
||||
>
|
||||
<DialogContent className={`quota-dialog`}>
|
||||
<DialogContent className={`quota-dialog flex-dialog`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("buy.choose")}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
@ -275,6 +275,12 @@ function Quota() {
|
||||
4.3 / 1k token
|
||||
</div>
|
||||
</div>
|
||||
<div className={`row desc`}>
|
||||
<div className={`column info`}>
|
||||
<Info className={`h-4 w-4`} />
|
||||
{t("buy.gpt4-tip")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
185
app/src/routes/Subscription.tsx
Normal file
185
app/src/routes/Subscription.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import {
|
||||
dialogSelector,
|
||||
expiredSelector,
|
||||
isSubscribedSelector, refreshSubscription,
|
||||
refreshSubscriptionTask,
|
||||
setDialog
|
||||
} from "../store/subscription.ts";
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from "../components/ui/dialog.tsx";
|
||||
import {useDispatch, useSelector} from "react-redux";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useToast} from "../components/ui/use-toast.ts";
|
||||
import React, {useEffect} from "react";
|
||||
import "../assets/subscription.less";
|
||||
import {
|
||||
Calendar,
|
||||
Compass,
|
||||
Globe,
|
||||
Image,
|
||||
ImagePlus,
|
||||
LifeBuoy,
|
||||
MessageSquare,
|
||||
MessagesSquare, Plus,
|
||||
ServerCrash, Webhook
|
||||
} from "lucide-react";
|
||||
import {Button} from "../components/ui/button.tsx";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "../components/ui/select.tsx";
|
||||
import {Badge} from "../components/ui/badge.tsx";
|
||||
import {buySubscription} from "../conversation/addition.ts";
|
||||
|
||||
function calc_prize(month: number): number {
|
||||
if (month >= 12) {
|
||||
return 8 * month * 0.9;
|
||||
}
|
||||
return 8 * month;
|
||||
}
|
||||
|
||||
type UpgradeProps = {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
async function callBuyAction(t: any, toast: any, month: number): Promise<boolean> {
|
||||
const res = await buySubscription(month);
|
||||
if (res.status) {
|
||||
toast({
|
||||
title: t("sub.success"),
|
||||
description: t("sub.success-prompt", {
|
||||
month,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("sub.failed"),
|
||||
description: t("sub.failed-prompt"),
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.open(
|
||||
"https://deeptrain.lightxi.com/home/wallet",
|
||||
);
|
||||
}, 2000);
|
||||
}
|
||||
return res.status;
|
||||
}
|
||||
function Upgrade({ children }: UpgradeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [month, setMonth] = React.useState(1);
|
||||
const dispatch = useDispatch();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className={`flex-dialog`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ t('sub.select-time') }</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="upgrade-wrapper">
|
||||
<Select onValueChange={
|
||||
(value: number) => setMonth(value)
|
||||
}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder={t(`sub.time.${month}`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={1}>{t(`sub.time.1`)}</SelectItem>
|
||||
<SelectItem value={3}>{t(`sub.time.3`)}</SelectItem>
|
||||
<SelectItem value={6}>{t(`sub.time.6`)}</SelectItem>
|
||||
<SelectItem value={12}>
|
||||
{t(`sub.time.12`)}
|
||||
<Badge className={`ml-2 cent`}>{t(`percent`, { cent: 9 })}</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className={`price`}>{ t('sub.price', { price: calc_prize(month) }) }</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant={`outline`} onClick={
|
||||
() => setOpen(false)
|
||||
}>{ t('cancel') }</Button>
|
||||
<Button>
|
||||
<Plus className={`h-4 w-4 mr-1`} onClick={
|
||||
async () => {
|
||||
const res = await callBuyAction(t, toast, month);
|
||||
if (res) {
|
||||
setOpen(false);
|
||||
await refreshSubscription(dispatch);
|
||||
}
|
||||
}
|
||||
} />
|
||||
{ t('confirm') }
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Subscription() {
|
||||
const { t } = useTranslation();
|
||||
const open = useSelector(dialogSelector);
|
||||
const subscription = useSelector(isSubscribedSelector);
|
||||
const expired = useSelector(expiredSelector);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
refreshSubscriptionTask(dispatch);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state: boolean) => dispatch(setDialog(state))}
|
||||
>
|
||||
<DialogContent className={`sub-dialog flex-dialog`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sub.dialog-title")}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className={`sub-wrapper`}>
|
||||
{
|
||||
subscription && (
|
||||
<div className={`date`}>
|
||||
<Calendar className={`h-4 w-4 mr-1`} />
|
||||
{t("sub.expired", { expired })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className={`plan-wrapper`}>
|
||||
<div className={`plan`}>
|
||||
<div className={`title`}>{t("sub.free")}</div>
|
||||
<div className={`price`}>{t("sub.free-price")}</div>
|
||||
<div className={`desc`}>
|
||||
<div><MessageSquare className={`h-4 w-4 mr-1`} />{t("sub.free-gpt3")}</div>
|
||||
<div><Image className={`h-4 w-4 mr-1`} />{t("sub.free-dalle")}</div>
|
||||
<div><Globe className={`h-4 w-4 mr-1`} />{t("sub.free-web")}</div>
|
||||
<div><MessagesSquare className={`h-4 w-4 mr-1`} />{t("sub.free-conversation")}</div>
|
||||
<div><Webhook className={`h-4 w-4 mr-1`} />{t("sub.free-api")}</div>
|
||||
</div>
|
||||
<Button className={`action`} variant={`outline`} disabled>
|
||||
{subscription ? t("sub.cannot-select") : t("sub.current")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`plan pro`}>
|
||||
<div className={`title`}>{t("sub.pro")}</div>
|
||||
<div className={`price`}>{t("sub.pro-price")}</div>
|
||||
<div className={`desc`}>
|
||||
<div><Compass className={`h-4 w-4 mr-1`} />{t("sub.pro-gpt4")}</div>
|
||||
<div><ImagePlus className={`h-4 w-4 mr-1`} />{t("sub.pro-dalle")}</div>
|
||||
<div><LifeBuoy className={`h-4 w-4 mr-1`} />{t("sub.pro-service")}</div>
|
||||
<div><ServerCrash className={`h-4 w-4 mr-1`} />{t("sub.pro-thread")}</div>
|
||||
</div>
|
||||
<Upgrade>
|
||||
<Button className={`action`} variant={`default`}>
|
||||
{subscription ? t("sub.renew") : t("sub.upgrade")}
|
||||
</Button>
|
||||
</Upgrade>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default Subscription;
|
@ -4,6 +4,7 @@ import authReducer from "./auth";
|
||||
import chatReducer from "./chat";
|
||||
import quotaReducer from "./quota";
|
||||
import packageReducer from "./package";
|
||||
import subscriptionReducer from "./subscription";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
@ -12,6 +13,7 @@ const store = configureStore({
|
||||
chat: chatReducer,
|
||||
quota: quotaReducer,
|
||||
package: packageReducer,
|
||||
subscription: subscriptionReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
57
app/src/store/subscription.ts
Normal file
57
app/src/store/subscription.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import {getSubscription} from "../conversation/addition.ts";
|
||||
|
||||
export const subscriptionSlice = createSlice({
|
||||
name: "subscription",
|
||||
initialState: {
|
||||
dialog: false,
|
||||
is_subscribed: false,
|
||||
expired: 0,
|
||||
},
|
||||
reducers: {
|
||||
toggleDialog: (state) => {
|
||||
state.dialog = !state.dialog;
|
||||
},
|
||||
setDialog: (state, action) => {
|
||||
state.dialog = action.payload as boolean;
|
||||
},
|
||||
openDialog: (state) => {
|
||||
state.dialog = true;
|
||||
},
|
||||
closeDialog: (state) => {
|
||||
state.dialog = false;
|
||||
},
|
||||
updateSubscription: (state, action) => {
|
||||
state.is_subscribed = action.payload.is_subscribed;
|
||||
state.expired = action.payload.expired;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleDialog,
|
||||
setDialog,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
updateSubscription,
|
||||
} = subscriptionSlice.actions;
|
||||
export default subscriptionSlice.reducer;
|
||||
|
||||
export const dialogSelector = (state: any): boolean => state.subscription.dialog;
|
||||
export const isSubscribedSelector = (state: any): boolean => state.subscription.is_subscribed;
|
||||
export const expiredSelector = (state: any): number => state.subscription.expired;
|
||||
|
||||
export const refreshSubscription = async (dispatch: any) => {
|
||||
const current = new Date().getTime(); //@ts-ignore
|
||||
if (window.hasOwnProperty("subscription") && current - window.subscription < 2500)
|
||||
return; //@ts-ignore
|
||||
window.subscription = current;
|
||||
|
||||
const response = await getSubscription();
|
||||
if (response.status) dispatch(updateSubscription(response));
|
||||
};
|
||||
|
||||
export const refreshSubscriptionTask = (dispatch: any) => {
|
||||
setInterval(() => refreshSubscription(dispatch), 20000);
|
||||
refreshSubscription(dispatch).then();
|
||||
};
|
Loading…
Reference in New Issue
Block a user