Compare commits

...

158 Commits

Author SHA1 Message Date
javayhu
2a6e322c0a Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-05 22:23:04 +08:00
javayhu
37f011cf74 refactor: rename getSocialLinks to useSocialLinks for improved clarity and consistency 2025-09-05 00:07:07 +08:00
javayhu
35d0ca9e12 refactor: rename getSidebarLinks to useSidebarLinks for improved clarity and consistency 2025-09-05 00:06:40 +08:00
javayhu
34baf20b31 refactor: rename getNavbarLinks to useNavbarLinks for improved clarity and consistency 2025-09-05 00:06:19 +08:00
javayhu
28fcbae6a2 refactor: rename getFooterLinks to useFooterLinks for improved clarity and consistency 2025-09-05 00:06:00 +08:00
javayhu
fc8cea13cd refactor: rename getAvatarLinks to useAvatarLinks for improved clarity and consistency 2025-09-05 00:05:41 +08:00
javayhu
6065c4af06 refactor: rename getPricePlans to usePricePlans for improved clarity and consistency 2025-09-05 00:05:10 +08:00
javayhu
ba7b950c01 refactor: rename getCreditPackages to useCreditPackages for improved clarity and consistency 2025-09-05 00:04:29 +08:00
javayhu
c94784e711 fix: add conditional rendering for payment data in credit packages 2025-09-05 00:00:16 +08:00
javayhu
48c045fb73 fix: add loading state handling in credit packages 2025-09-04 23:46:32 +08:00
javayhu
3fd47869a2 chore: update VSCode settings to exclude additional file types 2025-09-04 23:00:53 +08:00
javayhu
e3ac4a0a29 Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-03 01:19:48 +08:00
javayhu
47adbcfd06 refactor: move premium related components to new folder 2025-09-03 01:10:03 +08:00
javayhu
5d5eb82013 feat: docs support premium content 2025-09-03 01:09:01 +08:00
javayhu
b0a065ced9 Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-03 00:08:21 +08:00
javayhu
794c18a7e6 fix: fix localized callback url after login 2025-09-02 23:51:29 +08:00
javayhu
9899e1d164 fix: update billing card to reflect period end date and adjust related translations 2025-09-02 23:08:37 +08:00
javayhu
ad1cbedb56 Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-02 00:18:06 +08:00
javayhu
3707500ed8 feat: optimize fetching subscription period start and end time 2025-09-02 00:16:10 +08:00
javayhu
f36018945d fix: change message component background color 2025-09-01 23:47:31 +08:00
javayhu
9f5d4aec59 chore: update ai elements 2025-09-01 23:44:18 +08:00
javayhu
e3f44a85a5 Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-01 00:14:08 +08:00
javayhu
1f9a7c2621
Merge pull request #86 from MkSaaSHQ/dev/blog-premium
feat: premium content in blog posts
2025-08-31 22:03:43 +08:00
javayhu
a92ef86a71 fix: clarify test card number format and clean up imports in page component 2025-08-31 21:58:05 +08:00
javayhu
e2dfab2ca7 fix: update categories from "development" to "product" in premium blog post files 2025-08-31 21:44:14 +08:00
javayhu
e5061b3b67 custom: correct typo in source.config.ts and add premium content translations 2025-08-31 21:40:30 +08:00
javayhu
4faa89c0ee custom: replace premium Fumadocs blog post and integrate premium badge display 2025-08-31 21:40:09 +08:00
javayhu
481f3268db feat: implement premium access checks and enhance premium content handling in blog posts 2025-08-31 21:39:52 +08:00
javayhu
66d7dd3259 feat: add premium content feature with related components and configuration 2025-08-31 21:39:40 +08:00
javayhu
9aeb59dff2 chore: update .dockerignore, .gitignore, and biome.json to include .conductor directory 2025-08-31 16:50:13 +08:00
javayhu
2faedc2043 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-30 22:30:44 +08:00
javayhu
c0aa979382 chore: clean up imports and improve formatting in CreditsPageClient component 2025-08-30 00:24:12 +08:00
javayhu
fa2e981c16 fix: fix URL params when switch to balance from transactions 2025-08-28 23:23:52 +08:00
javayhu
0c415ee24b Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-28 10:09:34 +08:00
javayhu
21eee041ab
Merge pull request #85 from MkSaaSHQ/dev/nuqs
feat: integrate with nuqs for users and credit transaction tables
2025-08-28 01:10:48 +08:00
javayhu
658409cfbd Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-27 00:53:02 +08:00
javayhu
613bbd0d78 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-26 00:50:03 +08:00
javayhu
5f14259197 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-24 22:34:35 +08:00
javayhu
b4dab95c04 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-23 00:14:47 +08:00
javayhu
8221f1753f Merge branch 'cloudflare' of https://github.com/MkSaaSHQ/mksaas-template into cloudflare 2025-08-22 01:17:47 +08:00
javayhu
18691030e7 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-22 01:17:38 +08:00
javayhu
7aa7cb5603 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-21 10:04:48 +08:00
javayhu
ca30f95027 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-21 09:59:28 +08:00
javayhu
d747683f82 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-20 00:20:22 +08:00
javayhu
b55613b471 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-18 00:59:21 +08:00
javayhu
47679ab91e Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-18 00:32:31 +08:00
javayhu
f468638f49 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-17 23:27:24 +08:00
javayhu
35ddf5e08e Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-17 08:45:27 +08:00
javayhu
1f7c38f9f5 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-16 08:19:56 +08:00
javayhu
63dd4e52fb Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 23:03:55 +08:00
javayhu
200a9963f7 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 00:41:00 +08:00
javayhu
0da8f7d335 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 00:10:26 +08:00
javayhu
004edeecea Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-14 23:14:32 +08:00
javayhu
6bb12a2d86 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-11 07:41:56 +08:00
javayhu
97654d97ea Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-11 07:36:28 +08:00
javayhu
aa2e025270 cf: update cloudflare env types 2025-08-09 13:12:42 +08:00
javayhu
11bfcb731d cf: upgrade version of opennextjs and wrangler 2025-08-09 10:13:32 +08:00
javayhu
62eb4124be Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-03 15:20:14 +08:00
javayhu
d7cc9b956d Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-24 01:17:23 +08:00
javayhu
22d68c005a refactor: optimize credits rendering by memoizing and moving checks before hooks 2025-07-24 00:50:23 +08:00
javayhu
70446d10b3 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-24 00:10:11 +08:00
javayhu
313c783dbd Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-20 14:44:58 +08:00
javayhu
cc56f9d729 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-19 15:15:42 +08:00
javayhu
e5569dabd1 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-13 23:04:14 +08:00
javayhu
813d8ea0bb Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-13 23:02:55 +08:00
javayhu
c67b804f4f Merge branch 'cloudflare' of https://github.com/MkSaaSHQ/mksaas-template into cloudflare 2025-07-10 00:48:21 +08:00
javayhu
a44e4a669c Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-10 00:48:17 +08:00
javayhu
da4b018e8d Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-09 19:14:30 +08:00
javayhu
b838ddc293 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-02 22:53:01 +08:00
javayhu
8e63af3e7f Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-02 01:15:29 +08:00
javayhu
1e2e4d77f7 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-02 00:54:31 +08:00
javayhu
e94625ce4e custom: add command cf-dev on port 8787 2025-06-22 11:58:13 +08:00
javayhu
2153cf6771 chore: add dev.vars example file 2025-06-21 17:35:32 +08:00
javayhu
0164c833db Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-21 14:40:23 +08:00
javayhu
5d50135ed6 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-20 22:21:04 +08:00
javayhu
cbfe5e433d Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-20 02:04:54 +08:00
javayhu
7ab7d2d504 refactor(storage) replace with s3mini sdk & fix upload issue in cloudflare worker 2025-06-20 02:03:22 +08:00
javayhu
522d8de4ee chore: add comments for nodejs_compat 2025-06-20 01:36:35 +08:00
javayhu
0739c717d8 chore: fix lint and format issues 2025-06-20 01:18:48 +08:00
javayhu
71b9807433 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-19 00:40:41 +08:00
javayhu
8a72fb2409 chore: revert change website name 2025-06-18 00:18:58 +08:00
javayhu
e00c22d0fe Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-18 00:17:09 +08:00
javayhu
bd8ccf4cf3 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-18 00:09:28 +08:00
javayhu
d0aef4b7d4 chore: remove useless packages 2025-06-17 23:26:33 +08:00
javayhu
c006ee750d refactor(blog) remove blog toc component 2025-06-17 23:26:02 +08:00
javayhu
19a6c4d994 cf: fix stripe api issue by setting httpClient 2025-06-17 22:35:27 +08:00
javayhu
86f13a1748 chore: reset compatibility_flags to nodejs_compat & nodejs_compat_v2 not solve file upload issue 2025-06-17 22:15:12 +08:00
javayhu
745ba457df cf: add init open next cf for dev 2025-06-17 22:14:01 +08:00
javayhu
beb53639a3 chore: adjust locale handling in Providers and DocsRootLayout, and simplify search API request handling 2025-06-17 21:44:48 +08:00
javayhu
65fb8722bc chore: update DynamicCodeBlock component props 2025-06-17 21:28:36 +08:00
javayhu
160a7eb929 chore: fix fumadocs top empty banner shown when in cloudflare worker env 2025-06-17 21:17:34 +08:00
javayhu
c3d82d9183 chore: upgrade fumadocs ui and core 2025-06-17 21:16:46 +08:00
javayhu
767351c5cd cf: add nodejs_compat_v2 to compatibility_flags 2025-06-17 20:43:54 +08:00
javayhu
fd3c82baaf cf: do not remove logs in prod env 2025-06-17 20:06:20 +08:00
javayhu
168eae946f cf: enable worker log push 2025-06-17 19:41:18 +08:00
javayhu
69390fed70 refactor(blog) update sitemap for blog pages 2025-06-17 18:17:05 +08:00
javayhu
2cb041beb1 refactor(blog) blog category pages 2025-06-17 18:02:29 +08:00
javayhu
3645cf5773 refactor(blog) optimize inline toc & blog page layout 2025-06-17 17:45:10 +08:00
javayhu
c6ad6d0ad5 refactor(blog) blog page ready with toc 2025-06-17 17:29:15 +08:00
javayhu
53ab869f07 refactor(blog) refactor blog home page 2025-06-17 11:28:20 +08:00
javayhu
e0f408fb07 refactor(blog) update date in mdx files 2025-06-17 09:52:13 +08:00
javayhu
1216732a55 refactor(blog) remove content-collections & add blog source 2025-06-17 09:51:57 +08:00
javayhu
4c6fddf99d refactor(blog) update content about blog posts 2025-06-17 00:08:07 +08:00
javayhu
90d5db88ab refactor(pages) migrate custom pages to using fumadocs 2025-06-16 23:34:22 +08:00
javayhu
af5a3265a6 refactor(changelog) refactor release card component 2025-06-16 22:55:24 +08:00
javayhu
ec8ce54824 refactor(changelog) migrate changelog to use fumadocs 2025-06-16 01:29:02 +08:00
javayhu
f4d8a09ab6 refactor(docs) parse and render docs mdx files by fumadocs 2025-06-16 00:51:36 +08:00
javayhu
3b741b3b98 refactor(docs) remove math and package-install as code block languages 2025-06-16 00:43:27 +08:00
javayhu
b07be5fab4 chore: add fumadocs-mdx 2025-06-15 20:50:03 +08:00
javayhu
a22a5def4d chore: update hyperdrive localConnectionString 2025-06-15 17:44:17 +08:00
javayhu
d190bcb358 chore: add pg to fix error [Better Auth]: INTERNAL_SERVER_ERROR Error: Cannot find module 'cloudflare:sockets' when run pnpm preview 2025-06-15 17:44:00 +08:00
javayhu
7f1fe23407 chore: remove unused types from tsconfig.json 2025-06-15 12:25:06 +08:00
javayhu
05a7de4599 fix: add type annotation for data in GitHubStarsButton component 2025-06-15 12:24:57 +08:00
javayhu
c098300481 chore: update next config & fix build error UnhandledSchemeError 2025-06-15 12:23:10 +08:00
javayhu
e7240db823 fix: fix build error: Module build failed: UnhandledSchemeError: Reading from "cloudflare:sockets" is not handled by plugins (Unhandled scheme).
https://github.com/vercel/next.js/discussions/50177
2025-06-15 12:00:38 +08:00
javayhu
a4390d433b chore: add types to compilerOptions in ts config 2025-06-15 11:44:37 +08:00
javayhu
ae49d06cf4 chore: update db instance & bind hyperdrive 2025-06-15 09:05:27 +08:00
javayhu
6a448825a6 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-15 08:12:38 +08:00
javayhu
4d60d48212 cf: reset gitignore file same as main branch 2025-06-09 01:25:33 +08:00
javayhu
26a88eb2f0 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-09 00:58:21 +08:00
javayhu
c5d08a9846 Revert "cf: test remove preview pages only"
This reverts commit f5b4ed2859.
2025-06-09 00:42:09 +08:00
javayhu
f5b4ed2859 cf: test remove preview pages only 2025-06-09 00:22:45 +08:00
javayhu
b88aa9c1f5 Revert "cf: test remove docs pages and content only"
This reverts commit 708fac652f.
2025-06-09 00:21:43 +08:00
javayhu
593333c3dd Revert "cf: test remove docs pages and docs+blog content"
This reverts commit c3392320b3.
2025-06-09 00:21:34 +08:00
javayhu
f3b6603db7 Revert "cf: test remove docs and blog pages and content"
This reverts commit 9cb559a48d.
2025-06-09 00:21:28 +08:00
javayhu
9cb559a48d cf: test remove docs and blog pages and content 2025-06-09 00:17:09 +08:00
javayhu
c3392320b3 cf: test remove docs pages and docs+blog content 2025-06-09 00:08:06 +08:00
javayhu
708fac652f cf: test remove docs pages and content only 2025-06-09 00:05:32 +08:00
javayhu
ec124640f1 Revert "cf: test delete blog and docs content only"
This reverts commit 862132d8eb.
2025-06-09 00:03:44 +08:00
javayhu
862132d8eb cf: test delete blog and docs content only 2025-06-08 23:58:11 +08:00
javayhu
bf11c143fe Revert "cf: test remove all blog and docs and preview pages"
This reverts commit 6cfc76d621.
2025-06-08 23:57:16 +08:00
javayhu
6cfc76d621 cf: test remove all blog and docs and preview pages 2025-06-08 22:07:33 +08:00
javayhu
d935bcff76 cf: set @opennextjs/cloudflare as devDependencies 2025-06-08 21:49:19 +08:00
javayhu
a727a31e2f cf: revert the og image 2025-06-08 21:41:01 +08:00
javayhu
81cfc5f6b3 cf: update website name in zh.json 2025-06-08 21:38:14 +08:00
javayhu
8e8291c325 cf: update configs by the opennextjs docs 2025-06-08 21:18:42 +08:00
javayhu
6ff2ea6845 cf: upgrade opennextjs to 1.2.1 2025-06-08 20:04:51 +08:00
javayhu
b6836db12d Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-08 18:25:26 +08:00
javayhu
5f435b9614 chore: add mcp tool of context7 2025-05-18 17:36:30 +08:00
javayhu
9b03f6201f chore: show discord widget in homepage only 2025-05-18 16:55:37 +08:00
javayhu
111f00adaa chore: update @opennextjs/cloudflare version 2025-05-18 00:39:49 +08:00
javayhu
002d2090c2 Merge remote-tracking branch 'origin/main' into cloudflare 2025-05-17 23:40:51 +08:00
javayhu
c3913dbc88 chore: update open graph image 2025-05-13 01:43:25 +08:00
javayhu
9b68e3095e Merge remote-tracking branch 'origin/main' into cloudflare 2025-05-11 22:19:58 +08:00
javayhu
2fb627a6e9 chore: remove package-lock file 2025-05-08 00:07:35 +08:00
javayhu
f11e37374b Merge remote-tracking branch 'origin/main' into cloudflare 2025-05-08 00:03:57 +08:00
javayhu
3560616b52 Revert "chore: remove some doc files"
This reverts commit dd95dece87.
2025-05-08 00:03:34 +08:00
javayhu
80219fa10b Revert "fix: try fix build error, Error: EMFILE: too many open files"
This reverts commit a62abbf399.
2025-05-08 00:03:21 +08:00
javayhu
a62abbf399 fix: try fix build error, Error: EMFILE: too many open files
https://dash.cloudflare.com/b84ee5b2c0cdee9b0371c366945b0ab1/workers/services/view/mksaas-template/production/builds/7204761e-a2d1-490d-8d9d-b77a3985c1de
2025-05-07 00:06:49 +08:00
javayhu
dd95dece87 chore: remove some doc files 2025-05-06 23:52:32 +08:00
javayhu
c938122f7e chore: update package.json 2025-05-06 23:52:29 +08:00
javayhu
3887da26d0 chore: update wrangler config & add minify 2025-05-06 23:42:16 +08:00
javayhu
7af193f770 Merge remote-tracking branch 'origin/main' into cloudflare 2025-05-06 23:32:54 +08:00
javayhu
d6093394d8 chore: update cf scripts 2025-05-06 00:51:39 +08:00
javayhu
f1537e305a fix: build error & try add NEXT_PRIVATE_MAX_WORKER_THREADS=2 2025-05-06 00:42:31 +08:00
javayhu
1847ef4363 custom: update metadata name 2025-05-06 00:03:19 +08:00
javayhu
0fd695c8bc fix: build 2025-05-05 23:25:00 +08:00
javayhu
ae083a7992 feat: support cloudflare by diverce 2025-05-05 23:15:54 +08:00
57 changed files with 12813 additions and 253 deletions

