update subscription page

This commit is contained in:
Zhang Minghan 2023-09-10 22:23:21 +08:00
parent 618b0511ba
commit b7b9c3b582
16 changed files with 711 additions and 37 deletions

View File

@ -16,6 +16,7 @@
"@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^1.2.2",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",

50
app/pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ dependencies:
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.0.2 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) 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': '@radix-ui/react-separator':
specifier: ^1.0.3 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) 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 '@nodelib/fs.scandir': 2.1.5
fastq: 1.15.0 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: /@radix-ui/primitive@1.0.1:
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
dependencies: dependencies:
@ -2140,6 +2149,47 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false 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): /@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==} resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==}
peerDependencies: peerDependencies:

View File

@ -5,7 +5,7 @@ import { Button } from "./components/ui/button.tsx";
import router from "./router.ts"; import router from "./router.ts";
import I18nProvider from "./components/I18nProvider.tsx"; import I18nProvider from "./components/I18nProvider.tsx";
import ProjectLink from "./components/ProjectLink.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 { Provider, useDispatch, useSelector } from "react-redux";
import { toggleMenu } from "./store/menu.ts"; import { toggleMenu } from "./store/menu.ts";
import store from "./store/index.ts"; import store from "./store/index.ts";
@ -30,7 +30,9 @@ import { useTranslation } from "react-i18next";
import Quota from "./routes/Quota.tsx"; import Quota from "./routes/Quota.tsx";
import { openDialog as openQuotaDialog, quotaSelector } from "./store/quota.ts"; import { openDialog as openQuotaDialog, quotaSelector } from "./store/quota.ts";
import { openDialog as openPackageDialog } from "./store/package.ts"; import { openDialog as openPackageDialog } from "./store/package.ts";
import { openDialog as openSub } from "./store/subscription.ts";
import Package from "./routes/Package.tsx"; import Package from "./routes/Package.tsx";
import Subscription from "./routes/Subscription.tsx";
function Settings() { function Settings() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -56,9 +58,15 @@ function Settings() {
{quota} {quota}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => dispatch(openQuotaDialog())}> <DropdownMenuItem onClick={() => dispatch(openQuotaDialog())}>
<BadgeCent className={`h-4 w-4 mr-1`} />
{t("quota")} {t("quota")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => dispatch(openSub())}>
<CalendarPlus className={`h-4 w-4 mr-1`} />
{t("sub.title")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => dispatch(openPackageDialog())}> <DropdownMenuItem onClick={() => dispatch(openPackageDialog())}>
<Boxes className={`h-4 w-4 mr-1`} />
{t("pkg.title")} {t("pkg.title")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -126,6 +134,7 @@ function App() {
<Toaster /> <Toaster />
<Quota /> <Quota />
<Package /> <Package />
<Subscription />
</Provider> </Provider>
); );
} }

View File

@ -70,3 +70,26 @@ strong {
flex-direction: row; flex-direction: row;
align-items: center; 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;
}

View File

@ -36,21 +36,6 @@
.quota-dialog { .quota-dialog {
max-width: min(90vw, 1044px) !important; 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 { .amount-container {
@ -224,6 +209,14 @@
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
svg {
flex-shrink: 0;
}
}
.info {
margin-top: 6px;
} }
} }

View 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;
}
}

View 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,
}

View File

