Sticker.Show
This commit is contained in:
parent
31c8f7bd7a
commit
c897ef82eb
88
.env.example
Normal file
88
.env.example
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# website URL
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# website name
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_WEBSITE_NAME="贴纸网站"
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# image alt text
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_IMAGE_ALT_ADDITION_TEXT="贴纸网站"
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# domain name
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_DOMAIN_NAME="Sticker"
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# postgres config
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
POSTGRES_URL="postgres://sticker:pass1234@127.0.0.1:5432/sticker"
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# Google auth config
|
||||||
|
# 0 代表不检查登录,则登录相关的按钮也不展示出来,1代表要检查
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_CHECK_GOOGLE_LOGIN=0
|
||||||
|
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_SECRET_ID=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# NEXTAUTH config create command: openssl rand -base64 32
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXTAUTH_URL=http://localhost
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# Google gtag id
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_GOOGLE_TAG_ID=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# Update these with your Stripe credentials from https://dashboard.stripe.com/apikeys
|
||||||
|
# 0 代表不检查支付,则支付页面也不展示出来,1代表要检查
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_CHECK_AVAILABLE_TIME=0
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
# 免费生成次数
|
||||||
|
FREE_TIMES=2
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# replicate config
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# replicate 生成结束后的回调地址,本地时我才用的 ngrok,线上的话就是你的域名
|
||||||
|
REPLICATE_WEBHOOK=
|
||||||
|
# replicate 的 API token,需要去你的 replicate 账号里面复制 https://replicate.com/account/api-tokens
|
||||||
|
REPLICATE_API_TOKEN=
|
||||||
|
# 生成贴纸的API版本 https://replicate.com/fofr/sticker-maker/versions ,最新的那个版本只生成一张图片了,下方这个版本是还会一次生成两张图片的
|
||||||
|
REPLICATE_API_VERSION="6443cc831f51eb01333f50b757157411d7cadb6215144cc721e3688b70004ad0"
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# cloudflare R2 config
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
|
STORAGE_DOMAIN=
|
||||||
|
R2_BUCKET=
|
||||||
|
R2_ACCOUNT_ID=
|
||||||
|
R2_ENDPOINT=
|
||||||
|
R2_TOKEN_VALUE=
|
||||||
|
R2_ACCESS_KEY_ID=
|
||||||
|
R2_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# openai config
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_API_BASE_URL=https://openrouter.ai/api
|
||||||
|
OPENAI_API_MODEL="openai/gpt-3.5-turbo"
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# stickers 里面每页显示多少条数据
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_PAGES_SIZE=24
|
88
.env.production
Normal file
88
.env.production
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# website URL
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_SITE_URL=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# website name
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_WEBSITE_NAME=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# image alt text
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_IMAGE_ALT_ADDITION_TEXT=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# domain name
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_DOMAIN_NAME=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# postgres config
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
POSTGRES_URL=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# Google auth config
|
||||||
|
# 0 代表不检查登录,则登录相关的按钮也不展示出来,1代表要检查
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_CHECK_GOOGLE_LOGIN=0
|
||||||
|
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_SECRET_ID=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# NEXTAUTH config create command: openssl rand -base64 32
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXTAUTH_URL=
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# Google gtag id
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_GOOGLE_TAG_ID=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# Update these with your Stripe credentials from https://dashboard.stripe.com/apikeys
|
||||||
|
# 0 代表不检查支付,则支付页面也不展示出来,1代表要检查
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_CHECK_AVAILABLE_TIME=0
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
# 免费生成次数
|
||||||
|
FREE_TIMES=2
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# replicate config
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# replicate 生成结束后的回调地址,本地时我才用的 ngrok,线上的话就是你的域名
|
||||||
|
REPLICATE_WEBHOOK=
|
||||||
|
# replicate 的 API token,需要去你的 replicate 账号里面复制 https://replicate.com/account/api-tokens
|
||||||
|
REPLICATE_API_TOKEN=
|
||||||
|
# 生成贴纸的API版本 https://replicate.com/fofr/sticker-maker/versions ,最新的那个版本只生成一张图片了,下方这个版本是还会一次生成两张图片的
|
||||||
|
REPLICATE_API_VERSION="6443cc831f51eb01333f50b757157411d7cadb6215144cc721e3688b70004ad0"
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# cloudflare R2 config
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
|
STORAGE_DOMAIN=
|
||||||
|
R2_BUCKET=
|
||||||
|
R2_ACCOUNT_ID=
|
||||||
|
R2_ENDPOINT=
|
||||||
|
R2_TOKEN_VALUE=
|
||||||
|
R2_ACCESS_KEY_ID=
|
||||||
|
R2_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# openai config
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_API_BASE_URL=https://openrouter.ai/api
|
||||||
|
OPENAI_API_MODEL="openai/gpt-3.5-turbo"
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
# stickers 里面每页显示多少条数据
|
||||||
|
#--------------------------------------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_PAGES_SIZE=24
|
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
73
README.md
73
README.md
@ -1,2 +1,75 @@
|
|||||||
# StickerShow
|
# StickerShow
|
||||||
Sticker.Show 代码,仅限哥飞的朋友们社群成员使用
|
Sticker.Show 代码,仅限哥飞的朋友们社群成员使用
|
||||||
|
|
||||||
|
### 1. 右上角 Fork 本项目到你自己的 github 仓库
|
||||||
|
|
||||||
|
### 2. Clone你自己的仓库代码到本地
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone (your git url)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd StickerShow && yarn
|
||||||
|
#or
|
||||||
|
cd StickerShow && npm install
|
||||||
|
#or
|
||||||
|
cd StickerShow && pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 复制 .env.example 重命名为 .env.local
|
||||||
|
|
||||||
|
修改.env.local其中的配置为你项目的配置,生产环境配置在 .env.production
|
||||||
|
|
||||||
|
### 5. 额外的配置
|
||||||
|
|
||||||
|
1) 谷歌登录认证配置 👉 [Google-Auth-Help](https://github.com/SoraWebui/SoraWebui/blob/login/help/Google-Auth.md)
|
||||||
|
2) 数据库配置 👉 Any PostgreSQL
|
||||||
|
3) 目录 /sql 下有需要的数据表,创建数据库并执行这些SQL创建数据表
|
||||||
|
4) R2的配置,为了存储生成的图片,需要去 Cloudflare 后台创建 bucket 后进行配置。项目用到了图像转换功能,通过URL来压缩图片,这个需要在 Cloudflare 的 「图像」->「转换」这里面针对具体的域名开启
|
||||||
|
5) Stripe 价格配置在 src/configs/stripeConfig.ts ,里面具体的的配置项需要你从 stripe 后台获取。
|
||||||
|
6) 对接 stripe 支付参考的是该项目👉 https://github.com/vercel/nextjs-subscription-payments
|
||||||
|
上边这个支付项目能单独运行,但它用的 supabase,当前项目是已经改成了支持任何 PostgreSQL 数据库的代码
|
||||||
|
|
||||||
|
### 6. 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
#or
|
||||||
|
npm run dev
|
||||||
|
#or
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 在浏览器打开 [http://localhost](http://localhost)
|
||||||
|
|
||||||
|
|
||||||
|
## 注意!!!
|
||||||
|
### 1. 贴纸生成结果是通过接收 replicate 的回调,于是本地调试的时候,需要将 REPLICATE_WEBHOOK 这个配置成公网URL,可以配置 [ngrok](https://ngrok.com/) 来接收结果,本地就能处理生成的图片了
|
||||||
|
|
||||||
|
类似配置成:https://0123-153-121-77-112.ngrok-free.app,这个是需要你本地运行 ngrok 与 其云端服务对上后生成的,直接用这个是不会通的哈
|
||||||
|
|
||||||
|
线上时,REPLICATE_WEBHOOK 这个配置为你的线上域名就可以
|
||||||
|
|
||||||
|
### 2. 项目用到 vercel 的定时任务来将贴纸文本翻译为其他语言,线上版本时会生效,配置在 vercel.json 这个文件,设置的一分钟运行一次,免费账户有限制(应该是只能一天调用一次),查看 vercel 的文档 https://vercel.com/docs/cron-jobs
|
||||||
|
|
||||||
|
当然,你可以删除 vercel.json 这个文件,那样就不会触发定时任务了。你可以采用别的方式来调用接口触发定时任务,比如你自己的服务器运行一个脚本定时调用接口来翻译
|
||||||
|
|
||||||
|
### 3. stripe 不激活的账号就是测试模式,就可以本地调通支付流程;对接支付参考的是这个项目👉 https://github.com/vercel/nextjs-subscription-payments
|
||||||
|
|
||||||
|
### 4. 最重要的是熟悉代码且会改代码,本项目对接 stripe 支付只是简易版本对接,各种边界条件没有考虑到。
|
||||||
|
|
||||||
|
比如本项目是支付后就无限制使用,没有对某个价格订阅的次数做限制,比如用户重复订阅的判断等等,这些需要你自己去完善。
|
||||||
|
|
||||||
|
每个人想给订阅增加的限制,无法做到通用配置,需要你自己去研究代码该怎么添加。
|
||||||
|
|
||||||
|
目前配置的是1个月付,1个年付,多个价格也可以,只是界面样式你得自己调整一下。
|
||||||
|
|
||||||
|
### 5. 本项目现有代码是能够跑通全流程的,只需将所有配置都做好。
|
||||||
|
配置项有点多,需要细心点去进行相关配置,不要遗漏。
|
||||||
|
|
||||||
|
|
||||||
|
## 有任何疑问联系 Wechat: GeFei55
|
||||||
|
|
||||||
|
3
global.d.ts
vendored
Executable file
3
global.d.ts
vendored
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
// Use type safe message keys with `next-intl`
|
||||||
|
type Messages = typeof import('./messages/en.json');
|
||||||
|
declare interface IntlMessages extends Messages {}
|
128
messages/en.json
Normal file
128
messages/en.json
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"FileDesc": {
|
||||||
|
"lang": "en",
|
||||||
|
"language": "English",
|
||||||
|
"languageInChineseSimple": "英语"
|
||||||
|
},
|
||||||
|
"IndexPageText": {
|
||||||
|
"title": "英文Title",
|
||||||
|
"description": "英文description",
|
||||||
|
"h1Text": "英文首页 H1",
|
||||||
|
"descriptionBelowH1Text": "英文首页 H1下方的文字"
|
||||||
|
},
|
||||||
|
"CommonText": {
|
||||||
|
"loadingText": "Loading...",
|
||||||
|
"generateText": "Generating...",
|
||||||
|
"placeholderText": "Type or paste text here...",
|
||||||
|
"buttonText": "Generate",
|
||||||
|
"footerDescText": "英文footer左下角文字",
|
||||||
|
"timesLeft": "You can generate",
|
||||||
|
"timesRight": "times",
|
||||||
|
"download": "Download",
|
||||||
|
"result": "Result",
|
||||||
|
"moreWorks": "My Stickers",
|
||||||
|
"generateNew": "Generate New",
|
||||||
|
"displayPublic": "display public",
|
||||||
|
"similarText": "similar",
|
||||||
|
"prompt": "input prompt",
|
||||||
|
"revised": "revised prompt",
|
||||||
|
"exploreMore": "More Stickers",
|
||||||
|
"keyword": "sticker",
|
||||||
|
"searchButtonText": "Search"
|
||||||
|
},
|
||||||
|
"MenuText": {
|
||||||
|
"header0": "Sticker Generator",
|
||||||
|
"header1": "My Stickers",
|
||||||
|
"header2": "Discover Stickers",
|
||||||
|
"header3": "Search Stickers",
|
||||||
|
"footerLegal": "Legal",
|
||||||
|
"footerLegal0": "Privacy Policy",
|
||||||
|
"footerLegal1": "Terms & Conditions",
|
||||||
|
"footerSupport": "Support",
|
||||||
|
"footerSupport0": "Pricing",
|
||||||
|
"footerSupport1": "Manage Subscribe"
|
||||||
|
},
|
||||||
|
"AuthText": {
|
||||||
|
"loginText": "Log in",
|
||||||
|
"loginModalDesc": "Please continue by logging in",
|
||||||
|
"loginModalButtonText": "Login with Google",
|
||||||
|
"logoutModalDesc": "Are you want to log out ?",
|
||||||
|
"confirmButtonText": "Confirm",
|
||||||
|
"cancelButtonText": "Cancel"
|
||||||
|
},
|
||||||
|
"PricingText": {
|
||||||
|
"title": "Pricing",
|
||||||
|
"description": "价格页面description",
|
||||||
|
"h1Text": "Get plan for generate",
|
||||||
|
"basic": "Basic",
|
||||||
|
"essential": "Essential",
|
||||||
|
"growth": "Growth",
|
||||||
|
"buyText": "Get started",
|
||||||
|
"popularText": "Popular",
|
||||||
|
"creditsText": "Generate times",
|
||||||
|
"creditText": "Generate",
|
||||||
|
"free": "Free",
|
||||||
|
"free0": "Free plan",
|
||||||
|
"freeText": "Current plan",
|
||||||
|
"freeIntro0": "%freeTimes% generate per month",
|
||||||
|
"freeIntro1": "Choose not to make the generated results public",
|
||||||
|
"freeIntro2": "Download images with transparent background",
|
||||||
|
"subscriptionIntro0": "Unlimited Generate",
|
||||||
|
"subscriptionIntro1": "Choose not to make the generated results public",
|
||||||
|
"subscriptionIntro2": "Download images with transparent background",
|
||||||
|
"subscriptionIntro3": "Download images in SVG format",
|
||||||
|
"subscriptionIntro4": "Image resolution increased to 1024X1024",
|
||||||
|
"monthText": "month",
|
||||||
|
"monthlyText": "billed monthly",
|
||||||
|
"annualText": "annual",
|
||||||
|
"annuallyText": "billed yearly",
|
||||||
|
"annuallySaveText": "save 50%"
|
||||||
|
},
|
||||||
|
"PrivacyPolicyText": {
|
||||||
|
"title": "英文隐私政策 title",
|
||||||
|
"description": "英文隐私政策description",
|
||||||
|
"h1Text": "英文隐私政策 H1",
|
||||||
|
"detailText": "英文隐私政策详情 markdown 文本"
|
||||||
|
},
|
||||||
|
"TermsOfServiceText": {
|
||||||
|
"title": "英文服务条款 title",
|
||||||
|
"description": "英文服务条款description",
|
||||||
|
"h1Text": "英文服务条款 H1",
|
||||||
|
"detailText": "英文服务条款详情 markdown 文本"
|
||||||
|
},
|
||||||
|
"WorksText": {
|
||||||
|
"title": "英文我的页面 title",
|
||||||
|
"description": "英文我的页面 description",
|
||||||
|
"h1Text": "英文我的页面 H1",
|
||||||
|
"descriptionBelowH1Text": "英文我的页面 H1下方的文字",
|
||||||
|
"descText": "Don't have sticker",
|
||||||
|
"toContinue": "to continue."
|
||||||
|
},
|
||||||
|
"ExploreText": {
|
||||||
|
"title": "Discover %countSticker% Free PNG Stickers for Download",
|
||||||
|
"description": "Discover over %countSticker% free stickers in PNG format for your creative projects. Easily download and add flair to social media, websites, and more!",
|
||||||
|
"h1Text": "Discover Stickers",
|
||||||
|
"descriptionBelowH1Text": "Your Go-To Free Online Custom Sticker Maker & Generator!",
|
||||||
|
"pageText": "Page %pageNumber%",
|
||||||
|
"h2Text": "Discover Our Collection of %countSticker% Free Stickers in PNG Format for Easy Download"
|
||||||
|
},
|
||||||
|
"DetailText": {
|
||||||
|
"title": "%prompt% Sticker, Download in PNG or SVG",
|
||||||
|
"description": "Discover the %prompt% sticker, Download in PNG or SVG format now!",
|
||||||
|
"h1Text": "%prompt% Sticker",
|
||||||
|
"descriptionBelowH1Text": "Discover the %prompt% sticker, Download in PNG or SVG format now!",
|
||||||
|
"numberText": "#%detailId%",
|
||||||
|
"h2Text": "%prompt% Stickers Collection"
|
||||||
|
},
|
||||||
|
"SearchText": {
|
||||||
|
"title": "Search %countSticker% Free PNG Stickers for Download",
|
||||||
|
"description": "Search over %countStickerAll% free stickers in PNG format for your creative projects. Easily download and add flair to social media, websites, and more!",
|
||||||
|
"h1Text": "Search Stickers",
|
||||||
|
"h2Text": "Search Our Collection of %countSticker% Free Stickers in PNG Format for Easy Download",
|
||||||
|
"titleSearch": "Search %countSticker% Free %prompt% Stickers",
|
||||||
|
"h2TextSearch": "Search Our Collection of %countSticker% Free %prompt% Stickers in PNG Format for Easy Download"
|
||||||
|
},
|
||||||
|
"QuestionText": {
|
||||||
|
"detailText": ""
|
||||||
|
}
|
||||||
|
}
|
128
messages/zh.json
Normal file
128
messages/zh.json
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"FileDesc": {
|
||||||
|
"lang": "en",
|
||||||
|
"language": "简体中文",
|
||||||
|
"languageInChineseSimple": "简体中文"
|
||||||
|
},
|
||||||
|
"IndexPageText": {
|
||||||
|
"title": "中文Title",
|
||||||
|
"description": "中文description",
|
||||||
|
"h1Text": "首页 H1",
|
||||||
|
"descriptionBelowH1Text": "首页 H1下方的文字"
|
||||||
|
},
|
||||||
|
"CommonText": {
|
||||||
|
"loadingText": "加载中...",
|
||||||
|
"generateText": "生成中...",
|
||||||
|
"placeholderText": "在此处输入或粘贴文本...",
|
||||||
|
"buttonText": "生成",
|
||||||
|
"footerDescText": "footer左下角文字",
|
||||||
|
"timesLeft": "您可以生成",
|
||||||
|
"timesRight": "次",
|
||||||
|
"download": "下载",
|
||||||
|
"result": "结果",
|
||||||
|
"moreWorks": "我的贴纸",
|
||||||
|
"generateNew": "生成新的",
|
||||||
|
"displayPublic": "公开展示",
|
||||||
|
"similarText": "相似",
|
||||||
|
"prompt": "输入提示",
|
||||||
|
"revised": "修订提示",
|
||||||
|
"exploreMore": "更多贴纸",
|
||||||
|
"keyword": "贴纸",
|
||||||
|
"searchButtonText": "搜索"
|
||||||
|
},
|
||||||
|
"MenuText": {
|
||||||
|
"header0": "贴纸生成器",
|
||||||
|
"header1": "我的贴纸",
|
||||||
|
"header2": "发现贴纸",
|
||||||
|
"header3": "搜索贴纸",
|
||||||
|
"footerLegal": "法律信息",
|
||||||
|
"footerLegal0": "隐私政策",
|
||||||
|
"footerLegal1": "条款和条件",
|
||||||
|
"footerSupport": "支持",
|
||||||
|
"footerSupport0": "定价",
|
||||||
|
"footerSupport1": "管理订阅"
|
||||||
|
},
|
||||||
|
"AuthText": {
|
||||||
|
"loginText": "登录",
|
||||||
|
"loginModalDesc": "请继续登录",
|
||||||
|
"loginModalButtonText": "使用 Google 登录",
|
||||||
|
"logoutModalDesc": "您想要登出吗?",
|
||||||
|
"confirmButtonText": "确认",
|
||||||
|
"cancelButtonText": "取消"
|
||||||
|
},
|
||||||
|
"PricingText": {
|
||||||
|
"title": "定价",
|
||||||
|
"description": "价格页面description",
|
||||||
|
"h1Text": "获取生成计划",
|
||||||
|
"basic": "基础",
|
||||||
|
"essential": "必要",
|
||||||
|
"growth": "成长",
|
||||||
|
"buyText": "开始",
|
||||||
|
"popularText": "受欢迎的",
|
||||||
|
"creditsText": "生成次数",
|
||||||
|
"creditText": "次",
|
||||||
|
"free": "免费",
|
||||||
|
"free0": "免费计划",
|
||||||
|
"freeText": "当前计划",
|
||||||
|
"freeIntro0": "每月生成 %freeTimes% 次",
|
||||||
|
"freeIntro1": "选择不公开生成结果",
|
||||||
|
"freeIntro2": "下载带有透明背景的图片",
|
||||||
|
"subscriptionIntro0": "无限生成",
|
||||||
|
"subscriptionIntro1": "选择不公开生成结果",
|
||||||
|
"subscriptionIntro2": "下载带有透明背景的图片",
|
||||||
|
"subscriptionIntro3": "以 SVG 格式下载图片",
|
||||||
|
"subscriptionIntro4": "图片分辨率提升至 1024X1024",
|
||||||
|
"monthText": "月",
|
||||||
|
"monthlyText": "按月计费",
|
||||||
|
"annualText": "年",
|
||||||
|
"annuallyText": "按年计费",
|
||||||
|
"annuallySaveText": "节省 50%"
|
||||||
|
},
|
||||||
|
"PrivacyPolicyText": {
|
||||||
|
"title": "隐私政策 title",
|
||||||
|
"description": "隐私政策description",
|
||||||
|
"h1Text": "隐私政策 H1",
|
||||||
|
"detailText": "隐私政策详情 markdown 文本"
|
||||||
|
},
|
||||||
|
"TermsOfServiceText": {
|
||||||
|
"title": "服务条款 title",
|
||||||
|
"description": "服务条款description",
|
||||||
|
"h1Text": "服务条款 H1",
|
||||||
|
"detailText": "服务条款详情 markdown 文本"
|
||||||
|
},
|
||||||
|
"WorksText": {
|
||||||
|
"title": "我的页面 title",
|
||||||
|
"description": "我的页面 description",
|
||||||
|
"h1Text": "我的页面 H1",
|
||||||
|
"descriptionBelowH1Text": "我的页面 H1下方的文字",
|
||||||
|
"descText": "没有贴纸",
|
||||||
|
"toContinue": "继续。"
|
||||||
|
},
|
||||||
|
"ExploreText": {
|
||||||
|
"title": "发现 %countSticker% 张免费 PNG 贴纸下载",
|
||||||
|
"description": "为您的创意项目发现超过 %countSticker% 张免费的 PNG 格式贴纸。轻松下载并为社交媒体、网站等增添风采!",
|
||||||
|
"h1Text": "发现页面 H1",
|
||||||
|
"descriptionBelowH1Text": "发现页面 H1下方的文字",
|
||||||
|
"pageText": "第 %pageNumber% 页",
|
||||||
|
"h2Text": "探索我们的贴纸系列,包含 %countSticker% 张免费的 PNG 格式贴纸,便于下载"
|
||||||
|
},
|
||||||
|
"DetailText": {
|
||||||
|
"title": "%prompt% 贴纸,以 PNG 或 SVG 格式下载",
|
||||||
|
"description": "发现 %prompt% 贴纸,现在就下载 PNG 或 SVG 格式!",
|
||||||
|
"h1Text": "%prompt% 贴纸",
|
||||||
|
"descriptionBelowH1Text": "发现 %prompt% 贴纸,现在就下载 PNG 或 SVG 格式!",
|
||||||
|
"numberText": "#%detailId%",
|
||||||
|
"h2Text": "%prompt% 贴纸系列"
|
||||||
|
},
|
||||||
|
"SearchText": {
|
||||||
|
"title": "搜索 %countSticker% 张免费 PNG 贴纸进行下载",
|
||||||
|
"description": "为您的创意项目搜索超过 %countSticker% 张免费的 %prompt% PNG 格式贴纸。轻松下载并为社交媒体、网站等增添风采!",
|
||||||
|
"h1Text": "搜索贴纸",
|
||||||
|
"h2Text": "搜索我们的 %countSticker% 张免费贴纸,支持 PNG 格式,便于下载",
|
||||||
|
"titleSearch": "搜索 %countSticker% 免费 %prompt% 贴纸",
|
||||||
|
"h2TextSearch": "搜索我们的 %countSticker% 张免费 %prompt% 贴纸,支持 PNG 格式,方便下载"
|
||||||
|
},
|
||||||
|
"QuestionText": {
|
||||||
|
"detailText": ""
|
||||||
|
}
|
||||||
|
}
|
18
next.config.mjs
Normal file
18
next.config.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{source: '/en', destination: '/', permanent: true},
|
||||||
|
{source: '/stickers/1', destination: '/stickers', permanent: true},
|
||||||
|
{source: '/stickers/0', destination: '/stickers', permanent: true},
|
||||||
|
{source: '/:locale/stickers/1', destination: '/:locale/stickers', permanent: true},
|
||||||
|
{source: '/:locale/stickers/0', destination: '/:locale/stickers', permanent: true},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig);
|
44
package.json
Normal file
44
package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "next-init",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 80",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^1.7.18",
|
||||||
|
"@heroicons/react": "^2.1.1",
|
||||||
|
"@next/third-parties": "^14.1.3",
|
||||||
|
"@stripe/stripe-js": "^3.0.7",
|
||||||
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
|
"ahooks": "^3.7.10",
|
||||||
|
"aws-sdk": "^2.1572.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
|
"google-auth-library": "^9.6.3",
|
||||||
|
"next": "14.1.3",
|
||||||
|
"next-auth": "^4.24.6",
|
||||||
|
"next-intl": "^3.9.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"react-icons": "^5.0.1",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"replicate": "^0.27.1",
|
||||||
|
"stripe": "^14.19.0",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"autoprefixer": "^10.0.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.1.3",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
172
public/appicon.svg
Normal file
172
public/appicon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 124 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
8
public/robots.txt
Executable file
8
public/robots.txt
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Allow: *
|
||||||
|
Disallow: /api
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /api*
|
||||||
|
Disallow: /*/api
|
||||||
|
Disallow: /*/api/
|
||||||
|
Disallow: /*/api*
|
124
public/sitemap.xml
Normal file
124
public/sitemap.xml
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/de/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/de/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/es/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/es/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/fr/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/fr/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/ja/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/ja/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/ko/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/ko/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/pt/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/pt/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/tw/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/tw/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/vi/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/vi/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/zh/</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sticker.show/zh/stickers</loc>
|
||||||
|
<lastmod>2024-03-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
</urlset>
|
BIN
public/top_blurred.png
Normal file
BIN
public/top_blurred.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 449 KiB |
336
public/website.svg
Normal file
336
public/website.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 423 KiB |
31
sql/tables/1_user_info.sql
Normal file
31
sql/tables/1_user_info.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- auto-generated definition
|
||||||
|
create table user_info
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
created_at timestamp with time zone default now() not null,
|
||||||
|
updated_at timestamp with time zone default now() not null,
|
||||||
|
user_id varchar,
|
||||||
|
name varchar,
|
||||||
|
email varchar,
|
||||||
|
image varchar,
|
||||||
|
last_login_ip varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table user_info is 'user info table';
|
||||||
|
|
||||||
|
comment on column user_info.id is 'sequence id';
|
||||||
|
|
||||||
|
comment on column user_info.created_at is 'create time';
|
||||||
|
|
||||||
|
comment on column user_info.updated_at is 'update time';
|
||||||
|
|
||||||
|
comment on column user_info.user_id is 'user uuid';
|
||||||
|
|
||||||
|
comment on column user_info.name is 'user name';
|
||||||
|
|
||||||
|
comment on column user_info.email is 'user email';
|
||||||
|
|
||||||
|
comment on column user_info.image is 'user avatar path';
|
||||||
|
|
||||||
|
comment on column user_info.last_login_ip is 'user last login ip';
|
25
sql/tables/2_user_available.sql
Normal file
25
sql/tables/2_user_available.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
-- auto-generated definition
|
||||||
|
create table user_available
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
created_at timestamp with time zone default now() not null,
|
||||||
|
updated_at timestamp with time zone default now() not null,
|
||||||
|
user_id varchar,
|
||||||
|
stripe_customer_id varchar,
|
||||||
|
available_times integer
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table user_available is '用户可用点数记录表';
|
||||||
|
|
||||||
|
comment on column user_available.id is '自增id';
|
||||||
|
|
||||||
|
comment on column user_available.created_at is '创建时间';
|
||||||
|
|
||||||
|
comment on column user_available.updated_at is '更新时间';
|
||||||
|
|
||||||
|
comment on column user_available.user_id is '用户id';
|
||||||
|
|
||||||
|
comment on column user_available.stripe_customer_id is 'stripe用户id';
|
||||||
|
|
||||||
|
comment on column user_available.available_times is '可用点数';
|
9
sql/tables/3_stripe_customers.sql
Normal file
9
sql/tables/3_stripe_customers.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-- auto-generated definition
|
||||||
|
create table stripe_customers
|
||||||
|
(
|
||||||
|
user_id varchar not null
|
||||||
|
primary key,
|
||||||
|
stripe_customer_id text
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table stripe_customers is 'stripe customers';
|
23
sql/tables/4_stripe_subscriptions.sql
Normal file
23
sql/tables/4_stripe_subscriptions.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused');
|
||||||
|
-- auto-generated definition
|
||||||
|
create table stripe_subscriptions
|
||||||
|
(
|
||||||
|
id text not null
|
||||||
|
primary key,
|
||||||
|
user_id varchar,
|
||||||
|
status subscription_status,
|
||||||
|
metadata jsonb,
|
||||||
|
price_id text,
|
||||||
|
quantity integer,
|
||||||
|
cancel_at_period_end boolean,
|
||||||
|
created timestamp with time zone default timezone('utc'::text, now()) not null,
|
||||||
|
current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
|
||||||
|
current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
|
||||||
|
ended_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
cancel_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
canceled_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
trial_start timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
trial_end timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table stripe_subscriptions is 'subscriptions';
|
47
sql/tables/5_works.sql
Normal file
47
sql/tables/5_works.sql
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
-- auto-generated definition
|
||||||
|
create table works
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
created_at timestamp with time zone default now() not null,
|
||||||
|
updated_at timestamp with time zone default now() not null,
|
||||||
|
uid varchar,
|
||||||
|
input_text varchar,
|
||||||
|
revised_text varchar,
|
||||||
|
output_url varchar,
|
||||||
|
is_public boolean default false,
|
||||||
|
status integer,
|
||||||
|
user_id varchar,
|
||||||
|
is_origin boolean,
|
||||||
|
origin_language varchar,
|
||||||
|
current_language varchar,
|
||||||
|
is_delete boolean default false
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table works is 'works';
|
||||||
|
|
||||||
|
comment on column works.id is 'sequence id';
|
||||||
|
|
||||||
|
comment on column works.created_at is 'create time';
|
||||||
|
|
||||||
|
comment on column works.updated_at is 'update time';
|
||||||
|
|
||||||
|
comment on column works.uid is 'uid';
|
||||||
|
|
||||||
|
comment on column works.input_text is 'input_text';
|
||||||
|
|
||||||
|
comment on column works.revised_text is 'revised_text';
|
||||||
|
|
||||||
|
comment on column works.output_url is 'output_url';
|
||||||
|
|
||||||
|
comment on column works.is_public is 'is_public';
|
||||||
|
|
||||||
|
comment on column works.status is 'status';
|
||||||
|
|
||||||
|
comment on column works.user_id is 'user_id';
|
||||||
|
|
||||||
|
comment on column works.is_origin is 'is_origin';
|
||||||
|
|
||||||
|
comment on column works.origin_language is 'origin_language';
|
||||||
|
|
||||||
|
comment on column works.current_language is 'current_language';
|
12
sql/tables/6_key_value.sql
Normal file
12
sql/tables/6_key_value.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- auto-generated definition
|
||||||
|
create table key_value
|
||||||
|
(
|
||||||
|
key varchar not null
|
||||||
|
constraint key_value_pk
|
||||||
|
primary key,
|
||||||
|
value varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on column key_value.key is 'key';
|
||||||
|
|
||||||
|
comment on column key_value.value is 'value';
|
25
sql/tables/7_works_translate_task.sql
Normal file
25
sql/tables/7_works_translate_task.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
-- auto-generated definition
|
||||||
|
create table works_translate_task
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
created_at timestamp with time zone default now() not null,
|
||||||
|
updated_at timestamp with time zone default now() not null,
|
||||||
|
uid varchar,
|
||||||
|
origin_language varchar,
|
||||||
|
status varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table works_translate_task is 'works_translate_task';
|
||||||
|
|
||||||
|
comment on column works_translate_task.id is '自增id';
|
||||||
|
|
||||||
|
comment on column works_translate_task.created_at is '创建时间';
|
||||||
|
|
||||||
|
comment on column works_translate_task.updated_at is '更新时间';
|
||||||
|
|
||||||
|
comment on column works_translate_task.uid is 'uid';
|
||||||
|
|
||||||
|
comment on column works_translate_task.origin_language is 'origin_language';
|
||||||
|
|
||||||
|
comment on column works_translate_task.status is 'status: 0未翻译,1已翻译';
|
25
sql/tables/8_search_log.sql
Normal file
25
sql/tables/8_search_log.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
-- auto-generated definition
|
||||||
|
create table search_log
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
created_at timestamp with time zone default now() not null,
|
||||||
|
search_text varchar,
|
||||||
|
result_count varchar,
|
||||||
|
user_agent varchar,
|
||||||
|
search_ip varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table search_log is 'search_log';
|
||||||
|
|
||||||
|
comment on column search_log.id is '自增id';
|
||||||
|
|
||||||
|
comment on column search_log.created_at is 'created_at';
|
||||||
|
|
||||||
|
comment on column search_log.search_text is 'search_text';
|
||||||
|
|
||||||
|
comment on column search_log.result_count is 'result_count';
|
||||||
|
|
||||||
|
comment on column search_log.user_agent is 'user_agent';
|
||||||
|
|
||||||
|
comment on column search_log.search_ip is 'search_ip';
|
16
sql/tables/9_sensitive_words.sql
Normal file
16
sql/tables/9_sensitive_words.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- auto-generated definition
|
||||||
|
create table sensitive_words
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
words varchar,
|
||||||
|
level varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table sensitive_words is 'sensitive';
|
||||||
|
|
||||||
|
comment on column sensitive_words.id is '自增id';
|
||||||
|
|
||||||
|
comment on column sensitive_words.words is 'words';
|
||||||
|
|
||||||
|
comment on column sensitive_words.level is 'level';
|
397
src/app/[locale]/PageComponent.tsx
Normal file
397
src/app/[locale]/PageComponent.tsx
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {useInterval} from "ahooks";
|
||||||
|
import PricingModal from "~/components/PricingModal";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {Switch} from "@headlessui/react";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import {getCompressionImageLink, getLinkHref, getShareToPinterest} from "~/configs/buildLink";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {getResultStrAddSticker} from "~/configs/buildStr";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {pinterestSvg} from '~/components/svg'
|
||||||
|
|
||||||
|
function classNames(...classes) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
indexText,
|
||||||
|
questionText,
|
||||||
|
resultInfoListInit,
|
||||||
|
searchParams,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pagePath] = useState("");
|
||||||
|
|
||||||
|
const {
|
||||||
|
setShowLoadingModal,
|
||||||
|
setShowLoginModal,
|
||||||
|
setShowPricingModal,
|
||||||
|
setShowGeneratingModal,
|
||||||
|
commonText,
|
||||||
|
userData,
|
||||||
|
pricingText,
|
||||||
|
menuText
|
||||||
|
} = useCommonContext();
|
||||||
|
const [resultInfoList, setResultInfoList] = useState(resultInfoListInit);
|
||||||
|
const [countRefresh, setCountRefresh] = useState(0);
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
getLocalStorage()
|
||||||
|
if (process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0' && process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0') {
|
||||||
|
setIntervalAvailableTimes(1000);
|
||||||
|
}
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
// setIntervalLatest(10000);
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getLocalStorage = () => {
|
||||||
|
const textStr = localStorage.getItem('textStr');
|
||||||
|
if (textStr) {
|
||||||
|
setTextStr(textStr);
|
||||||
|
localStorage.removeItem('textStr');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchParams?.prompt) {
|
||||||
|
setTextStr(searchParams.prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [textStr, setTextStr] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!textStr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!userData && process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
localStorage.setItem('textStr', textStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowGeneratingModal(true);
|
||||||
|
const requestData = {
|
||||||
|
textStr: textStr,
|
||||||
|
user_id: userData?.user_id,
|
||||||
|
is_public: isPublic
|
||||||
|
}
|
||||||
|
const responseData = await fetch(`/api/generate/handle`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestData)
|
||||||
|
});
|
||||||
|
const result = await responseData.json();
|
||||||
|
if (result.status == 601 && process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
localStorage.setItem('textStr', textStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status == 602 && process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
setShowPricingModal(true);
|
||||||
|
localStorage.setItem('textStr', textStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentUid = result.uid;
|
||||||
|
setUid(currentUid);
|
||||||
|
if (process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
setIntervalAvailableTimes(1000);
|
||||||
|
}
|
||||||
|
setIntervalResultInfo(6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [availableTimes, setAvailableTimes] = useState({
|
||||||
|
available_times: 0,
|
||||||
|
subscribeStatus: '0'
|
||||||
|
});
|
||||||
|
const [resultInfo, setResultInfo] = useState({
|
||||||
|
uid: '',
|
||||||
|
status: 0,
|
||||||
|
input_text: '',
|
||||||
|
output_url: [],
|
||||||
|
revised_text: '',
|
||||||
|
origin_language: '',
|
||||||
|
current_language: ''
|
||||||
|
});
|
||||||
|
const [intervalAvailableTimes, setIntervalAvailableTimes] = useState(undefined);
|
||||||
|
const [uid, setUid] = useState('');
|
||||||
|
const [intervalResultInfo, setIntervalResultInfo] = useState(undefined);
|
||||||
|
|
||||||
|
const getResultInfo = async () => {
|
||||||
|
if (!userData?.user_id && process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const userId = userData?.user_id;
|
||||||
|
const response = await fetch(`/api/works/getResultInfo?uid=${uid}&userId=${userId}`);
|
||||||
|
const resultInfo = await response.json();
|
||||||
|
if (resultInfo.status == 1) {
|
||||||
|
setResultInfo(resultInfo);
|
||||||
|
router.push(getLinkHref(locale, `sticker/${resultInfo.uid}`));
|
||||||
|
setShowGeneratingModal(false);
|
||||||
|
setIntervalResultInfo(undefined);
|
||||||
|
setIntervalAvailableTimes(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useInterval(() => {
|
||||||
|
getResultInfo();
|
||||||
|
}, intervalResultInfo);
|
||||||
|
|
||||||
|
const getAvailableTimes = async () => {
|
||||||
|
if (!userData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = userData.user_id;
|
||||||
|
if (userId) {
|
||||||
|
const response = await fetch(`/api/user/getAvailableTimes?userId=${userId}`);
|
||||||
|
const availableTimes = await response.json();
|
||||||
|
setAvailableTimes(availableTimes);
|
||||||
|
if (availableTimes.available_times >= 0) {
|
||||||
|
setIntervalAvailableTimes(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useInterval(() => {
|
||||||
|
getAvailableTimes();
|
||||||
|
}, intervalAvailableTimes);
|
||||||
|
|
||||||
|
const downloadResult = (url) => {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
|
|
||||||
|
|
||||||
|
const checkSubscribe = () => {
|
||||||
|
if (availableTimes.subscribeStatus == 'active') {
|
||||||
|
setIsPublic(!isPublic);
|
||||||
|
} else {
|
||||||
|
setShowPricingModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [intervalLatest, setIntervalLatest] = useState(undefined);
|
||||||
|
const getLatestList = async () => {
|
||||||
|
if (countRefresh >= 9) {
|
||||||
|
setIntervalLatest(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestData = {
|
||||||
|
locale: locale
|
||||||
|
}
|
||||||
|
const response = await fetch(`/api/works/getLatestPublicResultList`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestData)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
setCountRefresh(countRefresh + 1);
|
||||||
|
setResultInfoList(result);
|
||||||
|
}
|
||||||
|
useInterval(() => {
|
||||||
|
getLatestList();
|
||||||
|
}, intervalLatest);
|
||||||
|
|
||||||
|
const hasAnyKey = (obj) => {
|
||||||
|
return Object.keys(obj).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
hasAnyKey(searchParams) ?
|
||||||
|
<meta name="robots" content="noindex"/>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={indexText.title}
|
||||||
|
description={indexText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<PricingModal
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 my-auto">
|
||||||
|
<TopBlurred/>
|
||||||
|
<div className="block overflow-hidden text-white">
|
||||||
|
<div className="mx-auto w-full px-5 mb-5">
|
||||||
|
<div
|
||||||
|
className="mx-auto flex max-w-4xl flex-col items-center text-center py-10">
|
||||||
|
<h2 className="mb-4 text-4xl font-bold md:text-6xl">{indexText.h1Text}</h2>
|
||||||
|
<div className="mb-5 max-w-[628px] lg:mb-8">
|
||||||
|
<h1 className="text-[#7c8aaa] text-xl">{indexText.descriptionBelowH1Text}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"max-w-7xl px-9 mx-auto"}>
|
||||||
|
<div
|
||||||
|
className={"mx-auto rounded-tl-[30px] rounded-tr-[30px] border-[12px] border-[#ffffff1f] object-fill"}>
|
||||||
|
<form onSubmit={handleSubmit} className="relative shadow-lg">
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-tl-[20px] rounded-tr-[20px]">
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
name="description"
|
||||||
|
id="description"
|
||||||
|
className="custom-textarea block w-full resize-none text-gray-900 placeholder:text-gray-400 text-lg p-4 pl-4"
|
||||||
|
placeholder={commonText.placeholderText}
|
||||||
|
value={textStr}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTextStr(e.target.value);
|
||||||
|
}}
|
||||||
|
maxLength={400}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
userData?.user_id ?
|
||||||
|
<div
|
||||||
|
className="flex flex-col justify-start items-center md:flex-row md:justify-center md:items-center bg-white text-black md:pb-2">
|
||||||
|
{
|
||||||
|
process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0' && availableTimes.subscribeStatus == '' ?
|
||||||
|
<>
|
||||||
|
<p>{commonText.timesLeft} <span
|
||||||
|
className={"text-red-400"}>{availableTimes.available_times}</span> {commonText.timesRight}
|
||||||
|
<span className={"font-bold hidden md:inline-flex"}> | </span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0' && availableTimes.subscribeStatus == 'active' ?
|
||||||
|
<>
|
||||||
|
<span className={"text-red-400"}>{pricingText.subscriptionIntro0}</span>
|
||||||
|
<span className={"font-bold hidden md:inline-flex"}> | </span>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0' ?
|
||||||
|
<div className={"inline-flex mb-2 md:mb-0"}>
|
||||||
|
<span className={"text-black mr-1"}>{commonText.displayPublic}</span>
|
||||||
|
<Switch
|
||||||
|
checked={isPublic}
|
||||||
|
onChange={checkSubscribe}
|
||||||
|
className={classNames(
|
||||||
|
isPublic ? 'bg-[#f05011]' : 'bg-gray-200',
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Use setting</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
isPublic ? 'translate-x-5' : 'translate-x-0',
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
:null
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
<div className="inset-x-px bottom-1 bg-white">
|
||||||
|
<div
|
||||||
|
className="flex justify-center items-center space-x-3 border-t border-gray-200 px-2 py-2">
|
||||||
|
<div className="pt-2 w-1/4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full inline-flex justify-center items-center rounded-md bg-[#ffa11b] px-3 py-2 text-xs md:text-lg font-semibold text-white shadow-sm hover:bg-[#f05011]"
|
||||||
|
>
|
||||||
|
{commonText.buttonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"w-[90%] mx-auto mb-10 mt-8"}>
|
||||||
|
<div className={"flex justify-center items-start"}>
|
||||||
|
<h2 className="text-white text-3xl">{menuText.header2}</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||||
|
{resultInfoList.map((file, index) => (
|
||||||
|
<div key={file.input_text + index} className={"mt-6"}>
|
||||||
|
<div
|
||||||
|
className="rounded-xl flex justify-center items-start checkerboard relative">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, `sticker/${file.uid}`)}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"cursor-pointer"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getCompressionImageLink(file.output_url[1])}
|
||||||
|
alt={file.input_text}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
className={"rounded-lg"}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`https://pinterest.com/pin/create/button/?url=${getShareToPinterest(locale, 'sticker/' + file.uid, file.input_text)}`}
|
||||||
|
target={"_blank"}
|
||||||
|
className={"absolute top-1 left-1"}>
|
||||||
|
{pinterestSvg}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<p
|
||||||
|
className="pointer-events-none mt-2 block text-sm font-medium text-white w-[90%] line-clamp-2">{getResultStrAddSticker(file.input_text, commonText.keyword)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div key={"more"} className={"px-4"}>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'stickers')}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"flex justify-center items-center text-xl text-red-400 hover:text-blue-600"}>
|
||||||
|
{commonText.exploreMore} {'>>'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="prose w-full max-w-2xl mx-auto mt-8 text-gray-300 div-markdown-color">
|
||||||
|
<Markdown>
|
||||||
|
{questionText.detailText}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
5
src/app/[locale]/[...rest]/page.tsx
Executable file
5
src/app/[locale]/[...rest]/page.tsx
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
import {notFound} from 'next/navigation';
|
||||||
|
|
||||||
|
export default function CatchAllPage() {
|
||||||
|
notFound();
|
||||||
|
}
|
85
src/app/[locale]/api/auth/[...nextauth]/route.ts
Normal file
85
src/app/[locale]/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import NextAuth, {NextAuthOptions} from "next-auth";
|
||||||
|
import GoogleProvider from "next-auth/providers/google";
|
||||||
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
import {checkAndSaveUser, getUserByEmail} from "~/servers/user";
|
||||||
|
import {headers} from "next/headers";
|
||||||
|
|
||||||
|
const googleAuthClient = new OAuth2Client(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
|
||||||
|
const authOptions: NextAuthOptions = {
|
||||||
|
// Configure one or more authentication providers
|
||||||
|
providers: [
|
||||||
|
GoogleProvider({
|
||||||
|
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_SECRET_ID
|
||||||
|
}),
|
||||||
|
// connect with google api internally
|
||||||
|
CredentialsProvider({
|
||||||
|
// We will use this id later to specify for what Provider we want to trigger the signIn method
|
||||||
|
id: "googleonetap",
|
||||||
|
name: "google-one-tap",
|
||||||
|
// This means that the authentication will be done through a single credential called 'credential'
|
||||||
|
credentials: {
|
||||||
|
credential: { type: "text" },
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
authorize: async (credentials) => {
|
||||||
|
// These next few lines are simply the recommended way to use the Google Auth Javascript API as seen in the Google Auth docs
|
||||||
|
// What is going to happen is that t he Google One Tap UI will make an API call to Google and return a token associated with the user account
|
||||||
|
// This token is then passed to the authorize function and used to retrieve the customer information (payload).
|
||||||
|
// If this doesn't make sense yet, come back to it after having seen the custom hook.
|
||||||
|
|
||||||
|
const token = credentials!.credential;
|
||||||
|
const ticket = await googleAuthClient.verifyIdToken({
|
||||||
|
idToken: token,
|
||||||
|
audience: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = ticket.getPayload();
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("Cannot extract payload from signin token");
|
||||||
|
}
|
||||||
|
const { email, name, picture: image } = payload;
|
||||||
|
if (!email) {
|
||||||
|
throw new Error("Email not available");
|
||||||
|
}
|
||||||
|
const user = {email, name, image}
|
||||||
|
const headerAll = headers();
|
||||||
|
const userIp = headerAll.get("x-forwarded-for");
|
||||||
|
await checkAndSaveUser(user.name, user.email, user.image, userIp);
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
debug: false,
|
||||||
|
callbacks: {
|
||||||
|
async signIn({user, account, profile, email, credentials}) {
|
||||||
|
const headerAll = headers();
|
||||||
|
const userIp = headerAll.get("x-forwarded-for");
|
||||||
|
await checkAndSaveUser(user.name, user.email, user.image, userIp);
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async redirect({url, baseUrl}) {
|
||||||
|
// Allows relative callback URLs
|
||||||
|
if (url.startsWith("/")) return `${baseUrl}${url}`
|
||||||
|
// Allows callback URLs on the same origin
|
||||||
|
else if (new URL(url).origin === baseUrl) return url
|
||||||
|
return baseUrl
|
||||||
|
},
|
||||||
|
async session({session}) {
|
||||||
|
if (session) {
|
||||||
|
const email = session?.user?.email;
|
||||||
|
if (email) {
|
||||||
|
session.user = await getUserByEmail(email);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
export {handler as GET, handler as POST};
|
78
src/app/[locale]/api/cron/workTranslate/route.ts
Normal file
78
src/app/[locale]/api/cron/workTranslate/route.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {getDb} from "~/libs/db";
|
||||||
|
import {locales} from "~/config";
|
||||||
|
import {translateContent} from "~/servers/translate";
|
||||||
|
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
const timeFlag = 'workTranslate' + '-=->' + new Date().getTime();
|
||||||
|
console.time(timeFlag);
|
||||||
|
const results = await db.query('select * from works_translate_task where status=$1 order by created_at asc limit 3', [0]);
|
||||||
|
const rows = results.rows;
|
||||||
|
if (rows.length <= 0) {
|
||||||
|
console.log('没有任务需要处理');
|
||||||
|
console.timeEnd(timeFlag);
|
||||||
|
return Response.json({message: '没有任务需要处理'});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const oneTask = rows[i];
|
||||||
|
// 翻译为除了原始语言的其他语言,如果原始语言不在支持列表,就翻译为支持的十种语言
|
||||||
|
const origin_language = oneTask.origin_language;
|
||||||
|
const uid = oneTask.uid;
|
||||||
|
|
||||||
|
const needLanguage = getNeedTranslateLanguage(origin_language);
|
||||||
|
|
||||||
|
if (needLanguage.length <= 0) {
|
||||||
|
// 更新状态为已翻译完成
|
||||||
|
await db.query('update works_translate_task set status=$1 where uid=$2', [1, uid]);
|
||||||
|
console.log('没有需要翻译的语言-=->', uid);
|
||||||
|
console.timeEnd(timeFlag);
|
||||||
|
return Response.json({message: '没有需要翻译的语言'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查出原始数据
|
||||||
|
const resultsOrigin = await db.query('select * from works where uid=$1 and is_origin=$2 and is_delete=$3', [uid, true, false]);
|
||||||
|
const rowsOrigin = resultsOrigin.rows;
|
||||||
|
if (rowsOrigin.length <= 0) {
|
||||||
|
console.log('没有原始数据-=->', uid);
|
||||||
|
console.timeEnd(timeFlag);
|
||||||
|
// 更新状态为已翻译完成
|
||||||
|
await db.query('update works_translate_task set status=$1 where uid=$2', [1, uid]);
|
||||||
|
return Response.json({message: '没有原始数据'});
|
||||||
|
}
|
||||||
|
const originData = rowsOrigin[0];
|
||||||
|
if (originData.output_url == '') {
|
||||||
|
// 如果原始数据的url还没生成,就不翻译了
|
||||||
|
console.log('原始数据的url还没生成-=->', uid);
|
||||||
|
console.timeEnd(timeFlag);
|
||||||
|
return Response.json({message: '原始数据的url还没生成'});
|
||||||
|
}
|
||||||
|
const timeFlagCurrent = '翻译' + '-=->' + uid;
|
||||||
|
console.time(timeFlagCurrent);
|
||||||
|
for (let i = 0; i < needLanguage.length; i++) {
|
||||||
|
const toLanguage = needLanguage[i];
|
||||||
|
const translateText = await translateContent(originData.input_text, toLanguage);
|
||||||
|
const sqlStr = 'insert into works(uid, input_text, output_url, is_public, status, user_id, revised_text, is_origin, origin_language, current_language) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)';
|
||||||
|
const data = [uid, translateText, originData.output_url, originData.is_public, originData.status, originData.user_id, originData.revised_text, false, originData.origin_language, toLanguage];
|
||||||
|
await db.query(sqlStr, data);
|
||||||
|
}
|
||||||
|
console.timeEnd(timeFlagCurrent);
|
||||||
|
console.log('翻译完-=->', uid);
|
||||||
|
// 更新状态为已翻译完成
|
||||||
|
await db.query('update works_translate_task set status=$1 where uid=$2', [1, uid]);
|
||||||
|
}
|
||||||
|
console.timeEnd(timeFlag);
|
||||||
|
return Response.json({message: '本次翻译任务翻译完'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNeedTranslateLanguage(origin_language: string) {
|
||||||
|
const needTranslateLanguage = [];
|
||||||
|
// 判断出需要翻译的语言,并调用翻译
|
||||||
|
for (let i = 0; i < locales.length; i++) {
|
||||||
|
if (origin_language != locales[i]) {
|
||||||
|
needTranslateLanguage.push(locales[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return needTranslateLanguage;
|
||||||
|
}
|
50
src/app/[locale]/api/generate/callByReplicate/route.ts
Normal file
50
src/app/[locale]/api/generate/callByReplicate/route.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import {R2, r2Bucket, storageURL} from "~/libs/R2";
|
||||||
|
import {getDb} from "~/libs/db";
|
||||||
|
import {countSticker} from "~/servers/keyValue";
|
||||||
|
import {v4 as uuidv4} from 'uuid';
|
||||||
|
|
||||||
|
export const POST = async (req: Request) => {
|
||||||
|
const query = new URL(req.url).searchParams;
|
||||||
|
const uid = query.get("uid")!;
|
||||||
|
|
||||||
|
const json = await req.json();
|
||||||
|
|
||||||
|
const output = json.output;
|
||||||
|
console.log('callByReplicate ==>json.output==>', output);
|
||||||
|
|
||||||
|
// 使用fetch API下载文件,output是一个数组,可能多个
|
||||||
|
const len = output.length;
|
||||||
|
const output_urls = [];
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const url = output[i];
|
||||||
|
const currentFileContent = await fetch(url)
|
||||||
|
.then((v) => v.arrayBuffer())
|
||||||
|
.then(Buffer.from);
|
||||||
|
|
||||||
|
const currentKey = `${uuidv4()}.png`;
|
||||||
|
await R2.upload({
|
||||||
|
Bucket: r2Bucket,
|
||||||
|
Key: `${currentKey}`,
|
||||||
|
Body: currentFileContent,
|
||||||
|
}).promise();
|
||||||
|
const currentUrl = `${storageURL}/${currentKey}`;
|
||||||
|
output_urls.push(currentUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output_url = JSON.stringify(output_urls);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const results = await db.query('select * from works where uid=$1', [uid]);
|
||||||
|
const rows = results.rows;
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const row = rows[0];
|
||||||
|
if (row.is_public) {
|
||||||
|
// 公开的才加
|
||||||
|
await countSticker('countSticker', 1);
|
||||||
|
}
|
||||||
|
await db.query('update works set output_url=$1,status=$2,updated_at=now() where uid=$3', [output_url, 1, uid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({msg: 200});
|
||||||
|
}
|
85
src/app/[locale]/api/generate/handle/route.ts
Normal file
85
src/app/[locale]/api/generate/handle/route.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import {getUserById} from "~/servers/user";
|
||||||
|
import {checkUserTimes, countDownUserTimes} from "~/servers/manageUserTimes";
|
||||||
|
import {v4 as uuidv4} from 'uuid';
|
||||||
|
import {getReplicateClient} from "~/libs/replicateClient";
|
||||||
|
import {getInput} from "~/libs/replicate";
|
||||||
|
import {getDb} from "~/libs/db";
|
||||||
|
import {getLanguage} from "~/servers/language";
|
||||||
|
import {checkSubscribe} from "~/servers/subscribe";
|
||||||
|
import {checkSensitiveInputText} from "~/servers/checkInput";
|
||||||
|
|
||||||
|
|
||||||
|
export async function POST(req: Request, res: Response) {
|
||||||
|
let json = await req.json();
|
||||||
|
let textStr = json.textStr;
|
||||||
|
let user_id = json.user_id;
|
||||||
|
let is_public = json.is_public;
|
||||||
|
|
||||||
|
if (!user_id && process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
return Response.json({msg: "Login to continue.", status: 601});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户在数据库是否存在,不存在则返回需登录
|
||||||
|
const resultsUser = await getUserById(user_id);
|
||||||
|
if (resultsUser.email == '' && process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
return Response.json({msg: "Login to continue.", status: 601});
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkSubscribeStatus = await checkSubscribe(user_id);
|
||||||
|
// console.log('checkSubscribeStatus-=-=-', checkSubscribeStatus);
|
||||||
|
if (!is_public) {
|
||||||
|
// 判断用户是否订阅状态,否则返回错误
|
||||||
|
if (!checkSubscribeStatus) {
|
||||||
|
return Response.json({msg: "Pricing to continue.", status: 602});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkSubscribeStatus) {
|
||||||
|
const check = await checkUserTimes(user_id);
|
||||||
|
if (!check && process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0') {
|
||||||
|
return Response.json({msg: "Pricing to continue.", status: 602});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkSensitive = await checkSensitiveInputText(textStr);
|
||||||
|
if (!checkSensitive) {
|
||||||
|
// 敏感词没通过,校验是否订阅
|
||||||
|
if (!checkSubscribeStatus) {
|
||||||
|
// 未订阅则返回付费再继续
|
||||||
|
return Response.json({msg: "Pricing to continue.", status: 602});
|
||||||
|
} else {
|
||||||
|
// 订阅强制设置其为用户私有,不公开
|
||||||
|
is_public = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = uuidv4();
|
||||||
|
|
||||||
|
const replicateClient = getReplicateClient();
|
||||||
|
const origin_language = await getLanguage(textStr);
|
||||||
|
const input = await getInput(textStr, checkSubscribeStatus);
|
||||||
|
await replicateClient.predictions.create({
|
||||||
|
version: process.env.REPLICATE_API_VERSION,
|
||||||
|
input: input,
|
||||||
|
webhook: `${process.env.REPLICATE_WEBHOOK}/api/generate/callByReplicate?uid=${uid}`,
|
||||||
|
webhook_events_filter: ["completed"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
// 创建新的数据
|
||||||
|
await db.query('insert into works(uid, input_text, output_url, is_public, status, user_id, revised_text, is_origin, origin_language, current_language) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
|
||||||
|
[uid, textStr, '', is_public, 0, user_id, input.prompt, true, origin_language, origin_language]);
|
||||||
|
// 创建一条翻译任务
|
||||||
|
await db.query('insert into works_translate_task(uid,origin_language,status) values($1,$2,$3)', [uid, origin_language, 0]);
|
||||||
|
|
||||||
|
// 需要登录,且需要支付时,才操作该项
|
||||||
|
if (process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0' && process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0' && !checkSubscribeStatus) {
|
||||||
|
// 减少用户次数
|
||||||
|
await countDownUserTimes(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultInfo = {
|
||||||
|
uid: uid
|
||||||
|
}
|
||||||
|
return Response.json(resultInfo);
|
||||||
|
}
|
104
src/app/[locale]/api/stripe/create-checkout-session/route.ts
Normal file
104
src/app/[locale]/api/stripe/create-checkout-session/route.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import {stripe} from '~/libs/stripe';
|
||||||
|
import {createOrRetrieveCustomer} from '~/libs/handle-stripe';
|
||||||
|
import {getURL} from '~/libs/helpers';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import {getDb} from "~/libs/db";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const db = getDb();
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
// 1. Destructure the price and quantity from the POST body
|
||||||
|
const {price, quantity = 1, metadata = {}, redirectUrl, user_id} = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. select user
|
||||||
|
const userInfoRes = await db.query('select * from user_info where user_id = $1', [user_id]);
|
||||||
|
const userInfoRow = userInfoRes.rows;
|
||||||
|
if (userInfoRow.length <= 0) {
|
||||||
|
return new Response(JSON.stringify({message: 'Not have user'}), {
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const userInfo = userInfoRow[0];
|
||||||
|
const userEmail = userInfo.email;
|
||||||
|
|
||||||
|
// 3. Retrieve or create the customer in Stripe
|
||||||
|
const customer = await createOrRetrieveCustomer({
|
||||||
|
user_id: user_id,
|
||||||
|
email: userEmail
|
||||||
|
});
|
||||||
|
|
||||||
|
let redirectPath = `${getURL()}`;
|
||||||
|
if (redirectUrl) {
|
||||||
|
redirectPath = `${getURL()}${redirectUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create a checkout session in Stripe
|
||||||
|
let session: Stripe.Response<Stripe.Checkout.Session>;
|
||||||
|
if (price.type === 'recurring') {
|
||||||
|
// @ts-ignore
|
||||||
|
session = await stripe.checkout.sessions.create({
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
billing_address_collection: 'auto',
|
||||||
|
customer,
|
||||||
|
customer_update: {
|
||||||
|
address: 'auto'
|
||||||
|
},
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: price.id,
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
],
|
||||||
|
mode: 'subscription',
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
subscription_data: {
|
||||||
|
trial_from_plan: true,
|
||||||
|
metadata
|
||||||
|
},
|
||||||
|
success_url: redirectPath,
|
||||||
|
cancel_url: redirectPath
|
||||||
|
});
|
||||||
|
} else if (price.type === 'one_time') {
|
||||||
|
session = await stripe.checkout.sessions.create({
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
billing_address_collection: 'auto',
|
||||||
|
customer,
|
||||||
|
customer_update: {
|
||||||
|
address: 'auto'
|
||||||
|
},
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: price.id,
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
],
|
||||||
|
mode: 'payment',
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
success_url: redirectPath,
|
||||||
|
cancel_url: redirectPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
return new Response(JSON.stringify({sessionId: session.id}), {
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {statusCode: 500, message: 'Session is not defined'}
|
||||||
|
}),
|
||||||
|
{status: 200}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(err);
|
||||||
|
return new Response(JSON.stringify(err), {status: 500});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return new Response(JSON.stringify({message: 'Method Not Allowed'}), {
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
34
src/app/[locale]/api/stripe/create-portal-link/route.ts
Normal file
34
src/app/[locale]/api/stripe/create-portal-link/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import {getUserById} from "~/servers/user";
|
||||||
|
import {createOrRetrieveCustomer} from "~/libs/handle-stripe";
|
||||||
|
import {stripe} from '~/libs/stripe';
|
||||||
|
import {getURL} from "~/libs/helpers";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let json = await req.json();
|
||||||
|
let user_id = json.user_id;
|
||||||
|
|
||||||
|
if (!user_id) {
|
||||||
|
return Response.json({msg: "Login to continue.", status: 601});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户在数据库是否存在,不存在则返回需登录
|
||||||
|
const resultsUser = await getUserById(user_id);
|
||||||
|
if (resultsUser.email == '') {
|
||||||
|
return Response.json({msg: "Login to continue.", status: 601});
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await createOrRetrieveCustomer({
|
||||||
|
user_id: user_id,
|
||||||
|
email: resultsUser.email
|
||||||
|
});
|
||||||
|
if (!customer) throw Error('Could not get customer');
|
||||||
|
|
||||||
|
const {url} = await stripe.billingPortal.sessions.create({
|
||||||
|
customer,
|
||||||
|
return_url: `${getURL()}`
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({url: url}), {
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
77
src/app/[locale]/api/stripe/webhooks/route.ts
Normal file
77
src/app/[locale]/api/stripe/webhooks/route.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import Stripe from 'stripe';
|
||||||
|
import {stripe} from '~/libs/stripe';
|
||||||
|
import {
|
||||||
|
manageSubscriptionStatusChange
|
||||||
|
} from '~/libs/handle-stripe';
|
||||||
|
|
||||||
|
const relevantEvents = new Set([
|
||||||
|
'checkout.session.completed',
|
||||||
|
'customer.subscription.created',
|
||||||
|
'customer.subscription.updated',
|
||||||
|
'customer.subscription.deleted'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.text();
|
||||||
|
const sig = req.headers.get('stripe-signature') as string;
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
let event: Stripe.Event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!sig || !webhookSecret) return;
|
||||||
|
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`❌ Error message: ${err.message}`);
|
||||||
|
return new Response(`Webhook Error: ${err.message}`, {status: 200});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return new Response(`Webhook Error: event undefined`, {status: 200});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.type) {
|
||||||
|
return new Response(`Webhook Error: event.type undefined`, {status: 200});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relevantEvents.has(event.type)) {
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'customer.subscription.created':
|
||||||
|
case 'customer.subscription.updated':
|
||||||
|
case 'customer.subscription.deleted':
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
await manageSubscriptionStatusChange(
|
||||||
|
subscription.id,
|
||||||
|
subscription.customer as string,
|
||||||
|
event.type === 'customer.subscription.created'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||||
|
// console.log('checkoutSession==>', checkoutSession);
|
||||||
|
if (checkoutSession.mode === 'subscription') {
|
||||||
|
const subscriptionId = checkoutSession.subscription;
|
||||||
|
await manageSubscriptionStatusChange(
|
||||||
|
subscriptionId as string,
|
||||||
|
checkoutSession.customer as string,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('Unhandled relevant event!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return new Response(
|
||||||
|
'Webhook handler failed. View your nextjs function logs.',
|
||||||
|
{
|
||||||
|
status: 200
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({received: true}));
|
||||||
|
}
|
34
src/app/[locale]/api/user/getAvailableTimes/route.ts
Normal file
34
src/app/[locale]/api/user/getAvailableTimes/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import {getDb} from "~/libs/db";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
export const GET = async (req: Request) => {
|
||||||
|
const query = new URL(req.url).searchParams;
|
||||||
|
const userId = query.get("userId");
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
userId: userId,
|
||||||
|
available_times: 0,
|
||||||
|
subscribeStatus: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
|
||||||
|
const resultsSubscribe = await db.query('SELECT * FROM stripe_subscriptions where user_id=$1', [userId]);
|
||||||
|
const originSubscribe = resultsSubscribe.rows;
|
||||||
|
if (originSubscribe.length > 0) {
|
||||||
|
if (originSubscribe[0].status == 'active') {
|
||||||
|
result.subscribeStatus = originSubscribe[0].status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await db.query('SELECT * FROM user_available where user_id=$1', [userId]);
|
||||||
|
const origin = results.rows;
|
||||||
|
|
||||||
|
if (origin.length !== 0) {
|
||||||
|
result.available_times = origin[0].available_times;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json(result);
|
||||||
|
}
|
10
src/app/[locale]/api/user/getUserByEmail/route.ts
Normal file
10
src/app/[locale]/api/user/getUserByEmail/route.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import {getUserByEmail} from "~/servers/user";
|
||||||
|
|
||||||
|
export async function POST(req: Request, res: Response) {
|
||||||
|
let json = await req.json();
|
||||||
|
let email = json.email;
|
||||||
|
|
||||||
|
const result = await getUserByEmail(email);
|
||||||
|
return Response.json(result);
|
||||||
|
|
||||||
|
}
|
10
src/app/[locale]/api/user/getUserByUserId/route.ts
Normal file
10
src/app/[locale]/api/user/getUserByUserId/route.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import {getUserById} from "~/servers/user";
|
||||||
|
|
||||||
|
export async function POST(req: Request, res: Response) {
|
||||||
|
let json = await req.json();
|
||||||
|
let user_id = json.user_id;
|
||||||
|
|
||||||
|
const result = await getUserById(user_id);
|
||||||
|
return Response.json(result);
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
import {getLatestPublicResultList} from "~/servers/works";
|
||||||
|
|
||||||
|
export const revalidate = 10;
|
||||||
|
|
||||||
|
export async function POST(req: Request, res: Response) {
|
||||||
|
|
||||||
|
const json = await req.json();
|
||||||
|
const locale = json.locale;
|
||||||
|
|
||||||
|
const works = await getLatestPublicResultList(locale, 1);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(works), {
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
}
|
51
src/app/[locale]/api/works/getResultInfo/route.ts
Normal file
51
src/app/[locale]/api/works/getResultInfo/route.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import {getDb} from "~/libs/db";
|
||||||
|
import {getArrayUrlResult} from "~/configs/buildLink";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export const GET = async (req: Request) => {
|
||||||
|
const query = new URL(req.url).searchParams;
|
||||||
|
|
||||||
|
const userId = query.get("userId");
|
||||||
|
const uid = query.get("uid");
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
status: 404,
|
||||||
|
uid: uid,
|
||||||
|
input_text: '',
|
||||||
|
output_url: [],
|
||||||
|
is_public: false,
|
||||||
|
message: 'error',
|
||||||
|
user_id: userId,
|
||||||
|
revised_text: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!userId || userId === 'undefined') && process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
return Response.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
let results;
|
||||||
|
if (process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
results = await db.query('select * from works where uid=$1 and user_id=$2 and is_origin=$3 and is_delete=$4', [uid, userId, true, false]);
|
||||||
|
} else {
|
||||||
|
results = await db.query('select * from works where uid=$1 and is_origin=$2 and is_delete=$3', [uid, true, false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultData = results.rows;
|
||||||
|
if (resultData.length <= 0) {
|
||||||
|
return Response.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = resultData[0];
|
||||||
|
result.status = data.status;
|
||||||
|
result.input_text = data.input_text;
|
||||||
|
result.output_url = getArrayUrlResult(data.output_url);
|
||||||
|
result.is_public = data.is_public;
|
||||||
|
result.user_id = data.user_id;
|
||||||
|
result.uid = data.uid;
|
||||||
|
result.revised_text = data.revised_text;
|
||||||
|
|
||||||
|
return Response.json(result);
|
||||||
|
}
|
17
src/app/[locale]/api/works/getWorkList/route.ts
Normal file
17
src/app/[locale]/api/works/getWorkList/route.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {getWorkListByUserId} from "~/servers/works";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function POST(req: Request, res: Response) {
|
||||||
|
|
||||||
|
const json = await req.json();
|
||||||
|
const user_id = json.user_id;
|
||||||
|
const current_page = json.current_page;
|
||||||
|
|
||||||
|
const works = await getWorkListByUserId(user_id, current_page);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(works), {
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
}
|
19
src/app/[locale]/api/works/updateWork/route.ts
Normal file
19
src/app/[locale]/api/works/updateWork/route.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import {getDb} from "~/libs/db";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function POST(req: Request, res: Response) {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const json = await req.json();
|
||||||
|
const uid = json.uid;
|
||||||
|
|
||||||
|
// 更新数据为删除状态
|
||||||
|
await db.query('update works set is_delete=$1,updated_at=now() where uid=$2', [true, uid]);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({message: 'success'}), {
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
57
src/app/[locale]/layout.tsx
Normal file
57
src/app/[locale]/layout.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import {Inter} from 'next/font/google';
|
||||||
|
import {notFound} from 'next/navigation';
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
import {ReactNode} from 'react';
|
||||||
|
import {locales} from '~/config';
|
||||||
|
import {CommonProvider} from '~/context/common-context';
|
||||||
|
import {NextAuthProvider} from '~/context/next-auth-context';
|
||||||
|
import {getAuthText, getCommonText, getMenuText, getPricingText} from "~/configs/languageText";
|
||||||
|
|
||||||
|
const inter = Inter({subsets: ['latin']});
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
params: { locale: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.map((locale) => ({locale}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params: {locale}
|
||||||
|
}: Props) {
|
||||||
|
|
||||||
|
// Validate that the incoming `locale` parameter is valid
|
||||||
|
if (!locales.includes(locale as any)) notFound();
|
||||||
|
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
const commonText = await getCommonText();
|
||||||
|
const authText = await getAuthText();
|
||||||
|
const menuText = await getMenuText();
|
||||||
|
const pricingText = await getPricingText();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale}>
|
||||||
|
<head>
|
||||||
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
|
</head>
|
||||||
|
<body suppressHydrationWarning={true} className={clsx(inter.className, 'flex flex-col background-div')}>
|
||||||
|
<NextAuthProvider>
|
||||||
|
<CommonProvider
|
||||||
|
commonText={commonText}
|
||||||
|
authText={authText}
|
||||||
|
menuText={menuText}
|
||||||
|
pricingText={pricingText}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CommonProvider>
|
||||||
|
</NextAuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
164
src/app/[locale]/my/PageComponent.tsx
Normal file
164
src/app/[locale]/my/PageComponent.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {useInterval} from "ahooks";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {getCompressionImageLink, getLinkHref, getShareToPinterest} from "~/configs/buildLink";
|
||||||
|
import {getResultStrAddSticker} from "~/configs/buildStr";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {pinterestSvg} from "~/components/svg";
|
||||||
|
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
worksText
|
||||||
|
}) => {
|
||||||
|
const [pagePath] = useState('my');
|
||||||
|
|
||||||
|
const [resultInfoList, setResultInfoList] = useState([]);
|
||||||
|
const {
|
||||||
|
setShowLoadingModal,
|
||||||
|
userData,
|
||||||
|
commonText,
|
||||||
|
} = useCommonContext();
|
||||||
|
const [intervalWorkList, setIntervalWorkList] = useState(1000);
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCurrentWorkList = async (newPage) => {
|
||||||
|
if (!userData) {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
setIntervalWorkList(undefined);
|
||||||
|
const requestData = {
|
||||||
|
user_id: userData.user_id,
|
||||||
|
current_page: newPage
|
||||||
|
}
|
||||||
|
// setShowLoadingModal(true);
|
||||||
|
const response = await fetch(`/api/works/getWorkList`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestData)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
if (result.length == 0) {
|
||||||
|
setAlreadyLoadAll(true);
|
||||||
|
} else {
|
||||||
|
setResultInfoList([...resultInfoList, ...result]);
|
||||||
|
}
|
||||||
|
// setResultInfoList(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useInterval(() => {
|
||||||
|
getCurrentWorkList(currentPage)
|
||||||
|
}, intervalWorkList);
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [alreadyLoadAll, setAlreadyLoadAll] = useState(false);
|
||||||
|
|
||||||
|
const myRef = useRef(null);
|
||||||
|
const handleScroll = async () => {
|
||||||
|
const {scrollHeight, clientHeight, scrollTop} = myRef.current;
|
||||||
|
if (scrollHeight - clientHeight <= scrollTop + 1) {
|
||||||
|
console.log('Reached bottom');
|
||||||
|
if (!alreadyLoadAll) {
|
||||||
|
await getCurrentWorkList(currentPage + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<meta name="robots" content="noindex"/>
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={worksText.title}
|
||||||
|
description={worksText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<div className={"my-auto h-screen overflow-y-auto"} ref={myRef} onScroll={handleScroll}>
|
||||||
|
<TopBlurred/>
|
||||||
|
<div className="block overflow-hidden text-white">
|
||||||
|
<div className="mx-auto w-full px-5 mb-5">
|
||||||
|
<div
|
||||||
|
className="mx-auto flex max-w-4xl flex-col items-center text-center py-6">
|
||||||
|
<h1 className="text-4xl font-bold md:text-6xl">{worksText.h1Text}</h1>
|
||||||
|
</div>
|
||||||
|
<div key={"more"} className={"px-6"}>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, '')}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"flex justify-center items-center text-xl text-red-400 hover:text-blue-600"}>
|
||||||
|
{commonText.generateNew} {'>>'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"w-[90%] mx-auto mb-20"}>
|
||||||
|
<div role="list"
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||||
|
{resultInfoList?.map((file, index) => (
|
||||||
|
<div key={file.input_text + index}>
|
||||||
|
<div
|
||||||
|
className="rounded-xl flex justify-center items-start mt-6 checkerboard relative">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, `sticker/${file.uid}`)}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"cursor-pointer"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getCompressionImageLink(file.output_url[1])}
|
||||||
|
alt={file.input_text + ' ' + process.env.NEXT_PUBLIC_IMAGE_ALT_ADDITION_TEXT}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
className={"rounded-lg"}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`https://pinterest.com/pin/create/button/?url=${getShareToPinterest(locale, 'sticker/' + file.uid, file.input_text)}`}
|
||||||
|
target={"_blank"}
|
||||||
|
className={"absolute top-1 left-1"}>
|
||||||
|
{pinterestSvg}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<p
|
||||||
|
className="pointer-events-none mt-2 block text-sm font-medium text-white w-[90%] line-clamp-2">{getResultStrAddSticker(file.input_text, commonText.keyword)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
22
src/app/[locale]/my/page.tsx
Normal file
22
src/app/[locale]/my/page.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getWorksText
|
||||||
|
} from "~/configs/languageText";
|
||||||
|
|
||||||
|
export default async function IndexPage({params: {locale = ''}}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
const worksText = await getWorksText();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
worksText={worksText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
31
src/app/[locale]/page.tsx
Normal file
31
src/app/[locale]/page.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getIndexPageText,
|
||||||
|
getQuestionText
|
||||||
|
} from "~/configs/languageText";
|
||||||
|
import {getLatestPublicResultList} from "~/servers/works";
|
||||||
|
|
||||||
|
export const revalidate = 120;
|
||||||
|
export default async function IndexPage({params: {locale = ''}, searchParams: searchParams}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
const indexText = await getIndexPageText();
|
||||||
|
const questionText = await getQuestionText();
|
||||||
|
|
||||||
|
const resultInfoListInit = await getLatestPublicResultList(locale, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
indexText={indexText}
|
||||||
|
questionText={questionText}
|
||||||
|
resultInfoListInit={resultInfoListInit}
|
||||||
|
searchParams={searchParams}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
65
src/app/[locale]/pricing/PageComponent.tsx
Normal file
65
src/app/[locale]/pricing/PageComponent.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import Pricing from "~/components/PricingComponent";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
}) => {
|
||||||
|
const [pagePath] = useState('pricing');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setShowLoadingModal,
|
||||||
|
pricingText
|
||||||
|
} = useCommonContext();
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={pricingText.title}
|
||||||
|
description={pricingText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<div className={"mt-8 my-auto min-h-[90vh]"}>
|
||||||
|
<TopBlurred/>
|
||||||
|
<Pricing
|
||||||
|
redirectUrl={`${locale}/pricing`}
|
||||||
|
isPricing={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
15
src/app/[locale]/pricing/page.tsx
Normal file
15
src/app/[locale]/pricing/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
|
||||||
|
export default async function IndexPage({params: {locale = ''}}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
66
src/app/[locale]/privacy-policy/PageComponent.tsx
Normal file
66
src/app/[locale]/privacy-policy/PageComponent.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
privacyPolicyText,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const [pagePath] = useState("privacy-policy");
|
||||||
|
const {setShowLoadingModal} = useCommonContext();
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={privacyPolicyText.title}
|
||||||
|
description={privacyPolicyText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6 my-auto min-h-[80vh]">
|
||||||
|
<TopBlurred/>
|
||||||
|
<main className="w-[95%] md:w-[65%] lg:w-[55%] 2xl:w-[45%] mx-auto h-full my-8">
|
||||||
|
<div className="p-6 prose mx-auto text-gray-300 div-markdown-color">
|
||||||
|
<Markdown>
|
||||||
|
{privacyPolicyText.detailText}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
23
src/app/[locale]/privacy-policy/page.tsx
Normal file
23
src/app/[locale]/privacy-policy/page.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPrivacyPolicyText
|
||||||
|
} from "~/configs/languageText";
|
||||||
|
|
||||||
|
export default async function IndexPage({params: {locale = ''}}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
const privacyPolicyText = await getPrivacyPolicyText();
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
privacyPolicyText={privacyPolicyText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
187
src/app/[locale]/search/PageComponent.tsx
Normal file
187
src/app/[locale]/search/PageComponent.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {getCompressionImageLink, getLinkHref, getShareToPinterest} from "~/configs/buildLink";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {getResultStrAddSticker} from "~/configs/buildStr";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useInterval} from "ahooks";
|
||||||
|
import {pinterestSvg} from "~/components/svg";
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
searchText,
|
||||||
|
resultInfoListInit,
|
||||||
|
sticker,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pagePath] = useState(sticker ? ('search?sticker=' + encodeURIComponent(sticker)) : 'search');
|
||||||
|
const [resultInfoList, setResultInfoList] = useState(resultInfoListInit);
|
||||||
|
const {
|
||||||
|
setShowLoadingModal,
|
||||||
|
userData,
|
||||||
|
commonText
|
||||||
|
} = useCommonContext();
|
||||||
|
const [intervalCheckUser, setIntervalCheckUser] = useState(undefined);
|
||||||
|
|
||||||
|
const [textStr, setTextStr] = useState(sticker);
|
||||||
|
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!textStr) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
const pageNew = `search?sticker=${textStr}`;
|
||||||
|
const resultStr = getLinkHref(locale, pageNew);
|
||||||
|
window.location.href = process.env.NEXT_PUBLIC_SITE_URL + '/' + resultStr;
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
setIntervalCheckUser(500)
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateWork = async (uid) => {
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
const response = await fetch(`/api/works/updateWork`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({uid: uid})
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('result->', result);
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={searchText.title}
|
||||||
|
description={searchText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<div className={"my-auto mt-4 min-h-[90vh]"}>
|
||||||
|
<TopBlurred/>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-7xl px-5 py-4 md:px-10 md:py-12 lg:py-18">
|
||||||
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
|
<h2 className="mb-4 text-4xl font-bold md:text-6xl text-white">{searchText.h1Text}</h2>
|
||||||
|
<h1 className="mx-auto mb-6 text-sm md:text-xl text-[#7c8aaa]">
|
||||||
|
{searchText.h2Text}
|
||||||
|
</h1>
|
||||||
|
<div className="mx-auto mb-4 flex max-w-xl justify-center">
|
||||||
|
<form onSubmit={handleSubmit} className="relative w-full max-w-[80%]">
|
||||||
|
<input type="text"
|
||||||
|
className="rounded-lg h-9 w-full bg-white px-3 py-6 text-sm text-[#333333] custom-textarea"
|
||||||
|
placeholder={commonText.placeholderText}
|
||||||
|
value={textStr}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTextStr(e.target.value);
|
||||||
|
}}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<input type="submit"
|
||||||
|
value={commonText.searchButtonText}
|
||||||
|
className="rounded-r-md rounded-l-md md:rounded-l-none relative top-[5px] md:top-0 md:h-full w-full cursor-pointer bg-[#f05011] px-6 py-2 text-center font-semibold text-white sm:absolute md:right-0 sm:w-auto"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
resultInfoList.length <= 0 ?
|
||||||
|
<div className={"px-6 py-4"}>
|
||||||
|
<Link href={getLinkHref(locale, '')}
|
||||||
|
className={"flex justify-center items-center text-xl text-red-400 hover:text-blue-600"}>
|
||||||
|
{commonText.generateNew} {'>>'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
resultInfoList.length > 0 ?
|
||||||
|
<>
|
||||||
|
<div className={"w-[90%] mx-auto mb-10"}>
|
||||||
|
<div role="list"
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||||
|
{resultInfoList.map((file, index) => (
|
||||||
|
<div key={file.input_text + index} className={"mt-6"}>
|
||||||
|
<div
|
||||||
|
className="rounded-xl flex justify-center items-start checkerboard relative">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, `sticker/${file.uid}`)}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"cursor-pointer"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getCompressionImageLink(file.output_url[1])}
|
||||||
|
alt={file.input_text}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
className={"rounded-lg"}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`https://pinterest.com/pin/create/button/?url=${getShareToPinterest(locale, 'sticker/' + file.uid, file.input_text)}`}
|
||||||
|
target={"_blank"}
|
||||||
|
className={"absolute top-1 left-1"}>
|
||||||
|
{pinterestSvg}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<p
|
||||||
|
className="pointer-events-none mt-2 block text-sm font-medium text-white w-[90%] line-clamp-2">{getResultStrAddSticker(file.input_text, commonText.keyword)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div key={"more"} className={"px-4"}>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'stickers')}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"flex justify-center items-center text-xl text-red-400 hover:text-blue-600"}>
|
||||||
|
{commonText.exploreMore} {'>>'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
36
src/app/[locale]/search/page.tsx
Normal file
36
src/app/[locale]/search/page.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
import {getSearchText} from "~/configs/languageText";
|
||||||
|
import {getLatestPublicResultList} from "~/servers/works";
|
||||||
|
import {getCountSticker} from "~/servers/keyValue";
|
||||||
|
import {searchByWords, addSearchLog} from "~/servers/search";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function SearchPage({params: {locale = ''}, searchParams: {sticker = ''}}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
|
||||||
|
const countSticker = await getCountSticker();
|
||||||
|
|
||||||
|
let resultInfoListInit = [];
|
||||||
|
let searchText;
|
||||||
|
if (sticker) {
|
||||||
|
resultInfoListInit = await searchByWords(locale, sticker);
|
||||||
|
searchText = await getSearchText(resultInfoListInit.length, sticker, countSticker);
|
||||||
|
addSearchLog(sticker, resultInfoListInit.length);
|
||||||
|
} else {
|
||||||
|
resultInfoListInit = await getLatestPublicResultList(locale, 1);
|
||||||
|
searchText = await getSearchText(countSticker, sticker, countSticker);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
searchText={searchText}
|
||||||
|
resultInfoListInit={resultInfoListInit}
|
||||||
|
sticker={sticker}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
259
src/app/[locale]/sticker/[uid]/PageComponent.tsx
Normal file
259
src/app/[locale]/sticker/[uid]/PageComponent.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {getCompressionImageLink, getLinkHref, getShareToPinterest, getTotalLinkHref} from "~/configs/buildLink";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {useInterval} from "ahooks";
|
||||||
|
import PricingModal from "~/components/PricingModal";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {getResultStrAddSticker} from "~/configs/buildStr";
|
||||||
|
import {pinterestSvg} from "~/components/svg";
|
||||||
|
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
detailText,
|
||||||
|
workDetail,
|
||||||
|
similarList,
|
||||||
|
}) => {
|
||||||
|
const [pagePath] = useState(`sticker/${workDetail.uid}`);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setShowLoadingModal,
|
||||||
|
userData,
|
||||||
|
setShowPricingModal,
|
||||||
|
setShowLoginModal,
|
||||||
|
commonText
|
||||||
|
} = useCommonContext();
|
||||||
|
const [availableTimes, setAvailableTimes] = useState({
|
||||||
|
available_times: 0,
|
||||||
|
subscribeStatus: '0'
|
||||||
|
});
|
||||||
|
const [intervalAvailableTimes, setIntervalAvailableTimes] = useState(undefined);
|
||||||
|
|
||||||
|
const getAvailableTimes = async () => {
|
||||||
|
if (!userData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = userData.user_id;
|
||||||
|
if (userId) {
|
||||||
|
const response = await fetch(`/api/user/getAvailableTimes?userId=${userId}`);
|
||||||
|
const availableTimes = await response.json();
|
||||||
|
setAvailableTimes(availableTimes);
|
||||||
|
if (availableTimes.available_times >= 0) {
|
||||||
|
setIntervalAvailableTimes(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useInterval(() => {
|
||||||
|
getAvailableTimes();
|
||||||
|
}, intervalAvailableTimes);
|
||||||
|
|
||||||
|
const downloadResult = (url, index) => {
|
||||||
|
if (process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0') {
|
||||||
|
if (!userData) {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index == 0) {
|
||||||
|
window.location.href = url;
|
||||||
|
} else {
|
||||||
|
if (process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0') {
|
||||||
|
if (availableTimes.subscribeStatus != 'active') {
|
||||||
|
setShowPricingModal(true);
|
||||||
|
} else {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
if (process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0' && process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0') {
|
||||||
|
setIntervalAvailableTimes(1000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={detailText.title}
|
||||||
|
description={detailText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<PricingModal
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 my-auto min-h-[90vh]">
|
||||||
|
{/*<TopBlurred/>*/}
|
||||||
|
<div className="block overflow-hidden bg-cover bg-center text-white">
|
||||||
|
<div className="mx-auto w-full max-w-7xl px-5 mb-5">
|
||||||
|
<div
|
||||||
|
className="mx-auto flex max-w-4xl flex-col items-center text-center py-10">
|
||||||
|
<h1 className="mb-4 text-2xl font-bold md:text-4xl">{detailText.h1Text}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div key={"more"} className={"px-6 py-4"}>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, '') + '?prompt=' + workDetail.input_text}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
rel={"nofollow"}
|
||||||
|
className={"flex justify-center items-center text-xl text-red-400 hover:text-blue-600"}>
|
||||||
|
{commonText.generateNew} {'>>'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"mx-auto"}>
|
||||||
|
<div className={"flex-col px-8 py-4 justify-center items-center mx-auto"}>
|
||||||
|
<div className={"grid grid-cols-1 md:grid-cols-2 gap-2"}>
|
||||||
|
{
|
||||||
|
workDetail?.output_url?.length > 0 ?
|
||||||
|
<div className={"rounded-lg flex justify-center items-start"}>
|
||||||
|
<div className={"flex-col items-center"}>
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
title={workDetail.input_text}
|
||||||
|
src={getCompressionImageLink(workDetail?.output_url[0])}
|
||||||
|
alt={workDetail.input_text + ' ' + process.env.NEXT_PUBLIC_IMAGE_ALT_ADDITION_TEXT}
|
||||||
|
className={"rounded-lg checkerboard z-20"}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href={`${getShareToPinterest(locale, 'sticker/' + workDetail.uid, detailText.h1Text)}`}
|
||||||
|
target={"_blank"}
|
||||||
|
className={"absolute top-1 left-1"}>
|
||||||
|
{pinterestSvg}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex justify-center items-center space-x-3 px-2 py-2">
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onClick={() => downloadResult(workDetail?.output_url[0], 0)}
|
||||||
|
className="w-full inline-flex justify-center items-center rounded-md bg-[#ffa11b] px-3 py-2 text-xs md:text-lg font-semibold text-white shadow-sm hover:bg-[#f05011]"
|
||||||
|
>
|
||||||
|
{commonText.download}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
workDetail?.output_url?.length > 1 ?
|
||||||
|
<div className={"rounded-lg flex justify-center items-start"}>
|
||||||
|
<div className={"flex-col items-center"}>
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
title={workDetail.input_text}
|
||||||
|
src={getCompressionImageLink(workDetail?.output_url[1])}
|
||||||
|
alt={workDetail.input_text + ' ' + process.env.NEXT_PUBLIC_IMAGE_ALT_ADDITION_TEXT}
|
||||||
|
className={"rounded-lg checkerboard"}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href={`https://pinterest.com/pin/create/button/?url=${getShareToPinterest(locale, 'sticker/' + workDetail.uid, detailText.h1Text)}`}
|
||||||
|
target={"_blank"}
|
||||||
|
className={"absolute top-1 left-1"}>
|
||||||
|
{pinterestSvg}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex justify-center items-center space-x-3 px-2 py-2">
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onClick={() => downloadResult(workDetail?.output_url[1], 1)}
|
||||||
|
className="w-full inline-flex justify-center items-center rounded-md bg-[#ffa11b] px-3 py-2 text-xs md:text-lg font-semibold text-white shadow-sm hover:bg-[#f05011]"
|
||||||
|
>
|
||||||
|
{commonText.download} PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className={"w-[80%] mx-auto text-white pt-4 text-xl md:text-4xl flex justify-center items-center"}>{detailText.h2Text}</h2>
|
||||||
|
<div className={"w-[90%] mx-auto mb-20"}>
|
||||||
|
<div role="list"
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||||
|
{similarList?.map((file, index) => (
|
||||||
|
<div key={file.input_text + index} className={"mt-6"}>
|
||||||
|
<div
|
||||||
|
className="rounded-xl flex justify-center items-start checkerboard relative">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, `sticker/${file.uid}`)}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"cursor-pointer"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getCompressionImageLink(file.output_url[1])}
|
||||||
|
alt={file.input_text + ' ' + process.env.NEXT_PUBLIC_IMAGE_ALT_ADDITION_TEXT}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
className={"rounded-lg"}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`https://pinterest.com/pin/create/button/?url=${getShareToPinterest(locale, 'sticker/' + file.uid, detailText.h1Text)}`}
|
||||||
|
target={"_blank"}
|
||||||
|
className={"absolute top-1 left-1"}>
|
||||||
|
{pinterestSvg}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<p
|
||||||
|
className="pointer-events-none mt-2 block text-sm font-medium text-white w-[90%] line-clamp-2">{getResultStrAddSticker(file.input_text, commonText.keyword)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
36
src/app/[locale]/sticker/[uid]/page.tsx
Normal file
36
src/app/[locale]/sticker/[uid]/page.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDetailText,
|
||||||
|
} from "~/configs/languageText";
|
||||||
|
import {getSimilarList, getWorkDetailByUid} from "~/servers/works";
|
||||||
|
import {notFound} from "next/navigation";
|
||||||
|
|
||||||
|
// export const revalidate = 86400;
|
||||||
|
export const dynamicParams = true
|
||||||
|
export const dynamic = 'error';
|
||||||
|
|
||||||
|
export default async function IndexPage({params: {locale = '', uid = ''}}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
const workDetail = await getWorkDetailByUid(locale, uid);
|
||||||
|
if (workDetail.status == 404) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
const detailText = await getDetailText(workDetail);
|
||||||
|
|
||||||
|
const similarList = await getSimilarList(workDetail.revised_text, uid, locale)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
detailText={detailText}
|
||||||
|
workDetail={workDetail}
|
||||||
|
similarList={similarList}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
211
src/app/[locale]/stickers/PageComponent.tsx
Normal file
211
src/app/[locale]/stickers/PageComponent.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {ChevronLeftIcon, ChevronRightIcon} from '@heroicons/react/20/solid'
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {getCompressionImageLink, getLinkHref, getShareToPinterest} from "~/configs/buildLink";
|
||||||
|
import {getResultStrAddSticker} from "~/configs/buildStr";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {pinterestSvg} from "~/components/svg";
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
exploreText,
|
||||||
|
resultInfoData,
|
||||||
|
page = 1,
|
||||||
|
pageData = {
|
||||||
|
totalPage: 0,
|
||||||
|
pagination: []
|
||||||
|
},
|
||||||
|
}) => {
|
||||||
|
const [pagePath] = useState('stickers');
|
||||||
|
const [resultInfoList, setResultInfoList] = useState(resultInfoData);
|
||||||
|
const {
|
||||||
|
setShowLoadingModal,
|
||||||
|
commonText
|
||||||
|
} = useCommonContext();
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkShowLoading = (toPage) => {
|
||||||
|
if (page != toPage) {
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={exploreText.title}
|
||||||
|
description={exploreText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<div className={"my-auto mt-4 min-h-[90vh]"}>
|
||||||
|
<TopBlurred/>
|
||||||
|
<div className={"mb-8"}>
|
||||||
|
<h2
|
||||||
|
className={"text-white pt-4 text-3xl md:text-4xl flex justify-center items-center"}>{exploreText.h1Text}</h2>
|
||||||
|
<div className="mb-5 w-[80%] md:max-w-[628px] lg:mb-8 mx-auto mt-4">
|
||||||
|
<h1 className="text-[#7c8aaa] text-md md:text-xl">{exploreText.h2Text}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div key={"more"} className={"px-6 py-4"}>
|
||||||
|
<Link href={getLinkHref(locale, '')}
|
||||||
|
className={"flex justify-center items-center text-xl text-red-400 hover:text-blue-600"}>
|
||||||
|
{commonText.generateNew} {'>>'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"w-[90%] mx-auto mb-20"}>
|
||||||
|
<div role="list"
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||||
|
{resultInfoList?.map((file, index) => (
|
||||||
|
<div key={file.input_text + index} className={"mt-6"}>
|
||||||
|
<div
|
||||||
|
className="rounded-xl flex justify-center items-start checkerboard relative">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, `sticker/${file.uid}`)}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"cursor-pointer"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getCompressionImageLink(file.output_url[1])}
|
||||||
|
alt={file.input_text + ' ' + process.env.NEXT_PUBLIC_IMAGE_ALT_ADDITION_TEXT}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
className={"rounded-lg"}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`https://pinterest.com/pin/create/button/?url=${getShareToPinterest(locale, 'sticker/' + file.uid, file.input_text)}`}
|
||||||
|
target={"_blank"}
|
||||||
|
className={"absolute top-1 left-1"}>
|
||||||
|
{pinterestSvg}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<p
|
||||||
|
className="pointer-events-none mt-2 block text-sm font-medium text-white w-[90%] line-clamp-2">{getResultStrAddSticker(file.input_text, commonText.keyword)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
|
{
|
||||||
|
pageData?.pagination?.length > 0 ?
|
||||||
|
page == 2 ?
|
||||||
|
<Link
|
||||||
|
key={2}
|
||||||
|
href={getLinkHref(locale, `stickers`)}
|
||||||
|
className="no-underline relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(page)}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true"/>
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
<Link
|
||||||
|
key={1}
|
||||||
|
href={getLinkHref(locale, `stickers`)}
|
||||||
|
className="no-underline relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(page)}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true"/>
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
pageData?.pagination?.map((pa, index) => {
|
||||||
|
let href;
|
||||||
|
if (pa == 1) {
|
||||||
|
href = getLinkHref(locale, `stickers`);
|
||||||
|
} else if (pa == '...') {
|
||||||
|
href = `#`;
|
||||||
|
} else {
|
||||||
|
href = getLinkHref(locale, `stickers/${pa}`);
|
||||||
|
}
|
||||||
|
if (pa == page) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={pa}
|
||||||
|
href={href}
|
||||||
|
aria-current="page"
|
||||||
|
className="no-underline relative z-10 inline-flex items-center bg-[#de5c2d] px-4 py-2 text-sm font-semibold text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||||
|
onClick={() => checkShowLoading(pa)}
|
||||||
|
>
|
||||||
|
{pa}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={pa}
|
||||||
|
href={href}
|
||||||
|
className="no-underline relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(pa)}
|
||||||
|
>
|
||||||
|
{pa}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
{
|
||||||
|
pageData?.pagination?.length > 0 ?
|
||||||
|
page == pageData?.totalPage ?
|
||||||
|
<Link
|
||||||
|
key={page}
|
||||||
|
href={getLinkHref(locale, `stickers/${page}`)}
|
||||||
|
className="no-underline relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(page)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-5 w-5" aria-hidden="true"/>
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
<Link
|
||||||
|
key={2}
|
||||||
|
href={getLinkHref(locale, `stickers/${Number(page) + 1}`)}
|
||||||
|
className="no-underline relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(Number(page) + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-5 w-5" aria-hidden="true"/>
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
213
src/app/[locale]/stickers/[page]/PageComponent.tsx
Normal file
213
src/app/[locale]/stickers/[page]/PageComponent.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {ChevronLeftIcon, ChevronRightIcon} from '@heroicons/react/20/solid'
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {getCompressionImageLink, getLinkHref, getShareToPinterest} from "~/configs/buildLink";
|
||||||
|
import {getResultStrAddSticker} from "~/configs/buildStr";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {pinterestSvg} from "~/components/svg";
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
exploreText,
|
||||||
|
resultInfoData,
|
||||||
|
page = 1,
|
||||||
|
pageData = {
|
||||||
|
totalPage: 0,
|
||||||
|
pagination: []
|
||||||
|
},
|
||||||
|
}) => {
|
||||||
|
const [pagePath] = useState(`stickers/${page}`);
|
||||||
|
|
||||||
|
const [resultInfoList, setResultInfoList] = useState(resultInfoData);
|
||||||
|
const {
|
||||||
|
setShowLoadingModal,
|
||||||
|
commonText
|
||||||
|
} = useCommonContext();
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const checkShowLoading = (toPage) => {
|
||||||
|
if (page != toPage) {
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={exploreText.title}
|
||||||
|
description={exploreText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
<div className={"my-auto mt-4 min-h-[90vh]"}>
|
||||||
|
<TopBlurred/>
|
||||||
|
<div className={"mb-8"}>
|
||||||
|
<h2
|
||||||
|
className={"text-white pt-4 text-3xl md:text-4xl flex justify-center items-center"}>{exploreText.h1Text}</h2>
|
||||||
|
<div className="mb-5 w-[80%] md:max-w-[628px] lg:mb-8 mx-auto mt-4">
|
||||||
|
<h1 className="text-[#7c8aaa] text-md md:text-xl">{exploreText.h2Text}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div key={"more"} className={"px-6 py-4"}>
|
||||||
|
<Link href={getLinkHref(locale, ``)}
|
||||||
|
className={"flex justify-center items-center text-xl text-red-400 hover:text-blue-600"}>
|
||||||
|
{commonText.generateNew} {'>>'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"w-[90%] mx-auto mb-20"}>
|
||||||
|
<div role="list"
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||||
|
{resultInfoList?.map((file, index) => (
|
||||||
|
<div key={file.input_text + index}>
|
||||||
|
<div
|
||||||
|
className="rounded-xl flex justify-center items-start mt-6 checkerboard relative">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, `sticker/${file.uid}`)}
|
||||||
|
onClick={() => setShowLoadingModal(true)}
|
||||||
|
className={"cursor-pointer"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getCompressionImageLink(file.output_url[1])}
|
||||||
|
alt={file.input_text + ' ' + process.env.NEXT_PUBLIC_IMAGE_ALT_ADDITION_TEXT}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
className={"rounded-lg"}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`https://pinterest.com/pin/create/button/?url=${getShareToPinterest(locale, 'sticker/' + file.uid, file.input_text)}`}
|
||||||
|
target={"_blank"}
|
||||||
|
className={"absolute top-1 left-1"}>
|
||||||
|
{pinterestSvg}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<p
|
||||||
|
className="pointer-events-none mt-2 block text-sm font-medium text-white w-[90%] line-clamp-2">{getResultStrAddSticker(file.input_text, commonText.keyword)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
|
{
|
||||||
|
pageData?.pagination?.length > 0 ?
|
||||||
|
page == 2 ?
|
||||||
|
<Link
|
||||||
|
key={2}
|
||||||
|
href={getLinkHref(locale, `stickers`)}
|
||||||
|
className="no-underline relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(page)}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true"/>
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
<Link
|
||||||
|
key={24}
|
||||||
|
href={getLinkHref(locale, `stickers/${Number(page) - 1}`)}
|
||||||
|
className="no-underline relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(Number(page) - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true"/>
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
pageData?.pagination?.map((pa, index) => {
|
||||||
|
let href;
|
||||||
|
if (pa == 1) {
|
||||||
|
href = getLinkHref(locale, `stickers`);
|
||||||
|
} else if (pa == '...') {
|
||||||
|
href = `#`;
|
||||||
|
} else {
|
||||||
|
href = getLinkHref(locale, `stickers/${pa}`);
|
||||||
|
}
|
||||||
|
if (pa == page) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={pa}
|
||||||
|
href={href}
|
||||||
|
aria-current="page"
|
||||||
|
className="no-underline relative z-10 inline-flex items-center bg-[#de5c2d] px-4 py-2 text-sm font-semibold text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||||
|
onClick={() => checkShowLoading(pa)}
|
||||||
|
>
|
||||||
|
{pa}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={pa}
|
||||||
|
href={href}
|
||||||
|
className="no-underline relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(pa)}
|
||||||
|
>
|
||||||
|
{pa}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
{
|
||||||
|
pageData?.pagination?.length > 0 ?
|
||||||
|
page == pageData?.totalPage ?
|
||||||
|
<Link
|
||||||
|
key={page}
|
||||||
|
href={getLinkHref(locale, `stickers/${page}`)}
|
||||||
|
className="no-underline relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(page)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-5 w-5" aria-hidden="true"/>
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
<Link
|
||||||
|
key={2}
|
||||||
|
href={getLinkHref(locale, `stickers/${Number(page) + 1}`)}
|
||||||
|
className="no-underline relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-gray-100 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
onClick={() => checkShowLoading(Number(page) + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-5 w-5" aria-hidden="true"/>
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
43
src/app/[locale]/stickers/[page]/page.tsx
Normal file
43
src/app/[locale]/stickers/[page]/page.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getExploreText,
|
||||||
|
} from "~/configs/languageText";
|
||||||
|
import {getPagination, getPublicResultList} from "~/servers/works";
|
||||||
|
import {notFound} from "next/navigation";
|
||||||
|
import {getCountSticker} from "~/servers/keyValue";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
export const dynamic = "force-static";
|
||||||
|
|
||||||
|
export default async function IndexPage({params: {locale = '', page = 2}}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
const countSticker = await getCountSticker();
|
||||||
|
|
||||||
|
if (page == 0) {
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
const exploreText = await getExploreText(countSticker, page);
|
||||||
|
|
||||||
|
const resultInfoData = await getPublicResultList(locale, page);
|
||||||
|
const pageData = await getPagination(locale, page);
|
||||||
|
|
||||||
|
if (page > pageData.totalPage) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
exploreText={exploreText}
|
||||||
|
resultInfoData={resultInfoData}
|
||||||
|
page={page}
|
||||||
|
pageData={pageData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
35
src/app/[locale]/stickers/page.tsx
Normal file
35
src/app/[locale]/stickers/page.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getExploreText,
|
||||||
|
} from "~/configs/languageText";
|
||||||
|
import {getPagination, getPublicResultList} from "~/servers/works";
|
||||||
|
import {getCountSticker} from "~/servers/keyValue";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export default async function IndexPage({params: {locale = ''}}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
const countSticker = await getCountSticker();
|
||||||
|
|
||||||
|
const exploreText = await getExploreText(countSticker, 1);
|
||||||
|
|
||||||
|
const resultInfoData = await getPublicResultList(locale, 1);
|
||||||
|
const pageData = await getPagination(locale, 1);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
exploreText={exploreText}
|
||||||
|
resultInfoData={resultInfoData}
|
||||||
|
page={1}
|
||||||
|
pageData={pageData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
66
src/app/[locale]/terms-of-service/PageComponent.tsx
Normal file
66
src/app/[locale]/terms-of-service/PageComponent.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
import HeadInfo from "~/components/HeadInfo";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import TopBlurred from "~/components/TopBlurred";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
|
||||||
|
|
||||||
|
const PageComponent = ({
|
||||||
|
locale,
|
||||||
|
termsOfServiceText,
|
||||||
|
}) => {
|
||||||
|
const [pagePath] = useState("terms-of-service");
|
||||||
|
const {setShowLoadingModal} = useCommonContext();
|
||||||
|
|
||||||
|
const useCustomEffect = (effect, deps) => {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === 'production' || isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
||||||
|
useCustomEffect(() => {
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeadInfo
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
title={termsOfServiceText.title}
|
||||||
|
description={termsOfServiceText.description}
|
||||||
|
/>
|
||||||
|
<Header
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6 my-auto min-h-[90vh]">
|
||||||
|
<TopBlurred/>
|
||||||
|
<main className="w-[95%] md:w-[65%] lg:w-[55%] 2xl:w-[45%] mx-auto h-full my-8">
|
||||||
|
<div className="p-6 prose mx-auto text-gray-300 div-markdown-color">
|
||||||
|
<Markdown>
|
||||||
|
{termsOfServiceText.detailText}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer
|
||||||
|
locale={locale}
|
||||||
|
page={pagePath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageComponent
|
23
src/app/[locale]/terms-of-service/page.tsx
Normal file
23
src/app/[locale]/terms-of-service/page.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import PageComponent from "./PageComponent";
|
||||||
|
import {unstable_setRequestLocale} from 'next-intl/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTermsOfServiceText
|
||||||
|
} from "~/configs/languageText";
|
||||||
|
|
||||||
|
export default async function IndexPage({params: {locale = ''}}) {
|
||||||
|
// Enable static rendering
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
|
||||||
|
const termsOfServiceText = await getTermsOfServiceText();
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageComponent
|
||||||
|
locale={locale}
|
||||||
|
termsOfServiceText={termsOfServiceText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
19
src/app/error.tsx
Executable file
19
src/app/error.tsx
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect} from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
error: Error;
|
||||||
|
reset(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Error({error, reset}: Props) {
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>not found</div>
|
||||||
|
);
|
||||||
|
}
|
63
src/app/globals.css
Normal file
63
src/app/globals.css
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* styles.css */
|
||||||
|
.div-markdown-color h1,
|
||||||
|
.div-markdown-color h2,
|
||||||
|
.div-markdown-color h3,
|
||||||
|
.div-markdown-color h4,
|
||||||
|
.div-markdown-color h5,
|
||||||
|
.div-markdown-color h6 {
|
||||||
|
@apply text-gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.div-markdown-color a {
|
||||||
|
@apply text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.div-markdown-color strong {
|
||||||
|
@apply text-gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-textarea:focus {
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.background-div {
|
||||||
|
background-image: linear-gradient(to left top, #14171f, #151829, #181831, #201739, #2b133e);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-header {
|
||||||
|
background-image: linear-gradient(to left top, #14171f, #151829, #181831, #201739, #2b133e);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-footer {
|
||||||
|
background-image: linear-gradient(to left top, #14171f, #151829, #181831, #201739, #2b133e);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定义一个棋盘格背景的样式 */
|
||||||
|
.checkerboard {
|
||||||
|
/* 创建两个线性渐变,45度角,模拟棋盘格效果 */
|
||||||
|
background-image: linear-gradient(45deg, #f0f0f0 25%, transparent 25%, transparent 75%, #f0f0f0 75%, #f0f0f0),
|
||||||
|
linear-gradient(45deg, #f0f0f0 25%, #ffffff 25%, #ffffff 75%, #f0f0f0 75%, #f0f0f0);
|
||||||
|
/* 定义每个方格的大小为20px */
|
||||||
|
background-size: 20px 20px;
|
||||||
|
/* 偏移其中一个渐变,以产生交错效果 */
|
||||||
|
background-position: 0 0, 10px 10px;
|
||||||
|
}
|
12
src/app/layout.tsx
Normal file
12
src/app/layout.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import {ReactNode} from 'react';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Since we have a `not-found.tsx` page on the root, a layout file
|
||||||
|
// is required, even if it's just passing children through.
|
||||||
|
export default function RootLayout({children}: Props) {
|
||||||
|
return children;
|
||||||
|
}
|
29
src/app/not-found.tsx
Executable file
29
src/app/not-found.tsx
Executable file
@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Render the default Next.js 404 page when a route
|
||||||
|
// is requested that doesn't match the middleware and
|
||||||
|
// therefore doesn't have a locale associated with it.
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<main className="grid min-h-full place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-base font-semibold text-[#ffa11b]">404</p>
|
||||||
|
<h1 className="mt-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-5xl">Page not found</h1>
|
||||||
|
<p className="mt-6 text-base leading-7 text-gray-600">Sorry, we couldn’t find the page you’re looking for.</p>
|
||||||
|
<div className="mt-10 flex items-center justify-center gap-x-6">
|
||||||
|
<a
|
||||||
|
href={process.env.NEXT_PUBLIC_SITE_URL}
|
||||||
|
className="rounded-md bg-[#ffa11b] px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-[#f05011] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||||
|
>
|
||||||
|
Go back home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
6
src/app/page.tsx
Normal file
6
src/app/page.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import {redirect} from 'next/navigation';
|
||||||
|
|
||||||
|
// This page only renders when the app is built statically (output: 'export')
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect('/en');
|
||||||
|
}
|
160
src/components/Footer.tsx
Normal file
160
src/components/Footer.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {getLinkHref} from "~/configs/buildLink";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {GoogleAnalytics} from "@next/third-parties/google";
|
||||||
|
|
||||||
|
export default function Footer({
|
||||||
|
locale,
|
||||||
|
page,
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
userData,
|
||||||
|
setShowLoadingModal,
|
||||||
|
commonText,
|
||||||
|
menuText,
|
||||||
|
} = useCommonContext();
|
||||||
|
|
||||||
|
const manageSubscribe = async () => {
|
||||||
|
if (!userData?.user_id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const user_id = userData?.user_id;
|
||||||
|
const requestData = {
|
||||||
|
user_id: user_id
|
||||||
|
}
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
const responseData = await fetch(`/api/stripe/create-portal-link`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestData)
|
||||||
|
});
|
||||||
|
const result = await responseData.json();
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
if (result.url) {
|
||||||
|
window.location.href = result.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkPageAndLoading = (toPage) => {
|
||||||
|
if (page != toPage) {
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer aria-labelledby="footer-heading">
|
||||||
|
<div id="footer-heading" className="sr-only">
|
||||||
|
Footer
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-4">
|
||||||
|
<div className="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, '')}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="h-10"
|
||||||
|
src="/website.svg"
|
||||||
|
alt={process.env.NEXT_PUBLIC_DOMAIN_NAME}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
{commonText.footerDescText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
||||||
|
<div className="md:grid md:grid-cols-2 md:gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold leading-6 text-white"></div>
|
||||||
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 md:mt-0">
|
||||||
|
<div className="text-sm font-semibold leading-6 text-white"></div>
|
||||||
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
|
<a
|
||||||
|
className="text-sm leading-6 footer-link text-white"
|
||||||
|
target={"_blank"}
|
||||||
|
href={"https://sticker.show/"}
|
||||||
|
title={"Free Online AI Sticker Maker & Generator!"}
|
||||||
|
>
|
||||||
|
Source by Sticker.Show
|
||||||
|
</a>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:grid md:grid-cols-2 md:gap-8">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="text-sm font-semibold leading-6 text-white">
|
||||||
|
{process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME == '0' ? '' : menuText.footerSupport}
|
||||||
|
</div>
|
||||||
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
|
{
|
||||||
|
process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0' ?
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'pricing')}
|
||||||
|
className="text-sm leading-6 text-gray-300 hover:text-[#2d6ae0]"
|
||||||
|
onClick={()=>checkPageAndLoading('pricing')}
|
||||||
|
>
|
||||||
|
{menuText.footerSupport0}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
userData && process.env.NEXT_PUBLIC_CHECK_AVAILABLE_TIME != '0' ?
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
onClick={() => manageSubscribe()}
|
||||||
|
className="cursor-pointer text-sm leading-6 text-gray-300 hover:text-[#2d6ae0]">
|
||||||
|
{menuText.footerSupport1}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 md:mt-0">
|
||||||
|
<div className="text-sm font-semibold leading-6 text-white">{menuText.footerLegal}</div>
|
||||||
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'privacy-policy')}
|
||||||
|
className="text-sm leading-6 text-gray-300 hover:text-[#2d6ae0]"
|
||||||
|
onClick={()=>checkPageAndLoading('privacy-policy')}
|
||||||
|
>
|
||||||
|
{menuText.footerLegal0}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'terms-of-service')}
|
||||||
|
className="text-sm leading-6 text-gray-300 hover:text-[#2d6ae0]"
|
||||||
|
onClick={()=>checkPageAndLoading('terms-of-service')}
|
||||||
|
>
|
||||||
|
{menuText.footerLegal1}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
process.env.NEXT_PUBLIC_GOOGLE_TAG_ID ?
|
||||||
|
<>
|
||||||
|
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GOOGLE_TAG_ID}/>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
61
src/components/GeneratingModal.tsx
Normal file
61
src/components/GeneratingModal.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import {Fragment, useRef, useState} from 'react'
|
||||||
|
import {Dialog, Transition} from '@headlessui/react'
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
|
||||||
|
export default function GeneratingModal({
|
||||||
|
generatingText,
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const cancelButtonRef = useRef(null);
|
||||||
|
const {showGeneratingModal, setShowGeneratingModal} = useCommonContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={showGeneratingModal} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-40" initialFocus={cancelButtonRef} onClose={setShowGeneratingModal} onClick={() => setShowGeneratingModal(true)}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-4"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center px-4 py-2 font-semibold leading-6 text-xl transition ease-in-out duration-150"
|
||||||
|
style={{color: '#f05011'}}>
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-10 w-10" style={{color: '#f05011'}}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||||
|
strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{generatingText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
)
|
||||||
|
}
|
60
src/components/HeadInfo.tsx
Normal file
60
src/components/HeadInfo.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import {languages} from "~/config";
|
||||||
|
|
||||||
|
const HeadInfo = ({
|
||||||
|
locale,
|
||||||
|
page,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description}/>
|
||||||
|
{
|
||||||
|
languages.map((item) => {
|
||||||
|
const currentPage = page;
|
||||||
|
let hrefLang = item.code;
|
||||||
|
if (item.lang == 'en') {
|
||||||
|
hrefLang = 'x-default';
|
||||||
|
}
|
||||||
|
let href: string;
|
||||||
|
if (currentPage) {
|
||||||
|
href = `${process.env.NEXT_PUBLIC_SITE_URL}/${item.lang}/${currentPage}`;
|
||||||
|
if (item.lang == 'en') {
|
||||||
|
href = `${process.env.NEXT_PUBLIC_SITE_URL}/${currentPage}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
href = `${process.env.NEXT_PUBLIC_SITE_URL}/${item.lang}`;
|
||||||
|
if (item.lang == 'en') {
|
||||||
|
href = `${process.env.NEXT_PUBLIC_SITE_URL}/`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <link key={href} rel="alternate" hrefLang={hrefLang} href={href}/>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
{
|
||||||
|
languages.map((item) => {
|
||||||
|
const currentPage = page;
|
||||||
|
let hrefLang = item.code;
|
||||||
|
let href: string;
|
||||||
|
if (currentPage) {
|
||||||
|
href = `${process.env.NEXT_PUBLIC_SITE_URL}/${item.lang}/${currentPage}`;
|
||||||
|
if (item.lang == 'en') {
|
||||||
|
href = `${process.env.NEXT_PUBLIC_SITE_URL}/${currentPage}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
href = `${process.env.NEXT_PUBLIC_SITE_URL}/${item.lang}`;
|
||||||
|
if (item.lang == 'en') {
|
||||||
|
href = `${process.env.NEXT_PUBLIC_SITE_URL}/`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (locale == item.lang) {
|
||||||
|
return <link key={href + 'canonical'} rel="canonical" hrefLang={hrefLang} href={href}/>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HeadInfo
|
291
src/components/Header.tsx
Normal file
291
src/components/Header.tsx
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
'use client'
|
||||||
|
import {useState} from 'react'
|
||||||
|
import {Dialog} from '@headlessui/react'
|
||||||
|
import {Bars3Icon, XMarkIcon} from '@heroicons/react/24/outline'
|
||||||
|
import {GlobeAltIcon} from '@heroicons/react/24/outline'
|
||||||
|
import {Fragment} from 'react'
|
||||||
|
import {Menu, Transition} from '@headlessui/react'
|
||||||
|
import {ChevronDownIcon} from '@heroicons/react/20/solid'
|
||||||
|
import Link from "next/link";
|
||||||
|
import {languages} from "~/config";
|
||||||
|
import {useCommonContext} from '~/context/common-context'
|
||||||
|
import LoadingModal from "./LoadingModal";
|
||||||
|
import GeneratingModal from "~/components/GeneratingModal";
|
||||||
|
import LoginButton from './LoginButton';
|
||||||
|
import LoginModal from './LoginModal';
|
||||||
|
import LogoutModal from "./LogoutModal";
|
||||||
|
import {getLinkHref} from "~/configs/buildLink";
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
locale,
|
||||||
|
page
|
||||||
|
}) {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
const {
|
||||||
|
setShowLoadingModal,
|
||||||
|
userData,
|
||||||
|
commonText,
|
||||||
|
authText,
|
||||||
|
menuText
|
||||||
|
} = useCommonContext();
|
||||||
|
|
||||||
|
const [pageResult] = useState(getLinkHref(locale, page))
|
||||||
|
|
||||||
|
const checkLocalAndLoading = (lang) => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
if (locale != lang) {
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkPageAndLoading = (toPage) => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
if (page != toPage) {
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="top-0 z-20 w-full">
|
||||||
|
<LoadingModal loadingText={commonText.loadingText}/>
|
||||||
|
<GeneratingModal generatingText={commonText.generateText}/>
|
||||||
|
<LoginModal
|
||||||
|
loadingText={commonText.loadingText}
|
||||||
|
redirectPath={pageResult}
|
||||||
|
loginModalDesc={authText.loginModalDesc}
|
||||||
|
loginModalButtonText={authText.loginModalButtonText}
|
||||||
|
/>
|
||||||
|
<LogoutModal
|
||||||
|
logoutModalDesc={authText.logoutModalDesc}
|
||||||
|
confirmButtonText={authText.confirmButtonText}
|
||||||
|
cancelButtonText={authText.cancelButtonText}
|
||||||
|
redirectPath={pageResult}
|
||||||
|
/>
|
||||||
|
<nav className="mx-auto flex items-center justify-between p-6 lg:px-8" aria-label="Global">
|
||||||
|
<div className="flex">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, '')}
|
||||||
|
className="-m-1.5 ml-0.5 p-1.5"
|
||||||
|
onClick={() => checkLocalAndLoading(locale)}>
|
||||||
|
<img
|
||||||
|
className="h-8 w-auto"
|
||||||
|
src="/website.svg"
|
||||||
|
width={32}
|
||||||
|
height={24}
|
||||||
|
alt={process.env.NEXT_PUBLIC_DOMAIN_NAME}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-white"
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Open main menu</span>
|
||||||
|
<Bars3Icon className="h-6 w-6" aria-hidden="true"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:ml-14 lg:flex lg:flex-1 lg:gap-x-6">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, '')}
|
||||||
|
onClick={() => checkPageAndLoading('')}
|
||||||
|
className="text-sm font-semibold leading-6 text-white hover:text-blue-500">
|
||||||
|
{menuText.header0}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'stickers')}
|
||||||
|
onClick={() => checkPageAndLoading('stickers')}
|
||||||
|
className="text-sm font-semibold leading-6 text-white hover:text-blue-500">
|
||||||
|
{menuText.header2}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'search')}
|
||||||
|
onClick={() => checkPageAndLoading('search')}
|
||||||
|
className="text-sm font-semibold leading-6 text-white hover:text-blue-500">
|
||||||
|
{menuText.header3}
|
||||||
|
</Link>
|
||||||
|
{
|
||||||
|
userData.email ?
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'my')}
|
||||||
|
onClick={() => checkPageAndLoading('my')}
|
||||||
|
className="text-sm font-semibold leading-6 text-white hover:text-blue-500">
|
||||||
|
{menuText.header1}
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<Menu as="div" className="hidden lg:relative lg:inline-block lg:text-left z-30">
|
||||||
|
<div>
|
||||||
|
<Menu.Button
|
||||||
|
className="inline-flex w-full justify-center gap-x-1.5 border border-[rgba(255,255,255,0.5)] rounded-md px-3 py-2 text-sm font-semibold text-white hover:border-[rgba(255,255,255,0.9)]">
|
||||||
|
<GlobeAltIcon className="w-5 h-5 text-white"/>{locale == 'default' ? 'EN' : locale.toUpperCase()}
|
||||||
|
<ChevronDownIcon className="-mr-1 h-5 w-5 text-white" aria-hidden="true"/>
|
||||||
|
</Menu.Button>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
className="absolute right-0 z-30 mt-2 w-26 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
|
<div className="py-1 z-30">
|
||||||
|
{
|
||||||
|
languages.map((item) => {
|
||||||
|
let hrefValue = `/${item.lang}`;
|
||||||
|
if (page) {
|
||||||
|
hrefValue = `/${item.lang}/${page}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Menu.Item key={item.lang}>
|
||||||
|
<Link href={hrefValue} onClick={() => checkLocalAndLoading(item.lang)} className={"z-30"}>
|
||||||
|
<span
|
||||||
|
className={'text-gray-700 block px-4 py-2 text-sm hover:text-[#2d6ae0] z-30'}
|
||||||
|
>
|
||||||
|
{item.language}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
{
|
||||||
|
process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0' ?
|
||||||
|
<div className="hidden lg:ml-2 lg:relative lg:inline-block lg:text-left lg:text-white">
|
||||||
|
<LoginButton buttonType={userData.email ? 1 : 0} loginText={authText.loginText}/>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
<Dialog as="div" className="lg:hidden" open={mobileMenuOpen} onClose={setMobileMenuOpen}>
|
||||||
|
<div className="fixed inset-0 z-30"/>
|
||||||
|
<Dialog.Panel
|
||||||
|
className="fixed inset-y-0 right-0 z-30 w-full overflow-y-auto background-div px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex">
|
||||||
|
<Link href={getLinkHref(locale, '')} className="-m-1.5 ml-0.5 p-1.5"
|
||||||
|
onClick={() => checkLocalAndLoading(locale)}>
|
||||||
|
<img
|
||||||
|
className="h-8 w-auto"
|
||||||
|
src="/website.svg"
|
||||||
|
width={32}
|
||||||
|
height={24}
|
||||||
|
alt={process.env.NEXT_PUBLIC_DOMAIN_NAME}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="-m-2.5 rounded-md p-2.5 text-white z-20"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Close menu</span>
|
||||||
|
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flow-root">
|
||||||
|
<div className="divide-y divide-gray-500/10">
|
||||||
|
<div className="space-y-2 py-6">
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, '')}
|
||||||
|
onClick={() => checkPageAndLoading('')}
|
||||||
|
className="block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-white">
|
||||||
|
{menuText.header0}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'stickers')}
|
||||||
|
onClick={() => checkPageAndLoading('stickers')}
|
||||||
|
className="block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-white">
|
||||||
|
{menuText.header2}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'search')}
|
||||||
|
onClick={() => checkPageAndLoading('search')}
|
||||||
|
className="block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-white">
|
||||||
|
{menuText.header3}
|
||||||
|
</Link>
|
||||||
|
{
|
||||||
|
userData.email ?
|
||||||
|
<Link
|
||||||
|
href={getLinkHref(locale, 'my')}
|
||||||
|
onClick={() => checkPageAndLoading('my')}
|
||||||
|
className="block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-white">
|
||||||
|
{menuText.header1}
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 py-4">
|
||||||
|
<Menu as="div" className="relative inline-block text-left z-20">
|
||||||
|
<div>
|
||||||
|
<Menu.Button
|
||||||
|
className="inline-flex w-full justify-center gap-x-1.5 border border-[rgba(255,255,255,0.5)] rounded-md px-3 py-2 text-sm font-semibold text-white hover:border-[rgba(255,255,255,0.9)]">
|
||||||
|
<GlobeAltIcon className="w-5 h-5 text-white"/>{locale == 'default' ? 'EN' : locale.toUpperCase()}
|
||||||
|
<ChevronDownIcon className="-mr-1 h-5 w-5 text-white" aria-hidden="true"/>
|
||||||
|
</Menu.Button>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
className="absolute right-0 z-10 mt-2 w-26 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
|
<div className="py-1">
|
||||||
|
{
|
||||||
|
languages.map((item) => {
|
||||||
|
let hrefValue = `/${item.lang}`;
|
||||||
|
if (page) {
|
||||||
|
hrefValue = `/${item.lang}/${page}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Menu.Item key={item.lang}>
|
||||||
|
<Link href={hrefValue} onClick={() => checkLocalAndLoading(item.lang)}>
|
||||||
|
<span
|
||||||
|
className={'text-gray-700 block px-4 py-2 text-sm hover:text-[#2d6ae0]'}
|
||||||
|
>
|
||||||
|
{item.language}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
process.env.NEXT_PUBLIC_CHECK_GOOGLE_LOGIN != '0' ?
|
||||||
|
<div
|
||||||
|
className="relative inline-block text-left text-base font-semibold text-white ml-2">
|
||||||
|
<LoginButton buttonType={userData.email ? 1 : 0} loginText={authText.loginText}/>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
32
src/components/LoadingDots/LoadingDots.module.css
Normal file
32
src/components/LoadingDots/LoadingDots.module.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.root {
|
||||||
|
@apply inline-flex text-center items-center leading-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root span {
|
||||||
|
@apply bg-zinc-200 rounded-full h-2 w-2;
|
||||||
|
animation-name: blink;
|
||||||
|
animation-duration: 1.4s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root span:nth-of-type(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root span:nth-of-type(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
13
src/components/LoadingDots/LoadingDots.tsx
Normal file
13
src/components/LoadingDots/LoadingDots.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import s from './LoadingDots.module.css';
|
||||||
|
|
||||||
|
const LoadingDots = () => {
|
||||||
|
return (
|
||||||
|
<span className={s.root}>
|
||||||
|
<span/>
|
||||||
|
<span/>
|
||||||
|
<span/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingDots;
|
1
src/components/LoadingDots/index.ts
Normal file
1
src/components/LoadingDots/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './LoadingDots';
|
61
src/components/LoadingModal.tsx
Normal file
61
src/components/LoadingModal.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import {Fragment, useRef} from 'react'
|
||||||
|
import {Dialog, Transition} from '@headlessui/react'
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
|
||||||
|
export default function LoadingModal({
|
||||||
|
loadingText,
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const cancelButtonRef = useRef(null);
|
||||||
|
const {showLoadingModal, setShowLoadingModal} = useCommonContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={showLoadingModal} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-40" initialFocus={cancelButtonRef} onClose={setShowLoadingModal} onClick={() => setShowLoadingModal(true)}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-4"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center px-4 py-2 font-semibold leading-6 text-xl transition ease-in-out duration-150"
|
||||||
|
style={{color: '#f05011'}}>
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-10 w-10" style={{color: '#f05011'}}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||||
|
strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{loadingText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
)
|
||||||
|
}
|
94
src/components/LoginButton.tsx
Normal file
94
src/components/LoginButton.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
import React, {useState} from 'react'
|
||||||
|
import {useRouter} from 'next/navigation'
|
||||||
|
import {whiteLoadingSvg} from './svg';
|
||||||
|
import {useCommonContext} from '~/context/common-context';
|
||||||
|
import {useSession} from "next-auth/react";
|
||||||
|
|
||||||
|
const LoginButton = ({
|
||||||
|
buttonType = 0,
|
||||||
|
loginText = 'Log in'
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const {data: session, status} = useSession();
|
||||||
|
|
||||||
|
const {
|
||||||
|
userData,
|
||||||
|
setUserData,
|
||||||
|
setShowLoginModal,
|
||||||
|
setShowLogoutModal
|
||||||
|
} = useCommonContext()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function login(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true)
|
||||||
|
let _userData;
|
||||||
|
if (userData == null || Object.keys(userData).length == 0) {
|
||||||
|
if (status == 'authenticated') {
|
||||||
|
setUserData(session?.user)
|
||||||
|
_userData = session?.user
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_userData = userData
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_userData != null && Object.keys(_userData).length != 0) {
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setShowLoginModal(true)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
setShowLogoutModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
buttonType == 0 && (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
loading ? (
|
||||||
|
<button
|
||||||
|
className="inline-flex w-full justify-center gap-x-1.5 border border-[rgba(255,255,255,0.5)] rounded-md px-3 py-2 text-sm font-semibold hover:border-[rgba(255,255,255,0.9)]"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<p>Login</p>
|
||||||
|
{whiteLoadingSvg}
|
||||||
|
</button>
|
||||||
|
) :
|
||||||
|
(
|
||||||
|
<button
|
||||||
|
className="inline-flex w-full justify-center gap-x-1.5 border border-[rgba(255,255,255,0.5)] rounded-md px-3 py-2 text-sm font-semibold hover:border-[rgba(255,255,255,0.9)]"
|
||||||
|
onClick={login}
|
||||||
|
>
|
||||||
|
{loginText}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
buttonType == 1 && (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
<button
|
||||||
|
className="my-auto mx-auto mr-4 mt-1 inline-flex w-full justify-center gap-x-1.5 rounded-md text-sm font-semibold"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
<img className="h-8 w-auto rounded-full" src={userData.image} alt=""/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginButton
|
113
src/components/LoginModal.tsx
Normal file
113
src/components/LoginModal.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
import React from 'react'
|
||||||
|
import {Fragment, useState} from 'react'
|
||||||
|
import {Dialog, Transition} from '@headlessui/react'
|
||||||
|
import {FcGoogle} from 'react-icons/fc'
|
||||||
|
import {blackLoadingSvg} from './svg'
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {signInUseAuth} from "~/libs/nextAuthClient";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
loginGoogleBtn: 'inline-flex w-full justify-center items-center space-x-3 rounded-md px-3 py-2 text-sm font-semibold shadow-sm hover:border-indigo-400 border-2 border-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginModal = ({
|
||||||
|
loadingText,
|
||||||
|
redirectPath,
|
||||||
|
loginModalDesc,
|
||||||
|
loginModalButtonText,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const [loadGoogle, setLoadGoogle] = useState(false)
|
||||||
|
const {showLoginModal, setShowLoginModal} = useCommonContext();
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={showLoginModal} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={setShowLoginModal} onClick={() => setShowLoginModal(true)}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel
|
||||||
|
className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
|
<Dialog.Title as="h3"
|
||||||
|
className="gradient-text text-3xl font-bold flex justify-center items-center">
|
||||||
|
<a className="-m-1.5 ml-0.5 p-1.5">
|
||||||
|
<img
|
||||||
|
className="h-8 w-auto"
|
||||||
|
src="/website.svg"
|
||||||
|
width={32}
|
||||||
|
height={24}
|
||||||
|
alt={process.env.NEXT_PUBLIC_DOMAIN_NAME}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Dialog.Title>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{loginModalDesc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 sm:mt-6 space-y-3">
|
||||||
|
{
|
||||||
|
loadGoogle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={style.loginGoogleBtn}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{blackLoadingSvg}
|
||||||
|
<p>{loadingText}</p>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"inline-flex w-full justify-center items-center space-x-3 rounded-md px-3 py-2 text-sm font-semibold shadow-sm hover:border-indigo-400 border-2 border-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"}
|
||||||
|
onClick={async () => {
|
||||||
|
await signInUseAuth({
|
||||||
|
redirectPath: redirectPath
|
||||||
|
})
|
||||||
|
setLoadGoogle(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FcGoogle className='text-xl'/>
|
||||||
|
<p>{loginModalButtonText}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginModal
|
80
src/components/LogoutModal.tsx
Normal file
80
src/components/LogoutModal.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import {Fragment, useRef} from 'react'
|
||||||
|
import {Dialog, Transition} from '@headlessui/react'
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import {signOut} from "next-auth/react";
|
||||||
|
|
||||||
|
export default function LogoutModal({
|
||||||
|
logoutModalDesc,
|
||||||
|
confirmButtonText,
|
||||||
|
cancelButtonText,
|
||||||
|
redirectPath
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const cancelButtonRef = useRef(null);
|
||||||
|
const {showLogoutModal, setShowLogoutModal} = useCommonContext();
|
||||||
|
|
||||||
|
const confirmButton = () => {
|
||||||
|
sessionStorage.removeItem("user_id");
|
||||||
|
signOut({callbackUrl: redirectPath}).then(r => console.log(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={showLogoutModal} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-40" initialFocus={cancelButtonRef} onClose={setShowLogoutModal} onClick={() => setShowLogoutModal(true)}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel
|
||||||
|
className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||||
|
<div className="sm:flex sm:items-start">
|
||||||
|
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||||
|
<Dialog.Title as="h3" className="text-base font-semibold leading-6 text-gray-900">
|
||||||
|
{logoutModalDesc}
|
||||||
|
</Dialog.Title>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"
|
||||||
|
onClick={() => confirmButton()}
|
||||||
|
>
|
||||||
|
{confirmButtonText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
|
||||||
|
onClick={() => setShowLogoutModal(false)}
|
||||||
|
ref={cancelButtonRef}
|
||||||
|
>
|
||||||
|
{cancelButtonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
)
|
||||||
|
}
|
12
src/components/OneTapComponent.tsx
Normal file
12
src/components/OneTapComponent.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import useOneTapSignin from "~/libs/useOneTapSignin";
|
||||||
|
|
||||||
|
const OneTapComponent = () => {
|
||||||
|
const { showLoadingModal: oneTapIsLoading } = useOneTapSignin({
|
||||||
|
redirect: false,
|
||||||
|
parentContainerId: "oneTap",
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div id="oneTap" className="fixed top-0 right-0 " />; // This is done with tailwind. Update with system of choice
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OneTapComponent;
|
349
src/components/PricingComponent.tsx
Normal file
349
src/components/PricingComponent.tsx
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
import {getStripe} from '~/libs/stripeClient';
|
||||||
|
import React, {useState} from 'react';
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
import LoadingDots from "./LoadingDots";
|
||||||
|
import {priceList} from "~/configs/stripeConfig";
|
||||||
|
|
||||||
|
export default function Pricing({
|
||||||
|
redirectUrl,
|
||||||
|
isPricing = false
|
||||||
|
}) {
|
||||||
|
const [priceIdLoading, setPriceIdLoading] = useState<string>();
|
||||||
|
const {
|
||||||
|
setShowLoginModal,
|
||||||
|
userData,
|
||||||
|
pricingText
|
||||||
|
} = useCommonContext();
|
||||||
|
|
||||||
|
const handleCheckout = async (price) => {
|
||||||
|
setPriceIdLoading(price.id);
|
||||||
|
if (!userData || !userData.user_id) {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const user_id = userData.user_id;
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
price,
|
||||||
|
redirectUrl,
|
||||||
|
user_id
|
||||||
|
}
|
||||||
|
const url = `/api/stripe/create-checkout-session`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: new Headers({'Content-Type': 'application/json'}),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
const res = await response.json();
|
||||||
|
const sessionId = res.sessionId;
|
||||||
|
const stripe = await getStripe();
|
||||||
|
stripe?.redirectToCheckout({sessionId});
|
||||||
|
} catch (error) {
|
||||||
|
return alert((error as Error)?.message);
|
||||||
|
} finally {
|
||||||
|
setPriceIdLoading(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!priceList?.length)
|
||||||
|
return (
|
||||||
|
<div className={(isPricing ? "" : "background-div") + " flex flex-col items-center"}>
|
||||||
|
<div className="max-w-screen-lg grid place-items-center bg-slate-100 text-slate-600 p-4 gap-4">
|
||||||
|
<div id="introduction" className="bg-white shadow-md rounded border-slate-200 p-5">
|
||||||
|
<div className="text-slate-800 space-y-2 mb-0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={(isPricing ? "" : "background-div") + " bg-cover bg-center bg-no-repeat text-white"}>
|
||||||
|
<div className="mx-auto w-full max-w-7xl px-5 py-4 md:px-6 md:py-8">
|
||||||
|
<div className="flex flex-col items-center justify-start">
|
||||||
|
<div className="mx-auto mb-12 flex max-w-3xl flex-col items-center">
|
||||||
|
<h2 className="text-5xl font-bold">{pricingText.h1Text}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid w-full grid-cols-1 gap-16 md:grid-cols-3 md:gap-4 lg:gap-8">
|
||||||
|
<div className="mx-auto flex w-full max-w-[416px] flex-col">
|
||||||
|
<div className="flex w-full flex-col items-start rounded-xl bg-[#2b306b] p-10">
|
||||||
|
<div className="mb-4 rounded-lg bg-[#0a2836] px-4 py-1.5">
|
||||||
|
<p
|
||||||
|
className="text-sm font-bold text-white">{pricingText.free}</p>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-8 mb-6 text-3xl font-bold md:mb-8 md:text-5xl lg:mb-12">{pricingText.free0}</h2>
|
||||||
|
<div
|
||||||
|
className="w-full bg-[#2b306b] px-6 py-4 text-center font-semibold text-[#2b306b] cursor-pointer inline-flex justify-center items-center text-sm leading-6 shadow-sm"
|
||||||
|
>
|
||||||
|
{pricingText.buyText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 flex flex-col items-start gap-5">
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.freeIntro0}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-red-600">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="32" height="32" viewBox="0 0 32 32"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.freeIntro1}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-red-600">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="32" height="32" viewBox="0 0 32 32"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12"
|
||||||
|
fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.freeIntro2}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-red-600">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="32" height="32" viewBox="0 0 32 32"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12"
|
||||||
|
fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro4}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
priceList?.map((price, index) => {
|
||||||
|
if (!price) return null;
|
||||||
|
const priceString = new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: price.currency!,
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
}).format((price?.unit_amount || 0) / 100);
|
||||||
|
|
||||||
|
|
||||||
|
const perDownloadPrice = (price?.unit_amount || 0) / 100 / 12;
|
||||||
|
const pricePerString = new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: price.currency!,
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
}).format(perDownloadPrice);
|
||||||
|
|
||||||
|
if (index != 0) {
|
||||||
|
return (
|
||||||
|
<div key={price.id} className="mx-auto flex w-full max-w-[416px] flex-col">
|
||||||
|
<div className="flex w-full flex-col items-start rounded-xl bg-[#2b306b] p-10">
|
||||||
|
<div className="mb-4 rounded-lg bg-[#0a1836] px-4 py-1.5">
|
||||||
|
<p
|
||||||
|
className="text-sm font-bold text-white">{pricingText.basic}</p>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="mb-5 text-2xl font-bold md:mb-6 md:text-4xl lg:mb-8">{priceString}/{pricingText.monthText}</h2>
|
||||||
|
<h2
|
||||||
|
className="mb-2 text-lg font-bold md:mb-3 md:text-xl lg:mb-4">{priceString} {pricingText.monthlyText}</h2>
|
||||||
|
<a
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-full bg-[#f05011] px-6 py-4 text-center font-bold text-white cursor-pointer inline-flex justify-center items-center text-sm leading-6 shadow-sm"
|
||||||
|
onClick={() => handleCheckout(price)}
|
||||||
|
>
|
||||||
|
{pricingText.buyText}
|
||||||
|
{priceIdLoading && priceIdLoading == price.id && (
|
||||||
|
<i className="flex pl-2 m-0">
|
||||||
|
<LoadingDots/>
|
||||||
|
</i>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 flex flex-col items-start gap-5">
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro0}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro1}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro2}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro4}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div key={pricePerString} className="mx-auto flex w-full max-w-[416px] flex-col">
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-start rounded-xl bg-[#2d6ae0] bg-cover bg-center bg-no-repeat p-10 text-white"
|
||||||
|
style={{backgroundImage: 'https://assets.website-files.com/6502af467b2a8c4ee8159a5b/6502af467b2a8c4ee8159a8b_Mask%20group.svg'}}>
|
||||||
|
<div className="mb-4 flex flex-row flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-[#ffa11b] px-4 py-1.5 text-white">
|
||||||
|
<div className="flex">
|
||||||
|
<svg width="17" height="16" viewBox="0 0 17 16" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M15.2528 4.59353C15.1098 4.47414 14.9361 4.39736 14.7515 4.37194C14.567 4.34652 14.379 4.37349 14.2091 4.44977L11.0466 5.85602L9.20905 2.54353C9.12123 2.38887 8.99398 2.26026 8.84028 2.17079C8.68657 2.08132 8.5119 2.03418 8.33405 2.03418C8.1562 2.03418 7.98153 2.08132 7.82783 2.17079C7.67412 2.26026 7.54688 2.38887 7.45905 2.54353L5.62155 5.85602L2.45905 4.44977C2.28874 4.37361 2.10052 4.3466 1.91567 4.37181C1.73081 4.39701 1.5567 4.47343 1.413 4.59242C1.2693 4.71141 1.16176 4.86822 1.10252 5.04513C1.04329 5.22205 1.03473 5.412 1.0778 5.59353L2.6653 12.3623C2.69566 12.4933 2.7523 12.6168 2.8318 12.7253C2.9113 12.8338 3.012 12.9251 3.1278 12.9935C3.28458 13.0874 3.46383 13.137 3.64655 13.1373C3.73537 13.1371 3.82373 13.1245 3.90905 13.0998C6.80269 12.2998 9.85916 12.2998 12.7528 13.0998C13.017 13.1692 13.298 13.131 13.5341 12.9935C13.6506 12.926 13.7518 12.835 13.8314 12.7263C13.911 12.6177 13.9672 12.4937 13.9966 12.3623L15.5903 5.59353C15.6329 5.41195 15.6239 5.22208 15.5642 5.04537C15.5046 4.86866 15.3967 4.71215 15.2528 4.59353V4.59353Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-white">{pricingText.popularText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="mb-2 text-2xl font-bold md:mb-3 md:text-4xl lg:mb-6">{pricePerString}/{pricingText.monthText}</h2>
|
||||||
|
<h2
|
||||||
|
className="mb-2 text-lg font-bold md:mb-3 md:text-xl lg:mb-6">{priceString} {pricingText.annuallyText} {pricingText.annuallySaveText}</h2>
|
||||||
|
<a
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-full bg-[#f05011] px-6 py-4 text-center font-bold text-white cursor-pointer inline-flex justify-center items-center text-sm leading-6 shadow-sm"
|
||||||
|
onClick={() => handleCheckout(price)}
|
||||||
|
>
|
||||||
|
{pricingText.buyText}
|
||||||
|
{priceIdLoading && priceIdLoading == price.id && (
|
||||||
|
<i className="flex pl-2 m-0">
|
||||||
|
<LoadingDots/>
|
||||||
|
</i>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 flex flex-col items-start gap-5">
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro0}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro1}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro2}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-start text-white">
|
||||||
|
<div className="mr-2 flex text-[#2d6ae0]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7c8aaa]">
|
||||||
|
<span className="font-bold text-white">{pricingText.subscriptionIntro4}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/*<div className="flex flex-row items-start text-white">*/}
|
||||||
|
{/* <div className="mr-2 flex text-[#2d6ae0]">*/}
|
||||||
|
{/* <svg width="32" height="32" viewBox="0 0 32 32" fill="none"*/}
|
||||||
|
{/* xmlns="http://www.w3.org/2000/svg">*/}
|
||||||
|
{/* <path*/}
|
||||||
|
{/* d="M8.82291 15.198C8.47774 15.199 8.1399 15.3027 7.84846 15.4969C7.55703 15.6911 7.32392 15.968 7.1761 16.2955C7.02829 16.623 6.9718 16.9878 7.01319 17.3476C7.05457 17.7074 7.19213 18.0476 7.40995 18.3287L12.0534 24.3014C12.219 24.5172 12.4312 24.6885 12.6725 24.8009C12.9137 24.9134 13.177 24.9638 13.4406 24.9479C14.0042 24.9161 14.513 24.5995 14.8375 24.079L24.4831 7.76799C24.4847 7.76528 24.4863 7.76257 24.488 7.75991C24.5785 7.614 24.5492 7.32485 24.3624 7.1432C24.3111 7.09331 24.2506 7.05499 24.1846 7.03058C24.1186 7.00618 24.0486 6.99621 23.9789 7.00129C23.9091 7.00637 23.8411 7.02639 23.7789 7.06013C23.7168 7.09386 23.662 7.14059 23.6177 7.19743C23.6142 7.2019 23.6107 7.2063 23.607 7.21064L13.8792 18.7511C13.8422 18.795 13.7973 18.8308 13.747 18.8563C13.6967 18.8818 13.6421 18.8966 13.5863 18.8998C13.5305 18.9029 13.4747 18.8944 13.4221 18.8747C13.3695 18.8551 13.3211 18.8246 13.2798 18.7852L10.0513 15.7003C9.71603 15.3776 9.27778 15.1984 8.82291 15.198Z"*/}
|
||||||
|
{/* fill="currentColor"></path>*/}
|
||||||
|
{/* </svg>*/}
|
||||||
|
{/* </div>*/}
|
||||||
|
{/* <p className="text-[#7c8aaa]">*/}
|
||||||
|
{/* <span className="font-bold text-white">{pricingText.subscriptionIntro3}</span>*/}
|
||||||
|
{/* </p>*/}
|
||||||
|
{/*</div>*/}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
53
src/components/PricingModal.tsx
Normal file
53
src/components/PricingModal.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import {Fragment, useRef, useState} from 'react'
|
||||||
|
import {Dialog, Transition} from '@headlessui/react'
|
||||||
|
import Pricing from "~/components/PricingComponent";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
|
||||||
|
export default function PricingModal({
|
||||||
|
locale,
|
||||||
|
page
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const [redirectUrl] = useState(`${locale}/${page}`);
|
||||||
|
|
||||||
|
const cancelButtonRef = useRef(null)
|
||||||
|
const {showPricingModal, setShowPricingModal} = useCommonContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={showPricingModal} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-40" initialFocus={cancelButtonRef} onClose={setShowPricingModal} onClick={() => setShowPricingModal(true)}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-4"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl">
|
||||||
|
<Pricing
|
||||||
|
redirectUrl={redirectUrl}
|
||||||
|
/>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
)
|
||||||
|
}
|
16
src/components/TopBlurred.tsx
Normal file
16
src/components/TopBlurred.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const TopBlurred = () => {
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-auto left-0 right-0 top-0 -z-10">
|
||||||
|
<img
|
||||||
|
src="/top_blurred.png"
|
||||||
|
alt={process.env.NEXT_PUBLIC_WEBSITE_NAME}
|
||||||
|
className="relative bottom-auto mx-auto top-0 -z-10"
|
||||||
|
style={{width: '100%'}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopBlurred
|
32
src/components/svg.tsx
Normal file
32
src/components/svg.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export const whiteLoadingSvg = (
|
||||||
|
<svg aria-hidden="true" role="status" className="inline w-4 h-4 mr-3 text-white animate-spin" viewBox="0 0 100 101"
|
||||||
|
fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||||
|
fill="#E5E7EB"/>
|
||||||
|
<path
|
||||||
|
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||||
|
fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const blackLoadingSvg = (
|
||||||
|
<svg aria-hidden="true" role="status" className="inline w-4 h-4 mr-3 text-gray-200 animate-spin dark:text-gray-600"
|
||||||
|
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||||
|
fill="currentColor"/>
|
||||||
|
<path
|
||||||
|
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||||
|
fill="#1C64F2"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const pinterestSvg = (
|
||||||
|
<span className="[&>svg]:h-7 [&>svg]:w-7 [&>svg]:fill-[#e60023]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
|
||||||
|
<path
|
||||||
|
d="M496 256c0 137-111 248-248 248-25.6 0-50.2-3.9-73.4-11.1 10.1-16.5 25.2-43.5 30.8-65 3-11.6 15.4-59 15.4-59 8.1 15.4 31.7 28.5 56.8 28.5 74.8 0 128.7-68.8 128.7-154.3 0-81.9-66.9-143.2-152.9-143.2-107 0-163.9 71.8-163.9 150.1 0 36.4 19.4 81.7 50.3 96.1 4.7 2.2 7.2 1.2 8.3-3.3 .8-3.4 5-20.3 6.9-28.1 .6-2.5 .3-4.7-1.7-7.1-10.1-12.5-18.3-35.3-18.3-56.6 0-54.7 41.4-107.6 112-107.6 60.9 0 103.6 41.5 103.6 100.9 0 67.1-33.9 113.6-78 113.6-24.3 0-42.6-20.1-36.7-44.8 7-29.5 20.5-61.3 20.5-82.6 0-19-10.2-34.9-31.4-34.9-24.9 0-44.9 25.7-44.9 60.2 0 22 7.4 36.8 7.4 36.8s-24.5 103.8-29 123.2c-5 21.4-3 51.6-.9 71.2C65.4 450.9 0 361.1 0 256 0 119 111 8 248 8s248 111 248 248z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)
|
34
src/config.ts
Normal file
34
src/config.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import {Pathnames} from 'next-intl/navigation';
|
||||||
|
|
||||||
|
export const locales = ['en', 'zh'] as const;
|
||||||
|
|
||||||
|
export const languages = [
|
||||||
|
{
|
||||||
|
code: "en-US",
|
||||||
|
lang: "en",
|
||||||
|
language: "English",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "zh-CN",
|
||||||
|
lang: "zh",
|
||||||
|
language: "简体中文",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const pathnames = {
|
||||||
|
'/': '/',
|
||||||
|
} satisfies Pathnames<typeof locales>;
|
||||||
|
|
||||||
|
// Use the default: `always`,设置为 as-needed可不显示默认路由
|
||||||
|
export const localePrefix = 'as-needed';
|
||||||
|
|
||||||
|
export type AppPathnames = keyof typeof pathnames;
|
||||||
|
|
||||||
|
|
||||||
|
export const getLanguageByLang = (lang) => {
|
||||||
|
for (let i = 0; i < languages.length; i++) {
|
||||||
|
if (lang == languages[i].lang) {
|
||||||
|
return languages[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
49
src/configs/buildLink.ts
Normal file
49
src/configs/buildLink.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import {proc} from "vfile/do-not-use-conditional-minproc";
|
||||||
|
|
||||||
|
export const getLinkHref = (locale = 'en', page = '') => {
|
||||||
|
if (page == '') {
|
||||||
|
if (locale == 'en') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return `/${locale}/`;
|
||||||
|
}
|
||||||
|
if (locale == 'en') {
|
||||||
|
return `/${page}`;
|
||||||
|
}
|
||||||
|
return `/${locale}/${page}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getCompressionImageLink = (url) => {
|
||||||
|
const beginUrl = process.env.NEXT_PUBLIC_STORAGE_URL + '/cdn-cgi/image/width=512,quality=85/';
|
||||||
|
return beginUrl + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getArrayUrlResult = (origin) => {
|
||||||
|
if (origin) {
|
||||||
|
const jsonResult = JSON.parse(origin);
|
||||||
|
if (jsonResult.length > 0) {
|
||||||
|
return jsonResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTotalLinkHref = (locale = 'en', page = '') => {
|
||||||
|
if (page == '') {
|
||||||
|
if (locale == 'en') {
|
||||||
|
return process.env.NEXT_PUBLIC_SITE_URL + '/';
|
||||||
|
}
|
||||||
|
return process.env.NEXT_PUBLIC_SITE_URL + `/${locale}/`;
|
||||||
|
}
|
||||||
|
if (locale == 'en') {
|
||||||
|
return process.env.NEXT_PUBLIC_SITE_URL + `/${page}`;
|
||||||
|
}
|
||||||
|
return process.env.NEXT_PUBLIC_SITE_URL + `/${locale}/${page}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getShareToPinterest = (locale = 'en', page = '', sticker:string) => {
|
||||||
|
const pinterestUrl = 'https://pinterest.com/pin/create/button/';
|
||||||
|
return pinterestUrl + `?description=${encodeURIComponent(sticker)}` + `&url=` + encodeURIComponent(getTotalLinkHref(locale, page));
|
||||||
|
}
|
10
src/configs/buildStr.ts
Normal file
10
src/configs/buildStr.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const getResultStrAddSticker = (input_text, keyword) => {
|
||||||
|
// 检查字符串是否以 keyword 结尾
|
||||||
|
if (input_text.endsWith(keyword)) {
|
||||||
|
// 如果是,直接返回原始字符串
|
||||||
|
return input_text;
|
||||||
|
} else {
|
||||||
|
// 如果不是,添加 keyword 后缀
|
||||||
|
return input_text + ' ' + keyword;
|
||||||
|
}
|
||||||
|
}
|
260
src/configs/languageText.ts
Normal file
260
src/configs/languageText.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import {getTranslations} from "next-intl/server";
|
||||||
|
|
||||||
|
export const getIndexPageText = async () => {
|
||||||
|
const tIndex = await getTranslations('IndexPageText');
|
||||||
|
return {
|
||||||
|
title: tIndex('title'),
|
||||||
|
description: tIndex('description'),
|
||||||
|
h1Text: tIndex('h1Text'),
|
||||||
|
descriptionBelowH1Text: tIndex('descriptionBelowH1Text'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCommonText = async () => {
|
||||||
|
const tCommon = await getTranslations('CommonText');
|
||||||
|
return {
|
||||||
|
loadingText: tCommon('loadingText'),
|
||||||
|
generateText: tCommon('generateText'),
|
||||||
|
placeholderText: tCommon('placeholderText'),
|
||||||
|
buttonText: tCommon('buttonText'),
|
||||||
|
footerDescText: tCommon('footerDescText'),
|
||||||
|
timesLeft: tCommon('timesLeft'),
|
||||||
|
timesRight: tCommon('timesRight'),
|
||||||
|
download: tCommon('download'),
|
||||||
|
result: tCommon('result'),
|
||||||
|
moreWorks: tCommon('moreWorks'),
|
||||||
|
generateNew: tCommon('generateNew'),
|
||||||
|
displayPublic: tCommon('displayPublic'),
|
||||||
|
similarText: tCommon('similarText'),
|
||||||
|
prompt: tCommon('prompt'),
|
||||||
|
revised: tCommon('revised'),
|
||||||
|
exploreMore: tCommon('exploreMore'),
|
||||||
|
keyword: tCommon('keyword'),
|
||||||
|
searchButtonText: tCommon('searchButtonText'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthText = async () => {
|
||||||
|
const tAuth = await getTranslations('AuthText');
|
||||||
|
return {
|
||||||
|
loginText: tAuth('loginText'),
|
||||||
|
loginModalDesc: tAuth('loginModalDesc'),
|
||||||
|
loginModalButtonText: tAuth('loginModalButtonText'),
|
||||||
|
logoutModalDesc: tAuth('logoutModalDesc'),
|
||||||
|
confirmButtonText: tAuth('confirmButtonText'),
|
||||||
|
cancelButtonText: tAuth('cancelButtonText'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getPricingText = async () => {
|
||||||
|
const tPricing = await getTranslations('PricingText');
|
||||||
|
const title = tPricing('title') + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME;
|
||||||
|
const description = tPricing('description');
|
||||||
|
const h1Text = tPricing('h1Text');
|
||||||
|
const basic = tPricing('basic');
|
||||||
|
const essential = tPricing('essential');
|
||||||
|
const growth = tPricing('growth');
|
||||||
|
const buyText= tPricing('buyText');
|
||||||
|
const popularText = tPricing('popularText');
|
||||||
|
const creditsText = tPricing('creditsText');
|
||||||
|
const creditText = tPricing('creditText');
|
||||||
|
const free = tPricing('free');
|
||||||
|
const free0 = tPricing('free0');
|
||||||
|
const freeText = tPricing('freeText');
|
||||||
|
let freeIntro0 = tPricing('freeIntro0');
|
||||||
|
const freeIntro1 = tPricing('freeIntro1');
|
||||||
|
const freeIntro2 = tPricing('freeIntro2');
|
||||||
|
const subscriptionIntro0 = tPricing('subscriptionIntro0');
|
||||||
|
const subscriptionIntro1 = tPricing('subscriptionIntro1');
|
||||||
|
const subscriptionIntro2 = tPricing('subscriptionIntro2');
|
||||||
|
const subscriptionIntro3 = tPricing('subscriptionIntro3');
|
||||||
|
const subscriptionIntro4 = tPricing('subscriptionIntro4');
|
||||||
|
const monthText = tPricing('monthText');
|
||||||
|
const monthlyText = tPricing('monthlyText');
|
||||||
|
const annualText = tPricing('annualText');
|
||||||
|
const annuallyText = tPricing('annuallyText');
|
||||||
|
const annuallySaveText = tPricing('annuallySaveText');
|
||||||
|
|
||||||
|
// 免费生成次数
|
||||||
|
const freeTimes = process.env.FREE_TIMES;
|
||||||
|
freeIntro0 = freeIntro0.replace(/%freeTimes%/g, freeTimes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
h1Text: h1Text,
|
||||||
|
basic: basic,
|
||||||
|
essential: essential,
|
||||||
|
growth: growth,
|
||||||
|
buyText: buyText,
|
||||||
|
popularText: popularText,
|
||||||
|
creditsText: creditsText,
|
||||||
|
creditText: creditText,
|
||||||
|
free: free,
|
||||||
|
free0: free0,
|
||||||
|
freeText: freeText,
|
||||||
|
freeIntro0: freeIntro0,
|
||||||
|
freeIntro1: freeIntro1,
|
||||||
|
freeIntro2: freeIntro2,
|
||||||
|
subscriptionIntro0: subscriptionIntro0,
|
||||||
|
subscriptionIntro1: subscriptionIntro1,
|
||||||
|
subscriptionIntro2: subscriptionIntro2,
|
||||||
|
subscriptionIntro3: subscriptionIntro3,
|
||||||
|
subscriptionIntro4: subscriptionIntro4,
|
||||||
|
monthText: monthText,
|
||||||
|
monthlyText: monthlyText,
|
||||||
|
annualText: annualText,
|
||||||
|
annuallyText: annuallyText,
|
||||||
|
annuallySaveText: annuallySaveText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPrivacyPolicyText = async () => {
|
||||||
|
const tPrivacyPolicy = await getTranslations('PrivacyPolicyText');
|
||||||
|
return {
|
||||||
|
title: tPrivacyPolicy('title') + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME,
|
||||||
|
description: tPrivacyPolicy('description'),
|
||||||
|
h1Text: tPrivacyPolicy('h1Text'),
|
||||||
|
detailText: tPrivacyPolicy('detailText'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getTermsOfServiceText = async () => {
|
||||||
|
const tTermsOfService = await getTranslations('TermsOfServiceText');
|
||||||
|
return {
|
||||||
|
title: tTermsOfService('title') + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME,
|
||||||
|
description: tTermsOfService('description'),
|
||||||
|
h1Text: tTermsOfService('h1Text'),
|
||||||
|
detailText: tTermsOfService('detailText'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWorksText = async () => {
|
||||||
|
const tWorks = await getTranslations('WorksText');
|
||||||
|
return {
|
||||||
|
title: tWorks('title') + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME,
|
||||||
|
description: tWorks('description'),
|
||||||
|
h1Text: tWorks('h1Text'),
|
||||||
|
descriptionBelowH1Text: tWorks('descriptionBelowH1Text'),
|
||||||
|
descText: tWorks('descText'),
|
||||||
|
toContinue: tWorks('toContinue'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getExploreText = async (countSticker: string, page) => {
|
||||||
|
const tExplore = await getTranslations('ExploreText');
|
||||||
|
let title = tExplore('title');
|
||||||
|
let description = tExplore('description');
|
||||||
|
let h1Text = tExplore('h1Text');
|
||||||
|
let descriptionBelowH1Text = tExplore('descriptionBelowH1Text');
|
||||||
|
let pageText = tExplore('pageText');
|
||||||
|
let h2Text = tExplore('h2Text');
|
||||||
|
|
||||||
|
title = title.replace(/%countSticker%/g, countSticker);
|
||||||
|
description = description.replace(/%countSticker%/g, countSticker);
|
||||||
|
pageText = pageText.replace(/%pageNumber%/g, page);
|
||||||
|
|
||||||
|
if (page != '1') {
|
||||||
|
title = title + ", " + pageText + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME;
|
||||||
|
} else {
|
||||||
|
title = title + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME;
|
||||||
|
}
|
||||||
|
h2Text = h2Text.replace(/%countSticker%/g, countSticker);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
h1Text: h1Text,
|
||||||
|
descriptionBelowH1Text: descriptionBelowH1Text,
|
||||||
|
h2Text: h2Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDetailText = async (workDetail) => {
|
||||||
|
let promptResult = workDetail.input_text.slice(0, 20);
|
||||||
|
const tDetail = await getTranslations('DetailText');
|
||||||
|
let title = tDetail('title');
|
||||||
|
let description = tDetail('description');
|
||||||
|
let h1Text = tDetail('h1Text');
|
||||||
|
let descriptionBelowH1Text = tDetail('descriptionBelowH1Text');
|
||||||
|
let numberText = tDetail('numberText');
|
||||||
|
let h2Text = tDetail('h2Text');
|
||||||
|
|
||||||
|
title = title.replace(/%prompt%/g, promptResult);
|
||||||
|
description = description.replace(/%prompt%/g, promptResult);
|
||||||
|
h1Text = h1Text.replace(/%prompt%/g, promptResult);
|
||||||
|
descriptionBelowH1Text = descriptionBelowH1Text.replace(/%prompt%/g, promptResult);
|
||||||
|
|
||||||
|
numberText = numberText.replace(/%detailId%/g, workDetail.id);
|
||||||
|
title = title + ', ' + numberText + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME;
|
||||||
|
|
||||||
|
h2Text = h2Text.replace(/%prompt%/g, promptResult);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
h1Text: h1Text,
|
||||||
|
descriptionBelowH1Text: descriptionBelowH1Text,
|
||||||
|
h2Text: h2Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getQuestionText = async () => {
|
||||||
|
const tQuestion = await getTranslations('QuestionText');
|
||||||
|
return {
|
||||||
|
detailText: tQuestion('detailText')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMenuText = async () => {
|
||||||
|
const tMenu = await getTranslations('MenuText');
|
||||||
|
return {
|
||||||
|
header0: tMenu('header0'),
|
||||||
|
header1: tMenu('header1'),
|
||||||
|
header2: tMenu('header2'),
|
||||||
|
header3: tMenu('header3'),
|
||||||
|
footerLegal: tMenu('footerLegal'),
|
||||||
|
footerLegal0: tMenu('footerLegal0'),
|
||||||
|
footerLegal1: tMenu('footerLegal1'),
|
||||||
|
footerSupport: tMenu('footerSupport'),
|
||||||
|
footerSupport0: tMenu('footerSupport0'),
|
||||||
|
footerSupport1: tMenu('footerSupport1'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSearchText = async (countSticker, sticker: string, countStickerAll) => {
|
||||||
|
const tSearch = await getTranslations('SearchText');
|
||||||
|
let title = tSearch('title');
|
||||||
|
let description = tSearch('description');
|
||||||
|
let h1Text = tSearch('h1Text');
|
||||||
|
let h2Text = tSearch('h2Text');
|
||||||
|
let titleSearch = tSearch('titleSearch');
|
||||||
|
let h2TextSearch = tSearch('h2TextSearch');
|
||||||
|
|
||||||
|
title = title.replace(/%countSticker%/g, countSticker);
|
||||||
|
description = description.replace(/%countStickerAll%/g, countStickerAll);
|
||||||
|
h2Text = h2Text.replace(/%countSticker%/g, countSticker);
|
||||||
|
|
||||||
|
title = title + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME;
|
||||||
|
|
||||||
|
if (sticker) {
|
||||||
|
titleSearch = titleSearch.replace(/%countSticker%/g, countSticker);
|
||||||
|
let promptResult = sticker.slice(0, 20);
|
||||||
|
titleSearch = titleSearch.replace(/%prompt%/g, promptResult);
|
||||||
|
title = titleSearch + ' | ' + process.env.NEXT_PUBLIC_WEBSITE_NAME;
|
||||||
|
|
||||||
|
h2TextSearch = h2TextSearch.replace(/%countSticker%/g, countSticker);
|
||||||
|
h2TextSearch = h2TextSearch.replace(/%prompt%/g, promptResult);
|
||||||
|
h2Text = h2TextSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
h1Text: h1Text,
|
||||||
|
h2Text: h2Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
129
src/configs/openaiConfig.ts
Normal file
129
src/configs/openaiConfig.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
export const apiKey = process.env.OPENAI_API_KEY
|
||||||
|
export const model = process.env.OPENAI_API_MODEL
|
||||||
|
export const baseUrl = process.env.OPENAI_API_BASE_URL
|
||||||
|
export const temperature = 0
|
||||||
|
|
||||||
|
|
||||||
|
export const getCurrentLanguage = (to = '') => {
|
||||||
|
|
||||||
|
if (to !== '') {
|
||||||
|
for (let i = 0; i < translateLanguageList.length; i++) {
|
||||||
|
if (to === translateLanguageList[i].lang) {
|
||||||
|
return translateLanguageList[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lang: to,
|
||||||
|
language: to,
|
||||||
|
systemPrompt: "You are a professional and authentic translation engine. You only provide translations, without any explanations, and the translation results must retain all markdown formatting symbols.",
|
||||||
|
systemPrompt2: "if content like this: \"[](https://XXXXXXXX)\", only translate text middle of \"[\" and \"]\" other text should not change",
|
||||||
|
systemPrompt3: "if content contain '#',the result must also '#'",
|
||||||
|
systemPrompt4: `Please translate into ${to} (avoid explaining the original text). Return to me in json format {'text':'Place the translated text here'} `,
|
||||||
|
userPrompt: `here is the content that needs to be translated `
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const translateLanguageList = [
|
||||||
|
{
|
||||||
|
lang: "en",
|
||||||
|
language: "English",
|
||||||
|
languageInChineseSimple: "英语",
|
||||||
|
systemPrompt: "You are a professional and authentic translation engine. You only provide translations, without any explanations, and the translation results must retain all markdown formatting symbols. ",
|
||||||
|
systemPrompt2: "if content like this: \"[](https://XXXXXXXX)\", only translate text middle of \"[\" and \"]\" other text should not change. ",
|
||||||
|
systemPrompt3: "if content contain '#',the result must also '#' ",
|
||||||
|
systemPrompt4: "Please translate into English (avoid explaining the original text). Return to me in json format {'text':'Place the translated text here'} ",
|
||||||
|
userPrompt: "here is the content that needs to be translated "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "zh",
|
||||||
|
language: "简体中文",
|
||||||
|
languageInChineseSimple: "简体中文",
|
||||||
|
systemPrompt: "你是一个专业、地道的翻译引擎,你只返回译文,不含任何解释,翻译结果必须保留 markdown 的所有格式标记符号 ",
|
||||||
|
systemPrompt2: "如果原文类似这样: '[](https://XXXXXXXX)', 只翻译 '[' 和 ']' 中间的文字,其他的文字保留原样 ",
|
||||||
|
systemPrompt3: "如果原文有'#',则翻译结果必须保留原文的'#' ",
|
||||||
|
systemPrompt4: "请翻译为简体中文(避免解释原文)。以json格式{'text':'这里放翻译好的文字'}返回给我 ",
|
||||||
|
userPrompt: "以下是需要翻译的内容 "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "tw",
|
||||||
|
language: "繁體中文",
|
||||||
|
languageInChineseSimple: "繁体中文",
|
||||||
|
systemPrompt: "您是一個專業、道地的翻譯引擎,您僅返回譯文,不含任何解釋,翻譯結果必須保留 markdown 的所有格式標記符號。 ",
|
||||||
|
systemPrompt2: "如果原文類似這樣: '[](https://XXXXXXXX)', 只翻譯 '[' 和 ']' 中間的文字,其他的文字保留原樣 ",
|
||||||
|
systemPrompt3: "如果原文有'#',則翻譯結果必須保留原文的'#' ",
|
||||||
|
systemPrompt4: "請翻譯為繁體中文(避免解釋原文)。以json格式{'text':'這裡放翻譯好的文字'}返回給我 ",
|
||||||
|
userPrompt: "以下是需要翻譯的內容 "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "ko",
|
||||||
|
language: "한국어",
|
||||||
|
languageInChineseSimple: "韩语",
|
||||||
|
systemPrompt: "당신은 전문적이고 진정한 번역 엔진입니다. 당신은 번역 텍스트만을 반환하며, 어떠한 설명도 포함하지 않습니다. 번역 결과는 markdown의 모든 형식 표시 기호를 유지해야 합니다. ",
|
||||||
|
systemPrompt2: "원본 텍스트가 '[](https://XXXXXXXX)'와 유사한 경우 '['와 ']' 사이의 텍스트만 번역되고 다른 텍스트는 그대로 유지됩니다. ",
|
||||||
|
systemPrompt3: "원문에 '#'이 있는 경우 번역 결과는 원문의 '#'을 유지해야 합니다. ",
|
||||||
|
systemPrompt4: "한국어로 번역해주세요(원문 설명을 피하세요)。json 형식으로 {'text':'여기에 번역된 텍스트를 넣으세요'}로 반환해 주세요 ",
|
||||||
|
userPrompt: "다음은 번역이 필요한 내용입니다 "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "ja",
|
||||||
|
language: "日本語",
|
||||||
|
languageInChineseSimple: "日语",
|
||||||
|
systemPrompt: "あなたはプロフェッショナルで本格的な翻訳エンジンで、翻訳のみを提供し、いかなる説明も含まず、翻訳結果はすべてのmarkdownのフォーマット記号を保持しなければなりません。 ",
|
||||||
|
systemPrompt2: "元のテキストが「[](https://XXXXXXXX)」のような場合、「[」と「]」の間のテキストのみが翻訳され、他のテキストはそのまま残ります。 ",
|
||||||
|
systemPrompt3: "原文に「#」が含まれている場合、翻訳結果は原文の「#」を保持する必要があります。 ",
|
||||||
|
systemPrompt4: "日本語に翻訳してください(原文の説明は避けてください)。json形式で{'text':'翻訳されたテキストをここに置く'}として返してください。 ",
|
||||||
|
userPrompt: "以下が翻訳が必要な内容です "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "pt",
|
||||||
|
language: "Português",
|
||||||
|
languageInChineseSimple: "葡萄牙语",
|
||||||
|
systemPrompt: "Você é um motor de tradução profissional e autêntico. Você retorna apenas traduções, sem quaisquer explicações, e os resultados da tradução devem manter todos os símbolos de formatação do markdown. ",
|
||||||
|
systemPrompt2: "Se o texto original for semelhante a este: '[](https://XXXXXXXX)', apenas o texto entre '[' e ']' será traduzido, e o outro texto permanecerá como está. ",
|
||||||
|
systemPrompt3: "Se o texto original contiver '#', o resultado da tradução deverá reter o '#' do texto original ",
|
||||||
|
systemPrompt4: "Por favor, traduza para Português (evite explicar o texto original). Retorne para mim no formato json {'text':'Coloque o texto traduzido aqui'} ",
|
||||||
|
userPrompt: "a seguir está o conteúdo que precisa ser traduzido "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "es",
|
||||||
|
language: "Español",
|
||||||
|
languageInChineseSimple: "西班牙语",
|
||||||
|
systemPrompt: "Eres un motor de traducción profesional y auténtico. Solo devuelves textos traducidos, sin ninguna explicación, y los resultados de la traducción deben conservar todos los símbolos de formato de markdown. ",
|
||||||
|
systemPrompt2: "Si el texto original es similar a este: '[](https://XXXXXXXXX)', solo se traducirá el texto entre '[' y ']' y el resto del texto permanecerá como está. ",
|
||||||
|
systemPrompt3: "Si el texto original tiene '#', el resultado de la traducción debe conservar el '#' del texto original ",
|
||||||
|
systemPrompt4: "Por favor, traduce al español (evita explicar el texto original). Devuélvemelo en formato json {'text':'coloca aquí el texto traducido'} ",
|
||||||
|
userPrompt: "a continuación está el contenido que necesita ser traducido "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "de",
|
||||||
|
language: "Deutsch",
|
||||||
|
languageInChineseSimple: "德语",
|
||||||
|
systemPrompt: "Du bist ein professioneller und authentischer Übersetzungsmotor. Du lieferst nur Übersetzungen, ohne jegliche Erklärungen, und die Übersetzungsergebnisse müssen alle Markdown-Formatierungssymbole beibehalten. ",
|
||||||
|
systemPrompt2: "Wenn der Originaltext etwa so aussieht: „[](https://XXXXXXXX)“, wird nur der Text zwischen „[“ und „]“ übersetzt und der andere Text bleibt unverändert. ",
|
||||||
|
systemPrompt3: "Wenn der Originaltext ein „#“ enthält, muss das Übersetzungsergebnis das „#“ des Originaltexts beibehalten ",
|
||||||
|
systemPrompt4: "Bitte übersetzen Sie in Deutsch (ohne Erklärung des Originaltextes). Geben Sie mir die Übersetzung im JSON-Format {'text':'Hier den übersetzten Text einfügen'} zurück. ",
|
||||||
|
userPrompt: "Hier ist der zu übersetzende Inhalt "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "fr",
|
||||||
|
language: "Français",
|
||||||
|
languageInChineseSimple: "法语",
|
||||||
|
systemPrompt: "Vous êtes un moteur de traduction professionnel et authentique. Vous ne fournissez que des traductions, sans aucune explication, et les résultats de la traduction doivent conserver tous les symboles de formatage markdown. ",
|
||||||
|
systemPrompt2: "Si le texte original ressemble à ceci : '[](https://XXXXXXXX)', seul le texte entre '[' et ']' sera traduit, et l'autre texte restera tel quel. ",
|
||||||
|
systemPrompt3: "Si le texte original contient un « # », le résultat de la traduction doit conserver le « # » du texte original. ",
|
||||||
|
systemPrompt4: "Veuillez traduire en français (éviter d'expliquer le texte original). Renvoyez-moi au format json {'text':'Insérez le texte traduit ici'} ",
|
||||||
|
userPrompt: "voici le contenu à traduire "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "vi",
|
||||||
|
language: "Tiếng Việt",
|
||||||
|
languageInChineseSimple: "越南语",
|
||||||
|
systemPrompt: "Bạn là một công cụ dịch thuật chuyên nghiệp và đích thực, bạn chỉ trả lại bản dịch, không bao gồm bất kỳ giải thích nào, kết quả dịch phải giữ nguyên tất cả các dấu hiệu định dạng của markdown. ",
|
||||||
|
systemPrompt2: "Nếu văn bản gốc tương tự như sau: '[](https://XXXXXXXX)' thì chỉ văn bản nằm giữa '[' và ']' mới được dịch và văn bản còn lại sẽ giữ nguyên. ",
|
||||||
|
systemPrompt3: "Nếu văn bản gốc có “#” thì kết quả dịch phải giữ lại “#” của văn bản gốc ",
|
||||||
|
systemPrompt4: "Vui lòng dịch sang Tiếng Việt (tránh giải thích văn bản gốc). Trả lại cho tôi dưới dạng json {'text':'đặt văn bản đã dịch ở đây'} ",
|
||||||
|
userPrompt: "đây là nội dung cần được dịch "
|
||||||
|
},
|
||||||
|
];
|
31
src/configs/stripeConfig.ts
Normal file
31
src/configs/stripeConfig.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const priceProd = [
|
||||||
|
{
|
||||||
|
currency: "usd",
|
||||||
|
type: "recurring",
|
||||||
|
unit_amount: 11880,
|
||||||
|
id: "price_"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: "usd",
|
||||||
|
type: "recurring",
|
||||||
|
unit_amount: 1990,
|
||||||
|
id: "price_"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const priceTest = [
|
||||||
|
{
|
||||||
|
currency: "usd",
|
||||||
|
type: "recurring",
|
||||||
|
unit_amount: 11880,
|
||||||
|
id: "price_"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: "usd",
|
||||||
|
type: "recurring",
|
||||||
|
unit_amount: 1990,
|
||||||
|
id: "price_"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const priceList = (process.env.NODE_ENV === 'production' ? priceProd: priceTest);
|
72
src/context/common-context.tsx
Normal file
72
src/context/common-context.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
import {createContext, useContext, useState} from "react";
|
||||||
|
import {useSession} from "next-auth/react";
|
||||||
|
import {useInterval} from "ahooks";
|
||||||
|
|
||||||
|
|
||||||
|
const CommonContext = createContext(undefined);
|
||||||
|
export const CommonProvider = ({
|
||||||
|
children,
|
||||||
|
commonText,
|
||||||
|
authText,
|
||||||
|
menuText,
|
||||||
|
pricingText
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const {data: session, status} = useSession();
|
||||||
|
const [userData, setUserData] = useState({});
|
||||||
|
const [intervalUserData, setIntervalUserData] = useState(1000);
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const [showLogoutModal, setShowLogoutModal] = useState(false);
|
||||||
|
const [showLoadingModal, setShowLoadingModal] = useState(false);
|
||||||
|
const [showGeneratingModal, setShowGeneratingModal] = useState(false);
|
||||||
|
const [showPricingModal, setShowPricingModal] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
useInterval(() => {
|
||||||
|
init();
|
||||||
|
}, intervalUserData);
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (status == 'authenticated') {
|
||||||
|
const userData = {
|
||||||
|
// @ts-ignore
|
||||||
|
user_id: session?.user?.user_id,
|
||||||
|
name: session?.user?.name,
|
||||||
|
email: session?.user?.email,
|
||||||
|
image: session?.user?.image,
|
||||||
|
}
|
||||||
|
setUserData(userData);
|
||||||
|
setShowLoginModal(false);
|
||||||
|
setIntervalUserData(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonContext.Provider
|
||||||
|
value={{
|
||||||
|
userData,
|
||||||
|
setUserData,
|
||||||
|
showLoginModal,
|
||||||
|
setShowLoginModal,
|
||||||
|
showLogoutModal,
|
||||||
|
setShowLogoutModal,
|
||||||
|
showLoadingModal,
|
||||||
|
setShowLoadingModal,
|
||||||
|
showGeneratingModal,
|
||||||
|
setShowGeneratingModal,
|
||||||
|
showPricingModal,
|
||||||
|
setShowPricingModal,
|
||||||
|
commonText,
|
||||||
|
authText,
|
||||||
|
menuText,
|
||||||
|
pricingText,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CommonContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommonContext = () => useContext(CommonContext)
|
7
src/context/next-auth-context.tsx
Normal file
7
src/context/next-auth-context.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {SessionProvider} from "next-auth/react";
|
||||||
|
|
||||||
|
export function NextAuthProvider({children}: { children: React.ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
10
src/i18n.ts
Executable file
10
src/i18n.ts
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
import {getRequestConfig} from 'next-intl/server';
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({locale}) => ({
|
||||||
|
messages: (
|
||||||
|
await (locale === 'en'
|
||||||
|
? // When using Turbopack, this will enable HMR for `default`
|
||||||
|
import('../messages/en.json')
|
||||||
|
: import(`../messages/${locale}.json`))
|
||||||
|
).default
|
||||||
|
}));
|
18
src/libs/R2.ts
Normal file
18
src/libs/R2.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import {S3} from "aws-sdk";
|
||||||
|
|
||||||
|
export const storageDomain = process.env.STORAGE_DOMAIN
|
||||||
|
export const storageURL = "https://" + storageDomain
|
||||||
|
export const r2Bucket = process.env.R2_BUCKET
|
||||||
|
export const r2AccountId = process.env.R2_ACCOUNT_ID
|
||||||
|
export const r2Endpoint = process.env.R2_ENDPOINT
|
||||||
|
export const r2AccessKeyId = process.env.R2_ACCESS_KEY_ID
|
||||||
|
export const r2SecretAccessKey = process.env.R2_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
|
||||||
|
export const R2 = new S3({
|
||||||
|
endpoint: r2Endpoint,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: r2AccessKeyId,
|
||||||
|
secretAccessKey: r2SecretAccessKey,
|
||||||
|
},
|
||||||
|
});
|
15
src/libs/db.ts
Normal file
15
src/libs/db.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {Pool} from 'pg'
|
||||||
|
|
||||||
|
let globalPool: Pool
|
||||||
|
|
||||||
|
export function getDb() {
|
||||||
|
if (!globalPool) {
|
||||||
|
const connectionString = process.env.POSTGRES_URL;
|
||||||
|
// console.log("connectionString", connectionString);
|
||||||
|
|
||||||
|
globalPool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return globalPool;
|
||||||
|
}
|
138
src/libs/handle-stripe.ts
Normal file
138
src/libs/handle-stripe.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import {toDateTime} from './helpers';
|
||||||
|
import {stripe} from './stripe';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import {getDb} from "~/libs/db";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const createOrRetrieveCustomer = async ({
|
||||||
|
email,
|
||||||
|
user_id
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
user_id: string;
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const results = await db.query('SELECT * FROM stripe_customers where user_id=$1 limit 1', [user_id]);
|
||||||
|
const existUser = results.rows;
|
||||||
|
|
||||||
|
if (existUser.length <= 0) {
|
||||||
|
// 创建
|
||||||
|
const customerData: { metadata: { user_id: string }; email?: string } =
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
user_id: user_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (email) customerData.email = email;
|
||||||
|
const customer = await stripe.customers.create(customerData);
|
||||||
|
await db.query('insert into stripe_customers(user_id,stripe_customer_id) values($1, $2)', [user_id, customer.id]);
|
||||||
|
return customer.id;
|
||||||
|
}
|
||||||
|
return existUser[0].stripe_customer_id;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the billing details from the payment method to the customer object.
|
||||||
|
*/
|
||||||
|
const copyBillingDetailsToCustomer = async (
|
||||||
|
user_id: string,
|
||||||
|
payment_method: Stripe.PaymentMethod
|
||||||
|
) => {
|
||||||
|
//Todo: check this assertion
|
||||||
|
const customer = payment_method.customer as string;
|
||||||
|
const {name, phone, address} = payment_method.billing_details;
|
||||||
|
if (!name || !phone || !address) return;
|
||||||
|
//@ts-ignore
|
||||||
|
await stripe.customers.update(customer, {name, phone, address});
|
||||||
|
const db = getDb();
|
||||||
|
await db.query('update user_info set billing_address=$1,payment_method=$2 where user_id=$3',
|
||||||
|
[{...address}, {...payment_method[payment_method.type], user_id}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const manageSubscriptionStatusChange = async (
|
||||||
|
subscriptionId: string,
|
||||||
|
customerId: string,
|
||||||
|
createAction = false
|
||||||
|
) => {
|
||||||
|
const db = getDb();
|
||||||
|
// Get customer's UUID from mapping table.
|
||||||
|
const results = await db.query('SELECT * FROM stripe_customers where stripe_customer_id=$1 limit 1', [customerId]);
|
||||||
|
const existCustomer = results.rows;
|
||||||
|
if (existCustomer.length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const customerData = existCustomer[0];
|
||||||
|
|
||||||
|
const {user_id: user_id} = customerData!;
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
|
||||||
|
expand: ['default_payment_method']
|
||||||
|
});
|
||||||
|
// Upsert the latest status of the subscription object.
|
||||||
|
const subscriptionData =
|
||||||
|
{
|
||||||
|
id: subscription.id,
|
||||||
|
user_id: user_id,
|
||||||
|
metadata: subscription.metadata,
|
||||||
|
status: subscription.status,
|
||||||
|
price_id: subscription.items.data[0].price.id,
|
||||||
|
//TODO check quantity on subscription
|
||||||
|
// @ts-ignore
|
||||||
|
quantity: subscription.quantity,
|
||||||
|
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||||
|
cancel_at: subscription.cancel_at
|
||||||
|
? toDateTime(subscription.cancel_at).toISOString()
|
||||||
|
: null,
|
||||||
|
canceled_at: subscription.canceled_at
|
||||||
|
? toDateTime(subscription.canceled_at).toISOString()
|
||||||
|
: null,
|
||||||
|
current_period_start: toDateTime(
|
||||||
|
subscription.current_period_start
|
||||||
|
).toISOString(),
|
||||||
|
current_period_end: toDateTime(
|
||||||
|
subscription.current_period_end
|
||||||
|
).toISOString(),
|
||||||
|
created: toDateTime(subscription.created).toISOString(),
|
||||||
|
ended_at: subscription.ended_at
|
||||||
|
? toDateTime(subscription.ended_at).toISOString()
|
||||||
|
: null,
|
||||||
|
trial_start: subscription.trial_start
|
||||||
|
? toDateTime(subscription.trial_start).toISOString()
|
||||||
|
: null,
|
||||||
|
trial_end: subscription.trial_end
|
||||||
|
? toDateTime(subscription.trial_end).toISOString()
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultSubs = await db.query('SELECT * FROM stripe_subscriptions where user_id=$1 limit 1', [subscriptionData.user_id]);
|
||||||
|
const existSubs = resultSubs.rows;
|
||||||
|
if (existSubs.length <= 0) {
|
||||||
|
// 没有,新增
|
||||||
|
await db.query('insert into stripe_subscriptions(id,user_id,metadata,status,price_id,quantity,cancel_at_period_end,cancel_at,canceled_at,current_period_start,current_period_end,created,ended_at,trial_start,trial_end) values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)',
|
||||||
|
[subscriptionData.id, subscriptionData.user_id, subscriptionData.metadata, subscriptionData.status, subscriptionData.price_id, subscriptionData.quantity, subscriptionData.cancel_at_period_end, subscriptionData.cancel_at, subscriptionData.canceled_at, subscriptionData.current_period_start, subscriptionData.current_period_end, subscriptionData.created, subscriptionData.ended_at, subscriptionData.trial_start, subscriptionData.trial_end]);
|
||||||
|
} else {
|
||||||
|
// 有,更新
|
||||||
|
await db.query('update stripe_subscriptions set user_id=$1,metadata=$2,status=$3,price_id=$4,quantity=$5,cancel_at_period_end=$6,cancel_at=$7,canceled_at=$8,current_period_start=$9,current_period_end=$10,created=$11,ended_at=$12,trial_start=$13,trial_end=$14 where user_id=$15',
|
||||||
|
[subscriptionData.user_id, subscriptionData.metadata, subscriptionData.status, subscriptionData.price_id, subscriptionData.quantity, subscriptionData.cancel_at_period_end, subscriptionData.cancel_at, subscriptionData.canceled_at, subscriptionData.current_period_start, subscriptionData.current_period_end, subscriptionData.created, subscriptionData.ended_at, subscriptionData.trial_start, subscriptionData.trial_end, subscriptionData.user_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a new subscription copy the billing details to the customer object.
|
||||||
|
// NOTE: This is a costly operation and should happen at the very end.
|
||||||
|
if (createAction && subscription.default_payment_method && user_id)
|
||||||
|
//@ts-ignore
|
||||||
|
await copyBillingDetailsToCustomer(
|
||||||
|
user_id,
|
||||||
|
subscription.default_payment_method as Stripe.PaymentMethod
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
createOrRetrieveCustomer,
|
||||||
|
manageSubscriptionStatusChange,
|
||||||
|
};
|
17
src/libs/helpers.ts
Normal file
17
src/libs/helpers.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export const getURL = () => {
|
||||||
|
let url =
|
||||||
|
process?.env?.NEXT_PUBLIC_SITE_URL ?? // Set this to your site URL in production env.
|
||||||
|
process?.env?.NEXT_PUBLIC_VERCEL_URL ?? // Automatically set by Vercel.
|
||||||
|
'http://localhost/';
|
||||||
|
// Make sure to include `https://` when not localhost.
|
||||||
|
url = url.includes('http') ? url : `https://${url}`;
|
||||||
|
// Make sure to including trailing `/`.
|
||||||
|
url = url.charAt(url.length - 1) === '/' ? url : `${url}/`;
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toDateTime = (secs: number) => {
|
||||||
|
var t = new Date('1970-01-01T00:30:00Z'); // Unix epoch start.
|
||||||
|
t.setSeconds(secs);
|
||||||
|
return t;
|
||||||
|
};
|
7
src/libs/nextAuthClient.ts
Normal file
7
src/libs/nextAuthClient.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import {signIn} from "next-auth/react";
|
||||||
|
|
||||||
|
export async function signInUseAuth({redirectPath}) {
|
||||||
|
const result = await signIn('google', {
|
||||||
|
callbackUrl: redirectPath
|
||||||
|
})
|
||||||
|
}
|
25
src/libs/replicate.ts
Normal file
25
src/libs/replicate.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import {translateContent} from "~/servers/translate";
|
||||||
|
|
||||||
|
|
||||||
|
export const getInput = async (textStr, checkSubscribeStatus) => {
|
||||||
|
// 翻译成英语后返回
|
||||||
|
const revised_text = await translateContent(textStr, 'en');
|
||||||
|
|
||||||
|
let width = 512;
|
||||||
|
let height = 512;
|
||||||
|
let upscale = false;
|
||||||
|
if (checkSubscribeStatus) {
|
||||||
|
width = 1024;
|
||||||
|
height = 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps: 20,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
prompt: revised_text,
|
||||||
|
upscale: upscale,
|
||||||
|
upscale_steps: 2,
|
||||||
|
negative_prompt: "NSFW. No nudity or explicit content.No violence or gore.No sexual themes.Suitable for all ages.No illegal activities or substances.General audience appropriate.No offensive material.No hate speech or discrimination.Nothing disturbing or shocking.Respectful, non-exploitative content."
|
||||||
|
}
|
||||||
|
}
|
12
src/libs/replicateClient.ts
Normal file
12
src/libs/replicateClient.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Replicate from "replicate";
|
||||||
|
|
||||||
|
let replicateClient: Replicate;
|
||||||
|
|
||||||
|
export const getReplicateClient = () => {
|
||||||
|
if (!replicateClient) {
|
||||||
|
replicateClient = new Replicate({
|
||||||
|
auth: process.env.REPLICATE_API_TOKEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return replicateClient;
|
||||||
|
}
|
15
src/libs/stripe.ts
Normal file
15
src/libs/stripe.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
export const stripe = new Stripe(
|
||||||
|
process.env.STRIPE_SECRET_KEY_LIVE ?? process.env.STRIPE_SECRET_KEY ?? '',
|
||||||
|
{
|
||||||
|
// https://github.com/stripe/stripe-node#configuration
|
||||||
|
apiVersion: '2023-10-16',
|
||||||
|
// Register this as an official Stripe plugin.
|
||||||
|
// https://stripe.com/docs/building-plugins#setappinfo
|
||||||
|
appInfo: {
|
||||||
|
name: 'Next.js Subscription Starter',
|
||||||
|
version: '0.1.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
15
src/libs/stripeClient.ts
Normal file
15
src/libs/stripeClient.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {loadStripe, Stripe} from '@stripe/stripe-js';
|
||||||
|
|
||||||
|
let stripePromise: Promise<Stripe | null>;
|
||||||
|
|
||||||
|
export const getStripe = () => {
|
||||||
|
if (!stripePromise) {
|
||||||
|
stripePromise = loadStripe(
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE ??
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripePromise;
|
||||||
|
};
|
54
src/libs/useOneTapSignin.ts
Normal file
54
src/libs/useOneTapSignin.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
import { useSession, signIn, SignInOptions } from "next-auth/react";
|
||||||
|
import {useCommonContext} from "~/context/common-context";
|
||||||
|
|
||||||
|
interface OneTapSigninOptions {
|
||||||
|
parentContainerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useOneTapSignin = (options?: OneTapSigninOptions & Pick<SignInOptions, "redirect" | "callbackUrl">) => {
|
||||||
|
const { parentContainerId } = options || {};
|
||||||
|
const { showLoadingModal, setShowLoadingModal } = useCommonContext();
|
||||||
|
|
||||||
|
// Taking advantage in recent development of useSession hook.
|
||||||
|
// If user is unauthenticated, google one tap ui is initialized and rendered
|
||||||
|
const { status } = useSession({
|
||||||
|
required: true,
|
||||||
|
onUnauthenticated() {
|
||||||
|
if (!showLoadingModal) {
|
||||||
|
const { google } = window;
|
||||||
|
if (google) {
|
||||||
|
google.accounts.id.initialize({
|
||||||
|
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
||||||
|
callback: async (response: any) => {
|
||||||
|
setShowLoadingModal(true);
|
||||||
|
// Here we call our Provider with the token provided by google
|
||||||
|
await signIn("googleonetap", {
|
||||||
|
credential: response.credential,
|
||||||
|
redirect: true,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
setShowLoadingModal(false);
|
||||||
|
},
|
||||||
|
prompt_parent_id: parentContainerId,
|
||||||
|
});
|
||||||
|
// Here we just console.log some error situations and reason why the Google one tap
|
||||||
|
// is not displayed. You may want to handle it depending on your application
|
||||||
|
google.accounts.id.prompt((notification: any) => {
|
||||||
|
if (notification.isNotDisplayed()) {
|
||||||
|
console.log("getNotDisplayedReason ::", notification.getNotDisplayedReason());
|
||||||
|
} else if (notification.isSkippedMoment()) {
|
||||||
|
console.log("getSkippedReason ::", notification.getSkippedReason());
|
||||||
|
} else if (notification.isDismissedMoment()) {
|
||||||
|
console.log("getDismissedReason ::", notification.getDismissedReason());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { showLoadingModal };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useOneTapSignin;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user