🎉 chore(init): project initialization
@@ -0,0 +1,26 @@
|
||||
# Default Language
|
||||
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
|
||||
|
||||
# Site URL and API URL
|
||||
NEXT_PUBLIC_SITE_URL=https://user.ppanel.dev
|
||||
NEXT_PUBLIC_API_URL=https://api.ppanel.dev
|
||||
|
||||
# Contact Email
|
||||
NEXT_PUBLIC_EMAIL=support@ppanel.dev
|
||||
# Community Links
|
||||
NEXT_PUBLIC_TELEGRAM_LINK=https://t.me/VPNboardChat
|
||||
NEXT_PUBLIC_TWITTER_LINK=https://github.com/perfect-panel/ppanel-user-web
|
||||
NEXT_PUBLIC_DISCORD_LINK=https://github.com/perfect-panel/ppanel-user-web
|
||||
NEXT_PUBLIC_INSTAGRAM_LINK=https://github.com/perfect-panel/ppanel-user-web
|
||||
NEXT_PUBLIC_LINKEDIN_LINK=https://github.com/perfect-panel/ppanel-user-web
|
||||
NEXT_PUBLIC_FACEBOOK_LINK=https://github.com/perfect-panel/ppanel-user-web
|
||||
NEXT_PUBLIC_GITHUB_LINK=https://github.com/perfect-panel/ppanel-user-web
|
||||
|
||||
# Default Login User
|
||||
NEXT_PUBLIC_DEFAULT_USER_EMAIL=support@ppanel.dev
|
||||
NEXT_PUBLIC_DEFAULT_USER_PASSWORD=support@ppanel.dev
|
||||
|
||||
# Please put in the .env file, otherwise the i18n command will not work
|
||||
# OpenAI API key and proxy URL required for i18n command (optional)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_PROXY_URL=
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@repo/eslint-config/next.js'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entry": "./locales/zh-CN",
|
||||
"entryLocale": "zh-CN",
|
||||
"experimental": {
|
||||
"jsonMode": true
|
||||
},
|
||||
"markdown": {
|
||||
"entry": ["./README.md"],
|
||||
"entryLocale": "en-US",
|
||||
"outputLocales": ["zh-CN"]
|
||||
},
|
||||
"modelName": "gpt-3.5-turbo",
|
||||
"output": "./locales",
|
||||
"outputLocales": ["en-US", "zh-CN"]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<a name="readme-top"></a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img width="160" src="https://raw.githubusercontent.com/perfect-panel/ppanel-assets/refs/heads/main/logo.svg">
|
||||
|
||||
<h1>PPanel user web</h1>
|
||||
|
||||
This is a PPanel user web powered by PPanel
|
||||
|
||||
English
|
||||
·
|
||||
[Chinese](./README.zh-CN.md)
|
||||
·
|
||||
[Changelog](../../CHANGELOG.md)
|
||||
·
|
||||
[Report Bug][issues-link]
|
||||
·
|
||||
[Request Feature][issues-link]
|
||||
|
||||
<!-- SHIELD GROUP -->
|
||||
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-releasedate-shield]][github-releasedate-link]
|
||||
[![][github-action-test-shield]][github-action-test-link]
|
||||
[![][github-action-release-shield]][github-action-release-link]<br/>
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-issues-shield]][github-issues-link]
|
||||
[![][github-license-shield]][github-license-link]
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Table of contents</kbd></summary>
|
||||
|
||||
#### TOC
|
||||
|
||||
- [⌨️ Local Development](#️-local-development)
|
||||
- [🚀 Deploy on Vercel](#-deploy-on-vercel)
|
||||
- [🤝 Contributing](#-contributing)
|
||||
- [📝 License](#-license)
|
||||
|
||||
####
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ Local Development
|
||||
|
||||
You can use Github Codespaces for online development:
|
||||
|
||||
[![][codespaces-shield]][codespaces-link]
|
||||
|
||||
You can use Gitpod for online development:
|
||||
|
||||
[][gitpod-link]
|
||||
|
||||
or clone it for local development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/perfect-panel/ppanel-web.git
|
||||
cd ppanel-web
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run the development server
|
||||
cd apps/user
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open <http://localhost:3000> with your browser to see the result.
|
||||
|
||||
## 🚀 Deploy on Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?demo-description=PPanel%20is%20a%20pure%2C%20professional%2C%20and%20perfect%20open-source%20proxy%20panel%20tool%2C%20designed%20to%20be%20your%20ideal%20choice%20for%20learning%20and%20practical%20use&demo-image=https%3A%2F%2Furlscan.io%2Fliveshot%2F%3Fwidth%3D1920%26height%3D1080%26url%3Dhttps%3A%2F%user.ppanel.dev&demo-title=PPanel%20User%20Web&demo-url=https%3A%2F%2Fuser.ppanel.dev%2F&from=.&project-name=ppanel-user-web&repository-name=ppanel-web&repository-url=https%3A%2F%2Fgithub.com%2Fperfect-panel%2Fppanel-web&root-directory=apps%user&skippable-integrations=1)
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the
|
||||
[Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme)
|
||||
from the creators of Next.js.
|
||||
|
||||
Check out our
|
||||
[Next.js deployment documentation](https://nextjs.org/docs/deployment)
|
||||
for more details.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions of all types are more than welcome,
|
||||
if you're interested in contributing code, feel free to check out our GitHub
|
||||
[Issues][github-issues-link] to get stuck in to show us what you’re made of.
|
||||
|
||||
[![][pr-welcome-shield]][pr-welcome-link]
|
||||
|
||||
[![][contributors-contrib]][contributors-url]
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
Copyright © 2024 [PPanel][profile-link]. <br />
|
||||
This project is [GUN](../../LICENSE) licensed.
|
||||
|
||||
<!-- LINK GROUP -->
|
||||
|
||||
[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square
|
||||
[codespaces-link]: https://codespaces.new/perfect-panel/ppanel-web
|
||||
[codespaces-shield]: https://github.com/codespaces/badge.svg
|
||||
[contributors-contrib]: https://contrib.rocks/image?repo=perfect-panel/ppanel-web
|
||||
[contributors-url]: https://github.com/perfect-panel/ppanel-web/graphs/contributors
|
||||
[github-action-release-link]: https://github.com/perfect-panel/ppanel-web/actions/workflows/release.yml
|
||||
[github-action-release-shield]: https://img.shields.io/github/actions/workflow/status/perfect-panel/ppanel-web/release.yml?label=release&labelColor=black&logo=githubactions&logoColor=white&style=flat-square
|
||||
[github-action-test-link]: https://github.com/perfect-panel/ppanel-web/actions/workflows/test.yml
|
||||
[github-action-test-shield]: https://img.shields.io/github/actions/workflow/status/perfect-panel/ppanel-web/test.yml?label=test&labelColor=black&logo=githubactions&logoColor=white&style=flat-square
|
||||
[github-contributors-link]: https://github.com/perfect-panel/ppanel-web/graphs/contributors
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/perfect-panel/ppanel-web?color=c4f042&labelColor=black&style=flat-square
|
||||
[github-forks-link]: https://github.com/perfect-panel/ppanel-web/network/members
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/perfect-panel/ppanel-web?color=8ae8ff&labelColor=black&style=flat-square
|
||||
[github-issues-link]: https://github.com/perfect-panel/ppanel-web/issues
|
||||
[github-issues-shield]: https://img.shields.io/github/issues/perfect-panel/ppanel-web?color=ff80eb&labelColor=black&style=flat-square
|
||||
[github-license-link]: https://github.com/perfect-panel/ppanel-web/blob/master/LICENSE
|
||||
[github-license-shield]: https://img.shields.io/github/license/perfect-panel/ppanel-web?color=white&labelColor=black&style=flat-square
|
||||
[github-release-link]: https://github.com/perfect-panel/ppanel-web/releases
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/perfect-panel/ppanel-web?style=flat-square&sort=semver&logo=github
|
||||
[github-releasedate-link]: https://github.com/perfect-panel/ppanel-web/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/perfect-panel/ppanel-web?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/perfect-panel/ppanel-web/network/stargazers
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/perfect-panel/ppanel-web?color=ffcb47&labelColor=black&style=flat-square
|
||||
[gitpod-link]: https://gitpod.io/#https://github.com/perfect-panel/ppanel-web
|
||||
[issues-link]: https://github.com/perfect-panel/ppanel-web/issues/new/choose
|
||||
[pr-welcome-link]: https://github.com/perfect-panel/ppanel-web/pulls
|
||||
[pr-welcome-shield]: https://img.shields.io/badge/🤯_pr_welcome-%E2%86%92-ffcb47?labelColor=black&style=for-the-badge
|
||||
[profile-link]: https://github.com/perfect-panel
|
||||
@@ -0,0 +1,141 @@
|
||||
<a name="readme-top"></a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img width="160" src="https://raw.githubusercontent.com/perfect-panel/ppanel-assets/refs/heads/main/logo.svg">
|
||||
|
||||
<h1>PPanel 用户端</h1>
|
||||
|
||||
这是由 PPanel 提供支持的 PPanel 用户端
|
||||
|
||||
[英文](./README.md)
|
||||
·
|
||||
中文
|
||||
·
|
||||
[更新日志](../../CHANGELOG.md)
|
||||
·
|
||||
[报告问题][issues-link]
|
||||
·
|
||||
[请求功能][issues-link]
|
||||
|
||||
<!-- SHIELD GROUP -->
|
||||
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-releasedate-shield]][github-releasedate-link]
|
||||
[![][github-action-test-shield]][github-action-test-link]
|
||||
[![][github-action-release-shield]][github-action-release-link]<br/>
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-issues-shield]][github-issues-link]
|
||||
[![][github-license-shield]][github-license-link]
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><kbd>目录</kbd></summary>
|
||||
|
||||
#### 目录
|
||||
|
||||
- [⌨️ 本地开发](#️-本地开发)
|
||||
- [🚀 在 Vercel 上部署](#-在-vercel-上部署)
|
||||
- [🤝 贡献](#-贡献)
|
||||
- [📝 许可证](#-许可证)
|
||||
|
||||
####
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ 本地开发
|
||||
|
||||
您可以使用 Github Codespaces 进行在线开发:
|
||||
|
||||
[![][codespaces-shield]][codespaces-link]
|
||||
|
||||
您可以使用 Gitpod 进行在线开发:
|
||||
|
||||
[][gitpod-link]
|
||||
|
||||
或者克隆项目进行本地开发:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/perfect-panel/ppanel-web.git
|
||||
cd ppanel-web
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 运行开发服务器
|
||||
cd apps/user
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
在浏览器中打开 <http://localhost:3000> 查看结果。
|
||||
|
||||
## 🚀 在 Vercel 上部署
|
||||
|
||||
[](https://vercel.com/new/clone?demo-description=PPanel%20is%20a%20pure%2C%20professional%2C%20and%20perfect%20open-source%20proxy%20panel%20tool%2C%20designed%20to%20be%20your%20ideal%20choice%20for%20learning%20and%20practical%20use&demo-image=https%3A%2F%2Furlscan.io%2Fliveshot%2F%3Fwidth%3D1920%26height%3D1080%26url%3Dhttps%3A%2F%2Fuser.ppanel.dev&demo-title=PPanel%20User%20Web&demo-url=https%3A%2F%2Fuser.ppanel.dev%2F&from=.&project-name=ppanel-user-web&repository-name=ppanel-web&repository-url=https%3A%2F%2Fgithub.com%2Fperfect-panel%2Fppanel-web&root-directory=apps%2Fuser&skippable-integrations=1)
|
||||
|
||||
部署 Next.js 应用的最简单方式是使用
|
||||
[ Vercel 平台](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme)
|
||||
由 Next.js 的创建者提供支持。
|
||||
|
||||
查看我们的
|
||||
[Next.js 部署文档](https://nextjs.org/docs/deployment)
|
||||
获取更多详情。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎各种类型的贡献,
|
||||
如果您有兴趣贡献代码,请随时查看我们的 GitHub
|
||||
[问题][github-issues-link] 来展示您的能力。
|
||||
|
||||
[![][pr-welcome-shield]][pr-welcome-link]
|
||||
|
||||
[![][contributors-contrib]][contributors-url]
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 许可证
|
||||
|
||||
版权所有 © 2024 [PPanel][profile-link]。<br />
|
||||
本项目使用 [GUN](./LICENSE) 许可证。
|
||||
|
||||
<!-- LINK GROUP -->
|
||||
|
||||
[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square
|
||||
[codespaces-link]: https://codespaces.new/perfect-panel/ppanel-web
|
||||
[codespaces-shield]: https://github.com/codespaces/badge.svg
|
||||
[contributors-contrib]: https://contrib.rocks/image?repo=perfect-panel/ppanel-web
|
||||
[contributors-url]: https://github.com/perfect-panel/ppanel-web/graphs/contributors
|
||||
[github-action-release-link]: https://github.com/perfect-panel/ppanel-web/actions/workflows/release.yml
|
||||
[github-action-release-shield]: https://img.shields.io/github/actions/workflow/status/perfect-panel/ppanel-web/release.yml?label=release&labelColor=black&logo=githubactions&logoColor=white&style=flat-square
|
||||
[github-action-test-link]: https://github.com/perfect-panel/ppanel-web/actions/workflows/test.yml
|
||||
[github-action-test-shield]: https://img.shields.io/github/actions/workflow/status/perfect-panel/ppanel-web/test.yml?label=test&labelColor=black&logo=githubactions&logoColor=white&style=flat-square
|
||||
[github-contributors-link]: https://github.com/perfect-panel/ppanel-web/graphs/contributors
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/perfect-panel/ppanel-web?color=c4f042&labelColor=black&style=flat-square
|
||||
[github-forks-link]: https://github.com/perfect-panel/ppanel-web/network/members
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/perfect-panel/ppanel-web?color=8ae8ff&labelColor=black&style=flat-square
|
||||
[github-issues-link]: https://github.com/perfect-panel/ppanel-web/issues
|
||||
[github-issues-shield]: https://img.shields.io/github/issues/perfect-panel/ppanel-web?color=ff80eb&labelColor=black&style=flat-square
|
||||
[github-license-link]: https://github.com/perfect-panel/ppanel-web/blob/master/LICENSE
|
||||
[github-license-shield]: https://img.shields.io/github/license/perfect-panel/ppanel-web?color=white&labelColor=black&style=flat-square
|
||||
[github-release-link]: https://github.com/perfect-panel/ppanel-web/releases
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/perfect-panel/ppanel-web?style=flat-square&sort=semver&logo=github
|
||||
[github-releasedate-link]: https://github.com/perfect-panel/ppanel-web/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/perfect-panel/ppanel-web?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/perfect-panel/ppanel-web/network/stargazers
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/perfect-panel/ppanel-web?color=ffcb47&labelColor=black&style=flat-square
|
||||
[gitpod-link]: https://gitpod.io/#https://github.com/perfect-panel/ppanel-web
|
||||
[issues-link]: https://github.com/perfect-panel/ppanel-web/issues/new/choose
|
||||
[pr-welcome-link]: https://github.com/perfect-panel/ppanel-web/pulls
|
||||
[pr-welcome-shield]: https://img.shields.io/badge/🤯_pr_welcome-%E2%86%92-ffcb47?labelColor=black&style=for-the-badge
|
||||
[profile-link]: https://github.com/perfect-panel
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { queryAnnouncement } from '@/services/user/announcement';
|
||||
import Empty from '@repo/ui/empty';
|
||||
import { Markdown } from '@repo/ui/markdown';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export default function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['queryAnnouncement'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryAnnouncement({
|
||||
page: 1,
|
||||
size: 20,
|
||||
});
|
||||
return data.data?.announcements || [];
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className='flex flex-col gap-5'>
|
||||
{data?.length ? (
|
||||
data.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
<CardDescription>{formatDate(item.updated_at)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown>{item.content}</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { queryAnnouncement } from '@/services/user/announcement';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Empty from '@repo/ui/empty';
|
||||
import { Markdown } from '@repo/ui/markdown';
|
||||
import { Card } from '@shadcn/ui/card';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Announcement() {
|
||||
const t = useTranslations('dashboard');
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['queryAnnouncement', 1],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryAnnouncement({
|
||||
page: 1,
|
||||
size: 1,
|
||||
});
|
||||
return (data.data?.announcements?.[0] as API.AnnouncementDetails) || {};
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className='flex items-center gap-1.5 font-semibold'>
|
||||
<Icon icon='uil:bell' className='size-5' />
|
||||
{t('latestAnnouncement')}
|
||||
</h2>
|
||||
<Card className='p-6'>
|
||||
{data?.content ? <Markdown>{data?.content}</Markdown> : <Empty />}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { queryApplicationConfig } from '@/services/user/subscribe';
|
||||
import { queryUserSubscribe } from '@/services/user/user';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { getNextResetDate, isBrowser } from '@repo/ui/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@shadcn/ui/accordion';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@shadcn/ui/alert-dialog';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { differenceInDays } from '@shadcn/ui/lib/date-fns';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import Renewal from '../order/renewal';
|
||||
import ResetTraffic from '../order/reset-traffic';
|
||||
import Subscribe from '../subscribe/page';
|
||||
import Announcement from './announcemnet';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('dashboard');
|
||||
const { getUserSubscribe, getAppSubLink } = useGlobalStore();
|
||||
|
||||
const [protocol, setProtocol] = useState('');
|
||||
|
||||
const { data: userSubscribe = [] } = useQuery({
|
||||
queryKey: ['queryUserSubscribe'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryUserSubscribe();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
const { data: application } = useQuery({
|
||||
queryKey: ['queryApplicationConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryApplicationConfig();
|
||||
return data.data as API.QueryApplicationConfigResponse;
|
||||
},
|
||||
});
|
||||
const [platform, setPlatform] = useState<keyof API.QueryApplicationConfigResponse>('windows');
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t('copySuccess'));
|
||||
} catch {
|
||||
toast.error(t('copyFailure'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[calc(100vh-64px-58px-32px-114px)] w-full flex-col gap-4 overflow-hidden'>
|
||||
<Announcement />
|
||||
{userSubscribe.length ? (
|
||||
<>
|
||||
<h2 className='flex items-center gap-1.5 font-semibold'>
|
||||
<Icon icon='uil:servers' className='size-5' />
|
||||
{t('mySubscriptions')}
|
||||
</h2>
|
||||
<div className='flex flex-wrap justify-between gap-4'>
|
||||
<Tabs
|
||||
value={platform}
|
||||
onValueChange={(value) =>
|
||||
setPlatform(value as keyof API.QueryApplicationConfigResponse)
|
||||
}
|
||||
className='w-full max-w-full md:w-auto'
|
||||
>
|
||||
<TabsList className='flex *:flex-auto'>
|
||||
{application &&
|
||||
Object.keys(application)?.map((item) => (
|
||||
<TabsTrigger value={item} key={item} className='px-1 uppercase lg:px-3'>
|
||||
{item}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Tabs
|
||||
value={protocol}
|
||||
onValueChange={setProtocol}
|
||||
className='w-full max-w-full md:w-auto'
|
||||
>
|
||||
<TabsList className='flex *:flex-auto'>
|
||||
{['all', 'ss', 'vmess', 'vless', 'trojan'].map((item) => (
|
||||
<TabsTrigger
|
||||
value={item === 'all' ? '' : item}
|
||||
key={item}
|
||||
className='px-1 uppercase lg:px-3'
|
||||
>
|
||||
{item}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
{userSubscribe.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader className='flex flex-row flex-wrap items-center justify-between gap-2 space-y-0'>
|
||||
<CardTitle className='font-medium'>{item.subscribe.name}</CardTitle>
|
||||
<div className='flex gap-2'>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size='sm' variant='destructive'>
|
||||
{t('resetSubscription')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('prompt')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('confirmResetSubscription')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => toast.success(t('resetSuccess'))}>
|
||||
{t('confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ResetTraffic
|
||||
id={item.subscribe_id}
|
||||
mark={item.mark}
|
||||
replacement={item.subscribe.replacement}
|
||||
/>
|
||||
<Renewal mark={item.mark} subscribe={item.subscribe} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4'>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('used')}</span>
|
||||
<span className='text-2xl font-bold'>
|
||||
<Display
|
||||
type='traffic'
|
||||
value={item.upload + item.download}
|
||||
unlimited={!item.traffic}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('totalTraffic')}</span>
|
||||
<span className='text-2xl font-bold'>
|
||||
<Display type='traffic' value={item.traffic} unlimited={!item.traffic} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('nextResetDays')}</span>
|
||||
<span className='text-2xl font-semibold'>
|
||||
{differenceInDays(getNextResetDate(item.start_time), new Date()) ||
|
||||
t('unknown')}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('expirationDays')}</span>
|
||||
<span className='text-2xl font-semibold'>
|
||||
{differenceInDays(new Date(item.expire_time), new Date()) || t('unknown')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Separator className='mt-4' />
|
||||
<Accordion type='single' collapsible defaultValue='0' className='w-full'>
|
||||
{getUserSubscribe(item.mark, protocol)?.map((url, index) => (
|
||||
<AccordionItem key={url} value={String(index)}>
|
||||
<AccordionTrigger className='hover:no-underline'>
|
||||
<div className='flex w-full flex-row items-center justify-between'>
|
||||
<CardTitle className='text-sm font-medium'>
|
||||
{t('subscriptionUrl')} {index + 1}
|
||||
</CardTitle>
|
||||
<span
|
||||
className='text-primary hover:bg-accent mr-4 flex cursor-pointer rounded p-2 text-sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(url);
|
||||
}}
|
||||
>
|
||||
<Icon icon='uil:copy' className='mr-2 size-5' />
|
||||
{t('copy')}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className='grid grid-cols-3 gap-4 lg:grid-cols-4 xl:grid-cols-7'>
|
||||
{application &&
|
||||
application[platform]?.map((app) => (
|
||||
<div
|
||||
key={app.name}
|
||||
className='text-muted-foreground flex size-full flex-col items-center justify-between gap-2 text-xs'
|
||||
>
|
||||
<span>{app.name}</span>
|
||||
{app.icon && (
|
||||
<Image src={app.icon} alt={app.name} width={50} height={50} />
|
||||
)}
|
||||
<div className='flex'>
|
||||
<Button size='sm' variant='secondary' className='px-1.5' asChild>
|
||||
<Link href={app.url!}>{t('download')}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
handleCopy(url);
|
||||
const href = getAppSubLink(app.subscribe_type, url);
|
||||
if (isBrowser() && href) {
|
||||
window.location.href = href;
|
||||
} else {
|
||||
toast.info(t('manualImportMessage'));
|
||||
}
|
||||
}}
|
||||
className='p-2'
|
||||
>
|
||||
{t('import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className='text-muted-foreground hidden size-full flex-col items-center justify-between gap-2 text-sm lg:flex'>
|
||||
<span>{t('qrCode')}</span>
|
||||
<QRCodeCanvas
|
||||
value={url}
|
||||
size={80}
|
||||
bgColor='transparent'
|
||||
fgColor='rgb(59, 130, 246)'
|
||||
/>
|
||||
<span className='text-center'>{t('scanToSubscribe')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className='flex items-center gap-1.5 font-semibold'>
|
||||
<Icon icon='uil:shop' className='size-5' />
|
||||
{t('purchaseSubscription')}
|
||||
</h2>
|
||||
<Subscribe />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { ProList } from '@/components/pro-list';
|
||||
import { queryDocumentDetail, queryDocumentList } from '@/services/user/document';
|
||||
import { Markdown } from '@repo/ui/markdown';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('document');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<number>();
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: !!selected,
|
||||
queryKey: ['queryDocumentDetail', selected],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryDocumentDetail({
|
||||
id: selected!,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{selected ? (
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Button variant='outline' onClick={() => setSelected(undefined)}>
|
||||
<ChevronLeft className='size-4' />
|
||||
{t('back')}
|
||||
</Button>
|
||||
<CardTitle className='font-medium'>{data?.title}</CardTitle>
|
||||
<CardDescription>{formatDate(data?.updated_at)}</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown>{data?.content || ''}</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ProList<API.DocumentItem, { tag: string }>
|
||||
params={[
|
||||
{
|
||||
key: 'tag',
|
||||
placeholder: t('category'),
|
||||
options: tags.map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
request={async (_, filter) => {
|
||||
const response = await queryDocumentList();
|
||||
const list = response.data.data?.list || [];
|
||||
setTags(
|
||||
Array.from(new Set(list.reduce((acc: string[], item) => acc.concat(item.tags), []))),
|
||||
);
|
||||
const filterList = list.filter((item) =>
|
||||
filter.tag ? item.tags.includes(filter.tag) : true,
|
||||
);
|
||||
return {
|
||||
list: filterList,
|
||||
total: filterList.length || 0,
|
||||
};
|
||||
}}
|
||||
renderItem={(item) => {
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
<CardDescription>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setSelected(item.id);
|
||||
}}
|
||||
>
|
||||
{t('read')}
|
||||
</Button>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='p-3 text-sm'>
|
||||
<ul className='grid gap-3 *:flex *:flex-col lg:grid-cols-2'>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('tags')}</span>
|
||||
<span>{item.tags.join(', ')}</span>
|
||||
</li>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('updatedAt')}</span>
|
||||
<time>{formatDate(item.updated_at)}</time>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import { SidebarInset, SidebarProvider } from '@shadcn/ui/sidebar';
|
||||
import { SidebarLeft } from './sidebar-left';
|
||||
import { SidebarRight } from './sidebar-right';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider className='container'>
|
||||
<SidebarLeft className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
|
||||
<SidebarInset className='flex-grow overflow-hidden'>
|
||||
{/* <Header /> */}
|
||||
<div className='flex h-[calc(100vh-56px)] flex-1 flex-col gap-4 p-4'>
|
||||
<ScrollArea className='h-full flex-grow overflow-hidden'>{children}</ScrollArea>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<SidebarRight className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProList, ProListActions } from '@/components/pro-list';
|
||||
import { closeOrder, queryOrderList } from '@/services/user/order';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Button, buttonVariants } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('order');
|
||||
|
||||
const ref = useRef<ProListActions>(null);
|
||||
return (
|
||||
<ProList<API.OrderDetails, Record<string, unknown>>
|
||||
action={ref}
|
||||
request={async (pagination, filter) => {
|
||||
const response = await queryOrderList({ ...pagination, ...filter });
|
||||
return {
|
||||
list: response.data.data?.list || [],
|
||||
total: response.data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
renderItem={(item) => {
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
|
||||
<CardTitle>
|
||||
{t('orderNo')}
|
||||
<p className='text-sm'>{item.orderNo}</p>
|
||||
</CardTitle>
|
||||
<CardDescription className='flex gap-2'>
|
||||
{item.status === 1 ? (
|
||||
<>
|
||||
<Link
|
||||
key='payment'
|
||||
href={`/payment?order_no=${item.orderNo}`}
|
||||
className={buttonVariants({ size: 'sm' })}
|
||||
>
|
||||
{t('payment')}
|
||||
</Link>
|
||||
<Button
|
||||
key='cancel'
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={async () => {
|
||||
await closeOrder({ orderNo: item.orderNo });
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
key='detail'
|
||||
href={`/payment?order_no=${item.orderNo}`}
|
||||
className={buttonVariants({ size: 'sm' })}
|
||||
>
|
||||
{t('detail')}
|
||||
</Link>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='p-3 text-sm'>
|
||||
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col lg:grid-cols-4'>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('name')}</span>
|
||||
<span>{item.subscribe.name || t(`type.${item.type}`)}</span>
|
||||
</li>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('paymentAmount')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={item.amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('status.0')}</span>
|
||||
<span>{t(`status.${item.status}`)}</span>
|
||||
</li>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('createdAt')}</span>
|
||||
<time>{formatDate(item.created_at)}</time>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { checkoutOrder, preCreateOrder, purchase } from '@/services/user/order';
|
||||
import { getAvailablePaymentMethods } from '@/services/user/payment';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent } from '@shadcn/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@shadcn/ui/dialog';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
import { SubscribeBilling } from '../subscribe/billing';
|
||||
import { SubscribeDetail } from '../subscribe/detail';
|
||||
|
||||
export default function Purchase({
|
||||
subscribe,
|
||||
setSubscribe,
|
||||
}: {
|
||||
subscribe?: API.SubscribeDetails;
|
||||
setSubscribe: (subscribe?: API.SubscribeDetails) => void;
|
||||
}) {
|
||||
const t = useTranslations('order');
|
||||
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<API.PurchaseOrderRequest>({
|
||||
quantity: 1,
|
||||
subscribe_id: subscribe?.id as number,
|
||||
payment: 'balance',
|
||||
coupon: '',
|
||||
});
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
const { data: order } = useQuery({
|
||||
queryKey: ['preCreateOrder', params],
|
||||
queryFn: async () => {
|
||||
const { data } = await preCreateOrder({
|
||||
...params,
|
||||
subscribe_id: subscribe?.id as number,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: !!params?.subscribe_id,
|
||||
});
|
||||
const { data: paymentMethods } = useQuery({
|
||||
queryKey: ['getAvailablePaymentMethods'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAvailablePaymentMethods();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscribe) {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
quantity: 1,
|
||||
subscribe_id: subscribe?.id,
|
||||
}));
|
||||
}
|
||||
}, [subscribe]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!subscribe?.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSubscribe(undefined);
|
||||
}}
|
||||
>
|
||||
<DialogContent className='flex h-full max-w-screen-lg flex-col overflow-hidden border-none md:h-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('buySubscription')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='grid w-full gap-3 lg:grid-cols-2'>
|
||||
<Card className='border-transparent shadow-none md:border-inherit md:shadow'>
|
||||
<CardContent className='grid gap-3 p-0 text-sm md:p-6'>
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...subscribe,
|
||||
quantity: params.quantity,
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<SubscribeBilling
|
||||
order={{
|
||||
...order,
|
||||
quantity: params.quantity,
|
||||
unit_price: subscribe?.unit_price,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className='flex flex-col justify-between text-sm'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>{t('purchaseDuration')}</div>
|
||||
<RadioGroup
|
||||
value={String(params.quantity)}
|
||||
onValueChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
quantity: Number(value),
|
||||
});
|
||||
}}
|
||||
className='flex flex-wrap gap-2'
|
||||
>
|
||||
<div className='relative'>
|
||||
<RadioGroupItem value='1' id='1' className='peer sr-only' />
|
||||
<Label
|
||||
htmlFor='1'
|
||||
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
|
||||
>
|
||||
1 {t('month')}
|
||||
</Label>
|
||||
</div>
|
||||
{subscribe?.discount.map((item) => (
|
||||
<div key={item.months}>
|
||||
<RadioGroupItem
|
||||
value={String(item.months)}
|
||||
id={String(item.months)}
|
||||
className='peer sr-only'
|
||||
/>
|
||||
<Label
|
||||
htmlFor={String(item.months)}
|
||||
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
|
||||
>
|
||||
{item.months} {t('months')}
|
||||
{item.discount < 100 && (
|
||||
<Badge variant='destructive'>-{100 - item.discount}%</Badge>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className='flex'>
|
||||
<Input
|
||||
placeholder={t('enterCoupon')}
|
||||
value={params.coupon}
|
||||
onChange={(e) => {
|
||||
setParams({
|
||||
...params,
|
||||
coupon: e.target.value.trim(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='font-semibold'>{t('paymentMethod')}</div>
|
||||
<RadioGroup
|
||||
className='grid grid-cols-5 gap-2'
|
||||
value={params.payment}
|
||||
onValueChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
payment: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{paymentMethods?.map((item) => {
|
||||
return (
|
||||
<div key={item.mark}>
|
||||
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
|
||||
<Label
|
||||
htmlFor={item.mark}
|
||||
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
|
||||
>
|
||||
<div className='mb-3 size-12'>
|
||||
<Image
|
||||
src={item.icon || `/payment/${item.mark}.svg`}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.name!}
|
||||
/>
|
||||
</div>
|
||||
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
|
||||
{item.name || t(`methods.${item.mark}`)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Button
|
||||
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await purchase(params);
|
||||
const orderNo = response.data.data?.order_no;
|
||||
if (orderNo) {
|
||||
const { data } = await checkoutOrder({
|
||||
orderNo,
|
||||
});
|
||||
const type = data.data?.type;
|
||||
const checkout_url = data.data?.checkout_url;
|
||||
if (type === 'link') {
|
||||
const width = 600;
|
||||
const height = 800;
|
||||
const left = (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 2;
|
||||
window.open(
|
||||
checkout_url,
|
||||
'newWindow',
|
||||
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
|
||||
);
|
||||
}
|
||||
getUserInfo();
|
||||
router.push(`/payment?order_no=${orderNo}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{loading && <LoaderCircle className='mr-2 animate-spin' />}
|
||||
{t('buyNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { checkoutOrder, recharge } from '@/services/user/order';
|
||||
import { getAvailablePaymentMethods } from '@/services/user/payment';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Button, ButtonProps } from '@shadcn/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@shadcn/ui/dialog';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
export default function Recharge(props: ButtonProps) {
|
||||
const t = useTranslations('order');
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
const [params, setParams] = useState<API.RechargeOrderRequest>({
|
||||
amount: 0,
|
||||
payment: '',
|
||||
});
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
queryKey: ['getAvailablePaymentMethods'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAvailablePaymentMethods();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (paymentMethods?.length) {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
payment: paymentMethods.find((item) => item.mark !== 'balance')?.mark as string,
|
||||
}));
|
||||
}
|
||||
}, [paymentMethods]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button {...props}>{t('recharge')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='flex h-full flex-col overflow-hidden md:h-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('balanceRecharge')}</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='flex flex-col justify-between text-sm'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>{t('rechargeAmount')}</div>
|
||||
<div className='flex'>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
placeholder={t('enterAmount')}
|
||||
min={0}
|
||||
value={params.amount}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
amount: value as number,
|
||||
}));
|
||||
}}
|
||||
prefix={currency.currency_symbol}
|
||||
suffix={currency.currency_unit}
|
||||
/>
|
||||
</div>
|
||||
<div className='font-semibold'>{t('paymentMethod')}</div>
|
||||
<RadioGroup
|
||||
className='grid grid-cols-5 gap-2'
|
||||
value={params.payment}
|
||||
onValueChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
payment: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{paymentMethods
|
||||
?.filter((item) => item.mark !== 'balance')
|
||||
?.map((item) => {
|
||||
return (
|
||||
<div key={item.mark}>
|
||||
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
|
||||
<Label
|
||||
htmlFor={item.mark}
|
||||
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
|
||||
>
|
||||
<div className='mb-3 size-12'>
|
||||
<Image
|
||||
src={item.icon || `/payment/${item.mark}.svg`}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.name!}
|
||||
/>
|
||||
</div>
|
||||
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
|
||||
{item.name || t(`methods.${item.mark}`)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Button
|
||||
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
|
||||
disabled={loading || !params.amount}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await recharge(params);
|
||||
const orderNo = response.data.data?.order_no;
|
||||
if (orderNo) {
|
||||
const { data } = await checkoutOrder({
|
||||
orderNo,
|
||||
});
|
||||
const type = data.data?.type;
|
||||
const checkout_url = data.data?.checkout_url;
|
||||
if (type === 'link') {
|
||||
const width = 600;
|
||||
const height = 800;
|
||||
const left = (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 2;
|
||||
window.open(
|
||||
checkout_url,
|
||||
'newWindow',
|
||||
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
|
||||
);
|
||||
}
|
||||
router.push(`/payment?order_no=${orderNo}`);
|
||||
setOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{loading && <LoaderCircle className='mr-2 animate-spin' />}
|
||||
{t('rechargeNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { checkoutOrder, preCreateOrder, renewal } from '@/services/user/order';
|
||||
import { getAvailablePaymentMethods } from '@/services/user/payment';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent } from '@shadcn/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@shadcn/ui/dialog';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
import { SubscribeBilling } from '../subscribe/billing';
|
||||
import { SubscribeDetail } from '../subscribe/detail';
|
||||
|
||||
export default function Renewal({
|
||||
mark,
|
||||
subscribe,
|
||||
}: {
|
||||
mark: string;
|
||||
subscribe: Omit<API.SubscribeDetails, 'discount'> & {
|
||||
discount: string | API.UserSubscribeDiscount[];
|
||||
};
|
||||
}) {
|
||||
const t = useTranslations('order');
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<API.RenewalOrderRequest>({
|
||||
quantity: 1,
|
||||
subscribe_id: subscribe.id,
|
||||
payment: 'balance',
|
||||
coupon: '',
|
||||
subscribe_mark: mark,
|
||||
});
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
const { data: order } = useQuery({
|
||||
queryKey: ['preCreateOrder', params],
|
||||
queryFn: async () => {
|
||||
const { data } = await preCreateOrder({
|
||||
...params,
|
||||
subscribe_id: subscribe.id,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: !!subscribe.id,
|
||||
});
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
queryKey: ['getAvailablePaymentMethods'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAvailablePaymentMethods();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscribe.id && mark) {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
quantity: 1,
|
||||
subscribe_id: subscribe.id,
|
||||
subscribe_mark: mark,
|
||||
}));
|
||||
}
|
||||
}, [subscribe.id, mark]);
|
||||
|
||||
function getDiscount() {
|
||||
try {
|
||||
if (typeof subscribe.discount === 'string') {
|
||||
return JSON.parse(subscribe?.discount) as API.UserSubscribeDiscount[];
|
||||
}
|
||||
return subscribe?.discount;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size='sm'>{t('renew')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='flex h-full max-w-screen-lg flex-col overflow-hidden md:h-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('renewSubscription')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='grid w-full gap-3 lg:grid-cols-2'>
|
||||
<Card className='border-transparent shadow-none md:border-inherit md:shadow'>
|
||||
<CardContent className='grid gap-3 p-0 text-sm md:p-6'>
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...subscribe,
|
||||
quantity: params.quantity,
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<SubscribeBilling
|
||||
order={{
|
||||
...order,
|
||||
quantity: params.quantity,
|
||||
unit_price: subscribe?.unit_price,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className='flex flex-col justify-between text-sm'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>{t('purchaseDuration')}</div>
|
||||
<RadioGroup
|
||||
value={String(params.quantity)}
|
||||
onValueChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
quantity: Number(value),
|
||||
});
|
||||
}}
|
||||
className='flex flex-wrap gap-2'
|
||||
>
|
||||
<div className='relative'>
|
||||
<RadioGroupItem value='1' id='1' className='peer sr-only' />
|
||||
<Label
|
||||
htmlFor='1'
|
||||
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
|
||||
>
|
||||
1 {t('month')}
|
||||
</Label>
|
||||
</div>
|
||||
{getDiscount().map((item) => (
|
||||
<div key={item.months}>
|
||||
<RadioGroupItem
|
||||
value={String(item.months)}
|
||||
id={String(item.months)}
|
||||
className='peer sr-only'
|
||||
/>
|
||||
<Label
|
||||
htmlFor={String(item.months)}
|
||||
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
|
||||
>
|
||||
{item.months} {t('months')}
|
||||
{item.discount < 100 && (
|
||||
<Badge variant='destructive'>-{100 - item.discount}%</Badge>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className='flex'>
|
||||
<Input
|
||||
placeholder={t('enterCoupon')}
|
||||
value={params.coupon}
|
||||
onChange={(e) => {
|
||||
setParams({
|
||||
...params,
|
||||
coupon: e.target.value.trim(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='font-semibold'>{t('paymentMethod')}</div>
|
||||
<RadioGroup
|
||||
className='grid grid-cols-5 gap-2'
|
||||
value={params.payment}
|
||||
onValueChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
payment: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{paymentMethods?.map((item) => {
|
||||
return (
|
||||
<div key={item.mark}>
|
||||
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
|
||||
<Label
|
||||
htmlFor={item.mark}
|
||||
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
|
||||
>
|
||||
<div className='mb-3 size-12'>
|
||||
<Image
|
||||
src={item.icon || `/payment/${item.mark}.svg`}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.name!}
|
||||
/>
|
||||
</div>
|
||||
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
|
||||
{item.name || t(`methods.${item.mark}`)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Button
|
||||
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await renewal(params);
|
||||
const orderNo = response.data.data?.order_no;
|
||||
if (orderNo) {
|
||||
const { data } = await checkoutOrder({
|
||||
orderNo,
|
||||
});
|
||||
const type = data.data?.type;
|
||||
const checkout_url = data.data?.checkout_url;
|
||||
if (type === 'link') {
|
||||
const width = 600;
|
||||
const height = 800;
|
||||
const left = (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 2;
|
||||
window.open(
|
||||
checkout_url,
|
||||
'newWindow',
|
||||
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
|
||||
);
|
||||
}
|
||||
getUserInfo();
|
||||
router.push(`/payment?order_no=${orderNo}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{loading && <LoaderCircle className='mr-2 animate-spin' />}
|
||||
{t('buyNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { checkoutOrder, resetTraffic } from '@/services/user/order';
|
||||
import { getAvailablePaymentMethods } from '@/services/user/payment';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@shadcn/ui/dialog';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
export default function ResetTraffic({
|
||||
id,
|
||||
mark,
|
||||
replacement,
|
||||
}: {
|
||||
id: number;
|
||||
mark: string;
|
||||
replacement?: number;
|
||||
}) {
|
||||
const t = useTranslations('order');
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<API.ResetTrafficOrderRequest>({
|
||||
subscribe_id: id,
|
||||
payment: 'balance',
|
||||
subscribe_mark: mark,
|
||||
});
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
queryKey: ['getAvailablePaymentMethods'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAvailablePaymentMethods();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (id && mark) {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
quantity: 1,
|
||||
subscribe_id: id,
|
||||
subscribe_mark: mark,
|
||||
}));
|
||||
}
|
||||
}, [id, mark]);
|
||||
|
||||
if (!replacement) return;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
{t('resetTraffic')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='flex h-full flex-col overflow-hidden md:h-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('resetTrafficTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('resetTrafficDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='flex flex-col justify-between text-sm'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='flex justify-between font-semibold'>
|
||||
<span>{t('resetPrice')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={replacement} />
|
||||
</span>
|
||||
</div>
|
||||
<div className='font-semibold'>{t('paymentMethod')}</div>
|
||||
<RadioGroup
|
||||
className='grid grid-cols-5 gap-2'
|
||||
value={params.payment}
|
||||
onValueChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
payment: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{paymentMethods?.map((item) => {
|
||||
return (
|
||||
<div key={item.mark}>
|
||||
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
|
||||
<Label
|
||||
htmlFor={item.mark}
|
||||
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
|
||||
>
|
||||
<div className='mb-3 size-12'>
|
||||
<Image
|
||||
src={item.icon || `/payment/${item.mark}.svg`}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.name!}
|
||||
/>
|
||||
</div>
|
||||
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
|
||||
{item.name || t(`methods.${item.mark}`)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Button
|
||||
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await resetTraffic(params);
|
||||
const orderNo = response.data.data?.order_no;
|
||||
if (orderNo) {
|
||||
const { data } = await checkoutOrder({
|
||||
orderNo,
|
||||
});
|
||||
const type = data.data?.type;
|
||||
const checkout_url = data.data?.checkout_url;
|
||||
if (type === 'link') {
|
||||
const width = 600;
|
||||
const height = 800;
|
||||
const left = (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 2;
|
||||
window.open(
|
||||
checkout_url,
|
||||
'newWindow',
|
||||
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
|
||||
);
|
||||
}
|
||||
getUserInfo();
|
||||
router.push(`/payment?order_no=${orderNo}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{loading && <LoaderCircle className='mr-2 animate-spin' />}
|
||||
{t('buyNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { checkoutOrder, queryOrderDetail } from '@/services/user/order';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { addMinutes, format } from '@shadcn/ui/lib/date-fns';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCountDown } from 'ahooks';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { SubscribeBilling } from '../subscribe/billing';
|
||||
import { SubscribeDetail } from '../subscribe/detail';
|
||||
import StripePayment from './stripe';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('order');
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const [order_no, setOrderNo] = useState<string>();
|
||||
const [enabled, setEnabled] = useState<boolean>(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: enabled,
|
||||
queryKey: ['queryOrderDetail', order_no],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryOrderDetail({ order_no: order_no! });
|
||||
if (data?.data?.status !== 1) {
|
||||
getUserInfo();
|
||||
setEnabled(false);
|
||||
}
|
||||
return data?.data;
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: payment } = useQuery({
|
||||
enabled: !!order_no,
|
||||
queryKey: ['checkoutOrder', order_no],
|
||||
queryFn: async () => {
|
||||
const { data } = await checkoutOrder({ orderNo: order_no! });
|
||||
return data?.data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
if (searchParams.get('order_no')) {
|
||||
setOrderNo(searchParams.get('order_no')!);
|
||||
setEnabled(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [countDown, formattedRes] = useCountDown({
|
||||
targetDate: data && format(addMinutes(data?.created_at, 15), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"),
|
||||
});
|
||||
|
||||
const { hours, minutes, seconds } = formattedRes;
|
||||
|
||||
const countdownDisplay =
|
||||
countDown > 0 ? (
|
||||
<>
|
||||
{hours.toString().length === 1 ? `0${hours}` : hours} :{' '}
|
||||
{minutes.toString().length === 1 ? `0${minutes}` : minutes} :{' '}
|
||||
{seconds.toString().length === 1 ? `0${seconds}` : seconds}
|
||||
</>
|
||||
) : (
|
||||
<>{t('timeExpired')}</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-2'>
|
||||
<Card className='order-2 xl:order-1'>
|
||||
<CardHeader className='bg-muted/50 flex flex-row items-start'>
|
||||
<div className='grid gap-0.5'>
|
||||
<CardTitle className='flex flex-col text-lg'>
|
||||
{t('orderNumber')}
|
||||
<span>{data?.orderNo}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('createdAt')}: {formatDate(data?.created_at)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-3 p-6 text-sm'>
|
||||
<div className='font-semibold'>{t('paymentMethod')}</div>
|
||||
<dl className='grid gap-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='text-muted-foreground'>
|
||||
<Badge>{t(`methods.${data?.method}`)}</Badge>
|
||||
</dt>
|
||||
</div>
|
||||
</dl>
|
||||
<Separator />
|
||||
|
||||
{data?.type && [1, 2].includes(data.type) && (
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...data?.subscribe,
|
||||
quantity: data?.quantity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{data?.type === 3 && (
|
||||
<>
|
||||
<div className='font-semibold'>重置流量</div>
|
||||
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground line-clamp-2 flex-1'>重置价格</span>
|
||||
<span>
|
||||
<Display type='currency' value={data.amount} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{data?.type === 4 && (
|
||||
<>
|
||||
<div className='font-semibold'>{t('balanceRecharge')}</div>
|
||||
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground line-clamp-2 flex-1'>
|
||||
{t('rechargeAmount')}
|
||||
</span>
|
||||
<span>
|
||||
<Display type='currency' value={data.amount} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<Separator />
|
||||
<SubscribeBilling
|
||||
order={{
|
||||
...data,
|
||||
coupon: data?.reduction,
|
||||
quantity: data?.quantity,
|
||||
unit_price: data?.subscribe?.unit_price,
|
||||
type: data?.type,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='order-1 flex flex-auto items-center justify-center xl:order-2'>
|
||||
<CardContent className='py-16'>
|
||||
{data?.status && [2, 5].includes(data?.status) && (
|
||||
<div className='flex flex-col items-center gap-8 text-center'>
|
||||
<h3 className='text-2xl font-bold tracking-tight'>{t('paymentSuccess')}</h3>
|
||||
<Icon icon='mdi:success-circle-outline' className='text-7xl text-green-500' />
|
||||
<div className='flex gap-4'>
|
||||
<Button asChild>
|
||||
<Link href='/dashboard'>{t('subscribeNow')}</Link>
|
||||
</Button>
|
||||
<Button variant='outline'>
|
||||
<Link href='/document'>{t('viewDocument')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data?.status === 1 && payment?.type === 'link' && (
|
||||
<div className='flex flex-col items-center gap-8 text-center'>
|
||||
<h3 className='text-2xl font-bold tracking-tight'>{t('waitingForPayment')}</h3>
|
||||
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
|
||||
<Icon icon='mdi:access-time' className='text-muted-foreground text-7xl' />
|
||||
<div className='flex gap-4'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const width = 600;
|
||||
const height = 800;
|
||||
const left = (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 2;
|
||||
window.open(
|
||||
payment?.checkout_url,
|
||||
'newWindow',
|
||||
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('goToPayment')}
|
||||
</Button>
|
||||
<Button variant='outline'>
|
||||
<Link href='/subscribe'>{t('productList')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.status === 1 && payment?.type === 'qr' && (
|
||||
<div className='flex flex-col items-center gap-8 text-center'>
|
||||
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
|
||||
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
|
||||
<QRCodeCanvas
|
||||
value={payment?.checkout_url || ''}
|
||||
size={208}
|
||||
imageSettings={{
|
||||
src: `/payment/alipay_f2f.svg`,
|
||||
width: 48,
|
||||
height: 48,
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
<div className='flex gap-4'>
|
||||
<Button asChild>
|
||||
<Link href='/subscribe'>{t('productList')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant='outline'>
|
||||
<Link href='/order'>{t('orderList')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.status === 1 && payment?.type === 'stripe' && (
|
||||
<div className='flex flex-col items-center gap-8 text-center'>
|
||||
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
|
||||
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
|
||||
{payment.stripe && <StripePayment {...payment.stripe} />}
|
||||
<div className='flex gap-4'>
|
||||
<Button asChild>
|
||||
<Link href='/subscribe'>{t('productList')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant='outline'>
|
||||
<Link href='/order'>{t('orderList')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.status && [3, 4].includes(data?.status) && (
|
||||
<div className='flex flex-col items-center gap-8 text-center'>
|
||||
<h3 className='text-2xl font-bold tracking-tight'>{t('orderClosed')}</h3>
|
||||
<Icon icon='mdi:cancel' className='text-7xl text-red-500' />
|
||||
<div className='flex gap-4'>
|
||||
<Button asChild>
|
||||
<Link href='/subscribe'>{t('productList')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant='outline'>
|
||||
<Link href='/order'>{t('orderList')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Elements, useStripe } from '@stripe/react-stripe-js';
|
||||
import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface StripePaymentProps {
|
||||
method: string;
|
||||
client_secret: string;
|
||||
publishable_key: string;
|
||||
}
|
||||
|
||||
const StripePayment: React.FC<StripePaymentProps> = ({
|
||||
method,
|
||||
client_secret,
|
||||
publishable_key,
|
||||
}) => {
|
||||
const stripePromise = useMemo(() => loadStripe(publishable_key), [publishable_key]);
|
||||
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<CheckoutForm method={method} client_secret={client_secret} />
|
||||
</Elements>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckoutForm: React.FC<Omit<StripePaymentProps, 'publishable_key'>> = ({
|
||||
client_secret,
|
||||
method,
|
||||
}) => {
|
||||
const stripe = useStripe();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
const handleError = useCallback((message: string) => {
|
||||
setErrorMessage(message);
|
||||
setIsSubmitted(false);
|
||||
}, []);
|
||||
|
||||
const confirmPayment = useCallback(async (): Promise<PaymentIntentResult | null> => {
|
||||
if (!stripe) {
|
||||
handleError('Stripe.js is not loaded.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method === 'alipay') {
|
||||
return await stripe.confirmAlipayPayment(
|
||||
client_secret,
|
||||
{ return_url: window.location.href },
|
||||
{ handleActions: false },
|
||||
);
|
||||
}
|
||||
|
||||
return await stripe.confirmWechatPayPayment(
|
||||
client_secret,
|
||||
{
|
||||
payment_method_options: { wechat_pay: { client: 'web' } },
|
||||
},
|
||||
{ handleActions: false },
|
||||
);
|
||||
}, [client_secret, method, stripe, handleError]);
|
||||
|
||||
const autoSubmit = useCallback(async () => {
|
||||
if (isSubmitted) return;
|
||||
|
||||
setIsSubmitted(true);
|
||||
|
||||
try {
|
||||
const result = await confirmPayment();
|
||||
if (!result) return;
|
||||
|
||||
const { error, paymentIntent } = result;
|
||||
if (error) return handleError(error.message!);
|
||||
|
||||
if (paymentIntent?.status === 'requires_action') {
|
||||
const nextAction = paymentIntent.next_action as any;
|
||||
const qrUrl =
|
||||
method === 'alipay'
|
||||
? nextAction?.alipay_handle_redirect?.url
|
||||
: nextAction?.wechat_pay_display_qr_code?.image_url_svg;
|
||||
|
||||
setQrCodeUrl(qrUrl || null);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError('An unexpected error occurred');
|
||||
}
|
||||
}, [confirmPayment, isSubmitted, handleError, method]);
|
||||
|
||||
useEffect(() => {
|
||||
autoSubmit();
|
||||
}, [autoSubmit]);
|
||||
|
||||
return qrCodeUrl ? (
|
||||
<QRCodeCanvas
|
||||
value={qrCodeUrl}
|
||||
size={208}
|
||||
imageSettings={{
|
||||
src: `/payment/${method}.svg`,
|
||||
width: 48,
|
||||
height: 48,
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
errorMessage
|
||||
);
|
||||
};
|
||||
|
||||
export default StripePayment;
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { updateUserPassword } from '@/services/user/user';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function ChangePassword() {
|
||||
const t = useTranslations('profile.accountSettings');
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
password: z.string(),
|
||||
repeat_password: z.string(),
|
||||
})
|
||||
.superRefine(({ password, repeat_password }, ctx) => {
|
||||
if (password !== repeat_password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('passwordMismatch'),
|
||||
path: ['repeat_password'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
await updateUserPassword({
|
||||
password: data.password,
|
||||
} as API.UpdateUserPasswordRequest);
|
||||
toast.success(t('updateSuccess'));
|
||||
form.setValue('password', '');
|
||||
form.setValue('repeat_password', '');
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='bg-muted/50 flex flex-row items-start'>
|
||||
<CardTitle>{t('accountSettings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 p-6 text-sm'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>{t('loginPassword')}</div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type='password' placeholder={t('newPassword')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='repeat_password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type='password' placeholder={t('repeatNewPassword')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='size-full' type='submit'>
|
||||
{t('updatePassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { updateUserNotify } from '@/services/user/user';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const FormSchema = z.object({
|
||||
enable_balance_notify: z.boolean(),
|
||||
enable_login_notify: z.boolean(),
|
||||
enable_subscribe_notify: z.boolean(),
|
||||
enable_trade_notify: z.boolean(),
|
||||
});
|
||||
|
||||
export default function NotifyEvent() {
|
||||
const t = useTranslations('profile.notifyEvent');
|
||||
const { user, getUserInfo } = useGlobalStore();
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
enable_balance_notify: user?.enable_balance_notify,
|
||||
enable_login_notify: user?.enable_login_notify,
|
||||
enable_subscribe_notify: user?.enable_subscribe_notify,
|
||||
enable_trade_notify: user?.enable_trade_notify,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
await updateUserNotify(data);
|
||||
toast.success(t('updateSuccess'));
|
||||
getUserInfo();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='bg-muted/50 flex flex-row items-start'>
|
||||
<CardTitle>{t('notificationEvents')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 p-6 text-sm'>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_balance_notify'
|
||||
render={({ field }) => (
|
||||
<FormItem className='text-muted-foreground flex items-center justify-between'>
|
||||
<FormLabel>{t('balanceChange')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_login_notify'
|
||||
render={({ field }) => (
|
||||
<FormItem className='text-muted-foreground flex items-center justify-between'>
|
||||
<FormLabel>{t('login')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_subscribe_notify'
|
||||
render={({ field }) => (
|
||||
<FormItem className='text-muted-foreground flex items-center justify-between'>
|
||||
<FormLabel>{t('subscribe')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_trade_notify'
|
||||
render={({ field }) => (
|
||||
<FormItem className='text-muted-foreground flex items-center justify-between'>
|
||||
<FormLabel>{t('finance')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { updateUserNotifySetting } from '@/services/user/user';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const FormSchema = z.object({
|
||||
telegram: z.number().nullish(),
|
||||
enable_email_notify: z.boolean(),
|
||||
enable_telegram_notify: z.boolean(),
|
||||
});
|
||||
|
||||
export default function NotifySettings() {
|
||||
const t = useTranslations('profile.notify');
|
||||
const { user } = useGlobalStore();
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
telegram: user?.telegram,
|
||||
enable_email_notify: user?.enable_email_notify,
|
||||
enable_telegram_notify: user?.enable_telegram_notify,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
await updateUserNotifySetting(data as API.UpdateUserNotifySettingRequet);
|
||||
toast.success(t('updateSuccess'));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='bg-muted/50 flex flex-row items-start'>
|
||||
<CardTitle>{t('notificationSettings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 p-6 text-sm'>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='telegram'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('telegramId')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex w-full items-center space-x-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder={t('telegramIdPlaceholder')}
|
||||
{...field}
|
||||
value={field.value ? field.value : ''}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value ? Number(e.target.value) : '');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
type='button'
|
||||
onClick={async () => {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
{t('bind')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_email_notify'
|
||||
render={({ field }) => (
|
||||
<FormItem className='text-muted-foreground flex items-center justify-between'>
|
||||
<FormLabel>{t('emailNotification')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_telegram_notify'
|
||||
render={({ field }) => (
|
||||
<FormItem className='text-muted-foreground flex items-center justify-between'>
|
||||
<FormLabel>{t('telegramNotification')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import ChangePassword from './change-password';
|
||||
import NotifyEvent from './notify-event';
|
||||
import NotifySettings from './notify-settings';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<NotifySettings />
|
||||
<NotifyEvent />
|
||||
<ChangePassword />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
import { navs } from '@/config/navs';
|
||||
import { Icon } from '@iconify/react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@shadcn/ui/sidebar';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const t = useTranslations('menu');
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Sidebar collapsible='none' side='left' {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarMenu>
|
||||
{navs.map((nav) => (
|
||||
<SidebarGroup key={nav.title}>
|
||||
{nav.items && <SidebarGroupLabel>{t(nav.title)}</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{(nav.items || [nav]).map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={t(item.title)}
|
||||
isActive={item.url === pathname}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <Icon icon={item.icon} />}
|
||||
<span>{t(item.title)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
import { Display } from '@/components/display';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Sidebar, SidebarContent } from '@shadcn/ui/sidebar';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@shadcn/ui/tooltip';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Recharge from './order/recharge';
|
||||
|
||||
export function SidebarRight({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { user } = useGlobalStore();
|
||||
const t = useTranslations('layout');
|
||||
return (
|
||||
<Sidebar collapsible='none' side='right' {...props}>
|
||||
<SidebarContent>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{t('accountBalance')}</CardTitle>
|
||||
<Recharge variant='link' className='p-0' />
|
||||
</CardHeader>
|
||||
<CardContent className='p-3 text-2xl font-bold'>
|
||||
<Display type='currency' value={user?.balance} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{t('totalCommission')}</CardTitle>
|
||||
<Icon icon='mdi:money' className='text-muted-foreground text-2xl' />
|
||||
</CardHeader>
|
||||
<CardContent className='p-3 text-2xl font-bold'>0.00</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{t('invitees')}</CardTitle>
|
||||
<Icon icon='mdi:users' className='text-muted-foreground text-2xl' />
|
||||
</CardHeader>
|
||||
<CardContent className='p-3 text-2xl font-bold'>0</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{t('inviteCode')}</CardTitle>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='size-5 p-0'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${location.origin}/auth?invite=${user?.refer_code}`,
|
||||
);
|
||||
toast.success(t('copySuccess'));
|
||||
}}
|
||||
>
|
||||
<Icon icon='mdi:content-copy' className='text-primary text-2xl' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('copyInviteLink')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CardHeader>
|
||||
<CardContent className='truncate p-3 font-bold'>{user?.refer_code}</CardContent>
|
||||
</Card>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface SubscribeBillingProps {
|
||||
order?: {
|
||||
type?: number;
|
||||
subscribe_id?: number;
|
||||
quantity?: number;
|
||||
price?: number;
|
||||
reduction?: number;
|
||||
coupon?: number;
|
||||
fee_amount?: number;
|
||||
amount?: number;
|
||||
unit_price?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function SubscribeBilling({ order }: SubscribeBillingProps) {
|
||||
const t = useTranslations('subscribe.billing');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='font-semibold'>{t('billingTitle')}</div>
|
||||
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
|
||||
{order?.type && [1, 2].includes(order?.type) && (
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('duration')}</span>
|
||||
<span>
|
||||
{order?.quantity || 1} {t('months')}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('price')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={order?.price || order?.unit_price} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('productDiscount')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={order?.reduction} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('couponDiscount')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={order?.coupon} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('fee')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={order?.fee_amount} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Separator />
|
||||
<div className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('total')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={order?.amount} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface SubscribeDetailProps {
|
||||
subscribe?: {
|
||||
traffic?: number | null;
|
||||
speed_limit?: number | null;
|
||||
device_limit?: number | null;
|
||||
name?: string | null;
|
||||
quantity?: number | null;
|
||||
unit_price?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function SubscribeDetail({ subscribe }: SubscribeDetailProps) {
|
||||
const t = useTranslations('subscribe.detail');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='font-semibold'>{t('productDetail')}</div>
|
||||
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
|
||||
{subscribe?.name && (
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground line-clamp-2 flex-1'>{subscribe?.name}</span>
|
||||
<span>
|
||||
x <span>{subscribe?.quantity || 1}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('availableTraffic')}</span>
|
||||
<span>
|
||||
<Display type='traffic' value={subscribe?.traffic} unlimited />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('connectionSpeed')}</span>
|
||||
<span>
|
||||
<Display type='traffic' value={subscribe?.speed_limit} unlimited />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('connectedDevices')}</span>
|
||||
<span>
|
||||
<Display value={subscribe?.device_limit} type='number' unlimited />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { querySubscribeGroupList, querySubscribeList } from '@/services/user/subscribe';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@shadcn/ui/card';
|
||||
import { cn } from '@shadcn/ui/lib/utils';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Purchase from '../order/purchase';
|
||||
import { SubscribeDetail } from './detail';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('subscribe');
|
||||
const [subscribe, setSubscribe] = useState<API.SubscribeDetails>();
|
||||
|
||||
const [group, setGroup] = useState<string>('');
|
||||
|
||||
const { data: groups } = useQuery({
|
||||
queryKey: ['querySubscribeGroupList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await querySubscribeGroupList();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['querySubscribeList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await querySubscribeList();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs value={group} onValueChange={setGroup} className='space-y-4'>
|
||||
{groups?.length && (
|
||||
<>
|
||||
<h1 className='text-muted-foreground w-full'>{t('category')}</h1>
|
||||
<TabsList>
|
||||
<TabsTrigger value=''>{t('all')}</TabsTrigger>
|
||||
{groups.map((group) => (
|
||||
<TabsTrigger key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<h2 className='text-muted-foreground w-full'>{t('products')}</h2>
|
||||
</>
|
||||
)}
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3'>
|
||||
{data
|
||||
?.filter((item) => (group ? item.group_id === Number(group) : true))
|
||||
?.map((item) => (
|
||||
<Card className='flex flex-col' key={item.id}>
|
||||
<CardHeader className='bg-muted/50 text-xl font-medium'>{item.name}</CardHeader>
|
||||
<CardContent className='flex flex-grow flex-col gap-3 p-6 *:!text-sm'>
|
||||
{/* <div className='font-semibold'>{t('productDescription')}</div> */}
|
||||
<ul className='flex flex-grow flex-col gap-3'>
|
||||
{(() => {
|
||||
let parsedDescription;
|
||||
try {
|
||||
parsedDescription = JSON.parse(item.description);
|
||||
} catch {
|
||||
parsedDescription = { description: '', features: [] };
|
||||
}
|
||||
|
||||
const { description, features } = parsedDescription;
|
||||
return (
|
||||
<>
|
||||
{description && <li className='text-muted-foreground'>{description}</li>}
|
||||
{features.map(
|
||||
(
|
||||
feature: {
|
||||
icon: string;
|
||||
label: string;
|
||||
type: 'default' | 'success' | 'destructive';
|
||||
support: boolean;
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<li
|
||||
className={cn('flex items-center gap-1', {
|
||||
'text-muted-foreground line-through':
|
||||
feature.type === 'destructive',
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
{feature.icon && (
|
||||
<Icon
|
||||
icon={feature.icon}
|
||||
className={cn('text-primary size-5', {
|
||||
'text-green-500': feature.type === 'success',
|
||||
'text-destructive': feature.type === 'destructive',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{feature.label}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ul>
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...item,
|
||||
name: null,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<Separator />
|
||||
<CardFooter className='relative mt-2 flex flex-col gap-2'>
|
||||
<h2 className='pb-5 text-2xl font-semibold sm:text-3xl'>
|
||||
<Display type='currency' value={item.unit_price} />
|
||||
<span className='text-base font-medium'>/{t('perMonth')}</span>
|
||||
</h2>
|
||||
<Button
|
||||
className='absolute bottom-0 w-full rounded-b-xl rounded-t-none'
|
||||
onClick={() => {
|
||||
setSubscribe(item);
|
||||
}}
|
||||
>
|
||||
{t('buy')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
<Purchase subscribe={subscribe} setSubscribe={setSubscribe} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import { ProList, ProListActions } from '@/components/pro-list';
|
||||
import {
|
||||
createUserTicket,
|
||||
createUserTicketFollow,
|
||||
getUserTicketDetails,
|
||||
getUserTicketList,
|
||||
updateUserTicketStatus,
|
||||
} from '@/services/user/ticket';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@shadcn/ui/dialog';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@shadcn/ui/drawer';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { cn } from '@shadcn/ui/lib/utils';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import { Textarea } from '@shadcn/ui/textarea';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import NextImage from 'next/legacy/image';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('ticket');
|
||||
|
||||
const [ticketId, setTicketId] = useState<any>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const { data: ticket, refetch: refetchTicket } = useQuery({
|
||||
queryKey: ['getUserTicketDetails', ticketId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserTicketDetails({ id: ticketId });
|
||||
return data.data as API.UserTicket;
|
||||
},
|
||||
enabled: !!ticketId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.children[1]?.scrollTo({
|
||||
top: scrollRef.current.children[1].scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 66);
|
||||
}, [ticket?.follow?.length]);
|
||||
|
||||
const ref = useRef<ProListActions>();
|
||||
const [create, setCreate] = useState<Partial<API.CreateUserTicketRequest & { open: boolean }>>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProList<API.UserTicket, { status: number }>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('ticketList'),
|
||||
toolbar: (
|
||||
<Dialog open={create?.open} onOpenChange={(open) => setCreate({ open })}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size='sm'>{t('createTicket')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('createTicket')}</DialogTitle>
|
||||
<DialogDescription>{t('createTicketDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4 py-4'>
|
||||
<Label htmlFor='title'>{t('title')}</Label>
|
||||
<Input
|
||||
id='title'
|
||||
defaultValue={create?.title}
|
||||
onChange={(e) => setCreate({ ...create, title: e.target.value! })}
|
||||
/>
|
||||
<Label htmlFor='content'>{t('description')}</Label>
|
||||
<Textarea
|
||||
id='content'
|
||||
defaultValue={create?.description}
|
||||
onChange={(e) => setCreate({ ...create, description: e.target.value! })}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={!create?.title || !create?.description}
|
||||
onClick={async () => {
|
||||
await createUserTicket({
|
||||
title: create!.title!,
|
||||
description: create!.description!,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
toast.success(t('createSuccess'));
|
||||
setCreate({ open: false });
|
||||
}}
|
||||
>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
placeholder: t('status.0'),
|
||||
options: [
|
||||
{
|
||||
label: t('close'),
|
||||
value: '4',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getUserTicketList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
renderItem={(item) => {
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
|
||||
<CardTitle>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-2 before:block before:size-1.5 before:animate-pulse before:rounded-full before:ring-2 before:ring-opacity-50',
|
||||
{
|
||||
'before:bg-yellow-500 before:ring-yellow-500': item.status === 1,
|
||||
'before:bg-rose-500 before:ring-rose-500': item.status === 2,
|
||||
'before:bg-green-500 before:ring-green-500': item.status === 3,
|
||||
'before:bg-zinc-500 before:ring-zinc-500': item.status === 4,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{t(`status.${item.status}`)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription className='flex gap-2'>
|
||||
{item.status !== 4 ? (
|
||||
<>
|
||||
<Button key='reply' size='sm' onClick={() => setTicketId(item.id)}>
|
||||
{t('reply')}
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
key='close'
|
||||
trigger={
|
||||
<Button variant='destructive' size='sm'>
|
||||
{t('close')}
|
||||
</Button>
|
||||
}
|
||||
title={t('confirmClose')}
|
||||
description={t('closeWarning')}
|
||||
onConfirm={async () => {
|
||||
await updateUserTicketStatus({ id: item.id, status: 4 });
|
||||
toast.success(t('closeSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button key='check' size='sm' onClick={() => setTicketId(item.id)}>
|
||||
{t('check')}
|
||||
</Button>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='p-3 text-sm'>
|
||||
<ul className='grid gap-3 *:flex *:flex-col lg:grid-cols-3'>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('title')}</span>
|
||||
<span> {item.title}</span>
|
||||
</li>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('description')}</span>
|
||||
<time>{item.description}</time>
|
||||
</li>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('updatedAt')}</span>
|
||||
<time>{formatDate(item.updated_at)}</time>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
open={!!ticketId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setTicketId(null);
|
||||
}}
|
||||
>
|
||||
<DrawerContent className='container mx-auto h-screen'>
|
||||
<DrawerHeader className='border-b text-left'>
|
||||
<DrawerTitle>{ticket?.title}</DrawerTitle>
|
||||
<DrawerDescription className='line-clamp-3'>{ticket?.description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<ScrollArea className='h-full overflow-hidden' ref={scrollRef}>
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
{ticket?.follow?.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn('flex items-center gap-4', {
|
||||
'flex-row-reverse': item.from !== 'System',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('flex flex-col gap-1', {
|
||||
'items-end': item.from !== 'System',
|
||||
})}
|
||||
>
|
||||
<p className='text-muted-foreground text-sm'>{formatDate(item.created_at)}</p>
|
||||
<p
|
||||
className={cn('bg-accent w-fit rounded-lg p-2 font-medium', {
|
||||
'bg-primary text-primary-foreground': item.from !== 'System',
|
||||
})}
|
||||
>
|
||||
{item.type === 1 && item.content}
|
||||
{item.type === 2 && (
|
||||
<NextImage
|
||||
src={item.content!}
|
||||
width={300}
|
||||
height={300}
|
||||
className='!size-auto object-cover'
|
||||
alt='image'
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{ticket?.status !== 4 && (
|
||||
<DrawerFooter>
|
||||
<form
|
||||
className='flex w-full flex-row items-center gap-2'
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (message) {
|
||||
await createUserTicketFollow({
|
||||
ticket_id: ticketId,
|
||||
from: 'User',
|
||||
type: 1,
|
||||
content: message,
|
||||
});
|
||||
refetchTicket();
|
||||
setMessage('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type='button' variant='outline' className='p-0'>
|
||||
<Label htmlFor='picture' className='p-2'>
|
||||
<Icon icon='uil:image-upload' className='text-2xl' />
|
||||
</Label>
|
||||
<Input
|
||||
id='picture'
|
||||
type='file'
|
||||
className='hidden'
|
||||
accept='image/*'
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.src = e.target?.result as string;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const maxWidth = 300;
|
||||
const maxHeight = 300;
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
height = Math.round((maxWidth / width) * height);
|
||||
width = maxWidth;
|
||||
}
|
||||
} else {
|
||||
if (height > maxHeight) {
|
||||
width = Math.round((maxHeight / height) * width);
|
||||
height = maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx?.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob!);
|
||||
reader.onloadend = async () => {
|
||||
await createUserTicketFollow({
|
||||
ticket_id: ticketId,
|
||||
from: 'User',
|
||||
type: 2,
|
||||
content: reader.result as string,
|
||||
});
|
||||
refetchTicket();
|
||||
};
|
||||
},
|
||||
'image/webp',
|
||||
0.8,
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Input
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
<Button type='submit' disabled={!message}>
|
||||
<Icon icon='uil:navigator' />
|
||||
</Button>
|
||||
</form>
|
||||
</DrawerFooter>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProList, ProListActions } from '@/components/pro-list';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { queryUserBalanceLog } from '@/services/user/user';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import Recharge from '../order/recharge';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('wallet');
|
||||
const { user } = useGlobalStore();
|
||||
const ref = useRef<ProListActions>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='mb-4'>
|
||||
<CardHeader>
|
||||
<CardTitle className='font-medium'>{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='flex items-center justify-between'>
|
||||
<div className='text-2xl font-bold'>
|
||||
<Display type='currency' value={user?.balance} />
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Recharge />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ProList<API.UserBalanceLog, Record<string, unknown>>
|
||||
action={ref}
|
||||
request={async (pagination, filter) => {
|
||||
const response = await queryUserBalanceLog({ ...pagination, ...filter });
|
||||
return {
|
||||
list: response.data.data?.list || [],
|
||||
total: response.data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
renderItem={(item) => {
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardContent className='p-3 text-sm'>
|
||||
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col lg:grid-cols-4'>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('createdAt')}</span>
|
||||
<time>{formatDate(item.created_at)}</time>
|
||||
</li>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('type.0')}</span>
|
||||
<span>{t(`type.${item.type}`)}</span>
|
||||
</li>
|
||||
<li className='font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('amount')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={item.amount} />
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('balance')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={item.balance} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Footer from '@/components/footer';
|
||||
import Header from '@/components/header';
|
||||
|
||||
export default async function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import {
|
||||
GiftIcon,
|
||||
GlobalMapIcon,
|
||||
LocationsIcon,
|
||||
NetworkSecurityIcon,
|
||||
ServersIcon,
|
||||
UsersIcon,
|
||||
} from '@repo/ui/lotties';
|
||||
import { Button, buttonVariants } from '@shadcn/ui/button';
|
||||
import Marquee from '@shadcn/ui/marquee';
|
||||
import { AnimationProps, motion, MotionProps } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import Link from 'next/link';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
function ScrollAnimationWrapper({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: AnimationProps &
|
||||
MotionProps & {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.section
|
||||
className={className}
|
||||
initial='offscreen'
|
||||
viewport={{ once: true, amount: 0.8 }}
|
||||
whileInView='onscreen'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
function getScrollAnimation() {
|
||||
return {
|
||||
offscreen: {
|
||||
y: 150,
|
||||
opacity: 0,
|
||||
},
|
||||
onscreen: ({ duration = 2 } = {}) => ({
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
duration,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const listUser = [
|
||||
{
|
||||
name: 'Users',
|
||||
number: '390',
|
||||
icon: <UsersIcon className='size-24' />,
|
||||
},
|
||||
{
|
||||
name: 'Locations',
|
||||
number: '20',
|
||||
icon: <LocationsIcon className='size-24' />,
|
||||
},
|
||||
{
|
||||
name: 'Server',
|
||||
number: '50',
|
||||
icon: <ServersIcon className='size-24' />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
const scrollAnimation = useMemo(() => getScrollAnimation(), []);
|
||||
const { common, user } = useGlobalStore();
|
||||
const { site } = common;
|
||||
const t = useTranslations('index');
|
||||
return (
|
||||
<main className='container space-y-16'>
|
||||
<ScrollAnimationWrapper>
|
||||
<motion.div
|
||||
className='grid grid-flow-row grid-rows-2 gap-8 pt-16 sm:grid-flow-col sm:grid-cols-2 md:grid-rows-1'
|
||||
variants={scrollAnimation}
|
||||
>
|
||||
<div className='row-start-2 flex flex-col items-start justify-center sm:row-start-1'>
|
||||
<h1 className='my-6 text-pretty text-4xl font-bold lg:text-6xl'>
|
||||
{t('welcome')} {site.site_name}
|
||||
</h1>
|
||||
<p className='text-muted-foreground mb-8 max-w-xl lg:text-xl'>{site.site_desc}</p>
|
||||
<div className='flex w-full flex-col gap-2 sm:flex-row md:justify-start'>
|
||||
<Link href={user ? '/dashboard' : '/auth'} className={buttonVariants()}>
|
||||
{t('started')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full'>
|
||||
<motion.div className='h-full w-full' variants={scrollAnimation}>
|
||||
<NetworkSecurityIcon />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</ScrollAnimationWrapper>
|
||||
<ScrollAnimationWrapper>
|
||||
<div className='divide-muted z-10 grid w-full grid-flow-row grid-cols-1 divide-y-2 rounded-lg sm:grid-flow-row sm:grid-cols-3 sm:divide-x-2 sm:divide-y-0'>
|
||||
{listUser.map((item, index) => (
|
||||
<motion.div
|
||||
className='mx-auto flex w-8/12 items-center justify-start px-4 py-4 sm:mx-0 sm:w-auto sm:justify-center sm:py-6'
|
||||
key={index}
|
||||
custom={{ duration: 2 + index }}
|
||||
variants={scrollAnimation}
|
||||
>
|
||||
<div className='mx-auto flex w-40 items-center sm:w-auto'>
|
||||
<div className='mr-6 flex h-24 w-24 items-center justify-center rounded-full'>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<p className='text-xl font-bold'>{item.number}+</p>
|
||||
<p className='text-muted-foreground text-lg'>{item.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAnimationWrapper>
|
||||
<div className='mx-auto flex w-full max-w-screen-xl flex-col justify-center px-6 text-center'>
|
||||
<div className='flex w-full flex-col'>
|
||||
<ScrollAnimationWrapper>
|
||||
<motion.h3
|
||||
variants={scrollAnimation}
|
||||
className='text-2xl font-medium leading-relaxed sm:text-3xl lg:text-4xl'
|
||||
>
|
||||
{t('choose_plan')}
|
||||
</motion.h3>
|
||||
<motion.p
|
||||
variants={scrollAnimation}
|
||||
className='mx-auto my-2 w-10/12 text-center leading-normal sm:w-7/12 lg:w-6/12'
|
||||
>
|
||||
{t('choose_plan_desc')}
|
||||
</motion.p>
|
||||
</ScrollAnimationWrapper>
|
||||
<div className='grid grid-flow-row grid-cols-1 gap-4 px-6 py-8 sm:grid-flow-col sm:grid-cols-3 sm:px-0 lg:gap-12 lg:px-6 lg:py-12'>
|
||||
<ScrollAnimationWrapper className='flex justify-center'>
|
||||
<motion.div
|
||||
variants={scrollAnimation}
|
||||
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<GiftIcon className='size-48' />
|
||||
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Free Plan</p>
|
||||
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
|
||||
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
|
||||
<li className='check custom-list relative my-2'>Encrypted Connection</li>
|
||||
<li className='check custom-list relative my-2'>No Traffic Logs</li>
|
||||
<li className='check custom-list relative my-2'>Works on All Devices</li>
|
||||
</ul>
|
||||
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
|
||||
<p className='mb-4 text-center text-2xl'>Free</p>
|
||||
<Button>Select</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</ScrollAnimationWrapper>
|
||||
<ScrollAnimationWrapper className='flex justify-center'>
|
||||
<motion.div
|
||||
variants={scrollAnimation}
|
||||
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<GiftIcon className='size-48' />
|
||||
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Standard Plan </p>
|
||||
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
|
||||
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
|
||||
<li className='check custom-list relative my-2'>Encrypted Connection</li>
|
||||
<li className='check custom-list relative my-2'>No Traffic Logs</li>
|
||||
<li className='check custom-list relative my-2'>Works on All Devices</li>
|
||||
<li className='check custom-list relative my-2'>Connect Anyware </li>
|
||||
</ul>
|
||||
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
|
||||
<p className='mb-4 text-center text-2xl'>
|
||||
$9 <span className='text-muted-foreground'>/ mo</span>
|
||||
</p>
|
||||
<Button>Select</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</ScrollAnimationWrapper>
|
||||
<ScrollAnimationWrapper className='flex justify-center'>
|
||||
<motion.div
|
||||
variants={scrollAnimation}
|
||||
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<GiftIcon className='size-48' />
|
||||
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Premium Plan </p>
|
||||
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
|
||||
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
|
||||
<li className='check custom-list relative my-2'>Encrypted Connection</li>
|
||||
<li className='check custom-list relative my-2'>No Traffic Logs</li>
|
||||
<li className='check custom-list relative my-2'>Works on All Devices</li>
|
||||
<li className='check custom-list relative my-2'>Connect Anyware </li>
|
||||
<li className='check custom-list relative my-2'>Get New Features </li>
|
||||
</ul>
|
||||
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
|
||||
<p className='mb-4 text-center text-2xl'>
|
||||
$12 <span className='text-muted-foreground'>/ mo</span>
|
||||
</p>
|
||||
|
||||
<Button>Select</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</ScrollAnimationWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollAnimationWrapper>
|
||||
<motion.h3
|
||||
variants={scrollAnimation}
|
||||
className='mx-auto text-center text-2xl font-medium leading-relaxed sm:text-3xl lg:text-4xl'
|
||||
>
|
||||
{t('huge_network')} {site.site_name}
|
||||
</motion.h3>
|
||||
<motion.p className='mx-auto my-2 text-center leading-normal' variants={scrollAnimation}>
|
||||
{site.site_name} {t('global_network_desc')}
|
||||
</motion.p>
|
||||
</ScrollAnimationWrapper>
|
||||
<ScrollAnimationWrapper>
|
||||
<motion.div className='aspect-[2/1] w-full overflow-hidden' variants={scrollAnimation}>
|
||||
<GlobalMapIcon className='-mt-[25%] w-full' />
|
||||
</motion.div>
|
||||
</ScrollAnimationWrapper>
|
||||
<ScrollAnimationWrapper>
|
||||
<motion.div
|
||||
className='relative mx-auto flex items-center justify-center overflow-hidden py-6'
|
||||
variants={scrollAnimation}
|
||||
>
|
||||
<Marquee pauseOnHover className='[--duration:20s]'>
|
||||
{[
|
||||
'facebook',
|
||||
'google',
|
||||
'hbo',
|
||||
'instagram',
|
||||
'netflix',
|
||||
'primevideo',
|
||||
'reddit',
|
||||
'snapchat',
|
||||
'spotify',
|
||||
'twitch',
|
||||
'twitter',
|
||||
'whatsapp',
|
||||
'youtube',
|
||||
].map((logo) => (
|
||||
<div
|
||||
className='mx-10 flex shrink-0 items-center justify-center dark:invert'
|
||||
key={logo}
|
||||
>
|
||||
<Image
|
||||
src={`/index/${logo}.png`}
|
||||
alt={logo}
|
||||
width={120}
|
||||
height={48}
|
||||
className='h-12 w-auto object-contain'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Marquee>
|
||||
|
||||
<div className='from-background absolute inset-y-0 left-0 w-12 bg-gradient-to-r to-transparent'></div>
|
||||
<div className='from-background absolute inset-y-0 right-0 w-12 bg-gradient-to-l to-transparent'></div>
|
||||
</motion.div>
|
||||
</ScrollAnimationWrapper>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Markdown } from '@repo/ui/markdown';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='container py-8'>
|
||||
<Markdown>隐私协议</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import LanguageSwitch from '@/components/language-switch';
|
||||
import ThemeSwitch from '@/components/theme-switch';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
|
||||
import { LoginIcon } from '@repo/ui/lotties';
|
||||
import UserAuthForm from './user-auth-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('auth');
|
||||
const { common } = useGlobalStore();
|
||||
const { site } = common;
|
||||
|
||||
return (
|
||||
<main className='bg-muted/50 flex h-full min-h-screen items-center'>
|
||||
<div className='flex size-full flex-auto flex-col lg:flex-row'>
|
||||
<div className='flex bg-cover bg-center lg:w-1/2 lg:flex-auto'>
|
||||
<div className='lg:py-15 md:px-15 flex w-full flex-col items-center justify-center px-5 py-7'>
|
||||
<Link className='mb-0 flex flex-col items-center lg:mb-12' href='/'>
|
||||
<Image src={site.site_logo} height={48} width={48} alt='logo' />
|
||||
<span className='text-2xl font-semibold'>{site.site_name}</span>
|
||||
</Link>
|
||||
<LoginIcon className='mx-auto hidden w-[275px] md:w-1/2 lg:block xl:w-[500px]' />
|
||||
<p className='hidden w-[275px] text-center md:w-1/2 lg:block xl:w-[500px]'>
|
||||
{site.site_desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-initial justify-center p-12 lg:flex-auto lg:justify-end'>
|
||||
<div className='lg:bg-background flex flex-col items-center rounded-2xl p-10 md:w-[600px] lg:flex-auto lg:shadow'>
|
||||
<div className='flex flex-col items-stretch justify-center md:w-[400px] lg:h-full'>
|
||||
<div className='pb-15 flex flex-col justify-center lg:flex-auto lg:pb-20'>
|
||||
<UserAuthForm />
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-5'>
|
||||
<LanguageSwitch />
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
<div className='text-primary flex gap-5 text-sm font-semibold'>
|
||||
<Link href='/tos'>{t('tos')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect } from 'react';
|
||||
import Turnstile, { useTurnstile } from 'react-turnstile';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
|
||||
export default function CloudFlareTurnstile({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id?: string;
|
||||
value?: null | string;
|
||||
onChange: (value?: string) => void;
|
||||
}) {
|
||||
const { common } = useGlobalStore();
|
||||
const { verify } = common;
|
||||
const { resolvedTheme } = useTheme();
|
||||
const locale = useLocale();
|
||||
const turnstile = useTurnstile();
|
||||
|
||||
useEffect(() => {
|
||||
if (value === '') {
|
||||
turnstile.reset();
|
||||
}
|
||||
}, [turnstile, value]);
|
||||
|
||||
return (
|
||||
verify.turnstile_site_key && (
|
||||
<Turnstile
|
||||
id={id}
|
||||
sitekey={verify.turnstile_site_key}
|
||||
theme={resolvedTheme as 'light' | 'dark'}
|
||||
language={locale.toLowerCase()}
|
||||
fixedSize
|
||||
onVerify={(token) => onChange(token)}
|
||||
// onError={() => {
|
||||
// onChange();
|
||||
// turnstile.reset();
|
||||
// }}
|
||||
onExpire={() => {
|
||||
onChange();
|
||||
turnstile.reset();
|
||||
}}
|
||||
onTimeout={() => {
|
||||
onChange();
|
||||
turnstile.reset();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { checkUser, resetPassword, userLogin, userRegister } from '@/services/common/auth';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { cn } from '@shadcn/ui/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ReactNode, useState, useTransition } from 'react';
|
||||
|
||||
import {
|
||||
NEXT_PUBLIC_DEFAULT_USER_EMAIL,
|
||||
NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
|
||||
} from '@/config/constants';
|
||||
import { getRedirectUrl, setAuthorization } from '@/utils/common';
|
||||
import UserCheckForm from './user-check-form';
|
||||
import UserLoginForm from './user-login-form';
|
||||
import UserRegisterForm from './user-register-form';
|
||||
import UserResetForm from './user-reset-form';
|
||||
|
||||
export default function UserAuthForm() {
|
||||
const t = useTranslations('auth');
|
||||
const { common } = useGlobalStore();
|
||||
const { register } = common;
|
||||
const router = useRouter();
|
||||
const [type, setType] = useState<'login' | 'register' | 'reset'>();
|
||||
const [loading, startTransition] = useTransition();
|
||||
const [initialValues, setInitialValues] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
}>({
|
||||
email: NEXT_PUBLIC_DEFAULT_USER_EMAIL,
|
||||
password: NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (params: any) => {
|
||||
const onLogin = async (token?: string) => {
|
||||
if (!token) return;
|
||||
setAuthorization(token);
|
||||
router.replace(getRedirectUrl());
|
||||
router.refresh();
|
||||
};
|
||||
startTransition(async () => {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'login':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const login = await userLogin(params);
|
||||
toast.success(t('login.success'));
|
||||
onLogin(login.data.data?.token);
|
||||
break;
|
||||
case 'register':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const create = await userRegister(params);
|
||||
toast.success(t('register.success'));
|
||||
onLogin(create.data.data?.token);
|
||||
break;
|
||||
case 'reset':
|
||||
await resetPassword(params);
|
||||
toast.success(t('reset.success'));
|
||||
setType('login');
|
||||
break;
|
||||
default:
|
||||
if (type === 'reset') break;
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const response = await checkUser(params);
|
||||
setInitialValues({
|
||||
...initialValues,
|
||||
...params,
|
||||
});
|
||||
setType(response.data.data?.exist ? 'login' : 'register');
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
};
|
||||
let UserForm: ReactNode = null;
|
||||
switch (type) {
|
||||
case 'login':
|
||||
UserForm = (
|
||||
<UserLoginForm
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
setInitialValues={setInitialValues}
|
||||
onSwitchForm={setType}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'register':
|
||||
UserForm = (
|
||||
<UserRegisterForm
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
setInitialValues={setInitialValues}
|
||||
onSwitchForm={setType}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'reset':
|
||||
UserForm = (
|
||||
<UserResetForm
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
setInitialValues={setInitialValues}
|
||||
onSwitchForm={setType}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
UserForm = (
|
||||
<UserCheckForm
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mb-11 text-center'>
|
||||
<h1 className='mb-3 text-2xl font-bold'>{t(`${type || 'check'}.title`)}</h1>
|
||||
<div className='text-muted-foreground font-medium'>
|
||||
{t(`${type || 'check'}.description`)}
|
||||
</div>
|
||||
</div>
|
||||
{!((type === 'register' && register.stop_register) || type === 'reset') && (
|
||||
<>
|
||||
<div className='mb-3 flex flex-wrap items-center justify-center gap-3 font-bold'>
|
||||
<Button type='button' variant='outline'>
|
||||
<Icon icon='uil:telegram' className='mr-2 size-5' />
|
||||
Telegram
|
||||
</Button>
|
||||
<Button type='button' variant='outline'>
|
||||
<Icon icon='uil:google' className='mr-2 size-5' />
|
||||
Google
|
||||
</Button>
|
||||
<Button type='button' variant='outline'>
|
||||
<Icon icon='uil:apple' className='mr-2 size-5' />
|
||||
Apple
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'my-14 flex h-0 items-center text-center',
|
||||
'before:mr-4 before:block before:w-1/2 before:border-b-[1px]',
|
||||
'after:ml-4 after:w-1/2 after:border-b-[1px]',
|
||||
)}
|
||||
>
|
||||
<span className='text-muted-foreground w-[125px] text-sm'>{t('orWithEmail')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{UserForm}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function UserCheckForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
}) {
|
||||
const t = useTranslations('auth.check');
|
||||
const { common } = useGlobalStore();
|
||||
const { register } = common;
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email(t('email'))
|
||||
.refine(
|
||||
(email) => {
|
||||
if (!register.enable_email_domain_suffix) return true;
|
||||
const domain = email.split('@')[1];
|
||||
return register.email_domain_suffix_list.split('\n').includes(domain || '');
|
||||
},
|
||||
{
|
||||
message: t('whitelist'),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleEmailChange = (value: string) => {
|
||||
form.setValue('email', value);
|
||||
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(async () => {
|
||||
const isValid = await form.trigger('email');
|
||||
if (isValid) {
|
||||
setIsSubmitting(true);
|
||||
form.handleSubmit(onSubmit)();
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues && initialValues.email) {
|
||||
handleEmailChange(initialValues.email);
|
||||
}
|
||||
return () => {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter your email...'
|
||||
type='email'
|
||||
{...field}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type='submit' disabled={!isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Icon icon='mdi:loading' className='mr-2 size-5 animate-spin' />
|
||||
{t('checking')}
|
||||
</>
|
||||
) : (
|
||||
t('continue')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import CloudFlareTurnstile from './turnstile';
|
||||
|
||||
export default function UserLoginForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: (type?: 'register' | 'reset') => void;
|
||||
}) {
|
||||
const t = useTranslations('auth.login');
|
||||
const { common } = useGlobalStore();
|
||||
const { verify } = common;
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
cf_token: verify.enable_login_verify ? z.string() : z.string().optional(),
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input disabled placeholder='Enter your email...' type='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter your password...'
|
||||
type='password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{verify.enable_login_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cf_token'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile id='login' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||
{t('title')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='mt-4 flex w-full justify-between text-sm'>
|
||||
<Button variant='link' type='button' className='p-0' onClick={() => onSwitchForm('reset')}>
|
||||
{t('forgotPassword')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0'
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm(undefined);
|
||||
}}
|
||||
>
|
||||
{t('switchAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { sendEmailCode } from '@/services/common/common';
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { Markdown } from '@repo/ui/markdown';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useCountDown } from 'ahooks';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import CloudFlareTurnstile from './turnstile';
|
||||
|
||||
export default function UserRegisterForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: (type?: 'register' | 'reset') => void;
|
||||
}) {
|
||||
const t = useTranslations('auth.register');
|
||||
const { common } = useGlobalStore();
|
||||
const { verify, register, invite } = common;
|
||||
|
||||
const [targetDate, setTargetDate] = useState<number>();
|
||||
const [, { seconds }] = useCountDown({
|
||||
targetDate,
|
||||
onEnd: () => {
|
||||
setTargetDate(undefined);
|
||||
},
|
||||
});
|
||||
const handleSendCode = async () => {
|
||||
await sendEmailCode({
|
||||
email: initialValues.email,
|
||||
type: 1,
|
||||
});
|
||||
setTargetDate(Date.now() + 60000);
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
repeat_password: z.string(),
|
||||
code: register.enable_email_verify ? z.string() : z.string().nullish(),
|
||||
invite: invite.forced_invite ? z.string() : z.string().nullish(),
|
||||
cf_token: verify.enable_register_verify ? z.string() : z.string().nullish(),
|
||||
})
|
||||
.superRefine(({ password, repeat_password }, ctx) => {
|
||||
if (password !== repeat_password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('passwordMismatch'),
|
||||
path: ['repeat_password'],
|
||||
});
|
||||
}
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
invite: sessionStorage.getItem('invite'),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{register.stop_register ? (
|
||||
<Markdown>{t('message')}</Markdown>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input disabled placeholder='Enter your email...' type='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter your password...'
|
||||
type='password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='repeat_password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter password again...'
|
||||
type='password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{register.enable_email_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter code...'
|
||||
type='text'
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
|
||||
{seconds > 0 ? `${seconds}s` : t('get')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='invite'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t('invite')}
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{verify.enable_register_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cf_token'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile id='register' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||
{t('title')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
<div className='mt-4 text-right text-sm'>
|
||||
{t('existingAccount')}
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0'
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm(undefined);
|
||||
}}
|
||||
>
|
||||
{t('switchToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { sendEmailCode } from '@/services/common/common';
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useCountDown } from 'ahooks';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
|
||||
import CloudFlareTurnstile from './turnstile';
|
||||
|
||||
export default function UserResetForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: (type?: 'register' | 'reset') => void;
|
||||
}) {
|
||||
const t = useTranslations('auth.reset');
|
||||
|
||||
const { common } = useGlobalStore();
|
||||
const { verify, register } = common;
|
||||
|
||||
const [targetDate, setTargetDate] = useState<number>();
|
||||
const [, { seconds }] = useCountDown({
|
||||
targetDate,
|
||||
onEnd: () => {
|
||||
setTargetDate(undefined);
|
||||
},
|
||||
});
|
||||
const handleSendCode = async () => {
|
||||
await sendEmailCode({
|
||||
email: initialValues.email,
|
||||
type: 2,
|
||||
});
|
||||
setTargetDate(Date.now() + 60000); // 60秒倒计时
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
code: register.enable_email_verify ? z.string() : z.string().nullish(),
|
||||
cf_token: verify.enable_register_verify ? z.string() : z.string().nullish(),
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input disabled placeholder='Enter your email...' type='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter your password...'
|
||||
type='password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{register.enable_email_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter code...'
|
||||
type='text'
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
|
||||
{seconds > 0 ? `${seconds}s` : t('get')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{verify.enable_reset_password_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cf_token'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile id='reset' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||
{t('title')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='mt-4 text-right text-sm'>
|
||||
{t('existingAccount')}
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0'
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm(undefined);
|
||||
}}
|
||||
>
|
||||
{t('switchToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Providers from '@/components/providers';
|
||||
import { geistMono, geistSans } from '@/config/fonts';
|
||||
import { getGlobalConfig } from '@/services/common/common';
|
||||
import { queryUserInfo } from '@/services/user/user';
|
||||
import '@shadcn/ui/globals.css';
|
||||
import { Toaster } from '@shadcn/ui/sonner';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
import { cookies } from 'next/headers';
|
||||
import { Metadata } from 'next/types';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import React from 'react';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getGlobalConfig({ skipErrorHandler: true }).then((res) => res.data.data!);
|
||||
const site = config?.site || {};
|
||||
return {
|
||||
title: {
|
||||
default: `${site.site_name}`,
|
||||
template: `%s | ${site.site_name}`,
|
||||
},
|
||||
description: site.site_desc,
|
||||
icons: {
|
||||
icon: site.site_logo
|
||||
? [
|
||||
{
|
||||
url: site.site_logo,
|
||||
sizes: 'any',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
let config, user;
|
||||
|
||||
try {
|
||||
config = await getGlobalConfig({ skipErrorHandler: true }).then((res) => res.data.data);
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
try {
|
||||
user = await queryUserInfo({
|
||||
skipErrorHandler: true,
|
||||
Authorization: (await cookies()).get('Authorization')?.value,
|
||||
}).then((res) => res.data.data);
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<head>
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
<body
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} size-full min-h-[calc(100dvh-env(safe-area-inset-top))] antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NextTopLoader showSpinner={false} />
|
||||
<Providers common={{ ...config }} user={user}>
|
||||
<Toaster richColors closeButton />
|
||||
{children}
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { formatBytes, unitConversion } from '@repo/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
type DisplayType = 'currency' | 'traffic' | 'number';
|
||||
|
||||
interface DisplayProps<T> {
|
||||
value?: T;
|
||||
unlimited?: boolean;
|
||||
type?: DisplayType;
|
||||
}
|
||||
|
||||
export function Display<T extends number | undefined | null>({
|
||||
value = 0,
|
||||
unlimited = false,
|
||||
type = 'number',
|
||||
}: DisplayProps<T>): string {
|
||||
const t = useTranslations('common');
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
|
||||
if (type === 'currency') {
|
||||
const formattedValue = `${currency?.currency_symbol ?? ''}${unitConversion('centsToDollars', value as number)?.toFixed(2) ?? '0.00'}`;
|
||||
return formattedValue;
|
||||
}
|
||||
|
||||
if (['traffic', 'number'].includes(type) && unlimited && !value) {
|
||||
return t('unlimited');
|
||||
}
|
||||
|
||||
if (type === 'traffic') {
|
||||
return value ? formatBytes(value as number) : '0';
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
return value ? value.toString() : '0';
|
||||
}
|
||||
|
||||
return '0';
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
NEXT_PUBLIC_DISCORD_LINK,
|
||||
NEXT_PUBLIC_EMAIL,
|
||||
NEXT_PUBLIC_GITHUB_LINK,
|
||||
NEXT_PUBLIC_INSTAGRAM_LINK,
|
||||
NEXT_PUBLIC_LINKEDIN_LINK,
|
||||
NEXT_PUBLIC_TELEGRAM_LINK,
|
||||
NEXT_PUBLIC_TWITTER_LINK,
|
||||
} from '@/config/constants';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
const Links = [
|
||||
{
|
||||
icon: 'uil:envelope',
|
||||
href: NEXT_PUBLIC_EMAIL ?? `mailto:${NEXT_PUBLIC_EMAIL}`,
|
||||
},
|
||||
{
|
||||
icon: 'uil:telegram',
|
||||
href: NEXT_PUBLIC_TELEGRAM_LINK,
|
||||
},
|
||||
{
|
||||
icon: 'uil:twitter',
|
||||
href: NEXT_PUBLIC_TWITTER_LINK,
|
||||
},
|
||||
{
|
||||
icon: 'uil:discord',
|
||||
href: NEXT_PUBLIC_DISCORD_LINK,
|
||||
},
|
||||
{
|
||||
icon: 'uil:instagram',
|
||||
href: NEXT_PUBLIC_INSTAGRAM_LINK,
|
||||
},
|
||||
{
|
||||
icon: 'uil:linkedin',
|
||||
href: NEXT_PUBLIC_LINKEDIN_LINK,
|
||||
},
|
||||
{
|
||||
icon: 'uil:github',
|
||||
href: NEXT_PUBLIC_GITHUB_LINK,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
const { common } = useGlobalStore();
|
||||
const { site } = common;
|
||||
const t = useTranslations('auth');
|
||||
return (
|
||||
<footer>
|
||||
<Separator className='my-14' />
|
||||
<div className='text-muted-foreground container mb-14 flex flex-wrap justify-between gap-4 text-sm'>
|
||||
<nav className='flex flex-wrap items-center gap-2'>
|
||||
{Links.filter((item) => item.href).map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
{index !== 0 && <Separator orientation='vertical' />}
|
||||
<Link href={item.href!}>
|
||||
<Icon icon={item.icon} className='text-foreground size-5' />
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</nav>
|
||||
<div>
|
||||
<strong className='text-foreground'>{site.site_name}</strong> © All rights reserved.
|
||||
<Link href='/tos' className='ml-2 underline'>
|
||||
{t('tos')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { buttonVariants } from '@shadcn/ui/button';
|
||||
import Image from 'next/legacy/image';
|
||||
import Link from 'next/link';
|
||||
import LanguageSwitch from './language-switch';
|
||||
// import { MobileSidebar } from './mobile-sidebar';
|
||||
import ThemeSwitch from './theme-switch';
|
||||
import { UserNav } from './user-nav';
|
||||
|
||||
export default function Header() {
|
||||
const { common, user } = useGlobalStore();
|
||||
const { site } = common;
|
||||
const Logo = (
|
||||
<Link href='/' className='flex items-center gap-2 text-lg font-bold'>
|
||||
{site.site_logo && <Image src={site.site_logo} width={48} height={48} alt='logo' />}
|
||||
<span>{site.site_name}</span>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<header className='sticky top-0 z-50 border-b backdrop-blur-md'>
|
||||
<div className='container flex h-16 items-center justify-between'>
|
||||
<nav className='flex-col gap-6 text-lg font-medium md:flex md:flex-row md:items-center md:gap-5 md:text-sm lg:gap-6'>
|
||||
{Logo}
|
||||
</nav>
|
||||
<div className='flex flex-1 items-center justify-end gap-2'>
|
||||
<LanguageSwitch />
|
||||
<ThemeSwitch />
|
||||
<UserNav />
|
||||
{!user && (
|
||||
<Link
|
||||
href='/auth'
|
||||
className={buttonVariants({
|
||||
size: 'sm',
|
||||
})}
|
||||
>
|
||||
登录/注册
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { locales } from '@/config/constants';
|
||||
import { setLocale } from '@/utils/common';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { getCountry } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@shadcn/ui/dropdown-menu';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LanguageSwitch() {
|
||||
const locale = useLocale();
|
||||
const country = getCountry(locale);
|
||||
const t = useTranslations('language');
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon'>
|
||||
<Icon icon={`flagpack:${country?.alpha2.toLowerCase()}`} className='!size-5' />
|
||||
<span className='sr-only'>Switch Language</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{locales.map(getCountry).map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={`${item?.lang}-${item?.alpha2}`}
|
||||
onClick={() => {
|
||||
setLocale(`${item?.lang}-${item?.alpha2}`);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon icon={`flagpack:${item?.alpha2.toLowerCase()}`} />
|
||||
{t(`${item?.lang}-${item?.alpha2}`)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
import { ProList as _ProList, ProListProps } from '@repo/ui/pro-list';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export { type ProListActions, type ProListProps } from '@repo/ui/pro-list';
|
||||
export function ProList<TData, TValue extends Record<string, unknown>>(
|
||||
props: ProListProps<TData, TValue>,
|
||||
) {
|
||||
const t = useTranslations('common.list');
|
||||
return (
|
||||
<_ProList
|
||||
{...props}
|
||||
texts={{
|
||||
selectedRowsText(total) {
|
||||
return t('selectedItems', { total });
|
||||
},
|
||||
textPageOf(current, total) {
|
||||
return t('pageInfo', { current, total });
|
||||
},
|
||||
textRowsPerPage: t('rowsPerPage'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore, { GlobalStore } from '@/config/use-global';
|
||||
import { Logout } from '@/utils/common';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function Providers({
|
||||
children,
|
||||
common,
|
||||
user,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
common: Partial<GlobalStore['common']>;
|
||||
user: GlobalStore['user'];
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { setCommon, setUser } = useGlobalStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setUser(user);
|
||||
} else {
|
||||
Logout();
|
||||
}
|
||||
}, [setUser, user]);
|
||||
|
||||
useEffect(() => {
|
||||
setCommon(common);
|
||||
}, [setCommon, common]);
|
||||
|
||||
return (
|
||||
<NextThemesProvider attribute='class' defaultTheme='system' enableSystem>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { MoonIcon, SunIcon } from '@repo/ui/lotties';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@shadcn/ui/dropdown-menu';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
export default function ThemeSwitch() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon'>
|
||||
<MoonIcon className='size-8 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
|
||||
<SunIcon className='roate-90 absolute size-8 scale-0 transition-all dark:rotate-0 dark:scale-100' />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { navs } from '@/config/navs';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Logout } from '@/utils/common';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@shadcn/ui/dropdown-menu';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function UserNav() {
|
||||
const t = useTranslations('menu');
|
||||
const { user, setUser } = useGlobalStore();
|
||||
const router = useRouter();
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size='icon' variant='default'>
|
||||
<Avatar className='size-8'>
|
||||
<AvatarImage alt={user?.avatar ?? ''} src={user?.avatar ?? ''} />
|
||||
<AvatarFallback className='rounded-none bg-transparent'>
|
||||
{user?.email?.[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent forceMount align='end' className='w-56'>
|
||||
<DropdownMenuLabel className='font-normal'>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<p className='text-muted-foreground text-xs leading-none'>ID: {user?.id}</p>
|
||||
<p className='text-sm font-medium leading-none'>{user?.email}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{navs.map((nav) => (
|
||||
<DropdownMenuGroup key={nav.title}>
|
||||
{/* {nav.items && <DropdownMenuLabel>{t(nav.title)}</DropdownMenuLabel>} */}
|
||||
{(nav.items || [nav]).map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.title}
|
||||
onClick={() => {
|
||||
router.push(`${item.url}`);
|
||||
}}
|
||||
>
|
||||
<Icon className='mr-2 size-4 flex-none' icon={item.icon!} />
|
||||
<span className='truncate'>{t(item.title)}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
Logout();
|
||||
setUser();
|
||||
}}
|
||||
>
|
||||
<Icon className='mr-2 size-4 flex-none' icon='uil:exit' />
|
||||
|
||||
{t('logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
export const locales = ['en-US', 'zh-CN'];
|
||||
|
||||
export const NEXT_PUBLIC_DEFAULT_LANGUAGE = env('NEXT_PUBLIC_DEFAULT_LANGUAGE');
|
||||
|
||||
export const NEXT_PUBLIC_SITE_URL = env('NEXT_PUBLIC_SITE_URL');
|
||||
export const NEXT_PUBLIC_API_URL = env('NEXT_PUBLIC_API_URL');
|
||||
|
||||
export const NEXT_PUBLIC_DEFAULT_USER_EMAIL = env('NEXT_PUBLIC_DEFAULT_USER_EMAIL');
|
||||
export const NEXT_PUBLIC_DEFAULT_USER_PASSWORD = env('NEXT_PUBLIC_DEFAULT_USER_PASSWORD');
|
||||
|
||||
export const NEXT_PUBLIC_EMAIL = env('NEXT_PUBLIC_EMAIL');
|
||||
|
||||
export const NEXT_PUBLIC_TELEGRAM_LINK = env('NEXT_PUBLIC_TELEGRAM_LINK');
|
||||
export const NEXT_PUBLIC_DISCORD_LINK = env('NEXT_PUBLIC_DISCORD_LINK');
|
||||
export const NEXT_PUBLIC_GITHUB_LINK = env('NEXT_PUBLIC_GITHUB_LINK');
|
||||
export const NEXT_PUBLIC_LINKEDIN_LINK = env('NEXT_PUBLIC_LINKEDIN_LINK');
|
||||
export const NEXT_PUBLIC_TWITTER_LINK = env('NEXT_PUBLIC_TWITTER_LINK');
|
||||
export const NEXT_PUBLIC_INSTAGRAM_LINK = env('NEXT_PUBLIC_INSTAGRAM_LINK');
|
||||
@@ -0,0 +1,12 @@
|
||||
import localFont from 'next/font/local';
|
||||
|
||||
export const geistSans = localFont({
|
||||
src: './GeistVF.woff',
|
||||
variable: '--font-geist-sans',
|
||||
weight: '100 900',
|
||||
});
|
||||
export const geistMono = localFont({
|
||||
src: './GeistMonoVF.woff',
|
||||
variable: '--font-geist-mono',
|
||||
weight: '100 900',
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
export const navs = [
|
||||
{
|
||||
title: 'dashboard',
|
||||
url: '/dashboard',
|
||||
icon: 'uil:dashboard',
|
||||
},
|
||||
{
|
||||
title: 'personal',
|
||||
items: [
|
||||
{
|
||||
title: 'profile',
|
||||
url: '/profile',
|
||||
icon: 'uil:user',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'server',
|
||||
items: [
|
||||
{
|
||||
url: '/subscribe',
|
||||
icon: 'uil:shop',
|
||||
title: 'subscribe',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'finance',
|
||||
items: [
|
||||
{
|
||||
url: '/order',
|
||||
icon: 'uil:notes',
|
||||
title: 'order',
|
||||
},
|
||||
{
|
||||
url: '/wallet',
|
||||
icon: 'uil:wallet',
|
||||
title: 'wallet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'help',
|
||||
items: [
|
||||
{
|
||||
url: '/document',
|
||||
icon: 'uil:book-alt',
|
||||
title: 'document',
|
||||
},
|
||||
{
|
||||
url: '/announcement',
|
||||
icon: 'uil:megaphone',
|
||||
title: 'announcement',
|
||||
},
|
||||
{
|
||||
url: '/ticket',
|
||||
icon: 'uil:message',
|
||||
title: 'ticket',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function findNavByUrl(url: string) {
|
||||
for (const nav of navs) {
|
||||
if (nav.url && nav.url === url) {
|
||||
return [nav];
|
||||
}
|
||||
if (nav.items) {
|
||||
const current = nav.items.find((item) => item.url === url);
|
||||
if (current) {
|
||||
return [nav, current];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const navItems = [
|
||||
{
|
||||
url: '/profile',
|
||||
icon: 'uil:user',
|
||||
title: 'profile',
|
||||
},
|
||||
{
|
||||
url: '/subscribe',
|
||||
icon: 'uil:shop',
|
||||
title: 'subscribe',
|
||||
},
|
||||
{
|
||||
url: '/order',
|
||||
icon: 'uil:notes',
|
||||
title: 'order',
|
||||
},
|
||||
{
|
||||
url: '/wallet',
|
||||
icon: 'uil:wallet',
|
||||
title: 'wallet',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_URL } from '@/config/constants';
|
||||
import { queryUserInfo } from '@/services/user/user';
|
||||
import { extractDomain } from '@repo/ui/utils';
|
||||
import Base64 from 'crypto-js/enc-base64';
|
||||
import UTF8 from 'crypto-js/enc-utf8';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ICommon extends API.GetGlobalConfigResponse {
|
||||
background: string;
|
||||
}
|
||||
|
||||
export interface GlobalStore {
|
||||
common: ICommon;
|
||||
user?: API.UserInfo;
|
||||
setCommon: (common: Partial<ICommon>) => void;
|
||||
setUser: (user?: API.UserInfo) => void;
|
||||
getUserInfo: () => Promise<void>;
|
||||
getUserSubscribe: (uuid: string, type?: string) => string[];
|
||||
getAppSubLink: (type: string, url: string) => string;
|
||||
}
|
||||
|
||||
export const useGlobalStore = create<GlobalStore>((set, get) => ({
|
||||
common: {
|
||||
background: '',
|
||||
site: {
|
||||
host: '',
|
||||
site_name: '',
|
||||
site_desc: '',
|
||||
site_logo: '',
|
||||
},
|
||||
verify: {
|
||||
enable_login_verify: false,
|
||||
enable_register_verify: false,
|
||||
enable_reset_password_verify: false,
|
||||
turnstile_site_key: '',
|
||||
},
|
||||
register: {
|
||||
stop_register: false,
|
||||
enable_email_verify: false,
|
||||
enable_email_domain_suffix: false,
|
||||
email_domain_suffix_list: '',
|
||||
},
|
||||
invite: {
|
||||
forced_invite: false,
|
||||
},
|
||||
currency: {
|
||||
currency_unit: 'USD',
|
||||
currency_symbol: '$',
|
||||
},
|
||||
subscribe: {
|
||||
single_model: false,
|
||||
subscribe_path: '',
|
||||
subscribe_domain: '',
|
||||
pan_domain: false,
|
||||
},
|
||||
},
|
||||
user: undefined,
|
||||
setCommon: (common) =>
|
||||
set((state) => ({
|
||||
common: {
|
||||
...state.common,
|
||||
...common,
|
||||
},
|
||||
})),
|
||||
setUser: (user) => set({ user }),
|
||||
getUserInfo: async () => {
|
||||
try {
|
||||
const { data } = await queryUserInfo();
|
||||
set({ user: data.data });
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user:', error);
|
||||
}
|
||||
},
|
||||
getUserSubscribe: (uuid: string, type?: string) => {
|
||||
const { pan_domain, subscribe_domain, subscribe_path } = get().common.subscribe || {};
|
||||
const domains = subscribe_domain
|
||||
? subscribe_domain.split('\n')
|
||||
: [extractDomain(NEXT_PUBLIC_API_URL || NEXT_PUBLIC_SITE_URL || '')];
|
||||
|
||||
return domains.map((domain) => {
|
||||
const enc_type = type ? Base64.stringify(UTF8.parse(type)) : '';
|
||||
const enc_uuid = Base64.stringify(UTF8.parse(uuid));
|
||||
if (pan_domain) {
|
||||
if (enc_type) return `https://${enc_type}.${enc_uuid}.${domain}`;
|
||||
return `https://${enc_uuid}.${domain}`;
|
||||
} else {
|
||||
if (enc_type) return `https://${domain}${subscribe_path}?mark=${enc_uuid}&type=${enc_type}`;
|
||||
return `https://${domain}${subscribe_path}?mark=${enc_uuid}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
getAppSubLink: (type: string, url: string) => {
|
||||
const name = get().common.site.site_name || '';
|
||||
switch (type) {
|
||||
// case 'Clash':
|
||||
// return `clash://install-config?url=${url}&name=${name}`;
|
||||
case 'Hiddify':
|
||||
return `hiddify://import/${url}#${name}`;
|
||||
case 'Loon':
|
||||
return `loon://import?sub=${encodeURI(url)}${name}`;
|
||||
case 'NekoBox':
|
||||
return `sn://subscription?url=${url}&name=${name}`;
|
||||
case 'NekoRay':
|
||||
return `sn://subscription?url=${url}&name=${name}`;
|
||||
// case 'Netch':
|
||||
// return ``;
|
||||
case 'Quantumult X':
|
||||
return `quantumult-x://add-resource?remote-resource=${url}`;
|
||||
case 'Shadowrocket':
|
||||
return `shadowrocket://add/sub://${window.btoa(url)}?remark=${encodeURI(name)}`;
|
||||
case 'Singbox':
|
||||
return `sing-box://import-remote-profile?url=${encodeURI(url)}#${name}`;
|
||||
case 'Surfboard':
|
||||
return `surfboard:///install-config?url=${encodeURI(url)}`;
|
||||
case 'Surge':
|
||||
return `surge:///install-config?url=${encodeURI(url)}`;
|
||||
case 'V2box':
|
||||
return `v2box://install-sub?url=${encodeURI(url)}&name=${name}`;
|
||||
case 'V2rayN':
|
||||
return `v2rayn://install-sub?url=${encodeURI(url)}&name=${name}`;
|
||||
case 'V2rayNg':
|
||||
return `v2rayng://install-sub?url=${encodeURI(url)}&name=${name}`;
|
||||
default:
|
||||
return `clash://install-config?url=${encodeURI(url)}&name=${name}`;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default useGlobalStore;
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"check": {
|
||||
"checking": "Checking...",
|
||||
"continue": "Continue",
|
||||
"description": "Please enter your email to continue logging in or registering.",
|
||||
"email": "Please enter a valid email address.",
|
||||
"title": "Login/Register",
|
||||
"whitelist": "The email domain is not in the allowed whitelist."
|
||||
},
|
||||
"login": {
|
||||
"description": "Please enter your account information to log in.",
|
||||
"forgotPassword": "Forgot Password?",
|
||||
"success": "Login successful!",
|
||||
"switchAccount": "Register/Switch Account",
|
||||
"title": "Login"
|
||||
},
|
||||
"orWithEmail": "Or with Email",
|
||||
"register": {
|
||||
"description": "Create a new account, fill in your information to register.",
|
||||
"existingAccount": "Already have an account?",
|
||||
"get": "Get",
|
||||
"invite": "Invitation code",
|
||||
"message": "#### Dear User, Hello!\n\nThank you for your attention and support. Due to adjustments in site operation strategy, we have closed the new user registration function. During this period, existing users will not be affected.\n\nWe are committed to providing you with better service and experience, so we will conduct comprehensive system optimization and feature upgrades during the registration closure. In the future, we will welcome you with better content and services.\n\nPlease follow our website and social media platforms to get the latest updates and notifications. Thank you for your understanding and support.\n\nIf you have any questions or need assistance, please feel free to contact our customer service team.\n\n**Thank you again for your support and understanding.**",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"success": "Registration successful, automatically logged in!",
|
||||
"switchToLogin": "Login/Reset Email",
|
||||
"title": "Register"
|
||||
},
|
||||
"reset": {
|
||||
"description": "Please enter your email address to reset your password.",
|
||||
"existingAccount": "Already have an account?",
|
||||
"get": "Get",
|
||||
"success": "Password reset successful, automatically switched to login!",
|
||||
"switchToLogin": "Login/Register",
|
||||
"title": "Reset Password"
|
||||
},
|
||||
"tos": "Terms of Service"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"empty": [
|
||||
"Imagine this space filled with exciting content! For now, you'll have to use your imagination...",
|
||||
"This area mysteriously disappeared, but we're summoning it back!",
|
||||
"Oh no, nothing happened... Feel free to fill in the blank!",
|
||||
"It's like discovering an empty stage at a concert... Why not go up and perform?",
|
||||
"You've found a blank canvas! How about building a house?",
|
||||
"This area is currently empty, but creativity starts here!",
|
||||
"Nothing here... but don't worry, it's just the beginning!",
|
||||
"This place was supposed to have a big surprise, but the surprise slipped away!",
|
||||
"There's nothing here for now, like an empty snack cabinet.",
|
||||
"This empty space is waiting for its protagonist to take the stage!"
|
||||
],
|
||||
"list": {
|
||||
"pageInfo": "Page {current} of {total}",
|
||||
"rowsPerPage": "Rows per page",
|
||||
"selectedItems": "{total} items selected"
|
||||
},
|
||||
"request": {
|
||||
"10001": "Database query failed, please try again later",
|
||||
"10002": "Data update failed, please try again later",
|
||||
"10003": "Data save failed, please try again later",
|
||||
"10004": "Data deletion failed, please try again later",
|
||||
"20001": "The username has already been registered",
|
||||
"20002": "User not found",
|
||||
"20003": "Incorrect password, please re-enter",
|
||||
"20004": "Your account has been disabled",
|
||||
"30001": "The node already exists",
|
||||
"30002": "Node not found",
|
||||
"30003": "The node group already exists",
|
||||
"30004": "Node group not found",
|
||||
"30005": "There is still data in the node group, cannot be deleted",
|
||||
"400": "Request parameters are incorrect, please check and resubmit",
|
||||
"40002": "Please retry after logging in",
|
||||
"40003": "Invalid login information, please log in again",
|
||||
"40004": "Login session has expired, please log in again",
|
||||
"401": "Request is too frequent, please try again later",
|
||||
"500": "Internal system error, we are urgently fixing it",
|
||||
"undefined": "System error occurred, please try again later"
|
||||
},
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmResetSubscription": "Are you sure you want to reset the subscription address?",
|
||||
"copy": "Copy",
|
||||
"copyFailure": "Copy failed, please copy manually",
|
||||
"copySuccess": "Copy successful",
|
||||
"download": "Download",
|
||||
"expirationDays": "Expiration Days",
|
||||
"import": "Import",
|
||||
"latestAnnouncement": "Latest Announcement",
|
||||
"manualImportMessage": "This app does not support activation. Please import manually. The subscription address has been copied.",
|
||||
"mySubscriptions": "My Subscriptions",
|
||||
"nextResetDays": "Next Reset in Days",
|
||||
"prompt": "Prompt",
|
||||
"purchaseSubscription": "Purchase Subscription",
|
||||
"qrCode": "QR Code",
|
||||
"resetSubscription": "Reset Subscription Address",
|
||||
"resetSuccess": "Reset successful",
|
||||
"scanToSubscribe": "Scan to Subscribe",
|
||||
"subscriptionUrl": "Subscription URL",
|
||||
"totalTraffic": "Total Traffic",
|
||||
"unknown": "Unknown",
|
||||
"used": "Used"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"back": "Back",
|
||||
"category": "Category",
|
||||
"read": "Read",
|
||||
"tags": "Tags",
|
||||
"title": "Title",
|
||||
"updatedAt": "Updated At"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"choose_plan": "Choose Your Plan",
|
||||
"choose_plan_desc": "Let's select the plan that best suits you and explore it happily.",
|
||||
"global_network_desc": "Easily access our services while on the move.",
|
||||
"huge_network": "Vast and Fast Global Network",
|
||||
"started": "Get Started",
|
||||
"tos": "Terms of Service",
|
||||
"welcome": "Welcome to"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"cs-CZ": "Czech",
|
||||
"de-DE": "German",
|
||||
"en-US": "English",
|
||||
"es-ES": "Spanish",
|
||||
"es-MX": "Spanish (Mexico)",
|
||||
"fi-FI": "Finnish",
|
||||
"fr-FR": "French",
|
||||
"hi-IN": "Hindi",
|
||||
"hu-HU": "Hungarian",
|
||||
"ja-JP": "Japanese",
|
||||
"ko-KR": "Korean",
|
||||
"no-NO": "Norwegian",
|
||||
"pl-PL": "Polish",
|
||||
"pt-BR": "Portuguese (Brazil)",
|
||||
"ro-RO": "Romanian",
|
||||
"ru-RU": "Russian",
|
||||
"th-TH": "Thai",
|
||||
"tr-TR": "Turkish",
|
||||
"uk-UA": "Ukrainian",
|
||||
"vi-VN": "Vietnamese",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"zh-TW": "Chinese (Traditional)"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"accountBalance": "Account Balance",
|
||||
"copyInviteLink": "Copy Invite Link",
|
||||
"copySuccess": "Invite Link Copied Successfully",
|
||||
"inviteCode": "Invite Code",
|
||||
"invitees": "Invitees",
|
||||
"recharge": "Recharge",
|
||||
"totalCommission": "Total Commission"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"announcement": "Announcement List",
|
||||
"dashboard": "Dashboard",
|
||||
"document": "Documentation",
|
||||
"finance": "Finance",
|
||||
"help": "Help",
|
||||
"logout": "Logout",
|
||||
"order": "Order List",
|
||||
"payment": "Order Details",
|
||||
"personal": "Personal",
|
||||
"profile": "Profile",
|
||||
"server": "Service",
|
||||
"subscribe": "Subscribe",
|
||||
"ticket": "My Tickets",
|
||||
"wallet": "Wallet"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"balanceRecharge": "Balance Recharge",
|
||||
"buyNow": "Buy Now",
|
||||
"buySubscription": "Buy Subscription",
|
||||
"cancel": "Cancel",
|
||||
"createdAt": "Created At",
|
||||
"description": "One-click recharge, easy to handle",
|
||||
"detail": "Detail",
|
||||
"enterAmount": "Enter recharge amount",
|
||||
"enterCoupon": "Enter Coupon Code",
|
||||
"goToPayment": "Go to Payment",
|
||||
"methods": {
|
||||
"alipay_f2f": "Alipay (Face to Face)",
|
||||
"balance": "Balance",
|
||||
"epay": "Epay",
|
||||
"stripe_alipay": "Stripe (Alipay)",
|
||||
"stripe_wechat_pay": "Stripe (WeChat)"
|
||||
},
|
||||
"month": "month",
|
||||
"months": "months",
|
||||
"name": "Name",
|
||||
"orderClosed": "Order Closed",
|
||||
"orderList": "Order List",
|
||||
"orderNo": "Order Number",
|
||||
"orderNumber": "Order Number",
|
||||
"payment": "Payment",
|
||||
"paymentAmount": "Payment Amount",
|
||||
"paymentMethod": "Payment Method",
|
||||
"paymentSuccess": "Payment Success",
|
||||
"productList": "Product List",
|
||||
"purchaseDuration": "Purchase Duration",
|
||||
"recharge": "Recharge",
|
||||
"rechargeAmount": "Recharge Amount",
|
||||
"rechargeNow": "Recharge Now",
|
||||
"renew": "Renew",
|
||||
"renewSubscription": "Renew Subscription",
|
||||
"resetPrice": "Reset Price",
|
||||
"resetTraffic": "Reset Traffic",
|
||||
"resetTrafficDescription": "Reset traffic for the current month only",
|
||||
"resetTrafficTitle": "Reset Traffic",
|
||||
"scanToPay": "Scan to Pay",
|
||||
"status": {
|
||||
"0": "Status",
|
||||
"1": "Pending",
|
||||
"2": "Paid",
|
||||
"3": "Cancelled",
|
||||
"4": "Closed",
|
||||
"5": "Completed"
|
||||
},
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"timeExpired": "Payment Time Expired",
|
||||
"type": {
|
||||
"0": "Type",
|
||||
"1": "New Purchase",
|
||||
"2": "Renewal",
|
||||
"3": "Reset Traffic",
|
||||
"4": "Recharge"
|
||||
},
|
||||
"viewDocument": "View Document",
|
||||
"waitingForPayment": "Waiting for Payment"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"accountSettings": {
|
||||
"accountSettings": "Account Settings",
|
||||
"loginPassword": "Login Password",
|
||||
"newPassword": "New Password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"repeatNewPassword": "Repeat New Password",
|
||||
"updatePassword": "Update Password",
|
||||
"updateSuccess": "Update Successful"
|
||||
},
|
||||
"notify": {
|
||||
"bind": "Bind",
|
||||
"emailNotification": "Email Notification",
|
||||
"notificationSettings": "Notification Settings",
|
||||
"telegramId": "Telegram ID",
|
||||
"telegramIdPlaceholder": "Enter Telegram ID",
|
||||
"telegramNotification": "Telegram Notification",
|
||||
"updateSuccess": "Update Successful"
|
||||
},
|
||||
"notifyEvent": {
|
||||
"balanceChange": "Balance Change",
|
||||
"finance": "Finance",
|
||||
"login": "Login",
|
||||
"notificationEvents": "Notification Events",
|
||||
"subscribe": "Subscribe",
|
||||
"updateSuccess": "Update Successful"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"all": "All",
|
||||
"billing": {
|
||||
"billingTitle": "Product Billing",
|
||||
"couponDiscount": "Coupon Discount",
|
||||
"duration": "Duration",
|
||||
"fee": "Fee",
|
||||
"months": "months",
|
||||
"price": "Price",
|
||||
"productDiscount": "Product Discount",
|
||||
"total": "Total"
|
||||
},
|
||||
"buy": "Buy",
|
||||
"category": "Category",
|
||||
"detail": {
|
||||
"availableTraffic": "Available Traffic",
|
||||
"connectedDevices": "Connected Devices",
|
||||
"connectionSpeed": "Connection Speed",
|
||||
"productDetail": "Product Details"
|
||||
},
|
||||
"methods": {
|
||||
"alipay_f2f": "Alipay (Face to Face)",
|
||||
"balance": "Balance",
|
||||
"epay": "Epay",
|
||||
"stripe_alipay": "Stripe (Alipay)",
|
||||
"stripe_wechat_pay": "Stripe (WeChat)"
|
||||
},
|
||||
"perMonth": "Per Month",
|
||||
"productDescription": "Product Description",
|
||||
"products": "Products"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"cancel": "Cancel",
|
||||
"check": "Check",
|
||||
"close": "Close",
|
||||
"closeSuccess": "Closed successfully",
|
||||
"closeWarning": "Once closed, the ticket cannot be operated on. Please proceed with caution.",
|
||||
"confirm": "Confirm",
|
||||
"confirmClose": "Are you sure you want to close?",
|
||||
"createSuccess": "Creation successful",
|
||||
"createTicket": "Create ticket",
|
||||
"createTicketDescription": "We will reply to the ticket within 24 hours",
|
||||
"description": "Description",
|
||||
"inputPlaceholder": "Please enter your question, we will reply as soon as possible.",
|
||||
"reply": "Reply",
|
||||
"status": ["Status", "Pending Reply", "Pending Follow-up", "Resolved", "Closed"],
|
||||
"submit": "Submit",
|
||||
"ticketList": "Ticket List",
|
||||
"title": "Title",
|
||||
"updatedAt": "Updated At"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"amount": "Amount",
|
||||
"balance": "Balance",
|
||||
"createdAt": "Time",
|
||||
"title": "Account Balance",
|
||||
"type": {
|
||||
"0": "Type",
|
||||
"1": "Recharge",
|
||||
"2": "Withdrawal",
|
||||
"3": "Purchase",
|
||||
"4": "Refund",
|
||||
"5": "Reward",
|
||||
"6": "Commission"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
|
||||
const locales = ['zh-CN', 'en-US'];
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const browserLocale = (await headers()).get('Accept-Language')?.split(',')?.[0] || '';
|
||||
const defaultLocale = locales.includes(browserLocale) ? browserLocale : '';
|
||||
const cookieLocale = (await cookies()).get('locale')?.value || '';
|
||||
|
||||
const locale =
|
||||
cookieLocale || defaultLocale || process.env.NEXT_PUBLIC_DEFAULT_LANGUAGE || locales[0];
|
||||
|
||||
const messages = {
|
||||
language: (await import(`./${locale}/language.json`)).default,
|
||||
menu: (await import(`./${locale}/menu.json`)).default,
|
||||
auth: (await import(`./${locale}/auth.json`)).default,
|
||||
common: (await import(`./${locale}/common.json`)).default,
|
||||
layout: (await import(`./${locale}/layout.json`)).default,
|
||||
index: (await import(`./${locale}/index.json`)).default,
|
||||
dashboard: (await import(`./${locale}/dashboard.json`)).default,
|
||||
profile: (await import(`./${locale}/profile.json`)).default,
|
||||
subscribe: (await import(`./${locale}/subscribe.json`)).default,
|
||||
order: (await import(`./${locale}/order.json`)).default,
|
||||
wallet: (await import(`./${locale}/wallet.json`)).default,
|
||||
ticket: (await import(`./${locale}/ticket.json`)).default,
|
||||
document: (await import(`./${locale}/document.json`)).default,
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getLocale } from '@/utils/common';
|
||||
import { createTranslator } from 'next-intl';
|
||||
|
||||
export async function getTranslations(namespace: string) {
|
||||
const locale = getLocale();
|
||||
const messages = (await import(`./${locale}/${namespace}.json`)).default;
|
||||
return createTranslator({ locale, messages });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"check": {
|
||||
"checking": "正在验证...",
|
||||
"continue": "继续",
|
||||
"description": "请输入您的电子邮件以继续登录或注册。",
|
||||
"email": "请输入有效的邮箱地址。",
|
||||
"title": "登录/注册",
|
||||
"whitelist": "邮箱域名不在允许的白名单中。"
|
||||
},
|
||||
"login": {
|
||||
"description": "请输入您的账户信息以登录。",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"success": "登录成功!",
|
||||
"switchAccount": "注册/切换账号",
|
||||
"title": "登录"
|
||||
},
|
||||
"orWithEmail": "或使用邮箱",
|
||||
"register": {
|
||||
"description": "创建新账户,填写您的信息以注册。",
|
||||
"existingAccount": "已有账户?",
|
||||
"get": "获取",
|
||||
"invite": "邀请码",
|
||||
"message": "#### 尊敬的用户,您好!\n\n感谢您对我们的关注和支持。由于站点运营策略的调整,我们已关闭新用户注册功能。在此期间,现有用户的使用不会受到任何影响。\n\n我们致力于为您提供更好的服务和体验,因此将在关闭注册期间进行全面的系统优化和功能升级。未来,我们将以更优质的内容和服务迎接您的到来。\n\n请关注我们的网站和社交媒体平台,获取最新的动态和通知。感谢您的理解与支持。\n\n如有任何疑问或需要帮助,请随时联系我们的客服团队。\n\n**再次感谢您的支持与理解。**",
|
||||
"passwordMismatch": "两次密码输入不一致",
|
||||
"success": "注册成功,已自动登录!",
|
||||
"switchToLogin": "登录/重置邮箱",
|
||||
"title": "注册"
|
||||
},
|
||||
"reset": {
|
||||
"description": "请输入您的电子邮件地址以重置密码。",
|
||||
"existingAccount": "已有账户?",
|
||||
"get": "获取",
|
||||
"success": "重置密码成功,已自动切换到登录!",
|
||||
"switchToLogin": "登录/注册",
|
||||
"title": "重置密码"
|
||||
},
|
||||
"tos": "服务条款"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"empty": [
|
||||
"想象一下这里充满了精彩的内容!暂时只能靠想象了…",
|
||||
"这片区域神秘消失了,我们正在召唤它回来!",
|
||||
"哎呀,什么都没发生…你可以自己填点什么!",
|
||||
"这就像在演唱会上发现舞台上没人…要不你上去表演?",
|
||||
"你找到了一片空白乐土!要不要建个房子?",
|
||||
"这里暂时空无一物,但创意从这里开始!",
|
||||
"什么也没有…但别担心,这只是个开始!",
|
||||
"这里原本准备了大惊喜,结果惊喜溜走了!",
|
||||
"暂时什么都没有,就像零食柜被吃空了一样。",
|
||||
"这片空地在等它的主角登场!"
|
||||
],
|
||||
"list": {
|
||||
"pageInfo": "第 {current} 页,共 {total} 页",
|
||||
"rowsPerPage": "每页行数",
|
||||
"selectedItems": "已选择 {total} 行"
|
||||
},
|
||||
"request": {
|
||||
"10001": "数据库查询失败,请稍后重试",
|
||||
"10002": "数据更新失败,请稍后重试",
|
||||
"10003": "数据保存失败,请稍后重试",
|
||||
"10004": "数据删除失败,请稍后重试",
|
||||
"20001": "该用户名已被注册",
|
||||
"20002": "找不到该用户",
|
||||
"20003": "密码错误,请重新输入",
|
||||
"20004": "您的账号已被禁用",
|
||||
"30001": "该节点已存在",
|
||||
"30002": "找不到该节点",
|
||||
"30003": "该节点组已存在",
|
||||
"30004": "找不到该节点组",
|
||||
"30005": "节点组中还有数据,无法删除",
|
||||
"400": "请求参数有误,请检查后重新提交",
|
||||
"40002": "请登录后重试",
|
||||
"40003": "登录信息无效,请重新登录",
|
||||
"40004": "登录已过期,请重新登录",
|
||||
"401": "请求过于频繁,请稍后重试",
|
||||
"500": "系统内部错误,我们正在紧急修复",
|
||||
"undefined": "系统发生错误,请稍后重试"
|
||||
},
|
||||
"unlimited": "无限制"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmResetSubscription": "是否确认重置订阅地址?",
|
||||
"copy": "复制",
|
||||
"copyFailure": "复制失败,请手动复制",
|
||||
"copySuccess": "复制成功",
|
||||
"download": "下载",
|
||||
"expirationDays": "到期时间/天",
|
||||
"import": "导入",
|
||||
"latestAnnouncement": "最新公告",
|
||||
"manualImportMessage": "该应用暂不支持唤起,请手动导入,已自动复制订阅地址",
|
||||
"mySubscriptions": "我的订阅",
|
||||
"nextResetDays": "下次重置/天",
|
||||
"prompt": "提示",
|
||||
"purchaseSubscription": "购买订阅",
|
||||
"qrCode": "二维码",
|
||||
"resetSubscription": "重置订阅地址",
|
||||
"resetSuccess": "重置成功",
|
||||
"scanToSubscribe": "扫描订阅",
|
||||
"subscriptionUrl": "订阅地址",
|
||||
"totalTraffic": "总流量",
|
||||
"unknown": "未知",
|
||||
"used": "已用"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"back": "返回",
|
||||
"category": "分类",
|
||||
"read": "阅读",
|
||||
"tags": "分类",
|
||||
"title": "标题",
|
||||
"updatedAt": "更新时间"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"choose_plan": "选择您的套餐",
|
||||
"choose_plan_desc": "让我们选择最适合您的套餐,快乐地探索它。",
|
||||
"global_network_desc": "让您在移动地点时更轻松地看到我们的服务。",
|
||||
"huge_network": "庞大的快速全球网络",
|
||||
"started": "开始使用",
|
||||
"tos": "服务协议",
|
||||
"welcome": "欢迎来到"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"cs-CZ": "捷克语",
|
||||
"de-DE": "德语",
|
||||
"en-US": "英语",
|
||||
"es-ES": "西班牙语",
|
||||
"es-MX": "西班牙语(墨西哥)",
|
||||
"fi-FI": "芬兰语",
|
||||
"fr-FR": "法语",
|
||||
"hi-IN": "印地语",
|
||||
"hu-HU": "匈牙利语",
|
||||
"ja-JP": "日语",
|
||||
"ko-KR": "韩语",
|
||||
"no-NO": "挪威语",
|
||||
"pl-PL": "波兰语",
|
||||
"pt-BR": "葡萄牙语(巴西)",
|
||||
"ro-RO": "罗马尼亚语",
|
||||
"ru-RU": "俄语",
|
||||
"th-TH": "泰语",
|
||||
"tr-TR": "土耳其语",
|
||||
"uk-UA": "乌克兰语",
|
||||
"vi-VN": "越南语",
|
||||
"zh-CN": "中文(简体)",
|
||||
"zh-TW": "中文(繁体)"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"accountBalance": "账户余额",
|
||||
"copyInviteLink": "复制邀请链接",
|
||||
"copySuccess": "邀请链接复制成功",
|
||||
"inviteCode": "邀请码",
|
||||
"invitees": "邀请人数",
|
||||
"recharge": "充值",
|
||||
"totalCommission": "佣金总额"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"announcement": "公告列表",
|
||||
"dashboard": "主页",
|
||||
"document": "使用文档",
|
||||
"finance": "财务",
|
||||
"help": "帮助",
|
||||
"logout": "退出登录",
|
||||
"order": "订单列表",
|
||||
"payment": "订单详情",
|
||||
"personal": "个人",
|
||||
"profile": "个人中心",
|
||||
"server": "服务",
|
||||
"subscribe": "购买订阅",
|
||||
"ticket": "我的工单",
|
||||
"wallet": "财务中心"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"balanceRecharge": "余额充值",
|
||||
"buyNow": "立即购买",
|
||||
"buySubscription": "购买订阅",
|
||||
"cancel": "取消",
|
||||
"createdAt": "创建时间",
|
||||
"description": "一键充值,轻松搞定",
|
||||
"detail": "详情",
|
||||
"enterAmount": "请输入充值金额",
|
||||
"enterCoupon": "请输入折扣码",
|
||||
"goToPayment": "前往支付",
|
||||
"methods": {
|
||||
"alipay_f2f": "支付宝(面对面)",
|
||||
"balance": "余额",
|
||||
"epay": "易支付",
|
||||
"stripe_alipay": "Stripe(支付宝)",
|
||||
"stripe_wechat_pay": "Stripe(微信)"
|
||||
},
|
||||
"month": "个月",
|
||||
"months": "个月",
|
||||
"name": "名称",
|
||||
"orderClosed": "订单已关闭",
|
||||
"orderList": "订单列表",
|
||||
"orderNo": "订单号",
|
||||
"orderNumber": "订单编号",
|
||||
"payment": "去支付",
|
||||
"paymentAmount": "支付金额",
|
||||
"paymentMethod": "支付方式",
|
||||
"paymentSuccess": "支付成功",
|
||||
"productList": "产品列表",
|
||||
"purchaseDuration": "购买时长",
|
||||
"recharge": "充值",
|
||||
"rechargeAmount": "充值金额",
|
||||
"rechargeNow": "立即充值",
|
||||
"renew": "续费",
|
||||
"renewSubscription": "续费订阅",
|
||||
"resetPrice": "重置价格",
|
||||
"resetTraffic": "重置流量",
|
||||
"resetTrafficDescription": "仅重置当月流量哦",
|
||||
"resetTrafficTitle": "重置流量",
|
||||
"scanToPay": "请扫码支付",
|
||||
"status": {
|
||||
"0": "状态",
|
||||
"1": "待支付",
|
||||
"2": "已支付",
|
||||
"3": "已取消",
|
||||
"4": "已关闭",
|
||||
"5": "已完成"
|
||||
},
|
||||
"subscribeNow": "立即订阅",
|
||||
"timeExpired": "支付时间已过",
|
||||
"type": {
|
||||
"0": "类型",
|
||||
"1": "新购",
|
||||
"2": "续费",
|
||||
"3": "重置流量",
|
||||
"4": "充值"
|
||||
},
|
||||
"viewDocument": "查看文档",
|
||||
"waitingForPayment": "等待支付"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"accountSettings": {
|
||||
"accountSettings": "账户设置",
|
||||
"loginPassword": "登录密码",
|
||||
"newPassword": "新密码",
|
||||
"passwordMismatch": "两次密码输入不一致",
|
||||
"repeatNewPassword": "重复新密码",
|
||||
"updatePassword": "更新密码",
|
||||
"updateSuccess": "更新成功"
|
||||
},
|
||||
"notify": {
|
||||
"bind": "绑定",
|
||||
"emailNotification": "邮件通知",
|
||||
"notificationSettings": "通知设置",
|
||||
"telegramId": "Telegram ID",
|
||||
"telegramIdPlaceholder": "输入 Telegram ID",
|
||||
"telegramNotification": "Telegram 通知",
|
||||
"updateSuccess": "更新成功"
|
||||
},
|
||||
"notifyEvent": {
|
||||
"balanceChange": "余额变动",
|
||||
"finance": "财务",
|
||||
"login": "登录",
|
||||
"notificationEvents": "通知事件",
|
||||
"subscribe": "订阅",
|
||||
"updateSuccess": "更新成功"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"all": "全部",
|
||||
"billing": {
|
||||
"billingTitle": "商品账单",
|
||||
"couponDiscount": "折扣码优惠",
|
||||
"duration": "套餐时长",
|
||||
"fee": "手续费",
|
||||
"months": "个月",
|
||||
"price": "价格",
|
||||
"productDiscount": "商品折扣",
|
||||
"total": "总价"
|
||||
},
|
||||
"buy": "购买",
|
||||
"category": "类别",
|
||||
"detail": {
|
||||
"availableTraffic": "可用流量",
|
||||
"connectedDevices": "同时连接 IP 数",
|
||||
"connectionSpeed": "连接速度",
|
||||
"productDetail": "商品详情"
|
||||
},
|
||||
"methods": {
|
||||
"alipay_f2f": "支付宝(面对面)",
|
||||
"balance": "余额",
|
||||
"epay": "易支付",
|
||||
"stripe_alipay": "Stripe(支付宝)",
|
||||
"stripe_wechat_pay": "Stripe(微信)"
|
||||
},
|
||||
"perMonth": "月",
|
||||
"productDescription": "商品描述",
|
||||
"products": "商品"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"cancel": "取消",
|
||||
"check": "查看",
|
||||
"close": "关闭",
|
||||
"closeSuccess": "工单关闭成功",
|
||||
"closeWarning": "关闭工单后将无法继续回复。",
|
||||
"confirm": "确认",
|
||||
"confirmClose": "确认关闭工单吗?",
|
||||
"createSuccess": "创建成功",
|
||||
"createTicket": "创建工单",
|
||||
"createTicketDescription": "工单24小时内回复哦",
|
||||
"description": "描述",
|
||||
"inputPlaceholder": "输入您的消息",
|
||||
"reply": "回复",
|
||||
"status": {
|
||||
"0": "状态",
|
||||
"1": "待处理",
|
||||
"2": "处理中",
|
||||
"3": "已解决",
|
||||
"4": "已关闭"
|
||||
},
|
||||
"submit": "提交",
|
||||
"ticketList": "工单列表",
|
||||
"title": "标题",
|
||||
"updatedAt": "更新时间"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"amount": "金额",
|
||||
"balance": "余额",
|
||||
"createdAt": "时间",
|
||||
"title": "账户余额",
|
||||
"type": {
|
||||
"0": "类型",
|
||||
"1": "充值",
|
||||
"2": "提取",
|
||||
"3": "购买",
|
||||
"4": "退款",
|
||||
"5": "奖励",
|
||||
"6": "佣金"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./locales/request.ts');
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@shadcn/ui', '@repo/ui'],
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.**.**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.**',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
@@ -0,0 +1,16 @@
|
||||
const config = [
|
||||
{
|
||||
requestLibPath: "import request from '@/utils/request';",
|
||||
schemaPath: 'https://swagger.ppanel.dev/common.json',
|
||||
serversPath: './services',
|
||||
projectName: 'common',
|
||||
},
|
||||
{
|
||||
requestLibPath: "import request from '@/utils/request';",
|
||||
schemaPath: 'https://swagger.ppanel.dev/user.json',
|
||||
serversPath: './services',
|
||||
projectName: 'admin',
|
||||
},
|
||||
];
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "ppanel-user-web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev --turbopack -p 3001",
|
||||
"lint": "next lint && tsc --noEmit",
|
||||
"locale": "lobe-i18n --with-md",
|
||||
"openapi": "openapi2ts",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^5.0.2",
|
||||
"@repo/ui": "workspace:*",
|
||||
"@shadcn/ui": "workspace:*",
|
||||
"@stripe/react-stripe-js": "^2.9.0",
|
||||
"@stripe/stripe-js": "^4.10.0",
|
||||
"@tanstack/react-query": "^5.59.20",
|
||||
"@tanstack/react-query-next-experimental": "^5.59.20",
|
||||
"ahooks": "^3.8.1",
|
||||
"axios": "^1.7.7",
|
||||
"crypto-js": "^4.2.0",
|
||||
"framer-motion": "^11.11.11",
|
||||
"lucide-react": "^0.446.0",
|
||||
"mathjs": "^13.2.1",
|
||||
"next": "^15.0.3",
|
||||
"next-intl": "^3.25.0",
|
||||
"next-runtime-env": "^3.2.2",
|
||||
"next-themes": "^0.4.3",
|
||||
"nextjs-toploader": "^3.7.15",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"radash": "^12.1.0",
|
||||
"react": "rc",
|
||||
"react-dom": "rc",
|
||||
"react-turnstile": "^1.1.4",
|
||||
"universal-cookie": "^7.2.2",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 394 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 512 512"><path fill="#FF306D" fill-rule="evenodd" d="M255.986 455.276V492c120.206 0 199.318-115.8 199.318-236H492c0-120.2-115.808-199.276-236.014-199.276V20C135.777 20 56.698 135.8 56.698 256H20c0 120.2 115.777 199.276 235.986 199.276Zm80.302-196.836c5.291-9.387 7.936-19.797 7.936-31.232 0-18.944-5.973-34.048-17.92-45.312C314.528 170.632 297.547 165 275.36 165H196v181.248h56.832v-57.6h22.528c14.677 0 27.136-2.731 37.376-8.192 10.411-5.461 18.261-12.8 23.552-22.016Zm-49.664-31.232c0 11.093-5.888 16.64-17.664 16.64h-16.128v-33.28h16.128c11.776 0 17.664 5.547 17.664 16.64Z" clip-rule="evenodd"><animate attributeName="fill" dur="8s" repeatCount="indefinite" values="rgb(255, 48, 109);rgb(250, 204, 21);rgb(59, 130, 246);rgb(34, 197, 94);rgb(34, 197, 94);rgb(59, 130, 246);rgb(250, 204, 21);rgb(255, 48, 109);"/><animateTransform attributeName="transform" dur="3s" repeatCount="indefinite" type="rotate" values="-30 256 256;30 256 256;-30 256 256"/></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 35 KiB |