@ -18,3 +18,5 @@ export function login() {
axios.defaults.baseURL = rest_api; axios.defaults.baseURL = rest_api;
axios.defaults.headers.post["Content-Type"] = "application/json"; axios.defaults.headers.post["Content-Type"] = "application/json";
console.debug(`chatnio application (version: ${version})`);

View File

@ -11,6 +11,17 @@ type PackageResponse = {
teenager: boolean; 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> { export async function buyQuota(quota: number): Promise<QuotaResponse> {
try { try {
const resp = await axios.post(`/buy`, { quota }); 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> { export async function getPackage(): Promise<PackageResponse> {
try { try {
const resp = await axios.get(`/package`); const resp = await axios.get(`/package`);
if (resp.data.status === false) {
return { status: false, cert: false, teenager: false };
}
return { return {
status: resp.data.status, status: resp.data.status,
cert: resp.data.data.cert, cert: resp.data.data.cert,
@ -34,3 +48,32 @@ export async function getPackage(): Promise<PackageResponse> {
return { status: false, cert: false, teenager: false }; 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" };
}
}

View File

@ -75,6 +75,7 @@ const resources = {
failed: "Purchase failed", failed: "Purchase failed",
"failed-prompt": "failed-prompt":
"Failed to purchase points. Please make sure you have enough balance, you will soon jump to deeptrain wallet to pay balance.", "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: { pkg: {
title: "Packages", title: "Packages",
@ -91,6 +92,43 @@ const resources = {
false: "Not Received", 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: { cn: {
@ -157,6 +195,7 @@ const resources = {
failed: "购买失败", failed: "购买失败",
"failed-prompt": "failed-prompt":
"购买点数失败。请确保您有足够的余额,您即将跳转到 deeptrain 钱包支付余额。", "购买点数失败。请确保您有足够的余额,您即将跳转到 deeptrain 钱包支付余额。",
"gpt4-tip": "提示web 联网版功能可能会带来更多的输入点数消耗",
}, },
pkg: { pkg: {
title: "礼包", title: "礼包",
@ -172,6 +211,43 @@ const resources = {
false: "无法领取", 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}}折",
}, },
}, },
}; };

View File

@ -5,9 +5,7 @@ import "./conf.ts";
import "./i18n.ts"; import "./i18n.ts";
import "./assets/main.less"; import "./assets/main.less";
import "./assets/globals.less"; import "./assets/globals.less";
import {version} from "./conf.ts"; import "./conf.ts";
console.debug(`chatnio application (version: ${version})`);
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>

View File

@ -1,7 +1,7 @@
import { useToast } from "../components/ui/use-toast.ts"; import { useToast } from "../components/ui/use-toast.ts";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { ToastAction } from "../components/ui/toast.tsx"; import { ToastAction } from "../components/ui/toast.tsx";
import { login, tokenField } from "../conf.ts"; import { login } from "../conf.ts";
import { useEffect } from "react"; import { useEffect } from "react";
import Loader from "../components/Loader.tsx"; import Loader from "../components/Loader.tsx";
import "../assets/auth.less"; import "../assets/auth.less";
@ -16,23 +16,24 @@ function Auth() {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const search = new URLSearchParams(useLocation().search); const search = new URLSearchParams(useLocation().search);
const token = (search.get(tokenField) || "").trim(); const token = (search.get("token") || "").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);
}
useEffect(() => { 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 axios
.post("/login", { token }) .post("/login", { token })
.then((res) => { .then((res) => {

View File

@ -20,7 +20,7 @@ import {
Cloud, Cloud,
ExternalLink, ExternalLink,
HardDriveDownload, HardDriveDownload,
HardDriveUpload, HardDriveUpload, Info,
Plus, Plus,
} from "lucide-react"; } from "lucide-react";
import { Input } from "../components/ui/input.tsx"; import { Input } from "../components/ui/input.tsx";
@ -88,7 +88,7 @@ function Quota() {
open={open} open={open}
onOpenChange={(state: boolean) => dispatch(setDialog(state))} onOpenChange={(state: boolean) => dispatch(setDialog(state))}
> >
<DialogContent className={`quota-dialog`}> <DialogContent className={`quota-dialog flex-dialog`}>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("buy.choose")}</DialogTitle> <DialogTitle>{t("buy.choose")}</DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>
@ -275,6 +275,12 @@ function Quota() {
4.3 / 1k token 4.3 / 1k token
</div> </div>
</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> </div>
</div> </div>

View 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;

View File

@ -4,6 +4,7 @@ import authReducer from "./auth";
import chatReducer from "./chat"; import chatReducer from "./chat";
import quotaReducer from "./quota"; import quotaReducer from "./quota";
import packageReducer from "./package"; import packageReducer from "./package";
import subscriptionReducer from "./subscription";
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
@ -12,6 +13,7 @@ const store = configureStore({
chat: chatReducer, chat: chatReducer,
quota: quotaReducer, quota: quotaReducer,
package: packageReducer, package: packageReducer,
subscription: subscriptionReducer,
}, },
}); });

View 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();
};