Compare commits
187 Commits
dev/ai-ele
...
cloudflare
Author | SHA1 | Date | |
---|---|---|---|
|
2a6e322c0a | ||
|
37f011cf74 | ||
|
35d0ca9e12 | ||
|
34baf20b31 | ||
|
28fcbae6a2 | ||
|
fc8cea13cd | ||
|
6065c4af06 | ||
|
ba7b950c01 | ||
|
c94784e711 | ||
|
48c045fb73 | ||
|
3fd47869a2 | ||
|
e3ac4a0a29 | ||
|
47adbcfd06 | ||
|
5d5eb82013 | ||
|
b0a065ced9 | ||
|
794c18a7e6 | ||
|
9899e1d164 | ||
|
ad1cbedb56 | ||
|
3707500ed8 | ||
|
f36018945d | ||
|
9f5d4aec59 | ||
|
e3f44a85a5 | ||
|
1f9a7c2621 | ||
|
a92ef86a71 | ||
|
e2dfab2ca7 | ||
|
e5061b3b67 | ||
|
4faa89c0ee | ||
|
481f3268db | ||
|
66d7dd3259 | ||
|
9aeb59dff2 | ||
|
2faedc2043 | ||
|
c0aa979382 | ||
|
fa2e981c16 | ||
|
0c415ee24b | ||
|
21eee041ab | ||
|
6c584c75e2 | ||
|
797ee9b7e5 | ||
|
658409cfbd | ||
|
422c323467 | ||
|
de7e87e5b8 | ||
|
613bbd0d78 | ||
|
4434f1900d | ||
|
895e02bfdd | ||
|
7cc1fd5835 | ||
|
4bad9714fa | ||
|
fa4b9a19a1 | ||
|
1c0c46fa34 | ||
|
0ae3f27c78 | ||
|
fc024ea0da | ||
|
80851fcf44 | ||
|
31829ce17b | ||
|
7c9b0a2697 | ||
|
5f14259197 | ||
|
15da1ee48a | ||
|
c2d7e51f5b | ||
|
00405d5335 | ||
|
610346055f | ||
|
cb9c3132fd | ||
|
32fc3d6dc9 | ||
|
69143ace47 | ||
|
8c3ef9bfaf | ||
|
7851a715a3 | ||
|
0fb4ef93d2 | ||
|
95a6f3b9d5 | ||
|
0794c7d297 | ||
|
395f753025 | ||
|
fc53045d99 | ||
|
b4dab95c04 | ||
|
8221f1753f | ||
|
18691030e7 | ||
|
7aa7cb5603 | ||
|
ca30f95027 | ||
|
d747683f82 | ||
|
b55613b471 | ||
|
47679ab91e | ||
|
f468638f49 | ||
|
35ddf5e08e | ||
|
1f7c38f9f5 | ||
|
63dd4e52fb | ||
|
200a9963f7 | ||
|
0da8f7d335 | ||
|
004edeecea | ||
|
6bb12a2d86 | ||
|
97654d97ea | ||
|
aa2e025270 | ||
|
11bfcb731d | ||
|
62eb4124be | ||
|
d7cc9b956d | ||
|
22d68c005a | ||
|
70446d10b3 | ||
|
313c783dbd | ||
|
cc56f9d729 | ||
|
e5569dabd1 | ||
|
813d8ea0bb | ||
|
c67b804f4f | ||
|
a44e4a669c | ||
|
da4b018e8d | ||
|
b838ddc293 | ||
|
8e63af3e7f | ||
|
1e2e4d77f7 | ||
|
e94625ce4e | ||
|
2153cf6771 | ||
|
0164c833db | ||
|
5d50135ed6 | ||
|
cbfe5e433d | ||
|
7ab7d2d504 | ||
|
522d8de4ee | ||
|
0739c717d8 | ||
|
71b9807433 | ||
|
8a72fb2409 | ||
|
e00c22d0fe | ||
|
bd8ccf4cf3 | ||
|
d0aef4b7d4 | ||
|
c006ee750d | ||
|
19a6c4d994 | ||
|
86f13a1748 | ||
|
745ba457df | ||
|
beb53639a3 | ||
|
65fb8722bc | ||
|
160a7eb929 | ||
|
c3d82d9183 | ||
|
767351c5cd | ||
|
fd3c82baaf | ||
|
168eae946f | ||
|
69390fed70 | ||
|
2cb041beb1 | ||
|
3645cf5773 | ||
|
c6ad6d0ad5 | ||
|
53ab869f07 | ||
|
e0f408fb07 | ||
|
1216732a55 | ||
|
4c6fddf99d | ||
|
90d5db88ab | ||
|
af5a3265a6 | ||
|
ec8ce54824 | ||
|
f4d8a09ab6 | ||
|
3b741b3b98 | ||
|
b07be5fab4 | ||
|
a22a5def4d | ||
|
d190bcb358 | ||
|
7f1fe23407 | ||
|
05a7de4599 | ||
|
c098300481 | ||
|
e7240db823 | ||
|
a4390d433b | ||
|
ae49d06cf4 | ||
|
6a448825a6 | ||
|
4d60d48212 | ||
|
26a88eb2f0 | ||
|
c5d08a9846 | ||
|
f5b4ed2859 | ||
|
b88aa9c1f5 | ||
|
593333c3dd | ||
|
f3b6603db7 | ||
|
9cb559a48d | ||
|
c3392320b3 | ||
|
708fac652f | ||
|
ec124640f1 | ||
|
862132d8eb | ||
|
bf11c143fe | ||
|
6cfc76d621 | ||
|
d935bcff76 | ||
|
a727a31e2f | ||
|
81cfc5f6b3 | ||
|
8e8291c325 | ||
|
6ff2ea6845 | ||
|
b6836db12d | ||
|
5f435b9614 | ||
|
9b03f6201f | ||
|
111f00adaa | ||
|
002d2090c2 | ||
|
c3913dbc88 | ||
|
9b68e3095e | ||
|
2fb627a6e9 | ||
|
f11e37374b | ||
|
3560616b52 | ||
|
80219fa10b | ||
|
a62abbf399 | ||
|
dd95dece87 | ||
|
c938122f7e | ||
|
3887da26d0 | ||
|
7af193f770 | ||
|
d6093394d8 | ||
|
f1537e305a | ||
|
1847ef4363 | ||
|
0fd695c8bc | ||
|
ae083a7992 |
@ -1,4 +1,7 @@
|
||||
.cursor
|
||||
.claude
|
||||
.conductor
|
||||
.kiro
|
||||
.github
|
||||
.next
|
||||
.open-next
|
||||
@ -10,4 +13,4 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
Dockerfile
|
||||
LICENSE
|
||||
LICENSE
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,6 +41,9 @@ certificates
|
||||
# claude code
|
||||
.claude
|
||||
|
||||
# conductor
|
||||
.conductor
|
||||
|
||||
# kiro
|
||||
.kiro
|
||||
|
||||
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@ -24,6 +24,12 @@
|
||||
".next": true,
|
||||
".source": true,
|
||||
".wrangler": true,
|
||||
".open-next": true
|
||||
".open-next": true,
|
||||
".vscode": true,
|
||||
".cursor": true,
|
||||
".claude": true,
|
||||
".conductor": true,
|
||||
".kiro": true,
|
||||
".github": true
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ If you found anything that could be improved, please let me know.
|
||||
- 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs)
|
||||
- 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap)
|
||||
- 👨💻 discord: [mksaas.link/discord](https://mksaas.link/discord)
|
||||
- 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube)
|
||||
- 📹 video: [mksaas.link/youtube](https://mksaas.link/youtube)
|
||||
|
||||
## Repositories
|
||||
|
||||
|
12
biome.json
12
biome.json
@ -12,6 +12,9 @@
|
||||
".open-next/**",
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".kiro/**",
|
||||
".conductor/**",
|
||||
".vscode/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
@ -27,8 +30,7 @@
|
||||
"src/app/[[]locale]/preview/**",
|
||||
"src/payment/types.ts",
|
||||
"src/credits/types.ts",
|
||||
"src/types/index.d.ts",
|
||||
"public/sw.js"
|
||||
"src/types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
@ -75,6 +77,9 @@
|
||||
".open-next/**",
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".conductor/**",
|
||||
".kiro/**",
|
||||
".vscode/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
@ -90,8 +95,7 @@
|
||||
"src/app/[[]locale]/preview/**",
|
||||
"src/payment/types.ts",
|
||||
"src/credits/types.ts",
|
||||
"src/types/index.d.ts",
|
||||
"public/sw.js"
|
||||
"src/types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"javascript": {
|
||||
|
7483
cloudflare-env.d.ts
vendored
Normal file
7483
cloudflare-env.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
56
content/blog/premium.mdx
Normal file
56
content/blog/premium.mdx
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "Premium Blog Post"
|
||||
description: "This blog post is a test for premium content."
|
||||
date: "2025-08-30"
|
||||
published: true
|
||||
premium: true
|
||||
categories: ["product"]
|
||||
author: "fox"
|
||||
image: "/images/blog/post-7.png"
|
||||
---
|
||||
|
||||
This blog post is a test for premium content.
|
||||
|
||||
You can read this part of the blog post if you are not a premium user.
|
||||
|
||||
But for the rest of the blog post, you need to be logged in as a premium user.
|
||||
|
||||
You can click the "Sign In" button to sign in as a user with free plan.
|
||||
|
||||
Then you can click the "Upgrade Now" button to upgrade to a premium plan.
|
||||
|
||||
<Callout type="warn">
|
||||
Don't worry, you don't actually pay any cents, because we are in the sandbox environment of Stripe.
|
||||
</Callout>
|
||||
|
||||
You can use the test card number to pay for monthly or yearly PRO plan or LIFETIME plan.
|
||||
|
||||
```
|
||||
Card number: 4242 4242 4242 4242
|
||||
Exp: 12/34
|
||||
CVV: 567
|
||||
```
|
||||
|
||||
After that, you can return to the blog post and you can read the rest of the blog post.
|
||||
|
||||
For more details, please check out the documentation: [Blog](https://mksaas.com/docs/blog).
|
||||
|
||||
Now the rest of the blog post is premium content.
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
<Callout type="info">
|
||||
This is the beginning of the premium content part.
|
||||
</Callout>
|
||||
|
||||
This is the premium content part.
|
||||
|
||||
You can read this paragraph only if you are a premium user.
|
||||
|
||||
Please don't share this blog post with others.
|
||||
|
||||
<Callout type="info">
|
||||
This is the end of the premium content part.
|
||||
</Callout>
|
||||
|
||||
</PremiumContent>
|
56
content/blog/premium.zh.mdx
Normal file
56
content/blog/premium.zh.mdx
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "测试专用付费文章"
|
||||
description: "这是一篇测试专用付费文章。"
|
||||
date: "2025-08-30"
|
||||
published: true
|
||||
premium: true
|
||||
categories: ["product"]
|
||||
author: "fox"
|
||||
image: "/images/blog/post-7.png"
|
||||
---
|
||||
|
||||
这是一篇测试专用的付费文章。
|
||||
|
||||
如果你不是付费用户,你可以阅读这篇文章的这部分内容。
|
||||
|
||||
但如果你想阅读剩下的内容,你需要成为一个付费用户。
|
||||
|
||||
你可以点击 "登录" 按钮来以免费用户的身份登录。
|
||||
|
||||
然后你可以点击 "立即升级" 按钮来升级到付费计划。
|
||||
|
||||
<Callout type="warn">
|
||||
不用担心,你实际上不需要支付任何费用,因为我们处于 Stripe 的沙盒环境中。
|
||||
</Callout>
|
||||
|
||||
你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。
|
||||
|
||||
```
|
||||
Card number: 4242 4242 4242 4242
|
||||
Exp: 12/34
|
||||
CVV: 567
|
||||
```
|
||||
|
||||
之后,你可以返回这篇博客文章,然后你可以阅读剩下的内容。
|
||||
|
||||
更多详情,请参考文档:[博客](https://mksaas.com/docs/blog)。
|
||||
|
||||
现在剩下的内容是付费内容。
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
<Callout type="info">
|
||||
这是付费内容部分的开始。
|
||||
</Callout>
|
||||
|
||||
这是付费内容部分。
|
||||
|
||||
你可以阅读这篇内容,只要你是一个付费用户。
|
||||
|
||||
请不要分享这篇文章给其他人。
|
||||
|
||||
<Callout type="info">
|
||||
这是付费内容部分的结束。
|
||||
</Callout>
|
||||
|
||||
</PremiumContent>
|
@ -2,6 +2,7 @@
|
||||
title: What is Fumadocs
|
||||
description: Introducing Fumadocs, a docs framework that you can break.
|
||||
icon: CircleHelp
|
||||
premium: true
|
||||
---
|
||||
|
||||
Fumadocs was created because I wanted a more customisable experience for building docs, to be a docs framework that is not opinionated, **a "framework" that you can break**.
|
||||
@ -18,6 +19,8 @@ You are still using features of Next.js App Router, like **Static Site Generatio
|
||||
**Opinionated on UI:** The only thing Fumadocs UI (the default theme) offers is **User Interface**. The UI is opinionated for bringing better mobile responsiveness and user experience.
|
||||
Instead, we use a much more flexible approach inspired by Shadcn UI — [Fumadocs CLI](/docs/cli), so we can iterate our design quick, and welcome for more feedback about the UI.
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
## Why Fumadocs
|
||||
|
||||
Fumadocs is designed with flexibility in mind.
|
||||
@ -56,3 +59,5 @@ docs easier, with less boilerplate.
|
||||
Fumadocs is maintained by Fuma and many contributors, with care on the maintainability of codebase.
|
||||
While we don't aim to offer every functionality people wanted, we're more focused on making basic features perfect and well-maintained.
|
||||
You can also help Fumadocs to be more useful by contributing!
|
||||
|
||||
</PremiumContent>
|
||||
|
@ -2,6 +2,7 @@
|
||||
title: 什么是 Fumadocs
|
||||
description: 介绍 Fumadocs,一个可以打破常规的文档框架
|
||||
icon: CircleHelp
|
||||
premium: true
|
||||
---
|
||||
|
||||
Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体验,一个不固执己见的文档框架,**一个你可以"打破"的"框架"**。
|
||||
@ -18,6 +19,8 @@ Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体
|
||||
**对 UI 有自己的看法:** Fumadocs UI(默认主题)提供的唯一东西是**用户界面**。UI 的设计理念是提供更好的移动响应性和用户体验。
|
||||
相反,我们使用受 Shadcn UI 启发的更灵活的方法 — [Fumadocs CLI](/docs/cli),这样我们可以快速迭代设计,并欢迎更多关于 UI 的反馈。
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
## 为什么选择 Fumadocs
|
||||
|
||||
Fumadocs 的设计考虑了灵活性。
|
||||
@ -53,4 +56,6 @@ Fumadocs 为 Next.js 提供了额外的工具,包括语法高亮、文档搜
|
||||
|
||||
Fumadocs 由 Fuma 和许多贡献者维护,关注代码库的可维护性。
|
||||
虽然我们不打算提供人们想要的每一项功能,但我们更专注于使基本功能完美且维护良好。
|
||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
||||
|
||||
</PremiumContent>
|
||||
|
1
dev.vars.example
Normal file
1
dev.vars.example
Normal file
@ -0,0 +1 @@
|
||||
NEXTJS_ENV=development
|
@ -5,6 +5,7 @@
|
||||
"description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"Common": {
|
||||
"premium": "Premium",
|
||||
"login": "Log in",
|
||||
"logout": "Log out",
|
||||
"signUp": "Sign up",
|
||||
@ -292,8 +293,20 @@
|
||||
"nextPage": "Next",
|
||||
"chooseLanguage": "Select language",
|
||||
"title": "MkSaaS Docs",
|
||||
"homepage": "Homepage",
|
||||
"blog": "Blog"
|
||||
"homepage": "Homepage"
|
||||
},
|
||||
"PremiumContent": {
|
||||
"title": "Unlock Premium Content",
|
||||
"description": "Subscribe to our Pro plan to access all premium content and exclusive content.",
|
||||
"upgradeCta": "Upgrade Now",
|
||||
"benefit1": "All premium content",
|
||||
"benefit2": "Exclusive content",
|
||||
"benefit3": "Cancel anytime",
|
||||
"signIn": "Sign In",
|
||||
"loginRequired": "Sign in to continue reading",
|
||||
"loginDescription": "This is premium content. Sign in to your account to access the full content.",
|
||||
"checkingAccess": "Checking access...",
|
||||
"loadingContent": "Loading full content..."
|
||||
},
|
||||
"Marketing": {
|
||||
"navbar": {
|
||||
@ -320,6 +333,10 @@
|
||||
"title": "AI Image",
|
||||
"description": "Show how to use AI to generate beautiful images"
|
||||
},
|
||||
"chat": {
|
||||
"title": "AI Chat",
|
||||
"description": "Show how to use AI to chat with your customers"
|
||||
},
|
||||
"video": {
|
||||
"title": "AI Video",
|
||||
"description": "Show how to use AI to generate amazing videos"
|
||||
@ -574,7 +591,7 @@
|
||||
},
|
||||
"price": "Price:",
|
||||
"periodStartDate": "Period start date:",
|
||||
"nextBillingDate": "Next billing date:",
|
||||
"periodEndDate": "Period end date:",
|
||||
"trialEnds": "Trial ends:",
|
||||
"freePlanMessage": "You are currently on the free plan with limited features",
|
||||
"lifetimeMessage": "You have lifetime access to all premium features",
|
||||
@ -1040,17 +1057,18 @@
|
||||
},
|
||||
"AIImagePage": {
|
||||
"title": "AI Image",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIChatPage": {
|
||||
"title": "AI Chat",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIVideoPage": {
|
||||
"title": "AI Video",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIAudioPage": {
|
||||
"title": "AI Audio",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@
|
||||
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
|
||||
},
|
||||
"Common": {
|
||||
"premium": "付费文章",
|
||||
"login": "登录",
|
||||
"logout": "退出",
|
||||
"signUp": "注册",
|
||||
@ -292,8 +293,20 @@
|
||||
"nextPage": "下一页",
|
||||
"chooseLanguage": "选择语言",
|
||||
"title": "MkSaaS文档",
|
||||
"homepage": "首页",
|
||||
"blog": "博客"
|
||||
"homepage": "首页"
|
||||
},
|
||||
"PremiumContent": {
|
||||
"title": "解锁付费内容",
|
||||
"description": "订阅我们的付费计划,访问所有付费内容和独家内容。",
|
||||
"upgradeCta": "立即升级",
|
||||
"benefit1": "所有内容",
|
||||
"benefit2": "独家内容",
|
||||
"benefit3": "随时取消",
|
||||
"signIn": "登录",
|
||||
"loginRequired": "登录以继续阅读",
|
||||
"loginDescription": "这是一篇付费内容,请登录您的账户以访问完整内容。",
|
||||
"checkingAccess": "检查阅读权限...",
|
||||
"loadingContent": "加载完整内容..."
|
||||
},
|
||||
"Marketing": {
|
||||
"navbar": {
|
||||
@ -320,6 +333,10 @@
|
||||
"title": "AI 图像",
|
||||
"description": "展示如何使用 AI 生成精美图像"
|
||||
},
|
||||
"chat": {
|
||||
"title": "AI 聊天",
|
||||
"description": "展示如何使用 AI 与客户聊天"
|
||||
},
|
||||
"video": {
|
||||
"title": "AI 视频",
|
||||
"description": "展示如何使用 AI 生成惊人视频"
|
||||
@ -574,7 +591,7 @@
|
||||
},
|
||||
"price": "价格:",
|
||||
"periodStartDate": "周期开始日期:",
|
||||
"nextBillingDate": "下次账单日期:",
|
||||
"periodEndDate": "周期结束日期:",
|
||||
"trialEnds": "试用结束日期:",
|
||||
"freePlanMessage": "您当前使用的是功能有限的免费方案",
|
||||
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
|
||||
@ -1040,17 +1057,18 @@
|
||||
},
|
||||
"AIImagePage": {
|
||||
"title": "AI 图片",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIChatPage": {
|
||||
"title": "AI 聊天",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIVideoPage": {
|
||||
"title": "AI 视频",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIAudioPage": {
|
||||
"title": "AI 音频",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,18 @@ const nextConfig: NextConfig = {
|
||||
// removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
// https://github.com/vercel/next.js/discussions/50177#discussioncomment-6006702
|
||||
// fix build error: Module build failed: UnhandledSchemeError:
|
||||
// Reading from "cloudflare:sockets" is not handled by plugins (Unhandled scheme).
|
||||
webpack: (config, { webpack }) => {
|
||||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /^pg-native$|^cloudflare:sockets$/,
|
||||
})
|
||||
);
|
||||
return config;
|
||||
},
|
||||
|
||||
images: {
|
||||
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
|
||||
// https://nextjs.org/docs/app/api-reference/components/image#unoptimized
|
||||
@ -70,3 +82,9 @@ const withNextIntl = createNextIntlPlugin();
|
||||
const withMDX = createMDX();
|
||||
|
||||
export default withMDX(withNextIntl(nextConfig));
|
||||
|
||||
// https://opennext.js.org/cloudflare/get-started#12-develop-locally
|
||||
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
|
||||
|
||||
// during local development, to access in any of your server code, local versions of Cloudflare bindings
|
||||
initOpenNextCloudflareForDev();
|
||||
|
6
open-next.config.ts
Normal file
6
open-next.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
|
||||
});
|
@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"cf-dev": "next dev -p 8787",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
@ -86,7 +87,6 @@
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
"@widgetbot/react-embed": "^1.9.0",
|
||||
"ai": "^5.0.0",
|
||||
"better-auth": "^1.1.19",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
@ -112,6 +112,8 @@
|
||||
"next-intl": "^4.0.0",
|
||||
"next-safe-action": "^7.10.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.0",
|
||||
"nuqs": "^2.5.1",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^19.0.0",
|
||||
@ -143,6 +145,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@opennextjs/cloudflare": "^1.6.5",
|
||||
"@tailwindcss/postcss": "^4.0.14",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@types/mdx": "^2.0.13",
|
||||
@ -157,6 +160,7 @@
|
||||
"react-email": "3.0.7",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"wrangler": "^4.28.1"
|
||||
}
|
||||
}
|
||||
|
4368
pnpm-lock.yaml
generated
4368
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
public/_headers
Normal file
2
public/_headers
Normal file
@ -0,0 +1,2 @@
|
||||
/_next/static/*
|
||||
Cache-Control: public,max-age=31536000,immutable
|
129
public/sw.js
129
public/sw.js
@ -1,129 +0,0 @@
|
||||
// Service Worker for caching iframe content
|
||||
const CACHE_NAME = 'cnblocks-iframe-cache-v1'
|
||||
|
||||
// Add iframe URLs to this list to prioritize caching
|
||||
const URLS_TO_CACHE = [
|
||||
// Default assets that should be cached
|
||||
'/favicon.ico',
|
||||
// Images used in iframes
|
||||
'/payments.png',
|
||||
'/payments-light.png',
|
||||
'/origin-cal.png',
|
||||
'/origin-cal-dark.png',
|
||||
'/exercice.png',
|
||||
'/exercice-dark.png',
|
||||
'/charts-light.png',
|
||||
'/charts.png',
|
||||
'/music-light.png',
|
||||
'/music.png',
|
||||
'/mail-back-light.png',
|
||||
'/mail-upper.png',
|
||||
'/mail-back.png',
|
||||
'/card.png',
|
||||
'/dark-card.webp',
|
||||
]
|
||||
|
||||
// Install event - cache resources
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('Opened cache')
|
||||
return cache.addAll(URLS_TO_CACHE)
|
||||
})
|
||||
.then(() => self.skipWaiting()) // Activate SW immediately
|
||||
)
|
||||
})
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
const currentCaches = [CACHE_NAME]
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((cacheNames) => {
|
||||
return cacheNames.filter((cacheName) => !currentCaches.includes(cacheName))
|
||||
})
|
||||
.then((cachesToDelete) => {
|
||||
return Promise.all(
|
||||
cachesToDelete.map((cacheToDelete) => {
|
||||
return caches.delete(cacheToDelete)
|
||||
})
|
||||
)
|
||||
})
|
||||
.then(() => self.clients.claim()) // Take control of clients immediately
|
||||
)
|
||||
})
|
||||
|
||||
// Fetch event - serve from cache or fetch from network and cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Check if this is an iframe request - typically they'll be HTML or have 'preview' in the URL
|
||||
const isIframeRequest = event.request.url.includes('/preview/') || event.request.url.includes('/examples/')
|
||||
|
||||
if (isIframeRequest) {
|
||||
event.respondWith(
|
||||
caches.match(event.request, { ignoreSearch: true }).then((response) => {
|
||||
// Return cached response if found
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Clone the request (requests are one-time use)
|
||||
const fetchRequest = event.request.clone()
|
||||
|
||||
return fetch(fetchRequest).then((response) => {
|
||||
// Check if we received a valid response
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response
|
||||
}
|
||||
|
||||
// Clone the response (responses are one-time use)
|
||||
const responseToCache = response.clone()
|
||||
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache)
|
||||
})
|
||||
|
||||
return response
|
||||
})
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// For non-iframe requests, use a standard cache-first strategy
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
return fetch(event.request)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for messages from clients (to force cache update, etc)
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting()
|
||||
}
|
||||
|
||||
// Handle cache clearing
|
||||
if (event.data && event.data.type === 'CLEAR_IFRAME_CACHE') {
|
||||
const url = event.data.url
|
||||
|
||||
if (url) {
|
||||
// Clear specific URL from cache
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.delete(url).then(() => {
|
||||
console.log(`Cleared cache for: ${url}`)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Clear the entire cache
|
||||
caches.delete(CACHE_NAME).then(() => {
|
||||
console.log('Cleared entire iframe cache')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
@ -15,6 +15,7 @@ export const docs = defineDocs({
|
||||
schema: frontmatterSchema.extend({
|
||||
preview: z.string().optional(),
|
||||
index: z.boolean().default(false),
|
||||
premium: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
meta: {
|
||||
@ -85,7 +86,7 @@ export const category = defineCollections({
|
||||
/**
|
||||
* Blog posts
|
||||
*
|
||||
* dtitle is required, but description is optional in frontmatter
|
||||
* title is required, but description is optional in frontmatter
|
||||
*/
|
||||
export const blog = defineCollections({
|
||||
type: 'doc',
|
||||
@ -94,6 +95,7 @@ export const blog = defineCollections({
|
||||
image: z.string(),
|
||||
date: z.string().date(),
|
||||
published: z.boolean().default(true),
|
||||
premium: z.boolean().optional(),
|
||||
categories: z.array(z.string()),
|
||||
author: z.string(),
|
||||
}),
|
||||
|
@ -1,37 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getWebContentAnalysisCost } from '@/ai/text/utils/web-content-analyzer-config';
|
||||
import { getUserCredits, hasEnoughCredits } from '@/credits/credits';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
|
||||
/**
|
||||
* Check if user has enough credits for web content analysis
|
||||
*/
|
||||
export const checkWebContentAnalysisCreditsAction = userActionClient.action(
|
||||
async ({ ctx }) => {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
try {
|
||||
const requiredCredits = getWebContentAnalysisCost();
|
||||
const currentCredits = await getUserCredits(currentUser.id);
|
||||
const hasCredits = await hasEnoughCredits({
|
||||
userId: currentUser.id,
|
||||
requiredCredits,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
hasEnoughCredits: hasCredits,
|
||||
currentCredits,
|
||||
requiredCredits,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('check web content analysis credits error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Something went wrong',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
@ -9,8 +9,19 @@ import { userActionClient } from '@/lib/safe-action';
|
||||
*/
|
||||
export const getCreditBalanceAction = userActionClient.action(
|
||||
async ({ ctx }) => {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
const credits = await getUserCredits(currentUser.id);
|
||||
return { success: true, credits };
|
||||
try {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
const credits = await getUserCredits(currentUser.id);
|
||||
return { success: true, credits };
|
||||
} catch (error) {
|
||||
console.error('get credit balance error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch credit balance',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -44,17 +44,30 @@ export const getCreditTransactionsAction = userActionClient
|
||||
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
// search by type, amount, paymentId, description, and restrict to current user
|
||||
// Search logic: text fields use ilike, and if search is a number, also search amount fields
|
||||
const searchConditions = [];
|
||||
if (search) {
|
||||
// Always search text fields
|
||||
searchConditions.push(
|
||||
ilike(creditTransaction.type, `%${search}%`),
|
||||
ilike(creditTransaction.paymentId, `%${search}%`),
|
||||
ilike(creditTransaction.description, `%${search}%`)
|
||||
);
|
||||
|
||||
// If search is a valid number, also search numeric fields
|
||||
const numericSearch = Number.parseInt(search, 10);
|
||||
if (!Number.isNaN(numericSearch)) {
|
||||
searchConditions.push(
|
||||
eq(creditTransaction.amount, numericSearch),
|
||||
eq(creditTransaction.remainingAmount, numericSearch)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const where = search
|
||||
? and(
|
||||
eq(creditTransaction.userId, currentUser.id),
|
||||
or(
|
||||
ilike(creditTransaction.type, `%${search}%`),
|
||||
ilike(creditTransaction.amount, `%${search}%`),
|
||||
ilike(creditTransaction.remainingAmount, `%${search}%`),
|
||||
ilike(creditTransaction.paymentId, `%${search}%`),
|
||||
ilike(creditTransaction.description, `%${search}%`)
|
||||
)
|
||||
or(...searchConditions)
|
||||
)
|
||||
: eq(creditTransaction.userId, currentUser.id);
|
||||
|
||||
|
181
src/ai/chat/components/ChatBot.tsx
Normal file
181
src/ai/chat/components/ChatBot.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import { Loader } from '@/components/ai-elements/loader';
|
||||
import { Message, MessageContent } from '@/components/ai-elements/message';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputButton,
|
||||
PromptInputModelSelect,
|
||||
PromptInputModelSelectContent,
|
||||
PromptInputModelSelectItem,
|
||||
PromptInputModelSelectTrigger,
|
||||
PromptInputModelSelectValue,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputToolbar,
|
||||
PromptInputTools,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning';
|
||||
import { Response } from '@/components/ai-elements/response';
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/source';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { GlobeIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
const models = [
|
||||
{
|
||||
name: 'GPT 4o',
|
||||
value: 'openai/gpt-4o',
|
||||
},
|
||||
{
|
||||
name: 'Deepseek R1',
|
||||
value: 'deepseek/deepseek-r1',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChatBot() {
|
||||
const [input, setInput] = useState('');
|
||||
const [model, setModel] = useState<string>(models[0].value);
|
||||
const [webSearch, setWebSearch] = useState(false);
|
||||
const { messages, sendMessage, status } = useChat();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
sendMessage(
|
||||
{ text: input },
|
||||
{
|
||||
body: {
|
||||
model: model,
|
||||
webSearch: webSearch,
|
||||
},
|
||||
}
|
||||
);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto p-6 relative size-full h-screen rounded-lg bg-muted">
|
||||
<div className="flex flex-col h-full">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id}>
|
||||
{message.role === 'assistant' && (
|
||||
<Sources>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'source-url':
|
||||
return (
|
||||
<>
|
||||
<SourcesTrigger
|
||||
count={
|
||||
message.parts.filter(
|
||||
(part) => part.type === 'source-url'
|
||||
).length
|
||||
}
|
||||
/>
|
||||
<SourcesContent key={`${message.id}-${i}`}>
|
||||
<Source
|
||||
key={`${message.id}-${i}`}
|
||||
href={part.url}
|
||||
title={part.url}
|
||||
/>
|
||||
</SourcesContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Sources>
|
||||
)}
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Response key={`${message.id}-${i}`}>
|
||||
{part.text}
|
||||
</Response>
|
||||
);
|
||||
case 'reasoning':
|
||||
return (
|
||||
<Reasoning
|
||||
key={`${message.id}-${i}`}
|
||||
className="w-full"
|
||||
isStreaming={status === 'streaming'}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
))}
|
||||
{status === 'submitted' && <Loader />}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<PromptInput onSubmit={handleSubmit} className="mt-4">
|
||||
<PromptInputTextarea
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
value={input}
|
||||
/>
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
<PromptInputButton
|
||||
variant={webSearch ? 'default' : 'ghost'}
|
||||
onClick={() => setWebSearch(!webSearch)}
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span>Search</span>
|
||||
</PromptInputButton>
|
||||
<PromptInputModelSelect
|
||||
onValueChange={(value) => {
|
||||
setModel(value);
|
||||
}}
|
||||
value={model}
|
||||
>
|
||||
<PromptInputModelSelectTrigger>
|
||||
<PromptInputModelSelectValue />
|
||||
</PromptInputModelSelectTrigger>
|
||||
<PromptInputModelSelectContent>
|
||||
{models.map((model) => (
|
||||
<PromptInputModelSelectItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
>
|
||||
{model.name}
|
||||
</PromptInputModelSelectItem>
|
||||
))}
|
||||
</PromptInputModelSelectContent>
|
||||
</PromptInputModelSelect>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit disabled={!input} status={status} />
|
||||
</PromptInputToolbar>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -76,9 +76,9 @@ export function ImagePlayground({
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-background py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mx-auto">
|
||||
{/* header */}
|
||||
<ImageGeneratorHeader />
|
||||
{/* <ImageGeneratorHeader /> */}
|
||||
|
||||
{/* input prompt */}
|
||||
<PromptInput
|
||||
|
@ -1,57 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
|
||||
import { CoinsIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CONSUME_CREDITS = 50;
|
||||
|
||||
export function ConsumeCreditCard() {
|
||||
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
|
||||
const consumeCreditsMutation = useConsumeCredits();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const hasEnoughCredits = (amount: number) => balance >= amount;
|
||||
|
||||
const handleConsume = async () => {
|
||||
if (!hasEnoughCredits(CONSUME_CREDITS)) {
|
||||
toast.error('Insufficient credits, please buy more credits.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await consumeCreditsMutation.mutateAsync({
|
||||
amount: CONSUME_CREDITS,
|
||||
description: `AI Text Credit Consumption (${CONSUME_CREDITS} credits)`,
|
||||
});
|
||||
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to consume credits, please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 p-4 border rounded-lg">
|
||||
<div className="w-full flex flex-row items-center justify-end">
|
||||
<CreditsBalanceButton />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleConsume}
|
||||
disabled={
|
||||
loading || isLoadingBalance || consumeCreditsMutation.isPending
|
||||
}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<CoinsIcon className="size-4" />
|
||||
<span>Consume {CONSUME_CREDITS} credits</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -34,7 +34,6 @@ interface ErrorDisplayProps {
|
||||
const errorIcons = {
|
||||
[ErrorType.VALIDATION]: AlertCircleIcon,
|
||||
[ErrorType.NETWORK]: WifiOffIcon,
|
||||
[ErrorType.CREDITS]: CreditCardIcon,
|
||||
[ErrorType.SCRAPING]: ServerIcon,
|
||||
[ErrorType.ANALYSIS]: HelpCircleIcon,
|
||||
[ErrorType.TIMEOUT]: ClockIcon,
|
||||
@ -84,7 +83,6 @@ const severityColors = {
|
||||
const errorTitles = {
|
||||
[ErrorType.VALIDATION]: 'Invalid Input',
|
||||
[ErrorType.NETWORK]: 'Connection Error',
|
||||
[ErrorType.CREDITS]: 'Insufficient Credits',
|
||||
[ErrorType.SCRAPING]: 'Unable to Access Website',
|
||||
[ErrorType.ANALYSIS]: 'Analysis Failed',
|
||||
[ErrorType.TIMEOUT]: 'Request Timed Out',
|
||||
|
@ -1,5 +1,4 @@
|
||||
export { AnalysisResults } from './analysis-results';
|
||||
export { ConsumeCreditCard } from './consume-credit-card';
|
||||
export { LoadingStates } from './loading-states';
|
||||
export { UrlInputForm } from './url-input-form';
|
||||
export { WebContentAnalyzer } from './web-content-analyzer';
|
||||
|
@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { checkWebContentAnalysisCreditsAction } from '@/actions/check-web-content-analysis-credits';
|
||||
import type { UrlInputFormProps } from '@/ai/text/utils/web-content-analyzer';
|
||||
import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
|
||||
import { LoginWrapper } from '@/components/auth/login-wrapper';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
@ -20,21 +18,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useLocalePathname } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CoinsIcon,
|
||||
LinkIcon,
|
||||
Loader2Icon,
|
||||
LogInIcon,
|
||||
SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { LinkIcon, Loader2Icon, SparklesIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { useDebounce } from '../utils/performance';
|
||||
|
||||
@ -52,19 +39,9 @@ export function UrlInputForm({
|
||||
modelProvider,
|
||||
setModelProvider,
|
||||
}: UrlInputFormProps) {
|
||||
const [creditInfo, setCreditInfo] = useState<{
|
||||
hasEnoughCredits: boolean;
|
||||
currentCredits: number;
|
||||
requiredCredits: number;
|
||||
} | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Get authentication status and current path for callback
|
||||
const { data: session, isPending: isAuthLoading } = authClient.useSession();
|
||||
const isAuthenticated = !!session?.user;
|
||||
const currentPath = useLocalePathname();
|
||||
|
||||
// Prevent hydration mismatch by only rendering auth-dependent content after mount
|
||||
// Prevent hydration mismatch by only rendering content after mount
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
@ -84,42 +61,6 @@ export function UrlInputForm({
|
||||
webContentAnalyzerConfig.performance.urlInputDebounceMs
|
||||
);
|
||||
|
||||
const { execute: checkCredits, isExecuting: isCheckingCredits } = useAction(
|
||||
checkWebContentAnalysisCreditsAction,
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (result.data?.success) {
|
||||
setCreditInfo({
|
||||
hasEnoughCredits: result.data.hasEnoughCredits ?? false,
|
||||
currentCredits: result.data.currentCredits ?? 0,
|
||||
requiredCredits: result.data.requiredCredits ?? 0,
|
||||
});
|
||||
} else {
|
||||
// Only show error toast if it's not an auth error
|
||||
if (result.data?.error !== 'Unauthorized') {
|
||||
setTimeout(() => {
|
||||
toast.error(result.data?.error || 'Failed to check credits');
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Credit check error:', error);
|
||||
// Only show error toast for non-auth errors
|
||||
setTimeout(() => {
|
||||
toast.error('Failed to check credits');
|
||||
}, 0);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Check credits only when user is authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isAuthLoading) {
|
||||
checkCredits();
|
||||
}
|
||||
}, [isAuthenticated, isAuthLoading, checkCredits]);
|
||||
|
||||
// Debounced URL validation effect
|
||||
useEffect(() => {
|
||||
if (debouncedUrl && debouncedUrl !== urlValue) {
|
||||
@ -129,23 +70,12 @@ export function UrlInputForm({
|
||||
}, [debouncedUrl, urlValue, form]);
|
||||
|
||||
const handleSubmit = (data: UrlFormData) => {
|
||||
// For authenticated users, check credits before submitting
|
||||
if (creditInfo && !creditInfo.hasEnoughCredits) {
|
||||
// Defer toast to avoid flushSync during render
|
||||
setTimeout(() => {
|
||||
toast.error(
|
||||
`Insufficient credits. You need ${creditInfo.requiredCredits} credits but only have ${creditInfo.currentCredits}.`
|
||||
);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
onSubmit(data.url ?? '', modelProvider);
|
||||
};
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(handleSubmit);
|
||||
|
||||
const isInsufficientCredits = creditInfo && !creditInfo.hasEnoughCredits;
|
||||
const isFormDisabled = isLoading || disabled || !!isInsufficientCredits;
|
||||
const isFormDisabled = isLoading || disabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -161,10 +91,10 @@ export function UrlInputForm({
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
||||
<SelectItem value="openai">OpenAI GPT-4o</SelectItem>
|
||||
<SelectItem value="gemini">Google Gemini</SelectItem>
|
||||
<SelectItem value="deepseek">DeepSeek</SelectItem>
|
||||
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
||||
<SelectItem value="deepseek">DeepSeek R1</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -194,67 +124,20 @@ export function UrlInputForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Credit Information - Only show for authenticated users */}
|
||||
{isAuthenticated && creditInfo && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<CoinsIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Cost: {creditInfo.requiredCredits} credits
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
creditInfo.hasEnoughCredits
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
>
|
||||
Balance: {creditInfo.currentCredits}
|
||||
</span>
|
||||
{!creditInfo.hasEnoughCredits && (
|
||||
<AlertCircleIcon className="size-4 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Insufficient Credits Warning */}
|
||||
{isAuthenticated && isInsufficientCredits && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-400">
|
||||
<AlertCircleIcon className="size-4 flex-shrink-0" />
|
||||
<span>
|
||||
Insufficient credits. You need {creditInfo.requiredCredits}{' '}
|
||||
credits but only have {creditInfo.currentCredits}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!mounted ? (
|
||||
// Show loading state during hydration to prevent mismatch
|
||||
<Button type="button" disabled className="w-full" size="lg">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</Button>
|
||||
) : isAuthenticated ? (
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFormDisabled || !urlValue?.trim()}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isAuthLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : isCheckingCredits ? (
|
||||
<>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Checking Credits...</span>
|
||||
</>
|
||||
) : isLoading ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Analyzing...</span>
|
||||
@ -262,24 +145,10 @@ export function UrlInputForm({
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon className="size-4" />
|
||||
<span>
|
||||
Analyze Website
|
||||
{creditInfo && ` (${creditInfo.requiredCredits} credits)`}
|
||||
</span>
|
||||
<span>Analyze Website</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<LoginWrapper mode="modal" asChild callbackUrl={currentPath}>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full cursor-pointer"
|
||||
size="lg"
|
||||
>
|
||||
<LogInIcon className="size-4" />
|
||||
<span>Sign In First</span>
|
||||
</Button>
|
||||
</LoginWrapper>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
@ -194,7 +194,8 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
|
||||
const [state, dispatch] = useReducer(analysisReducer, initialState);
|
||||
|
||||
// Model provider state
|
||||
const [modelProvider, setModelProvider] = useState<ModelProvider>('openai');
|
||||
const [modelProvider, setModelProvider] =
|
||||
useState<ModelProvider>('openrouter');
|
||||
|
||||
// Enhanced error state
|
||||
const [analyzedError, setAnalyzedError] =
|
||||
@ -232,16 +233,6 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
|
||||
errorType = ErrorType.VALIDATION;
|
||||
retryable = false;
|
||||
break;
|
||||
case 401:
|
||||
errorType = ErrorType.AUTHENTICATION;
|
||||
severity = ErrorSeverity.HIGH;
|
||||
retryable = false;
|
||||
break;
|
||||
case 402:
|
||||
errorType = ErrorType.CREDITS;
|
||||
severity = ErrorSeverity.HIGH;
|
||||
retryable = false;
|
||||
break;
|
||||
case 408:
|
||||
errorType = ErrorType.TIMEOUT;
|
||||
break;
|
||||
|
@ -9,7 +9,6 @@ import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-c
|
||||
export enum ErrorType {
|
||||
VALIDATION = 'validation',
|
||||
NETWORK = 'network',
|
||||
CREDITS = 'credits',
|
||||
SCRAPING = 'scraping',
|
||||
ANALYSIS = 'analysis',
|
||||
TIMEOUT = 'timeout',
|
||||
@ -96,22 +95,6 @@ export function classifyError(error: unknown): WebContentAnalyzerError {
|
||||
);
|
||||
}
|
||||
|
||||
// Credit errors
|
||||
if (
|
||||
message.includes('credit') ||
|
||||
message.includes('insufficient') ||
|
||||
message.includes('balance')
|
||||
) {
|
||||
return new WebContentAnalyzerError(
|
||||
ErrorType.CREDITS,
|
||||
error.message,
|
||||
'Insufficient credits to perform analysis. Please purchase more credits.',
|
||||
ErrorSeverity.HIGH,
|
||||
false,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Scraping errors
|
||||
if (
|
||||
message.includes('scrape') ||
|
||||
@ -278,16 +261,6 @@ export function getRecoveryActions(error: WebContentAnalyzerError): Array<{
|
||||
{ label: 'Try Simpler URL', action: 'simplify_url' },
|
||||
];
|
||||
|
||||
case ErrorType.CREDITS:
|
||||
return [
|
||||
{
|
||||
label: 'Purchase Credits',
|
||||
action: 'purchase_credits',
|
||||
primary: true,
|
||||
},
|
||||
{ label: 'Check Balance', action: 'check_balance' },
|
||||
];
|
||||
|
||||
case ErrorType.SCRAPING:
|
||||
return [
|
||||
{ label: 'Try Again', action: 'retry', primary: true },
|
||||
|
@ -6,11 +6,6 @@
|
||||
*/
|
||||
|
||||
export const webContentAnalyzerConfig = {
|
||||
/**
|
||||
* Credit cost for performing a web content analysis
|
||||
*/
|
||||
creditsCost: 100,
|
||||
|
||||
/**
|
||||
* Maximum content length for AI analysis (in characters)
|
||||
* Optimized to prevent token limit issues while maintaining quality
|
||||
@ -118,21 +113,15 @@ export const webContentAnalyzerConfig = {
|
||||
maxTokens: 2000,
|
||||
},
|
||||
openrouter: {
|
||||
model: 'openrouter/horizon-beta',
|
||||
// model: 'openrouter/horizon-beta',
|
||||
// model: 'x-ai/grok-3-beta',
|
||||
// model: 'openai/gpt-4o-mini',
|
||||
model: 'deepseek/deepseek-r1:free',
|
||||
temperature: 0.1,
|
||||
maxTokens: 2000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get the credit cost for web content analysis
|
||||
*/
|
||||
export function getWebContentAnalysisCost(): number {
|
||||
return webContentAnalyzerConfig.creditsCost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the Firecrawl API key is configured
|
||||
*/
|
||||
@ -151,8 +140,6 @@ export function validateFirecrawlConfig(): boolean {
|
||||
*/
|
||||
export function validateWebContentAnalyzerConfig(): boolean {
|
||||
return (
|
||||
typeof webContentAnalyzerConfig.creditsCost === 'number' &&
|
||||
webContentAnalyzerConfig.creditsCost > 0 &&
|
||||
typeof webContentAnalyzerConfig.maxContentLength === 'number' &&
|
||||
webContentAnalyzerConfig.maxContentLength > 0 &&
|
||||
typeof webContentAnalyzerConfig.timeoutMillis === 'number' &&
|
||||
|
@ -67,7 +67,7 @@ export interface AnalysisState {
|
||||
}
|
||||
|
||||
// Component Props Interfaces
|
||||
export type ModelProvider = 'openai' | 'gemini' | 'deepseek';
|
||||
export type ModelProvider = 'openai' | 'gemini' | 'deepseek' | 'openrouter';
|
||||
|
||||
export interface WebContentAnalyzerProps {
|
||||
className?: string;
|
||||
|
@ -1,5 +1,4 @@
|
||||
import Container from '@/components/layout/container';
|
||||
import { BlurFadeDemo } from '@/components/magicui/example/blur-fade-example';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
@ -98,9 +97,6 @@ export default async function AboutPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* image section */}
|
||||
{/* <BlurFadeDemo /> */}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
13
src/app/[locale]/(marketing)/(pages)/test/page.tsx
Normal file
13
src/app/[locale]/(marketing)/(pages)/test/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import Container from '@/components/layout/container';
|
||||
import { ConsumeCreditsCard } from '@/components/test/consume-credits-card';
|
||||
|
||||
export default async function TestPage() {
|
||||
return (
|
||||
<Container className="py-16 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* credits test */}
|
||||
<ConsumeCreditsCard />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -42,10 +42,6 @@ export default async function AIAudioPage() {
|
||||
<div className="size-32 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,183 +1,46 @@
|
||||
'use client';
|
||||
import ChatBot from '@/ai/chat/components/ChatBot';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { ZapIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Locale } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import { Loader } from '@/components/ai-elements/loader';
|
||||
import { Message, MessageContent } from '@/components/ai-elements/message';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputButton,
|
||||
PromptInputModelSelect,
|
||||
PromptInputModelSelectContent,
|
||||
PromptInputModelSelectItem,
|
||||
PromptInputModelSelectTrigger,
|
||||
PromptInputModelSelectValue,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputToolbar,
|
||||
PromptInputTools,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning';
|
||||
import { Response } from '@/components/ai-elements/response';
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/source';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { GlobeIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}): Promise<Metadata | undefined> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
||||
const pt = await getTranslations({ locale, namespace: 'AIChatPage' });
|
||||
|
||||
const models = [
|
||||
{
|
||||
name: 'GPT 4o',
|
||||
value: 'openai/gpt-4o',
|
||||
},
|
||||
{
|
||||
name: 'Deepseek R1',
|
||||
value: 'deepseek/deepseek-r1',
|
||||
},
|
||||
];
|
||||
return constructMetadata({
|
||||
title: pt('title') + ' | ' + t('title'),
|
||||
description: pt('description'),
|
||||
canonicalUrl: getUrlWithLocale('/ai/chat', locale),
|
||||
});
|
||||
}
|
||||
|
||||
const ChatBotDemo = () => {
|
||||
const [input, setInput] = useState('');
|
||||
const [model, setModel] = useState<string>(models[0].value);
|
||||
const [webSearch, setWebSearch] = useState(false);
|
||||
const { messages, sendMessage, status } = useChat();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
sendMessage(
|
||||
{ text: input },
|
||||
{
|
||||
body: {
|
||||
model: model,
|
||||
webSearch: webSearch,
|
||||
},
|
||||
}
|
||||
);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
export default async function AIChatPage() {
|
||||
const t = await getTranslations('AIChatPage');
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 relative size-full h-screen rounded-lg bg-muted/50">
|
||||
<div className="flex flex-col h-full">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id}>
|
||||
{message.role === 'assistant' && (
|
||||
<Sources>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'source-url':
|
||||
return (
|
||||
<>
|
||||
<SourcesTrigger
|
||||
count={
|
||||
message.parts.filter(
|
||||
(part) => part.type === 'source-url'
|
||||
).length
|
||||
}
|
||||
/>
|
||||
<SourcesContent key={`${message.id}-${i}`}>
|
||||
<Source
|
||||
key={`${message.id}-${i}`}
|
||||
href={part.url}
|
||||
title={part.url}
|
||||
/>
|
||||
</SourcesContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Sources>
|
||||
)}
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Response key={`${message.id}-${i}`}>
|
||||
{part.text}
|
||||
</Response>
|
||||
);
|
||||
case 'reasoning':
|
||||
return (
|
||||
<Reasoning
|
||||
key={`${message.id}-${i}`}
|
||||
className="w-full"
|
||||
isStreaming={status === 'streaming'}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
))}
|
||||
{status === 'submitted' && <Loader />}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
{/* Header Section */}
|
||||
<div className="text-center space-y-6 mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
|
||||
<ZapIcon className="size-4" />
|
||||
{t('title')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptInput onSubmit={handleSubmit} className="mt-4">
|
||||
<PromptInputTextarea
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
value={input}
|
||||
/>
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
<PromptInputButton
|
||||
variant={webSearch ? 'default' : 'ghost'}
|
||||
onClick={() => setWebSearch(!webSearch)}
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span>Search</span>
|
||||
</PromptInputButton>
|
||||
<PromptInputModelSelect
|
||||
onValueChange={(value) => {
|
||||
setModel(value);
|
||||
}}
|
||||
value={model}
|
||||
>
|
||||
<PromptInputModelSelectTrigger>
|
||||
<PromptInputModelSelectValue />
|
||||
</PromptInputModelSelectTrigger>
|
||||
<PromptInputModelSelectContent>
|
||||
{models.map((model) => (
|
||||
<PromptInputModelSelectItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
>
|
||||
{model.name}
|
||||
</PromptInputModelSelectItem>
|
||||
))}
|
||||
</PromptInputModelSelectContent>
|
||||
</PromptInputModelSelect>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit disabled={!input} status={status} />
|
||||
</PromptInputToolbar>
|
||||
</PromptInput>
|
||||
{/* Chat Bot */}
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<ChatBot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatBotDemo;
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { ImagePlayground } from '@/ai/image/components/ImagePlayground';
|
||||
import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Locale } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
@ -26,8 +27,21 @@ export default async function AIImagePage() {
|
||||
const t = await getTranslations('AIImagePage');
|
||||
|
||||
return (
|
||||
<div className="mx-auto space-y-8">
|
||||
<ImagePlayground suggestions={getRandomSuggestions(5)} />
|
||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
{/* Header Section */}
|
||||
<div className="text-center space-y-6 mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
|
||||
<ImageIcon className="size-4" />
|
||||
{t('title')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Playground Component */}
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<ImagePlayground suggestions={getRandomSuggestions(5)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ export default async function AITextPage() {
|
||||
const t = await getTranslations('AITextPage');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background rounded-lg">
|
||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
{/* Header Section */}
|
||||
<div className="text-center space-y-6 mb-12">
|
||||
|
@ -42,10 +42,6 @@ export default async function AIVideoPage() {
|
||||
<div className="size-32 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,16 +0,0 @@
|
||||
import { categories } from '@/components/tailark/blocks';
|
||||
import BlocksNav from '@/components/tailark/blocks-nav';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
/**
|
||||
* The locale inconsistency issue has been fixed in the BlocksNav component
|
||||
*/
|
||||
export default function BlockCategoryLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<BlocksNav categories={categories} />
|
||||
|
||||
<main>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
import BlockPreview from '@/components/tailark/block-preview';
|
||||
import { blocks, categories } from '@/components/tailark/blocks';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Locale } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return categories.map((category) => ({
|
||||
category: category,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale; category: string }>;
|
||||
}): Promise<Metadata | undefined> {
|
||||
const { locale, category } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
||||
return constructMetadata({
|
||||
title: category + ' | ' + t('title'),
|
||||
description: t('description'),
|
||||
canonicalUrl: getUrlWithLocale(`/blocks/${category}`, locale),
|
||||
});
|
||||
}
|
||||
|
||||
interface BlockCategoryPageProps {
|
||||
params: Promise<{ category: string }>;
|
||||
}
|
||||
|
||||
export default async function BlockCategoryPage({
|
||||
params,
|
||||
}: BlockCategoryPageProps) {
|
||||
const { category } = await params;
|
||||
const categoryBlocks = blocks.filter((b) => b.category === category);
|
||||
|
||||
if (categoryBlocks.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{categoryBlocks.map((block, index) => (
|
||||
<BlockPreview {...block} key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@ -2,10 +2,14 @@ import AllPostsButton from '@/components/blog/all-posts-button';
|
||||
import BlogGrid from '@/components/blog/blog-grid';
|
||||
import { getMDXComponents } from '@/components/docs/mdx-components';
|
||||
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
|
||||
import { PremiumBadge } from '@/components/premium/premium-badge';
|
||||
import { PremiumGuard } from '@/components/premium/premium-guard';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { checkPremiumAccess } from '@/lib/premium-access';
|
||||
import { getSession } from '@/lib/server';
|
||||
import {
|
||||
type BlogType,
|
||||
authorSource,
|
||||
@ -13,6 +17,7 @@ import {
|
||||
categorySource,
|
||||
} from '@/lib/source';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
||||
import { CalendarIcon, FileTextIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Locale } from 'next-intl';
|
||||
@ -21,7 +26,6 @@ import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import '@/styles/mdx.css';
|
||||
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
||||
|
||||
/**
|
||||
* get related posts, random pick from all posts with same locale, different slug,
|
||||
@ -83,7 +87,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { date, title, description, image, author, categories } = post.data;
|
||||
const { date, title, description, image, author, categories, premium } =
|
||||
post.data;
|
||||
const publishDate = formatDate(new Date(date));
|
||||
|
||||
const blogAuthor = authorSource.getPage([author], locale);
|
||||
@ -91,6 +96,13 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
.getPages(locale)
|
||||
.filter((category) => categories.includes(category.slugs[0] ?? ''));
|
||||
|
||||
// Check premium access for premium posts
|
||||
const session = await getSession();
|
||||
const hasPremiumAccess =
|
||||
premium && session?.user?.id
|
||||
? await checkPremiumAccess(session.user.id)
|
||||
: !premium; // Non-premium posts are always accessible
|
||||
|
||||
const MDX = post.data.body;
|
||||
|
||||
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
|
||||
@ -121,7 +133,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* blog post date */}
|
||||
{/* blog post date and premium badge */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
||||
@ -129,6 +141,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
{publishDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{premium && <PremiumBadge size="sm" />}
|
||||
</div>
|
||||
|
||||
{/* blog post title */}
|
||||
@ -141,8 +155,14 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
{/* blog post content */}
|
||||
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
|
||||
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
|
||||
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
|
||||
<MDX components={getMDXComponents()} />
|
||||
<div className="mt-8">
|
||||
<PremiumGuard
|
||||
isPremium={!!premium}
|
||||
canAccess={hasPremiumAccess}
|
||||
className="max-w-none"
|
||||
>
|
||||
<MDX components={getMDXComponents()} />
|
||||
</PremiumGuard>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start my-16">
|
||||
@ -212,8 +232,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
{relatedPosts && relatedPosts.length > 0 && (
|
||||
<div className="flex flex-col gap-8 mt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-lg tracking-wider font-semibold text-gradient_indigo-purple">
|
||||
<FileTextIcon className="size-4 text-primary" />
|
||||
<h2 className="text-lg tracking-wider font-semibold text-primary">
|
||||
{t('morePosts')}
|
||||
</h2>
|
||||
</div>
|
||||
|
@ -1,5 +1,7 @@
|
||||
import * as Preview from '@/components/docs';
|
||||
import { getMDXComponents } from '@/components/docs/mdx-components';
|
||||
import { PremiumBadge } from '@/components/premium/premium-badge';
|
||||
import { PremiumGuard } from '@/components/premium/premium-guard';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
@ -7,6 +9,8 @@ import {
|
||||
} from '@/components/ui/hover-card';
|
||||
import { LOCALES } from '@/i18n/routing';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { checkPremiumAccess } from '@/lib/premium-access';
|
||||
import { getSession } from '@/lib/server';
|
||||
import { source } from '@/lib/source';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import Link from 'fumadocs-core/link';
|
||||
@ -86,6 +90,14 @@ export default async function DocPage({ params }: DocPageProps) {
|
||||
}
|
||||
|
||||
const preview = page.data.preview;
|
||||
const { premium } = page.data;
|
||||
|
||||
// Check premium access for premium docs
|
||||
const session = await getSession();
|
||||
const hasPremiumAccess =
|
||||
premium && session?.user?.id
|
||||
? await checkPremiumAccess(session.user.id)
|
||||
: !premium; // Non-premium docs are always accessible
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
@ -98,44 +110,54 @@ export default async function DocPage({ params }: DocPageProps) {
|
||||
}}
|
||||
>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
{premium && <PremiumBadge size="sm" className="mt-2" />}
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
{/* Preview Rendered Component */}
|
||||
{preview ? <PreviewRenderer preview={preview} /> : null}
|
||||
|
||||
{/* MDX Content */}
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
a: ({ href, ...props }: { href?: string; [key: string]: any }) => {
|
||||
const found = source.getPageByHref(href ?? '', {
|
||||
dir: page.file.dirname,
|
||||
});
|
||||
<PremiumGuard
|
||||
isPremium={!!premium}
|
||||
canAccess={hasPremiumAccess}
|
||||
className="max-w-none"
|
||||
>
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
a: ({
|
||||
href,
|
||||
...props
|
||||
}: { href?: string; [key: string]: any }) => {
|
||||
const found = source.getPageByHref(href ?? '', {
|
||||
dir: page.file.dirname,
|
||||
});
|
||||
|
||||
if (!found) return <Link href={href} {...props} />;
|
||||
if (!found) return <Link href={href} {...props} />;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
href={
|
||||
found.hash
|
||||
? `${found.page.url}#${found.hash}`
|
||||
: found.page.url
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="text-sm">
|
||||
<p className="font-medium">{found.page.data.title}</p>
|
||||
<p className="text-fd-muted-foreground">
|
||||
{found.page.data.description}
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
href={
|
||||
found.hash
|
||||
? `${found.page.url}#${found.hash}`
|
||||
: found.page.url
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="text-sm">
|
||||
<p className="font-medium">{found.page.data.title}</p>
|
||||
<p className="text-fd-muted-foreground">
|
||||
{found.page.data.description}
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</PremiumGuard>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
|
@ -12,6 +12,7 @@ import { routing } from '@/i18n/routing';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { Providers } from './providers';
|
||||
@ -57,15 +58,17 @@ export default async function LocaleLayout({
|
||||
fontBricolageGrotesque.variable
|
||||
)}
|
||||
>
|
||||
<NextIntlClientProvider>
|
||||
<Providers locale={locale}>
|
||||
{children}
|
||||
<NuqsAdapter>
|
||||
<NextIntlClientProvider>
|
||||
<Providers locale={locale}>
|
||||
{children}
|
||||
|
||||
<Toaster richColors position="top-right" offset={64} />
|
||||
<TailwindIndicator />
|
||||
<Analytics />
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
<Toaster richColors position="top-right" offset={64} />
|
||||
<TailwindIndicator />
|
||||
<Analytics />
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
@ -29,7 +29,7 @@ interface ProvidersProps {
|
||||
*/
|
||||
export function Providers({ children, locale }: ProvidersProps) {
|
||||
const theme = useTheme();
|
||||
const defaultMode = websiteConfig.metadata.mode?.defaultMode ?? 'system';
|
||||
const defaultMode = websiteConfig.ui.mode?.defaultMode ?? 'system';
|
||||
|
||||
// available languages that will be displayed in the docs UI
|
||||
// make sure `locale` is consistent with your i18n config
|
||||
|
@ -13,12 +13,9 @@ import {
|
||||
validateUrl,
|
||||
} from '@/ai/text/utils/web-content-analyzer';
|
||||
import {
|
||||
getWebContentAnalysisCost,
|
||||
validateFirecrawlConfig,
|
||||
webContentAnalyzerConfig,
|
||||
} from '@/ai/text/utils/web-content-analyzer-config';
|
||||
import { consumeCredits, hasEnoughCredits } from '@/credits/credits';
|
||||
import { getSession } from '@/lib/server';
|
||||
import { createDeepSeek } from '@ai-sdk/deepseek';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
@ -30,7 +27,6 @@ import { z } from 'zod';
|
||||
|
||||
// Constants from configuration
|
||||
const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis;
|
||||
const CREDITS_COST = getWebContentAnalysisCost();
|
||||
const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength;
|
||||
|
||||
// Initialize Firecrawl client
|
||||
@ -361,28 +357,6 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
const authError = new WebContentAnalyzerError(
|
||||
ErrorType.AUTHENTICATION,
|
||||
'Authentication required',
|
||||
'Please sign in to analyze web content.',
|
||||
ErrorSeverity.HIGH,
|
||||
false
|
||||
);
|
||||
|
||||
logError(authError, { requestId });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authError.userMessage,
|
||||
} satisfies AnalyzeContentResponse,
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if Firecrawl is configured
|
||||
if (!validateFirecrawlConfig()) {
|
||||
const configError = new WebContentAnalyzerError(
|
||||
@ -404,39 +378,7 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has sufficient credits before starting analysis
|
||||
const hasCredits = await hasEnoughCredits({
|
||||
userId: session.user.id,
|
||||
requiredCredits: CREDITS_COST,
|
||||
});
|
||||
|
||||
if (!hasCredits) {
|
||||
const creditError = new WebContentAnalyzerError(
|
||||
ErrorType.CREDITS,
|
||||
'Insufficient credits to perform analysis',
|
||||
"You don't have enough credits to analyze this webpage. Please purchase more credits.",
|
||||
ErrorSeverity.HIGH,
|
||||
false
|
||||
);
|
||||
|
||||
logError(creditError, {
|
||||
requestId,
|
||||
userId: session.user.id,
|
||||
requiredCredits: CREDITS_COST,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: creditError.userMessage,
|
||||
} satisfies AnalyzeContentResponse,
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Starting analysis [requestId=${requestId}, url=${url}, userId=${session.user.id}]`
|
||||
);
|
||||
console.log(`Starting analysis [requestId=${requestId}, url=${url}]`);
|
||||
|
||||
// Perform analysis with timeout and enhanced error handling
|
||||
const analysisPromise = (async () => {
|
||||
@ -447,13 +389,6 @@ export async function POST(req: NextRequest) {
|
||||
// Step 2: Analyze content with AI (pass provider)
|
||||
const analysis = await analyzeContent(content, url, modelProvider);
|
||||
|
||||
// Step 3: Consume credits (only on successful analysis)
|
||||
await consumeCredits({
|
||||
userId: session.user.id,
|
||||
amount: CREDITS_COST,
|
||||
description: `Web content analysis: ${url}`,
|
||||
});
|
||||
|
||||
return { analysis, screenshot };
|
||||
} catch (error) {
|
||||
// If it's already a WebContentAnalyzerError, just re-throw
|
||||
@ -477,7 +412,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
creditsConsumed: CREDITS_COST,
|
||||
} satisfies AnalyzeContentResponse);
|
||||
} catch (error) {
|
||||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||||
@ -499,12 +433,6 @@ export async function POST(req: NextRequest) {
|
||||
case ErrorType.VALIDATION:
|
||||
statusCode = 400;
|
||||
break;
|
||||
case ErrorType.AUTHENTICATION:
|
||||
statusCode = 401;
|
||||
break;
|
||||
case ErrorType.CREDITS:
|
||||
statusCode = 402;
|
||||
break;
|
||||
case ErrorType.TIMEOUT:
|
||||
statusCode = 408;
|
||||
break;
|
||||
|
@ -4,31 +4,56 @@ import { UsersTable } from '@/components/admin/users-table';
|
||||
import { useUsers } from '@/hooks/use-users';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
parseAsIndex,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function UsersPageClient() {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
|
||||
const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting);
|
||||
const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] =
|
||||
useQueryStates({
|
||||
page: parseAsIndex.withDefault(0), // parseAsIndex adds +1 to URL, so 0-based internally, 1-based in URL
|
||||
pageSize: parseAsInteger.withDefault(10),
|
||||
search: parseAsString.withDefault(''),
|
||||
sortId: parseAsString.withDefault('createdAt'),
|
||||
sortDesc: parseAsInteger.withDefault(1),
|
||||
});
|
||||
|
||||
const sorting: SortingState = useMemo(
|
||||
() => [{ id: sortId, desc: Boolean(sortDesc) }],
|
||||
[sortId, sortDesc]
|
||||
);
|
||||
|
||||
// page is already 0-based internally thanks to parseAsIndex
|
||||
const { data, isLoading } = useUsers(page, pageSize, search, sorting);
|
||||
|
||||
return (
|
||||
<UsersTable
|
||||
data={data?.items || []}
|
||||
total={data?.total || 0}
|
||||
pageIndex={pageIndex}
|
||||
pageIndex={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
loading={isLoading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
onSearch={(newSearch) => setQueryStates({ search: newSearch, page: 0 })}
|
||||
onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setQueryStates({ pageSize: newPageSize, page: 0 })
|
||||
}
|
||||
onSortingChange={(newSorting) => {
|
||||
if (newSorting.length > 0) {
|
||||
setQueryStates({
|
||||
sortId: newSorting[0].id,
|
||||
sortDesc: newSorting[0].desc ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -59,6 +59,7 @@ import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Label } from '../ui/label';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@ -116,12 +117,27 @@ function DataTableColumnHeader<TData, TValue>({
|
||||
);
|
||||
}
|
||||
|
||||
function TableRowSkeleton({ columns }: { columns: number }) {
|
||||
return (
|
||||
<TableRow>
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<TableCell key={index} className="py-4">
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<Skeleton className="h-6 w-full max-w-32" />
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsersTableProps {
|
||||
data: User[];
|
||||
total: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
sorting?: SortingState;
|
||||
loading?: boolean;
|
||||
onSearch: (search: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
@ -138,6 +154,7 @@ export function UsersTable({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting = [{ id: 'createdAt', desc: true }],
|
||||
loading,
|
||||
onSearch,
|
||||
onPageChange,
|
||||
@ -146,9 +163,6 @@ export function UsersTable({
|
||||
}: UsersTableProps) {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const tTable = useTranslations('Common.table');
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
@ -351,7 +365,6 @@ export function UsersTable({
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSorting(next);
|
||||
onSortingChange?.(next);
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@ -444,7 +457,12 @@ export function UsersTable({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
{loading ? (
|
||||
// Show skeleton rows while loading
|
||||
Array.from({ length: pageSize }).map((_, index) => (
|
||||
<TableRowSkeleton key={index} columns={columns.length} />
|
||||
))
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
@ -466,7 +484,7 @@ export function UsersTable({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{loading ? tTable('loading') : tTable('noResults')}
|
||||
{tTable('noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
@ -43,7 +43,6 @@ export const CodeBlock = ({
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* @ts-expect-error - SyntaxHighlighter is not a valid JSX component */}
|
||||
<SyntaxHighlighter
|
||||
className="overflow-hidden dark:hidden"
|
||||
codeTagProps={{
|
||||
@ -67,7 +66,6 @@ export const CodeBlock = ({
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
{/* @ts-expect-error - SyntaxHighlighter is not a valid JSX component */}
|
||||
<SyntaxHighlighter
|
||||
className="hidden overflow-hidden dark:block"
|
||||
codeTagProps={{
|
||||
|
@ -34,12 +34,13 @@ export const MessageContent = ({
|
||||
className={cn(
|
||||
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
|
||||
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
|
||||
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
|
||||
'group-[.is-assistant]:bg-card group-[.is-assistant]:text-card-foreground',
|
||||
'is-user:dark',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="is-user:dark">{children}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -54,10 +55,7 @@ export const MessageAvatar = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageAvatarProps) => (
|
||||
<Avatar
|
||||
className={cn('size-8 ring ring-1 ring-border', className)}
|
||||
{...props}
|
||||
>
|
||||
<Avatar className={cn('size-8 ring-1 ring-border', className)} {...props}>
|
||||
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
|
||||
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
@ -38,13 +38,14 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
@ -70,23 +71,22 @@ export const Reasoning = memo(
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.round((Date.now() - startTime) / 1000));
|
||||
setDuration(Math.round((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen) {
|
||||
setIsOpen(true);
|
||||
} else if (!isStreaming && isOpen && !defaultOpen && !hasAutoClosedRef) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosedRef(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosedRef) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosedRef(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@ -110,19 +110,10 @@ export const Reasoning = memo(
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
title?: string;
|
||||
};
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
title = 'Reasoning',
|
||||
children,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
|
74
src/components/ai-elements/sources.tsx
Normal file
74
src/components/ai-elements/sources.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BookIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type SourcesProps = ComponentProps<'div'>;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4 text-primary text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<'a'>;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
@ -50,7 +50,7 @@ const getStatusBadge = (status: ToolUIPart['state']) => {
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Badge className="rounded-full text-xs" variant="secondary">
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
|
@ -17,7 +17,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@ -45,10 +45,10 @@ export const LoginForm = ({
|
||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||
const locale = useLocale();
|
||||
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||
DEFAULT_LOGIN_REDIRECT,
|
||||
locale
|
||||
);
|
||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
||||
// console.log('login form, propCallbackUrl', propCallbackUrl);
|
||||
// console.log('login form, paramCallbackUrl', paramCallbackUrl);
|
||||
// console.log('login form, defaultCallbackUrl', defaultCallbackUrl);
|
||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||
console.log('login form, callbackUrl', callbackUrl);
|
||||
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
||||
@ -40,10 +40,10 @@ export const RegisterForm = ({
|
||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||
const locale = useLocale();
|
||||
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||
DEFAULT_LOGIN_REDIRECT,
|
||||
locale
|
||||
);
|
||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
||||
// console.log('register form, propCallbackUrl', propCallbackUrl);
|
||||
// console.log('register form, paramCallbackUrl', paramCallbackUrl);
|
||||
// console.log('register form, defaultCallbackUrl', defaultCallbackUrl);
|
||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||
console.log('register form, callbackUrl', callbackUrl);
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { GoogleIcon } from '@/components/icons/google';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
@ -37,10 +37,7 @@ export const SocialLoginButton = ({
|
||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||
const locale = useLocale();
|
||||
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||
DEFAULT_LOGIN_REDIRECT,
|
||||
locale
|
||||
);
|
||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||
const [isLoading, setIsLoading] = useState<'google' | 'github' | null>(null);
|
||||
console.log('social login button, callbackUrl', callbackUrl);
|
||||
|
@ -66,7 +66,7 @@ export default function FeaturesSection() {
|
||||
<div className="grid gap-12 sm:px-12 lg:grid-cols-12 lg:gap-24 lg:px-0">
|
||||
<div className="lg:col-span-5 flex flex-col gap-8">
|
||||
<div className="lg:pr-0 text-left">
|
||||
<h3 className="text-3xl font-semibold lg:text-4xl text-gradient_indigo-purple leading-normal py-1">
|
||||
<h3 className="text-3xl font-semibold lg:text-4xl text-foreground leading-normal py-1">
|
||||
{t('title')}
|
||||
</h3>
|
||||
<p className="mt-4 text-muted-foreground">{t('description')}</p>
|
||||
|
@ -56,14 +56,13 @@ export default function HeroSection() {
|
||||
<AnimatedGroup variants={transitionVariants}>
|
||||
<LocaleLink
|
||||
href={linkIntroduction}
|
||||
className="hover:bg-background group mx-auto flex w-fit items-center gap-4 rounded-full border p-1 pl-4 shadow-md shadow-zinc-950/5 transition-colors duration-300 dark:shadow-zinc-950"
|
||||
className="hover:bg-accent group mx-auto flex w-fit items-center gap-2 rounded-full border p-1 pl-4"
|
||||
>
|
||||
<span className="text-foreground text-sm">
|
||||
{t('introduction')}
|
||||
</span>
|
||||
{/* <span className="dark:border-background block h-4 w-0.5 border-l bg-white dark:bg-zinc-700"></span> */}
|
||||
|
||||
<div className="bg-background group-hover:bg-muted size-6 overflow-hidden rounded-full duration-500">
|
||||
<div className="size-6 overflow-hidden rounded-full duration-500">
|
||||
<div className="flex w-12 -translate-x-1/2 duration-500 ease-in-out group-hover:translate-x-0">
|
||||
<span className="flex size-6">
|
||||
<ArrowRight className="m-auto size-3" />
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { PLACEHOLDER_IMAGE } from '@/lib/constants';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { type BlogType, authorSource, categorySource } from '@/lib/source';
|
||||
import Image from 'next/image';
|
||||
import { PremiumBadge } from '../premium/premium-badge';
|
||||
import BlogImage from './blog-image';
|
||||
|
||||
interface BlogCardProps {
|
||||
locale: string;
|
||||
@ -20,56 +21,46 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
|
||||
|
||||
return (
|
||||
<LocaleLink href={`/blog/${post.slugs}`} className="block h-full">
|
||||
<div className="group flex flex-col border rounded-lg overflow-hidden h-full">
|
||||
<div className="group flex flex-col border border-border rounded-lg overflow-hidden h-full transition-all duration-300 ease-in-out hover:border-primary hover:shadow-lg hover:shadow-primary/20">
|
||||
{/* Image container - fixed aspect ratio */}
|
||||
<div className="group overflow-hidden relative aspect-16/9 w-full">
|
||||
{image && (
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={image}
|
||||
alt={title || 'image for blog post'}
|
||||
title={title || 'image for blog post'}
|
||||
className="object-cover hover:scale-105 transition-transform duration-300"
|
||||
placeholder="blur"
|
||||
blurDataURL={PLACEHOLDER_IMAGE}
|
||||
fill
|
||||
/>
|
||||
<div className="relative w-full h-full">
|
||||
<BlogImage
|
||||
src={image}
|
||||
alt={title || 'image for blog post'}
|
||||
title={title || 'image for blog post'}
|
||||
/>
|
||||
|
||||
{blogCategories && blogCategories.length > 0 && (
|
||||
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{blogCategories.map((category, index) => (
|
||||
<span
|
||||
key={`${category?.slugs[0]}-${index}`}
|
||||
className="text-xs font-medium text-white bg-black/50 bg-opacity-50 px-2 py-1 rounded-md"
|
||||
>
|
||||
{category?.data.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Premium badge - top right */}
|
||||
{post.data.premium && (
|
||||
<div className="absolute top-2 right-2 z-20">
|
||||
<PremiumBadge size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* categories */}
|
||||
{blogCategories && blogCategories.length > 0 && (
|
||||
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300 z-20">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{blogCategories.map((category, index) => (
|
||||
<span
|
||||
key={`${category?.slugs[0]}-${index}`}
|
||||
className="text-xs font-medium text-white bg-black/50 bg-opacity-50 px-2 py-1 rounded-md"
|
||||
>
|
||||
{category?.data.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Post info container */}
|
||||
<div className="flex flex-col justify-between p-4 flex-1">
|
||||
<div>
|
||||
{/* Post title */}
|
||||
<h3 className="text-lg line-clamp-2 font-medium">
|
||||
<span
|
||||
className="bg-linear-to-r from-green-200 to-green-100
|
||||
bg-[length:0px_10px] bg-left-bottom bg-no-repeat
|
||||
transition-[background-size]
|
||||
duration-500
|
||||
hover:bg-[length:100%_3px]
|
||||
group-hover:bg-[length:100%_10px]
|
||||
dark:from-purple-800 dark:to-purple-900"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
<h3 className="text-lg line-clamp-2 font-medium">{title}</h3>
|
||||
|
||||
{/* Post excerpt */}
|
||||
<div className="mt-2">
|
||||
@ -111,12 +102,7 @@ export function BlogCardSkeleton() {
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden h-full">
|
||||
<div className="overflow-hidden relative aspect-16/9 w-full">
|
||||
<Image
|
||||
src={PLACEHOLDER_IMAGE}
|
||||
alt="Loading placeholder"
|
||||
className="object-cover"
|
||||
fill
|
||||
/>
|
||||
<Skeleton className="h-full w-full rounded-b-none" />
|
||||
</div>
|
||||
<div className="p-4 flex flex-col justify-between flex-1">
|
||||
<div>
|
||||
|
40
src/components/blog/blog-image.tsx
Normal file
40
src/components/blog/blog-image.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface BlogImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function BlogImage({ src, alt, title }: BlogImageProps) {
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{/* loading skeleton */}
|
||||
{imageLoading && (
|
||||
<Skeleton className="absolute inset-0 h-full w-full rounded-b-none z-10" />
|
||||
)}
|
||||
|
||||
{/* actual image */}
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title || alt}
|
||||
className={`object-cover hover:scale-105 transition-transform duration-300 ${
|
||||
imageLoading ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
fill
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -12,7 +12,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { getSidebarLinks } from '@/config/sidebar-config';
|
||||
import { useSidebarLinks } from '@/config/sidebar-config';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Routes } from '@/routes';
|
||||
@ -35,7 +35,7 @@ export function DashboardSidebar({
|
||||
const { state } = useSidebar();
|
||||
// console.log('sidebar currentUser:', currentUser);
|
||||
|
||||
const sidebarLinks = getSidebarLinks();
|
||||
const sidebarLinks = useSidebarLinks();
|
||||
const filteredSidebarLinks = sidebarLinks.filter((link) => {
|
||||
if (link.authorizeOnly) {
|
||||
return link.authorizeOnly.includes(currentUser?.role || '');
|
||||
|
@ -71,7 +71,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const showModeSwitch = websiteConfig.metadata.mode?.enableSwitch ?? false;
|
||||
const showModeSwitch = websiteConfig.ui.mode?.enableSwitch ?? false;
|
||||
const showLocaleSwitch = LOCALES.length > 1;
|
||||
|
||||
const handleSignOut = async () => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ImageWrapper } from '@/components/docs/image-wrapper';
|
||||
import { Wrapper } from '@/components/docs/wrapper';
|
||||
import { YoutubeVideo } from '@/components/docs/youtube-video';
|
||||
import { PremiumContent } from '@/components/premium/premium-content';
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { File, Files, Folder } from 'fumadocs-ui/components/files';
|
||||
@ -23,6 +24,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
||||
...LucideIcons,
|
||||
// ...((await import('lucide-react')) as unknown as MDXComponents),
|
||||
YoutubeVideo,
|
||||
PremiumContent,
|
||||
Tabs,
|
||||
Tab,
|
||||
TypeTable,
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
const COOKIE_NAME = 'active_theme';
|
||||
const DEFAULT_THEME = websiteConfig.metadata.theme?.defaultTheme ?? 'default';
|
||||
const DEFAULT_THEME = websiteConfig.ui.theme?.defaultTheme ?? 'default';
|
||||
|
||||
function setThemeCookie(theme: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
@ -4,8 +4,8 @@ import Container from '@/components/layout/container';
|
||||
import { Logo } from '@/components/layout/logo';
|
||||
import { ModeSwitcherHorizontal } from '@/components/layout/mode-switcher-horizontal';
|
||||
import BuiltWithButton from '@/components/shared/built-with-button';
|
||||
import { getFooterLinks } from '@/config/footer-config';
|
||||
import { getSocialLinks } from '@/config/social-config';
|
||||
import { useFooterLinks } from '@/config/footer-config';
|
||||
import { useSocialLinks } from '@/config/social-config';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -13,8 +13,8 @@ import type React from 'react';
|
||||
|
||||
export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
|
||||
const t = useTranslations();
|
||||
const footerLinks = getFooterLinks();
|
||||
const socialLinks = getSocialLinks();
|
||||
const footerLinks = useFooterLinks();
|
||||
const socialLinks = useSocialLinks();
|
||||
|
||||
return (
|
||||
<footer className={cn('border-t', className)}>
|
||||
|
@ -43,7 +43,7 @@ export function HeaderSection({
|
||||
{title ? (
|
||||
<TitleComponent
|
||||
className={cn(
|
||||
'uppercase tracking-wider text-gradient_indigo-purple font-semibold font-mono',
|
||||
'uppercase tracking-wider text-primary font-semibold font-mono',
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
|
@ -12,7 +12,7 @@ import { useEffect, useState } from 'react';
|
||||
* Mode switcher component, used in the footer
|
||||
*/
|
||||
export function ModeSwitcherHorizontal() {
|
||||
if (!websiteConfig.metadata.mode?.enableSwitch) {
|
||||
if (!websiteConfig.ui.mode?.enableSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ import { useTheme } from 'next-themes';
|
||||
* Mode switcher component, used in the navbar
|
||||
*/
|
||||
export function ModeSwitcher() {
|
||||
if (!websiteConfig.metadata.mode?.enableSwitch) {
|
||||
if (!websiteConfig.ui.mode?.enableSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { getNavbarLinks } from '@/config/navbar-config';
|
||||
import { useNavbarLinks } from '@/config/navbar-config';
|
||||
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -146,7 +146,7 @@ interface MainMobileMenuProps {
|
||||
function MainMobileMenu({ userLoggedIn, onLinkClicked }: MainMobileMenuProps) {
|
||||
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
|
||||
const t = useTranslations();
|
||||
const menuLinks = getNavbarLinks();
|
||||
const menuLinks = useNavbarLinks();
|
||||
const localePathname = useLocalePathname();
|
||||
|
||||
return (
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import { getNavbarLinks } from '@/config/navbar-config';
|
||||
import { useNavbarLinks } from '@/config/navbar-config';
|
||||
import { useScroll } from '@/hooks/use-scroll';
|
||||
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
@ -37,14 +37,14 @@ const customNavigationMenuTriggerStyle = cn(
|
||||
'relative bg-transparent text-muted-foreground cursor-pointer',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-active:font-semibold data-active:bg-transparent data-active:text-foreground',
|
||||
'data-[state=open]:bg-transparent data-[state=open]:text-foreground'
|
||||
'data-active:font-semibold data-active:bg-transparent data-active:text-accent-foreground',
|
||||
'data-[state=open]:bg-transparent data-[state=open]:text-accent-foreground'
|
||||
);
|
||||
|
||||
export function Navbar({ scroll }: NavBarProps) {
|
||||
const t = useTranslations();
|
||||
const scrolled = useScroll(50);
|
||||
const menuLinks = getNavbarLinks();
|
||||
const menuLinks = useNavbarLinks();
|
||||
const localePathname = useLocalePathname();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
@ -132,10 +132,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center transition-colors',
|
||||
'bg-transparent text-muted-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
||||
isSubItemActive &&
|
||||
'bg-transparent text-foreground'
|
||||
'bg-transparent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{subItem.icon ? subItem.icon : null}
|
||||
@ -144,10 +144,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm font-medium text-muted-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
||||
isSubItemActive &&
|
||||
'bg-transparent text-foreground'
|
||||
'bg-transparent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{subItem.title}
|
||||
@ -156,10 +156,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-foreground/80',
|
||||
'group-focus:bg-transparent group-focus:text-foreground/80',
|
||||
'group-hover:bg-transparent group-hover:text-accent-foreground/80',
|
||||
'group-focus:bg-transparent group-focus:text-accent-foreground/80',
|
||||
isSubItemActive &&
|
||||
'bg-transparent text-foreground/80'
|
||||
'bg-transparent text-accent-foreground/80'
|
||||
)}
|
||||
>
|
||||
{subItem.description}
|
||||
@ -170,10 +170,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
<ArrowUpRightIcon
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
||||
isSubItemActive &&
|
||||
'bg-transparent text-foreground'
|
||||
'bg-transparent text-accent-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
@ -21,7 +21,7 @@ import { useThemeConfig } from './active-theme-provider';
|
||||
* https://github.com/TheOrcDev/orcish-dashboard/blob/main/components/theme-selector.tsx
|
||||
*/
|
||||
export function ThemeSelector() {
|
||||
if (!websiteConfig.metadata.theme?.enableSwitch) {
|
||||
if (!websiteConfig.ui.theme?.enableSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer';
|
||||
import { getAvatarLinks } from '@/config/avatar-config';
|
||||
import { useAvatarLinks } from '@/config/avatar-config';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import type { User } from 'better-auth';
|
||||
@ -25,7 +25,7 @@ interface UserButtonProps {
|
||||
|
||||
export function UserButtonMobile({ user }: UserButtonProps) {
|
||||
const t = useTranslations();
|
||||
const avatarLinks = getAvatarLinks();
|
||||
const avatarLinks = useAvatarLinks();
|
||||
const localeRouter = useLocaleRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeDrawer = () => {
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { getAvatarLinks } from '@/config/avatar-config';
|
||||
import { useAvatarLinks } from '@/config/avatar-config';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
@ -25,7 +25,7 @@ interface UserButtonProps {
|
||||
|
||||
export function UserButton({ user }: UserButtonProps) {
|
||||
const t = useTranslations();
|
||||
const avatarLinks = getAvatarLinks();
|
||||
const avatarLinks = useAvatarLinks();
|
||||
const localeRouter = useLocaleRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleSignOut = async () => {
|
||||
|
47
src/components/premium/premium-badge.tsx
Normal file
47
src/components/premium/premium-badge.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CrownIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface PremiumBadgeProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'outline' | 'secondary';
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
}
|
||||
|
||||
export function PremiumBadge({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
}: PremiumBadgeProps) {
|
||||
const t = useTranslations('Common');
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs h-5',
|
||||
default: 'text-xs h-6',
|
||||
lg: 'text-sm h-7',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'size-3',
|
||||
default: 'size-3',
|
||||
lg: 'size-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 font-medium',
|
||||
'bg-orange-400 text-white border-0',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CrownIcon className={iconSizes[size]} />
|
||||
{t('premium')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
32
src/components/premium/premium-content.tsx
Normal file
32
src/components/premium/premium-content.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PremiumContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side Premium Content component
|
||||
* Note: This component now serves as a fallback for client-side rendering.
|
||||
* The main security filtering happens server-side in PremiumGuard component.
|
||||
*/
|
||||
export function PremiumContent({ children }: PremiumContentProps) {
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: paymentData } = useCurrentPlan(currentUser?.id);
|
||||
|
||||
// Determine if user has premium access
|
||||
const hasPremiumAccess =
|
||||
paymentData?.currentPlan &&
|
||||
(paymentData.currentPlan.isLifetime || !paymentData.currentPlan.isFree);
|
||||
|
||||
// Only show content if user has premium access
|
||||
// This is a client-side fallback - main security is handled server-side
|
||||
if (!currentUser || !hasPremiumAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="premium-content-section">{children}</div>;
|
||||
}
|
227
src/components/premium/premium-guard.tsx
Normal file
227
src/components/premium/premium-guard.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { LoginWrapper } from '@/components/auth/login-wrapper';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircleIcon,
|
||||
CrownIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PremiumGuardProps {
|
||||
children: ReactNode;
|
||||
isPremium: boolean;
|
||||
canAccess?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PremiumGuard({
|
||||
children,
|
||||
isPremium,
|
||||
canAccess,
|
||||
className,
|
||||
}: PremiumGuardProps) {
|
||||
// All hooks must be called unconditionally at the top
|
||||
const t = useTranslations('PremiumContent');
|
||||
const pathname = useLocalePathname();
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
|
||||
currentUser?.id
|
||||
);
|
||||
|
||||
// For non-premium articles, show content immediately with no extra processing
|
||||
if (!isPremium) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if user has premium access
|
||||
const hasPremiumAccess =
|
||||
paymentData?.currentPlan &&
|
||||
(!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime);
|
||||
|
||||
// If server-side check has already determined access, use that
|
||||
if (canAccess !== undefined) {
|
||||
// Server has determined the user has access
|
||||
if (canAccess) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Server determined no access, show appropriate message
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{/* Show partial content before protection */}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Enhanced login prompt for server-side blocked content */}
|
||||
<div className="mt-8">
|
||||
<div className="w-full p-12 rounded-lg bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
|
||||
<div className="flex flex-col items-center justify-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<LockIcon className="size-8 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t('loginRequired')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t('loginDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LoginWrapper mode="modal" asChild callbackUrl={pathname}>
|
||||
<Button size="lg" className="min-w-[160px] cursor-pointer">
|
||||
<LockIcon className="mr-2 size-4" />
|
||||
{t('signIn')}
|
||||
</Button>
|
||||
</LoginWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If user is not logged in
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Enhanced login prompt */}
|
||||
<div className="mt-8">
|
||||
<div className="w-full p-12 rounded-lg bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
|
||||
<div className="flex flex-col items-center justify-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<LockIcon className="size-8 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold">{t('loginRequired')}</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t('loginDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LoginWrapper mode="modal" asChild callbackUrl={pathname}>
|
||||
<Button size="lg" className="min-w-[160px] cursor-pointer">
|
||||
<LockIcon className="mr-2 size-4" />
|
||||
{t('signIn')}
|
||||
</Button>
|
||||
</LoginWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If payment data is still loading
|
||||
if (isLoadingPayment) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
{isLoadingPayment && (
|
||||
<div className="mt-8 flex items-center justify-center text-primary font-semibold">
|
||||
<Loader2Icon className="size-5 animate-spin mr-2" />
|
||||
<span>{t('checkingAccess')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If user doesn't have premium access
|
||||
if (!hasPremiumAccess) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Inline subscription banner for logged-in non-members */}
|
||||
<div className="mt-8">
|
||||
<Card className="bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<CrownIcon className="size-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{t('title')}</h3>
|
||||
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
{t('description')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
|
||||
<Button asChild size="lg" className="min-w-[160px]">
|
||||
<LocaleLink
|
||||
href="/pricing"
|
||||
className="text-white no-underline hover:text-white/90"
|
||||
>
|
||||
{t('upgradeCta')}
|
||||
<ArrowRightIcon className="ml-2 size-4" />
|
||||
</LocaleLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 text-primary" />
|
||||
{t('benefit1')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 text-primary" />
|
||||
{t('benefit2')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 text-primary" />
|
||||
{t('benefit3')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show full content for premium users
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { getPricePlans } from '@/config/price-config';
|
||||
import { usePricePlans } from '@/config/price-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
PaymentTypes,
|
||||
@ -36,7 +36,7 @@ export function PricingTable({
|
||||
const [interval, setInterval] = useState<PlanInterval>(PlanIntervals.MONTH);
|
||||
|
||||
// Get price plans with translations
|
||||
const pricePlans = getPricePlans();
|
||||
const pricePlans = usePricePlans();
|
||||
const plans = Object.values(pricePlans);
|
||||
|
||||
// Current plan ID for comparison
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getPricePlans } from '@/config/price-config';
|
||||
import { usePricePlans } from '@/config/price-config';
|
||||
import { useMounted } from '@/hooks/use-mounted';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
@ -50,7 +50,7 @@ export default function BillingCard() {
|
||||
const subscription = paymentData?.subscription;
|
||||
|
||||
// Get price plans with translations - must be called here to maintain hook order
|
||||
const pricePlans = getPricePlans();
|
||||
const pricePlans = usePricePlans();
|
||||
const plans = Object.values(pricePlans);
|
||||
|
||||
// Convert current plan to a plan with translations
|
||||
@ -60,23 +60,21 @@ export default function BillingCard() {
|
||||
const isFreePlan = currentPlanWithTranslations?.isFree || false;
|
||||
const isLifetimeMember = currentPlanWithTranslations?.isLifetime || false;
|
||||
|
||||
// Get subscription price details
|
||||
const currentPrice =
|
||||
subscription &&
|
||||
currentPlanWithTranslations?.prices.find(
|
||||
(price) => price.priceId === subscription?.priceId
|
||||
);
|
||||
|
||||
// Get current period start date
|
||||
const currentPeriodStart = subscription?.currentPeriodStart
|
||||
? formatDate(subscription.currentPeriodStart)
|
||||
: null;
|
||||
|
||||
// Format next billing date if subscription is active
|
||||
const nextBillingDate = subscription?.currentPeriodEnd
|
||||
// Get current period end date
|
||||
const currentPeriodEnd = subscription?.currentPeriodEnd
|
||||
? formatDate(subscription.currentPeriodEnd)
|
||||
: null;
|
||||
|
||||
// Get current trial end date
|
||||
const trialEndDate = subscription?.trialEndDate
|
||||
? formatDate(subscription.trialEndDate)
|
||||
: null;
|
||||
|
||||
// Retry payment data fetching
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch payment info');
|
||||
@ -117,7 +115,7 @@ export default function BillingCard() {
|
||||
<Skeleton className="h-6 w-3/5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Skeleton className="h-8 w-1/4" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@ -139,7 +137,7 @@ export default function BillingCard() {
|
||||
{loadPaymentError?.message}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
@ -229,40 +227,29 @@ export default function BillingCard() {
|
||||
)}
|
||||
|
||||
{/* Subscription plan message */}
|
||||
{subscription && currentPrice && (
|
||||
{subscription && (
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
{/* <div>
|
||||
{t('price')}{' '}
|
||||
{formatPrice(currentPrice.amount, currentPrice.currency)} /{' '}
|
||||
{currentPrice.interval === PlanIntervals.MONTH
|
||||
? t('interval.month')
|
||||
: currentPrice.interval === PlanIntervals.YEAR
|
||||
? t('interval.year')
|
||||
: t('interval.oneTime')}
|
||||
</div> */}
|
||||
|
||||
{currentPeriodStart && (
|
||||
<div className="text-muted-foreground">
|
||||
{t('periodStartDate')} {currentPeriodStart}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextBillingDate && (
|
||||
{currentPeriodEnd && (
|
||||
<div className="text-muted-foreground">
|
||||
{t('nextBillingDate')} {nextBillingDate}
|
||||
{t('periodEndDate')} {currentPeriodEnd}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscription.status === 'trialing' &&
|
||||
subscription.currentPeriodEnd && (
|
||||
<div className="text-amber-600">
|
||||
{t('trialEnds')} {formatDate(subscription.currentPeriodEnd)}
|
||||
</div>
|
||||
)}
|
||||
{subscription.status === 'trialing' && trialEndDate && (
|
||||
<div className="text-amber-600">
|
||||
{t('trialEnds')} {trialEndDate}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
{/* user is on free plan, show upgrade plan button */}
|
||||
{isFreePlan && (
|
||||
<Button variant="default" className="cursor-pointer" asChild>
|
||||
|
@ -8,11 +8,10 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { getCreditPackages } from '@/config/credits-config';
|
||||
import { useCreditPackages } from '@/config/credits-config';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { formatPrice } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
|
||||
@ -32,15 +31,27 @@ export function CreditPackages() {
|
||||
|
||||
// Get current user and payment info
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: paymentData } = useCurrentPlan(session?.user?.id);
|
||||
const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
|
||||
currentUser?.id
|
||||
);
|
||||
const currentPlan = paymentData?.currentPlan;
|
||||
|
||||
// Get credit packages with translations - must be called here to maintain hook order
|
||||
const creditPackages = Object.values(getCreditPackages()).filter(
|
||||
// This function contains useTranslations hook, so it must be called before any conditional returns
|
||||
const creditPackages = Object.values(useCreditPackages()).filter(
|
||||
(pkg) => !pkg.disabled && pkg.price.priceId
|
||||
);
|
||||
|
||||
// Don't render anything while loading to prevent flash
|
||||
if (isLoadingPayment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render anything if we don't have payment data yet
|
||||
if (!paymentData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user is on free plan and enablePackagesForFreePlan is false
|
||||
const isFreePlan = currentPlan?.isFree === true;
|
||||
|
||||
|
@ -76,6 +76,7 @@ import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Skeleton } from '../../ui/skeleton';
|
||||
|
||||
// Define the credit transaction interface
|
||||
export interface CreditTransaction {
|
||||
@ -152,12 +153,27 @@ function DataTableColumnHeader<TData, TValue>({
|
||||
);
|
||||
}
|
||||
|
||||
function TableRowSkeleton({ columns }: { columns: number }) {
|
||||
return (
|
||||
<TableRow>
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<TableCell key={index} className="py-4">
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<Skeleton className="h-6 w-full max-w-32" />
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreditTransactionsTableProps {
|
||||
data: CreditTransaction[];
|
||||
total: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
sorting?: SortingState;
|
||||
loading?: boolean;
|
||||
onSearch: (search: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
@ -171,6 +187,7 @@ export function CreditTransactionsTable({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting = [{ id: 'createdAt', desc: true }],
|
||||
loading,
|
||||
onSearch,
|
||||
onPageChange,
|
||||
@ -179,9 +196,6 @@ export function CreditTransactionsTable({
|
||||
}: CreditTransactionsTableProps) {
|
||||
const t = useTranslations('Dashboard.settings.credits.transactions');
|
||||
const tTable = useTranslations('Common.table');
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
@ -449,7 +463,6 @@ export function CreditTransactionsTable({
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSorting(next);
|
||||
onSortingChange?.(next);
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@ -538,7 +551,12 @@ export function CreditTransactionsTable({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
{loading ? (
|
||||
// Show skeleton rows while loading
|
||||
Array.from({ length: pageSize }).map((_, index) => (
|
||||
<TableRowSkeleton key={index} columns={columns.length} />
|
||||
))
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
@ -560,7 +578,7 @@ export function CreditTransactionsTable({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{loading ? tTable('loading') : tTable('noResults')}
|
||||
{tTable('noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
@ -4,22 +4,35 @@ import { CreditTransactionsTable } from '@/components/settings/credits/credit-tr
|
||||
import { useCreditTransactions } from '@/hooks/use-credits';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CreditTransactionsProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
sorting: SortingState;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
onSearch: (search: string) => void;
|
||||
onSortingChange: (sorting: SortingState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit transactions component
|
||||
*/
|
||||
export function CreditTransactions() {
|
||||
export function CreditTransactions({
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onSearch,
|
||||
onSortingChange,
|
||||
}: CreditTransactionsProps) {
|
||||
const t = useTranslations('Dashboard.settings.credits.transactions');
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
|
||||
const { data, isLoading } = useCreditTransactions(
|
||||
pageIndex,
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
sorting
|
||||
@ -29,14 +42,15 @@ export function CreditTransactions() {
|
||||
<CreditTransactionsTable
|
||||
data={data?.items || []}
|
||||
total={data?.total || 0}
|
||||
pageIndex={pageIndex}
|
||||
pageIndex={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
loading={isLoading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
onSearch={onSearch}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
onSortingChange={onSortingChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -102,11 +102,11 @@ export default function CreditsBalanceCard() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<Skeleton className="h-8 w-1/5" />
|
||||
<Skeleton className="h-9 w-1/5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="px-6 py-4 flex justify-between items-center bg-background rounded-none">
|
||||
<Skeleton className="h-6 w-3/5" />
|
||||
<CardFooter className="px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
@ -125,7 +125,7 @@ export default function CreditsBalanceCard() {
|
||||
{balanceError?.message || statsError?.message}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
@ -157,7 +157,7 @@ export default function CreditsBalanceCard() {
|
||||
{/* <Badge variant="outline">available</Badge> */}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="px-6 py-4 flex justify-between items-center bg-background rounded-none">
|
||||
<CardFooter className="px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
{/* Expiring credits warning */}
|
||||
{!isLoadingStats && creditStats && (
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
|
@ -4,7 +4,16 @@ import { CreditPackages } from '@/components/settings/credits/credit-packages';
|
||||
import { CreditTransactions } from '@/components/settings/credits/credit-transactions';
|
||||
import CreditsBalanceCard from '@/components/settings/credits/credits-balance-card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
parseAsIndex,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringLiteral,
|
||||
useQueryStates,
|
||||
} from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Credits page client, show credit balance and transactions
|
||||
@ -12,9 +21,47 @@ import { useTranslations } from 'next-intl';
|
||||
export default function CreditsPageClient() {
|
||||
const t = useTranslations('Dashboard.settings.credits');
|
||||
|
||||
// Manage all URL states in the parent component
|
||||
const [{ tab, page, pageSize, search, sortId, sortDesc }, setQueryStates] =
|
||||
useQueryStates({
|
||||
tab: parseAsStringLiteral(['balance', 'transactions']).withDefault(
|
||||
'balance'
|
||||
),
|
||||
// Transaction-specific parameters
|
||||
page: parseAsIndex.withDefault(0),
|
||||
pageSize: parseAsInteger.withDefault(10),
|
||||
search: parseAsString.withDefault(''),
|
||||
sortId: parseAsString.withDefault('createdAt'),
|
||||
sortDesc: parseAsInteger.withDefault(1),
|
||||
});
|
||||
|
||||
const sorting: SortingState = useMemo(
|
||||
() => [{ id: sortId, desc: Boolean(sortDesc) }],
|
||||
[sortId, sortDesc]
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === 'balance' || value === 'transactions') {
|
||||
if (value === 'balance') {
|
||||
// When switching to balance tab, clear transaction parameters
|
||||
setQueryStates({
|
||||
tab: value,
|
||||
page: null,
|
||||
pageSize: null,
|
||||
search: null,
|
||||
sortId: null,
|
||||
sortDesc: null,
|
||||
});
|
||||
} else {
|
||||
// When switching to transactions tab, just set the tab
|
||||
setQueryStates({ tab: value });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Tabs defaultValue="balance" className="w-full">
|
||||
<Tabs value={tab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="">
|
||||
<TabsTrigger value="balance">{t('tabs.balance')}</TabsTrigger>
|
||||
<TabsTrigger value="transactions">
|
||||
@ -36,7 +83,29 @@ export default function CreditsPageClient() {
|
||||
|
||||
<TabsContent value="transactions" className="mt-4">
|
||||
{/* Credit Transactions */}
|
||||
<CreditTransactions />
|
||||
<CreditTransactions
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
onPageChange={(newPageIndex) =>
|
||||
setQueryStates({ page: newPageIndex })
|
||||
}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setQueryStates({ pageSize: newPageSize, page: 0 })
|
||||
}
|
||||
onSearch={(newSearch) =>
|
||||
setQueryStates({ search: newSearch, page: 0 })
|
||||
}
|
||||
onSortingChange={(newSorting) => {
|
||||
if (newSorting.length > 0) {
|
||||
setQueryStates({
|
||||
sortId: newSorting[0].id,
|
||||
sortDesc: newSorting[0].desc ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
@ -173,7 +173,7 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-6 px-6 py-4 bg-background rounded-none">
|
||||
<CardFooter className="mt-6 px-6 py-4 bg-muted rounded-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('newsletter.hint')}
|
||||
</p>
|
||||
|
@ -171,7 +171,7 @@ export function UpdateAvatarCard({ className }: UpdateAvatarCardProps) {
|
||||
|
||||
<FormError message={error} />
|
||||
</CardContent>
|
||||
<CardFooter className="mt-auto px-6 py-4 flex justify-between items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-auto px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('avatar.recommendation')}
|
||||
</p>
|
||||
|
@ -140,7 +140,7 @@ export function UpdateNameCard({ className }: UpdateNameCardProps) {
|
||||
/>
|
||||
<FormError message={error} />
|
||||
</CardContent>
|
||||
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
<p className="text-sm text-muted-foreground">{t('name.hint')}</p>
|
||||
|
||||
<Button
|
||||
|
@ -97,7 +97,7 @@ export function DeleteAccountCard() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
|
@ -69,7 +69,7 @@ function PasswordSkeletonCard() {
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</CardContent>
|
||||
<CardFooter className="px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Skeleton className="h-8 w-1/4" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
@ -66,7 +66,7 @@ export function ResetPasswordCard({ className }: ResetPasswordCardProps) {
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
<p className="text-sm text-muted-foreground">{t('info')}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-auto px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-auto px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Button onClick={handleSetupPassword} className="cursor-pointer">
|
||||
{t('button')}
|
||||
</Button>
|
||||
|
@ -206,7 +206,7 @@ export function UpdatePasswordCard({ className }: UpdatePasswordCardProps) {
|
||||
/>
|
||||
<FormError message={error} />
|
||||
</CardContent>
|
||||
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
<p className="text-sm text-muted-foreground">{t('hint')}</p>
|
||||
|
||||
<Button
|
||||
|
@ -1,88 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DiscordIcon } from '@/components/icons/discord';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
import WidgetBot from '@widgetbot/react-embed';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Discord Widget, shows the channels and messages in the discord server
|
||||
*
|
||||
* @deprecated
|
||||
* This feature is deprecated for Discord Widget can not be used anymore.
|
||||
*
|
||||
* https://docs.widgetbot.io/embed/react-embed/
|
||||
*/
|
||||
export default function DiscordWidget() {
|
||||
if (!websiteConfig.features.enableDiscordWidget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID as string;
|
||||
const channelId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID as string;
|
||||
if (!serverId || !channelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const widgetRef = useRef<HTMLDivElement>(null);
|
||||
const { device, width: windowWidth, height: windowHeight } = useMediaQuery();
|
||||
|
||||
let widgetWidth = 800;
|
||||
let widgetHeight = 600;
|
||||
if (device === 'mobile') {
|
||||
widgetWidth = windowWidth ? Math.floor(windowWidth * 0.9) : 320;
|
||||
widgetHeight = windowHeight ? Math.floor(windowHeight * 0.8) : 400;
|
||||
} else if (device === 'tablet' || device === 'sm') {
|
||||
widgetWidth = windowWidth ? Math.floor(windowWidth * 0.9) : 600;
|
||||
widgetHeight = windowHeight ? Math.floor(windowHeight * 0.8) : 480;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (widgetRef.current && !widgetRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* discord icon button, show in bottom right corner */}
|
||||
{!open && (
|
||||
<button
|
||||
aria-label="Open Discord Widget"
|
||||
className="fixed bottom-[84px] right-10 z-50 cursor-pointer flex items-center justify-center rounded-full bg-[#5865F2] shadow-lg
|
||||
hover:scale-110 transition-transform duration-150"
|
||||
style={{ width: 48, height: 48 }}
|
||||
onClick={() => setOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<DiscordIcon width={32} height={32} className="text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* discord widget expand layer */}
|
||||
{open && (
|
||||
<div
|
||||
ref={widgetRef}
|
||||
className="fixed bottom-[84px] right-10 z-50 flex flex-col items-end"
|
||||
style={{ width: widgetWidth, height: widgetHeight }}
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden shadow-2xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
<WidgetBot
|
||||
server={serverId}
|
||||
channel={channelId}
|
||||
width={widgetWidth}
|
||||
height={widgetHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,368 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCopyToClipboard } from '@/hooks/use-clipboard';
|
||||
import { isUrlCached } from '@/lib/serviceWorker';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group';
|
||||
import { Check, Code2, Copy, Eye, Maximize, Terminal } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Panel,
|
||||
PanelGroup,
|
||||
PanelResizeHandle,
|
||||
type ImperativePanelGroupHandle,
|
||||
} from 'react-resizable-panels';
|
||||
import { useMedia } from 'use-media';
|
||||
|
||||
export interface BlockPreviewProps {
|
||||
code?: string;
|
||||
preview: string;
|
||||
title: string;
|
||||
category: string;
|
||||
previewOnly?: boolean;
|
||||
}
|
||||
|
||||
const radioItem =
|
||||
'rounded-(--radius) duration-200 flex items-center justify-center h-8 px-2.5 gap-2 transition-[color] data-[state=checked]:bg-muted';
|
||||
|
||||
const DEFAULTSIZE = 100;
|
||||
const SMSIZE = 30;
|
||||
const MDSIZE = 62;
|
||||
const LGSIZE = 82;
|
||||
|
||||
const getCacheKey = (src: string) => `iframe-cache-${src}`;
|
||||
|
||||
const titleToNumber = (title: string): number => {
|
||||
const titles = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty"];
|
||||
return titles.indexOf(title.toLowerCase()) + 1;
|
||||
};
|
||||
|
||||
export const BlockPreview: React.FC<BlockPreviewProps> = ({
|
||||
code,
|
||||
preview,
|
||||
title,
|
||||
category,
|
||||
previewOnly,
|
||||
}) => {
|
||||
const [width, setWidth] = useState(DEFAULTSIZE);
|
||||
const [mode, setMode] = useState<'preview' | 'code'>('preview');
|
||||
const [iframeHeight, setIframeHeight] = useState(0);
|
||||
const [shouldLoadIframe, setShouldLoadIframe] = useState(false);
|
||||
const [cachedHeight, setCachedHeight] = useState<number | null>(null);
|
||||
const [isIframeCached, setIsIframeCached] = useState(false);
|
||||
|
||||
const terminalCode = `pnpm dlx shadcn@canary add https://nsui.irung.me/r/${category}-${titleToNumber(title)}.json`;
|
||||
const { copied, copy } = useCopyToClipboard({ code: code as string, title, category, eventName: 'block_copy' })
|
||||
const { copied: cliCopied, copy: cliCopy } = useCopyToClipboard({ code: terminalCode, title, category, eventName: 'block_cli_copy' })
|
||||
|
||||
const ref = useRef<ImperativePanelGroupHandle>(null);
|
||||
const isLarge = useMedia('(min-width: 1024px)');
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
observer.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setShouldLoadIframe(true);
|
||||
observer.current?.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (blockRef.current) {
|
||||
observer.current.observe(blockRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.current?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCache = async () => {
|
||||
try {
|
||||
const isCached = await isUrlCached(preview);
|
||||
setIsIframeCached(isCached);
|
||||
if (isCached) {
|
||||
setShouldLoadIframe(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking cache status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkCache();
|
||||
|
||||
try {
|
||||
const cacheKey = getCacheKey(preview);
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const { height, timestamp } = JSON.parse(cached);
|
||||
const now = Date.now();
|
||||
if (now - timestamp < 24 * 60 * 60 * 1000) {
|
||||
setCachedHeight(height);
|
||||
setIframeHeight(height);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error retrieving cache:', error);
|
||||
}
|
||||
}, [preview]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !shouldLoadIframe) return;
|
||||
|
||||
const handleLoad = () => {
|
||||
try {
|
||||
const contentHeight = iframe.contentWindow!.document.body.scrollHeight;
|
||||
setIframeHeight(contentHeight);
|
||||
|
||||
const cacheKey = getCacheKey(preview);
|
||||
const cacheValue = JSON.stringify({
|
||||
height: contentHeight,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
localStorage.setItem(cacheKey, cacheValue);
|
||||
} catch (e) {
|
||||
console.error('Error accessing iframe content:', e);
|
||||
}
|
||||
};
|
||||
|
||||
iframe.addEventListener('load', handleLoad);
|
||||
return () => {
|
||||
iframe.removeEventListener('load', handleLoad);
|
||||
};
|
||||
}, [shouldLoadIframe, preview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blockRef.current || shouldLoadIframe) return;
|
||||
|
||||
const linkElement = document.createElement('link');
|
||||
linkElement.rel = 'preload';
|
||||
linkElement.href = preview;
|
||||
linkElement.as = 'document';
|
||||
|
||||
if (
|
||||
!document.head.querySelector(`link[rel="preload"][href="${preview}"]`)
|
||||
) {
|
||||
document.head.appendChild(linkElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const existingLink = document.head.querySelector(
|
||||
`link[rel="preload"][href="${preview}"]`
|
||||
);
|
||||
if (existingLink) {
|
||||
document.head.removeChild(existingLink);
|
||||
}
|
||||
};
|
||||
}, [preview, shouldLoadIframe]);
|
||||
|
||||
return (
|
||||
<section className="group mb-16 border-b [--color-border:color-mix(in_oklab,var(--color-zinc-200)_75%,transparent)] dark:[--color-border:color-mix(in_oklab,var(--color-zinc-800)_60%,transparent)]">
|
||||
<div className="relative border-y">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-4 -top-14 bottom-0 mx-auto max-w-7xl lg:inset-x-0"
|
||||
>
|
||||
<div className="to-(--color-border) absolute bottom-0 left-0 top-0 w-px bg-gradient-to-b from-transparent to-75%"></div>
|
||||
<div className="to-(--color-border) absolute bottom-0 right-0 top-0 w-px bg-gradient-to-b from-transparent to-75%"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto flex max-w-7xl justify-between py-1.5 pl-8 pr-6 [--color-border:var(--color-zinc-200)] md:py-2 lg:pl-6 lg:pr-2 dark:[--color-border:var(--color-zinc-800)]">
|
||||
<div className="-ml-3 flex items-center gap-3">
|
||||
{code && (
|
||||
<>
|
||||
<RadioGroup.Root className="flex gap-0.5">
|
||||
<RadioGroup.Item
|
||||
onClick={() => setMode('preview')}
|
||||
aria-label="Block preview"
|
||||
value="100"
|
||||
checked={mode == 'preview'}
|
||||
className={radioItem}
|
||||
>
|
||||
<Eye className="size-3.5 sm:opacity-50" />
|
||||
<span className="hidden text-[13px] sm:block">Preview</span>
|
||||
</RadioGroup.Item>
|
||||
|
||||
<RadioGroup.Item
|
||||
onClick={() => setMode('code')}
|
||||
aria-label="Code"
|
||||
value="0"
|
||||
checked={mode == 'code'}
|
||||
className={radioItem}
|
||||
>
|
||||
<Code2 className="size-3.5 sm:opacity-50" />
|
||||
<span className="hidden text-[13px] sm:block">Code</span>
|
||||
</RadioGroup.Item>
|
||||
</RadioGroup.Root>
|
||||
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="hidden !h-4 lg:block"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{previewOnly && (
|
||||
<>
|
||||
{' '}
|
||||
<span className="ml-2 text-sm capitalize">{title}</span>
|
||||
<Separator orientation="vertical" className="!h-4" />{' '}
|
||||
</>
|
||||
)}
|
||||
{/* <Button asChild variant="ghost" size="sm" className="size-8">
|
||||
<Link href={preview} passHref target="_blank">
|
||||
<Maximize className="size-4" />
|
||||
</Link>
|
||||
</Button> */}
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="hidden !h-4 lg:block"
|
||||
/>
|
||||
<span className="text-muted-foreground hidden text-sm lg:block">
|
||||
{width < MDSIZE
|
||||
? 'Mobile'
|
||||
: width < LGSIZE
|
||||
? 'Tablet'
|
||||
: 'Desktop'}
|
||||
</span>{' '}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{code && (
|
||||
<>
|
||||
<Button
|
||||
onClick={cliCopy}
|
||||
size="sm"
|
||||
className="size-8 shadow-none md:w-fit"
|
||||
variant="outline"
|
||||
aria-label="copy code">
|
||||
{cliCopied ? <Check className="size-4" /> : <Terminal className="!size-3.5" />}
|
||||
<span className="hidden font-mono text-xs md:block">
|
||||
pnpm dlx shadcn@canary add {category}-{titleToNumber(title)}
|
||||
</span>
|
||||
</Button>
|
||||
<Separator className="!h-4" orientation="vertical" />
|
||||
{/* <OpenInV0Button
|
||||
{...{ title, category }}
|
||||
block={`${category}-${titleToNumber(title)}`}
|
||||
/> */}
|
||||
<Separator className="!h-4" orientation="vertical" />
|
||||
|
||||
<Button
|
||||
onClick={copy}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="copy code"
|
||||
className="size-8">
|
||||
{copied ? <Check className="size-4" /> : <Copy className="!size-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!code && (
|
||||
<span className="hidden font-mono text-sm md:block">
|
||||
{/* pnpm dlx shadcn@canary add */}{category}-{titleToNumber(title)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-4 -bottom-14 mx-auto h-14 max-w-7xl lg:inset-x-0"
|
||||
>
|
||||
<div className="from-(--color-border) absolute bottom-0 left-0 top-0 w-px bg-gradient-to-b"></div>
|
||||
<div className="from-(--color-border) absolute bottom-0 right-0 top-0 w-px bg-gradient-to-b"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-4 lg:border-r lg:px-0">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white dark:bg-transparent',
|
||||
mode == 'code' && 'hidden'
|
||||
)}
|
||||
>
|
||||
<PanelGroup direction="horizontal" tagName="div" ref={ref}>
|
||||
<Panel
|
||||
id={`block-${title}`}
|
||||
order={1}
|
||||
onResize={(size) => {
|
||||
setWidth(Number(size));
|
||||
}}
|
||||
defaultSize={DEFAULTSIZE}
|
||||
minSize={SMSIZE}
|
||||
className="h-fit border-x"
|
||||
>
|
||||
<div ref={blockRef}>
|
||||
{shouldLoadIframe ? (
|
||||
<iframe
|
||||
key={`${category}-${title}-iframe`}
|
||||
loading={isIframeCached ? 'eager' : 'lazy'}
|
||||
allowFullScreen
|
||||
ref={iframeRef}
|
||||
title={title}
|
||||
height={cachedHeight || iframeHeight}
|
||||
className={cn(
|
||||
'h-(--iframe-height) block min-h-56 w-full duration-200 will-change-auto',
|
||||
!cachedHeight &&
|
||||
'@starting:opacity-0 @starting:blur-xl',
|
||||
isIframeCached && '!opacity-100 !blur-none'
|
||||
)}
|
||||
src={preview}
|
||||
id={`block-${title}`}
|
||||
style={
|
||||
{
|
||||
'--iframe-height': `${cachedHeight || iframeHeight}px`,
|
||||
display: 'block',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-56 items-center justify-center">
|
||||
<div className="border-primary size-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{isLarge && (
|
||||
<>
|
||||
<PanelResizeHandle className="relative w-2 before:absolute before:inset-0 before:m-auto before:h-12 before:w-1 before:rounded-full before:bg-zinc-300 before:transition-[height,background] hover:before:h-16 hover:before:bg-zinc-400 focus:before:bg-zinc-400 dark:before:bg-zinc-600 dark:hover:before:bg-zinc-500 dark:focus:before:bg-zinc-400" />
|
||||
<Panel
|
||||
id={`code-${title}`}
|
||||
order={2}
|
||||
defaultSize={100 - DEFAULTSIZE}
|
||||
className="-mr-[0.5px] ml-px"
|
||||
></Panel>
|
||||
</>
|
||||
)}
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-transparent">
|
||||
{/* {mode == 'code' && (
|
||||
<CodeBlock
|
||||
code={code as string}
|
||||
lang="tsx"
|
||||
maxHeight={iframeHeight}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockPreview;
|
63
src/components/test/consume-credits-card.tsx
Normal file
63
src/components/test/consume-credits-card.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
|
||||
import { CoinsIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CONSUME_CREDITS = 10;
|
||||
|
||||
export function ConsumeCreditsCard() {
|
||||
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
|
||||
const consumeCreditsMutation = useConsumeCredits();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const hasEnoughCredits = (amount: number) => balance >= amount;
|
||||
|
||||
const handleConsume = async () => {
|
||||
if (!hasEnoughCredits(CONSUME_CREDITS)) {
|
||||
toast.error('Insufficient credits, please buy more credits.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await consumeCreditsMutation.mutateAsync({
|
||||
amount: CONSUME_CREDITS,
|
||||
description: `Test credit consumption (${CONSUME_CREDITS} credits)`,
|
||||
});
|
||||
toast.success(`${CONSUME_CREDITS} credits consumed successfully!`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to consume credits');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border rounded-lg space-y-4">
|
||||
<h3 className="text-lg font-semibold">Credits Store Test</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>Store Balance:</strong> {balance}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleConsume}
|
||||
disabled={
|
||||
loading || consumeCreditsMutation.isPending || isLoadingBalance
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<CoinsIcon className="w-4 h-4 mr-2" />
|
||||
Consume {CONSUME_CREDITS} Credits
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
className={cn("bg-muted animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
@ -19,7 +19,7 @@ import { useTranslations } from 'next-intl';
|
||||
*
|
||||
* @returns The avatar config with translated titles
|
||||
*/
|
||||
export function getAvatarLinks(): MenuItem[] {
|
||||
export function useAvatarLinks(): MenuItem[] {
|
||||
const t = useTranslations('Marketing.avatar');
|
||||
|
||||
return [
|
||||
|
@ -16,7 +16,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The credit packages with translated content
|
||||
*/
|
||||
export function getCreditPackages(): Record<string, CreditPackage> {
|
||||
export function useCreditPackages(): Record<string, CreditPackage> {
|
||||
const t = useTranslations('CreditPackages');
|
||||
const creditConfig = websiteConfig.credits;
|
||||
const packages: Record<string, CreditPackage> = {};
|
||||
|
@ -15,7 +15,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The footer config with translated titles
|
||||
*/
|
||||
export function getFooterLinks(): NestedMenuItem[] {
|
||||
export function useFooterLinks(): NestedMenuItem[] {
|
||||
const t = useTranslations('Marketing.footer');
|
||||
|
||||
return [
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
LogInIcon,
|
||||
MailIcon,
|
||||
MailboxIcon,
|
||||
MessageCircleIcon,
|
||||
NewspaperIcon,
|
||||
RocketIcon,
|
||||
ShieldCheckIcon,
|
||||
@ -46,7 +47,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The navbar config with translated titles and descriptions
|
||||
*/
|
||||
export function getNavbarLinks(): NestedMenuItem[] {
|
||||
export function useNavbarLinks(): NestedMenuItem[] {
|
||||
const t = useTranslations('Marketing.navbar');
|
||||
|
||||
return [
|
||||
@ -95,6 +96,13 @@ export function getNavbarLinks(): NestedMenuItem[] {
|
||||
href: Routes.AIImage,
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: t('ai.items.chat.title'),
|
||||
description: t('ai.items.chat.description'),
|
||||
icon: <MessageCircleIcon className="size-4 shrink-0" />,
|
||||
href: Routes.AIChat,
|
||||
external: false,
|
||||
},
|
||||
// {
|
||||
// title: t('ai.items.video.title'),
|
||||
// description: t('ai.items.video.description'),
|
||||
|
@ -16,7 +16,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The price plans with translated content
|
||||
*/
|
||||
export function getPricePlans(): Record<string, PricePlan> {
|
||||
export function usePricePlans(): Record<string, PricePlan> {
|
||||
const t = useTranslations('PricePlans');
|
||||
const priceConfig = websiteConfig.price;
|
||||
const plans: Record<string, PricePlan> = {};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user