View File

@ -1,5 +1,6 @@
.cursor
.claude
.conductor
.kiro
.github
.next

3
.gitignore vendored
View File

@ -41,6 +41,9 @@ certificates
# claude code
.claude
# conductor
.conductor
# kiro
.kiro

View File

@ -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
}
}

View File

@ -14,6 +14,7 @@
".cursor/**",
".claude/**",
".kiro/**",
".conductor/**",
".vscode/**",
".source/**",
"node_modules/**",
@ -77,6 +78,7 @@
".wrangler/**",
".cursor/**",
".claude/**",
".conductor/**",
".kiro/**",
".vscode/**",
".source/**",

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

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

View File

@ -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>

View File

@ -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
View File

@ -0,0 +1 @@
NEXTJS_ENV=development

View File

@ -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": {
@ -578,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",

View File

@ -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": {
@ -578,7 +591,7 @@
},
"price": "价格:",
"periodStartDate": "周期开始日期:",
"nextBillingDate": "下次账单日期:",
"periodEndDate": "周期结束日期:",
"trialEnds": "试用结束日期:",
"freePlanMessage": "您当前使用的是功能有限的免费方案",
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",

View File

@ -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
View File

@ -0,0 +1,6 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({
});

View File

@ -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",
@ -111,6 +112,7 @@
"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",
@ -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"
}
}

4299
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
public/_headers Normal file
View File

@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

View File

@ -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(),
}),

View File

@ -70,7 +70,7 @@ export default function ChatBot() {
};
return (
<div className="mx-auto p-6 relative size-full h-screen rounded-lg bg-muted/50">
<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>

View File

@ -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">

View File

@ -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>
);

View File

@ -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>

View File

@ -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 (

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

View File

@ -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>

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -3,6 +3,7 @@ import { LocaleLink } from '@/i18n/navigation';
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 {
@ -30,6 +31,13 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
title={title || 'image for blog post'}
/>
{/* 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">

View File

@ -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 || '');

View File

@ -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,

View File

@ -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)}>

View File

@ -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 (

View File

@ -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';
@ -44,7 +44,7 @@ const customNavigationMenuTriggerStyle = cn(
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();

View File

@ -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 = () => {

View File

@ -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 () => {

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

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

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

View File

@ -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

View File

@ -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');
@ -229,36 +227,25 @@ 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>

View File

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

View File

@ -4,34 +4,33 @@ 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 {
parseAsIndex,
parseAsInteger,
parseAsString,
useQueryStates,
} from 'nuqs';
import { useMemo } 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 [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] =
useQueryStates({
page: parseAsIndex.withDefault(0), // 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]
);
const { data, isLoading } = useCreditTransactions(
page,
pageSize,
@ -48,19 +47,10 @@ export function CreditTransactions() {
search={search}
sorting={sorting}
loading={isLoading}
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,
});
}
}}
onSearch={onSearch}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onSortingChange={onSortingChange}
/>
);
}

View File

@ -4,8 +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 { parseAsStringLiteral, useQueryState } from 'nuqs';
import {
parseAsIndex,
parseAsInteger,
parseAsString,
parseAsStringLiteral,
useQueryStates,
} from 'nuqs';
import { useMemo } from 'react';
/**
* Credits page client, show credit balance and transactions
@ -13,24 +21,47 @@ import { parseAsStringLiteral, useQueryState } from 'nuqs';
export default function CreditsPageClient() {
const t = useTranslations('Dashboard.settings.credits');
const [activeTab, setActiveTab] = useQueryState(
'tab',
parseAsStringLiteral(['balance', 'transactions']).withDefault('balance')
// 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') {
setActiveTab(value);
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
value={activeTab}
onValueChange={handleTabChange}
className="w-full"
>
<Tabs value={tab} onValueChange={handleTabChange} className="w-full">
<TabsList className="">
<TabsTrigger value="balance">{t('tabs.balance')}</TabsTrigger>
<TabsTrigger value="transactions">
@ -52,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>

View File

@ -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 [

View File

@ -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> = {};

View File

@ -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 [

View File

@ -47,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 [

View File

@ -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> = {};

View File

@ -27,7 +27,7 @@ import { websiteConfig } from './website';
*
* @returns The sidebar config with translated titles and descriptions
*/
export function getSidebarLinks(): NestedMenuItem[] {
export function useSidebarLinks(): NestedMenuItem[] {
const t = useTranslations('Dashboard');
// if is demo website, allow user to access admin and user pages, but data is fake

View File

@ -25,7 +25,7 @@ import { websiteConfig } from './website';
*
* @returns The social config
*/
export function getSocialLinks(): MenuItem[] {
export function useSocialLinks(): MenuItem[] {
const socialLinks: MenuItem[] = [];
if (websiteConfig.metadata.social?.github) {

View File

@ -1,4 +1,4 @@
import { getCreditPackages } from '@/config/credits-config';
import { useCreditPackages } from '@/config/credits-config';
import type { CreditPackage } from './types';
/**
@ -6,7 +6,7 @@ import type { CreditPackage } from './types';
* @returns Credit packages
*/
export function getCreditPackagesInClient(): CreditPackage[] {
return Object.values(getCreditPackages());
return Object.values(useCreditPackages());
}
/**

View File

@ -2,17 +2,23 @@
* Connect to PostgreSQL Database (Supabase/Neon/Local PostgreSQL)
* https://orm.drizzle.team/docs/tutorials/drizzle-with-supabase
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
let db: ReturnType<typeof drizzle> | null = null;
// https://opennext.js.org/cloudflare/howtos/db#postgresql
export async function getDb() {
if (db) return db;
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString, { prepare: false });
db = drizzle(client, { schema });
const { env } = await getCloudflareContext({ async: true });
const pool = new Pool({
connectionString: env.HYPERDRIVE.connectionString,
// You don't want to reuse the same connection for multiple requests
maxUses: 1,
});
db = drizzle({ client: pool, schema });
return db;
}

89
src/lib/premium-access.ts Normal file
View File

@ -0,0 +1,89 @@
import { getDb } from '@/db';
import { payment } from '@/db/schema';
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
import { PaymentTypes } from '@/payment/types';
import { and, eq, gt, isNull, or } from 'drizzle-orm';
/**
* Check premium access for a specific user ID
*
* This function combines the logic from getLifetimeStatusAction and getActiveSubscriptionAction
* but optimizes it for a single database query to check premium access.
*/
export async function checkPremiumAccess(userId: string): Promise<boolean> {
try {
const db = await getDb();
// Get lifetime plan IDs for efficient checking
const plans = getAllPricePlans();
const lifetimePlanIds = plans
.filter((plan) => plan.isLifetime)
.map((plan) => plan.id);
// Single optimized query to check both lifetime and active subscriptions
const result = await db
.select({
id: payment.id,
priceId: payment.priceId,
type: payment.type,
status: payment.status,
periodEnd: payment.periodEnd,
cancelAtPeriodEnd: payment.cancelAtPeriodEnd,
})
.from(payment)
.where(
and(
eq(payment.userId, userId),
or(
// Check for completed lifetime payments
and(
eq(payment.type, PaymentTypes.ONE_TIME),
eq(payment.status, 'completed')
),
// Check for active subscriptions that haven't expired
and(
eq(payment.type, PaymentTypes.SUBSCRIPTION),
eq(payment.status, 'active'),
or(
// Either period hasn't ended yet
gt(payment.periodEnd, new Date()),
// Or period end is null (ongoing subscription)
isNull(payment.periodEnd)
)
)
)
)
);
if (!result || result.length === 0) {
return false;
}
// Check if any payment grants premium access
return result.some((p) => {
// For one-time payments, check if it's a lifetime plan
if (p.type === PaymentTypes.ONE_TIME && p.status === 'completed') {
const plan = findPlanByPriceId(p.priceId);
return plan && lifetimePlanIds.includes(plan.id);
}
// For subscriptions, check if they're active and not expired
if (p.type === PaymentTypes.SUBSCRIPTION && p.status === 'active') {
// If periodEnd is null, it's an ongoing subscription
if (!p.periodEnd) {
return true;
}
// Check if the subscription period hasn't ended yet
const now = new Date();
const periodEnd = new Date(p.periodEnd);
return periodEnd > now;
}
return false;
});
} catch (error) {
console.error('Error checking premium access for user:', error);
return false;
}
}

View File

@ -35,7 +35,7 @@ export function getUrlWithLocale(url: string, locale?: Locale | null): string {
* Input: http://localhost:3000/api/auth/reset-password/token?callbackURL=/auth/reset-password
* Output: http://localhost:3000/api/auth/reset-password/token?callbackURL=/zh/auth/reset-password
*
* http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/dashboard
* Input: http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/dashboard
* Output: http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/zh/dashboard
*
* @param url - The original URL with callbackURL parameter

View File

@ -57,7 +57,13 @@ export class StripeProvider implements PaymentProvider {
}
// Initialize Stripe without specifying apiVersion to use default/latest version
this.stripe = new Stripe(apiKey);
// https://opennext.js.org/cloudflare/howtos/stripeAPI
// When creating a Stripe object, the default http client implementation is based on
// node:https which is not implemented on Workers.
this.stripe = new Stripe(apiKey, {
// Cloudflare Workers use the Fetch API for their API requests.
httpClient: Stripe.createFetchHttpClient(),
});
this.webhookSecret = webhookSecret;
}
@ -537,6 +543,9 @@ export class StripeProvider implements PaymentProvider {
return;
}
const periodStart = this.getPeriodStart(stripeSubscription);
const periodEnd = this.getPeriodEnd(stripeSubscription);
// create fields
const createFields: any = {
id: randomUUID(),
@ -549,12 +558,8 @@ export class StripeProvider implements PaymentProvider {
status: this.mapSubscriptionStatusToPaymentStatus(
stripeSubscription.status
),
periodStart: stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: null,
periodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: null,
periodStart: periodStart,
periodEnd: periodEnd,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
@ -614,12 +619,8 @@ export class StripeProvider implements PaymentProvider {
.limit(1);
// get new period start and end
const newPeriodStart = stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: undefined;
const newPeriodEnd = stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: undefined;
const newPeriodStart = this.getPeriodStart(stripeSubscription);
const newPeriodEnd = this.getPeriodEnd(stripeSubscription);
// Check if this is a renewal (period has changed and subscription is active)
const isRenewal =
@ -972,4 +973,24 @@ export class StripeProvider implements PaymentProvider {
// Default to auto to let Stripe detect the language
return 'auto';
}
private getPeriodStart(subscription: Stripe.Subscription): Date | undefined {
const s: any = subscription as any;
const startUnix =
s.current_period_start ??
s?.items?.data?.[0]?.current_period_start ??
undefined;
return typeof startUnix === 'number'
? new Date(startUnix * 1000)
: undefined;
}
private getPeriodEnd(subscription: Stripe.Subscription): Date | undefined {
const s: any = subscription as any;
const endUnix =
s.current_period_end ??
s?.items?.data?.[0]?.current_period_end ??
undefined;
return typeof endUnix === 'number' ? new Date(endUnix * 1000) : undefined;
}
}

84
wrangler.jsonc Normal file
View File

@ -0,0 +1,84 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "mksaas-template",
"compatibility_date": "2024-12-30",
"compatibility_flags": [
// Enable Node.js API
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag
"nodejs_compat",
// This also enables nodejs_compat_v2 as long as compatibility date is 2024-09-23 or later.
// https://developers.cloudflare.com/workers/configuration/compatibility-dates/#nodejs-compatibility-flag
// Enable improved Node.js API with polyfills and native code
// see https://blog.cloudflare.com/zh-cn/more-npm-packages-on-cloudflare-workers-combining-polyfills-and-native-code/
// "nodejs_compat_v2",
// Enable auto-populating process.env
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-auto-populating-processenv
"nodejs_compat_populate_process_env",
// Allow to fetch URLs in your app
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
"global_fetch_strictly_public"
],
// Minification helps to keep the Worker bundle size down and improve start up time.
"minify": true,
// Enables Workers Trace Events Logpush for a Worker
"logpush": true,
// https://developers.cloudflare.com/workers/wrangler/configuration/#top-level-only-keys
// Whether Wrangler should keep variables configured in the dashboard on deploy
"keep_vars": true,
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
// https://developers.cloudflare.com/workers/wrangler/configuration/#observability
"observability": {
"enabled": true
},
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Hyperdrive
* https://opennext.js.org/cloudflare/howtos/db#hyperdrive-example
* https://developers.cloudflare.com/workers/tutorials/postgres/#8-use-hyperdrive-to-accelerate-queries
*/
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "8ba4508b28cf42f987f3533c1f09433c",
"localConnectionString": "postgresql://postgres:postgres@localhost:5432/postgres"
}
],
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
"vars": {},
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
"kv_namespaces": []
}