Compare commits

...

262 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
6c584c75e2 feat: enhance CreditTransactions component with loading skeleton and refactor state management using useQueryStates 2025-08-28 01:08:48 +08:00
javayhu
797ee9b7e5 feat: add nuqs package and integrate NuqsAdapter in layout and users page components 2025-08-28 00:47:37 +08:00
javayhu
658409cfbd Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-27 00:53:02 +08:00
javayhu
422c323467 chore: replace Image component with BlogImage for improved loading handling in BlogCard 2025-08-27 00:45:09 +08:00
javayhu
de7e87e5b8 chore: update Skeleton component styles 2025-08-27 00:35:00 +08:00
javayhu
613bbd0d78 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-26 00:50:03 +08:00
javayhu
4434f1900d chore: remove deprecated Discord widget and related configurations 2025-08-26 00:48:39 +08:00
javayhu
895e02bfdd chore: update .dockerignore and biome.json to include new directories and remove unused object-values type 2025-08-26 00:20:27 +08:00
javayhu
7cc1fd5835 refactor: update website configuration structure to use 'ui' instead of 'metadata' for theme and mode settings 2025-08-26 00:16:55 +08:00
javayhu
4bad9714fa refactor: remove BlockCategory pages, and BlockPreview components 2025-08-25 23:43:45 +08:00
javayhu
fa4b9a19a1 refactor: remove service worker and related registration utilities 2025-08-25 23:39:10 +08:00
javayhu
1c0c46fa34 Revert "feat: enhance credit hooks to trigger updates on store changes"
This reverts commit 7851a715a3.
2025-08-25 10:01:54 +08:00
javayhu
0ae3f27c78 refactor: change default website mode from 'system' to 'dark' in website configuration 2025-08-25 10:00:45 +08:00
javayhu
fc024ea0da refactor: simplify layout of ChatBot and ImagePlayground components, update model configuration in web content analyzer 2025-08-25 10:00:28 +08:00
javayhu
80851fcf44 refactor: remove credit-related functionality and components from the web content analysis module 2025-08-25 09:48:20 +08:00
javayhu
31829ce17b feat: add ConsumeCreditsCard component for credit consumption functionality 2025-08-25 09:23:27 +08:00
javayhu
7c9b0a2697 refactor: remove ConsumeCreditCard component and integrate its functionality into ConsumeCreditsCard 2025-08-25 09:22:56 +08:00
javayhu
5f14259197 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-24 22:34:35 +08:00
javayhu
15da1ee48a refactor: update global styles with a custom theme inspired by Qoder, adjusting color variables and shadow effects 2025-08-24 21:16:41 +08:00
javayhu
c2d7e51f5b refactor: update HeroSection component styles to use accent color for hover state and simplify layout 2025-08-24 21:15:48 +08:00
javayhu
00405d5335 refactor: update text colors in BlogPostPage, FeaturesSection, and HeaderSection components to use primary color 2025-08-24 21:10:08 +08:00
javayhu
610346055f refactor: update navbar component styles to use accent foreground for active and hover states 2025-08-24 21:01:36 +08:00
javayhu
cb9c3132fd refactor: update CardFooter background color from bg-background to bg-muted across multiple components 2025-08-24 20:34:50 +08:00
javayhu
32fc3d6dc9 chore: simplify BlogCard component by removing unnecessary span styling and enhancing border effects 2025-08-24 20:11:24 +08:00
javayhu
69143ace47 feat: implement error handling in getCreditBalanceAction and add CreditsTest component for credit consumption testing 2025-08-24 19:55:53 +08:00
javayhu
8c3ef9bfaf refactor: remove unused heading elements from AIAudioPage and AIVideoPage components 2025-08-24 19:41:02 +08:00
javayhu
7851a715a3 feat: enhance credit hooks to trigger updates on store changes 2025-08-24 19:26:54 +08:00
javayhu
0fb4ef93d2 chore: add CreditsTest component 2025-08-24 19:26:49 +08:00
javayhu
95a6f3b9d5 feat: update AI Chat, add ChatBot component 2025-08-24 15:36:20 +08:00
javayhu
0794c7d297 feat: add AI Chat route and localization support in English and Chinese 2025-08-24 11:39:04 +08:00
javayhu
395f753025 refactor: remove TypeScript error comments from CodeBlock component 2025-08-24 11:32:43 +08:00
javayhu
fc53045d99
Merge pull request #82 from MkSaaSHQ/dev/ai-elements
feat: AI Chat demo with ai elements
2025-08-24 11:27:58 +08:00
javayhu
64ba2711aa feat: implement AI chat functionality with new API route and chat component 2025-08-24 11:26:06 +08:00
javayhu
3a61c953a4 chore: add ai-elements to biome.json for component inclusion 2025-08-24 10:39:52 +08:00
javayhu
6de7dfebf3 chore: update shadcnui badge and scroll-area components 2025-08-24 10:38:30 +08:00
javayhu
e626bb9af4 feat: add ai-elements components 2025-08-24 10:37:37 +08:00
javayhu
33fe00b8dc chore: mark lastRefreshAt field as deprecated in userCredit schema 2025-08-24 10:22:31 +08:00
javayhu
2d0392db61 refactor: update credit eligibility checks to use canAddCreditsByType function for improved clarity and maintainability 2025-08-24 10:03:35 +08:00
javayhu
afdaeba2be refactor: remove updateUserLastRefreshAt function and its calls to streamline credit update logic 2025-08-24 09:56:19 +08:00
javayhu
258ddad399 refactor: consolidate credit addition logic into a single function to improve maintainability and clarity 2025-08-24 09:24:59 +08:00
javayhu
e6bc1ea9e8 refactor: rename 'credits' to 'amount' in credit-related configurations and components for consistency 2025-08-24 01:16:39 +08:00
javayhu
96d630f3ac chore: add new hostname configuration for service.firecrawl.dev in Next.js config 2025-08-24 00:52:06 +08:00
javayhu
e15d76461f feat: add function to check if subscription credits can be added based on last refresh time 2025-08-23 20:21:54 +08:00
javayhu
1ff42009d8 chore: update function parameters planId instead of priceId 2025-08-23 20:07:32 +08:00
javayhu
669ac94bad chore: update comments to reflect renaming of enableForFreePlan to enablePackagesForFreePlan for clarity 2025-08-23 17:18:37 +08:00
javayhu
d319bd8af2 chore: rename enableForFreePlan to enablePackagesForFreePlan for clarity in credits configuration 2025-08-23 10:20:30 +08:00
javayhu
01f5734dd5 chore: update credit expiration messaging and logic to reflect upcoming expiration in days 2025-08-23 09:52:28 +08:00
javayhu
6837c5a8d4 chore: simplify CreditsBalanceCard layout 2025-08-23 09:05:12 +08:00
javayhu
6927f4b234 chore: adjust skeleton component heights in billing and password cards for improved loading state visibility 2025-08-23 09:00:13 +08:00
javayhu
ffe5bc4ea5 chore: update PasswordCardWrapper to include CardFooter with skeleton loading state 2025-08-23 08:55:02 +08:00
javayhu
1be26638fc chore: update billing card rendering logic to include payment data check 2025-08-23 08:23:54 +08:00
javayhu
c66fedea27 chore: update upgrade card visibility logic to ensure data is loaded before rendering 2025-08-23 07:51:37 +08:00
javayhu
b4dab95c04 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-23 00:14:47 +08:00
javayhu
1e1cafff32 chore: delete loading component for protected routes 2025-08-23 00:08:33 +08:00
javayhu
23ddb90e1f chore: remove loading components for protected routes 2025-08-22 23:52:58 +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
7f4a7a61a2 refactor: enhance captcha handling in login and register forms with reset functionality 2025-08-22 01:12:13 +08:00
javayhu
a6a5d92dc1 refactor: implement batch processing for expired credits in credit cron job 2025-08-22 00:39:17 +08:00
javayhu
63a5e4f328 refactor: remove subscription and lifetime credits logic from credit stats and balance card 2025-08-22 00:04:40 +08:00
javayhu
19120ee7f1 refactor: replace password card logic with useHasCredentialProvider hook 2025-08-21 23:54:28 +08:00
javayhu
7aa7cb5603 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-21 10:04:48 +08:00
javayhu
d644611afd refactor: remove TANSTACK_QUERY_REFACTOR.md 2025-08-21 10:04:27 +08:00
javayhu
ca30f95027 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-21 09:59:28 +08:00
javayhu
8cc16a898c
Merge pull request #81 from MkSaaSHQ/dev/tanstack-query
feat: add tanstack query to optimize performance
2025-08-21 09:55:45 +08:00
javayhu
cf8a7f1242 refactor: optimize credits balance card logic after successful payment 2025-08-21 09:50:40 +08:00
javayhu
1fb89a2a05 refactor: enhance credit balance and stats hooks with improved error handling and logging 2025-08-21 01:48:40 +08:00
javayhu
13c23dab56 refactor: migrate state management from Zustand to TanStack Query for improved data fetching and caching across components 2025-08-21 00:54:40 +08:00
javayhu
ac8d4dee4b refactor: replace usePayment hook and store with useCurrentPlan for improved payment state management 2025-08-21 00:50:12 +08:00
javayhu
ff1e72df13 refactor: replace useCredits hook with custom hooks for credit balance, consumption, and transactions management 2025-08-21 00:27:26 +08:00
javayhu
d153ca655e refactor: implement custom hooks for user management and ban/unban user 2025-08-20 23:52:06 +08:00
javayhu
c00223c79a refactor: replace server actions with custom hooks for newsletter management and improve loading/error handling 2025-08-20 22:39:20 +08:00
javayhu
d59be1044a feat: add QueryProvider to manage React Query client and wrap Providers component 2025-08-20 22:37:52 +08:00
javayhu
5431160d62 chore: add @tanstack/react-query-devtools 2025-08-20 22:37:39 +08:00
javayhu
73baf946bd chore: add @tanstack/react-query and @tanstack/eslint-plugin-query 2025-08-20 22:04:19 +08:00
javayhu
d747683f82 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-20 00:20:22 +08:00
javayhu
978f13a368 refactor: remove unnecessary Skeleton component in billing and credits balance cards 2025-08-20 00:18:02 +08:00
javayhu
779493965c chore: remove images in about page 2025-08-20 00:13:26 +08:00
javayhu
3ae0411a44 chore: remove bg div in features section 2025-08-19 23:25:28 +08:00
javayhu
ccf064b0d5 chore: optimize pricing card badge 2025-08-19 23:09:41 +08:00
javayhu
b55613b471 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-18 00:59:21 +08:00
javayhu
7c0e6a5131 chore: enhance error handling and optimize database queries in user credit functions 2025-08-18 00:58:46 +08:00
javayhu
d86f89e3de fix: update URL routing in credits balance card component after successful payment 2025-08-18 00:45:51 +08:00
javayhu
9800b1d842 refactor: update CardFooter in credits balance card component 2025-08-18 00:42:53 +08:00
javayhu
9db52f352b refactor: adjust spacing in credits page layout and comment out button in credits balance card 2025-08-18 00:36:42 +08:00
javayhu
47679ab91e Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-18 00:32:31 +08:00
javayhu
90757475ac refactor: implement credits page with balance and transactions tabs 2025-08-18 00:29:56 +08:00
javayhu
904dceec44 refactor: update billing and credits pages & move credit balance and packages to credits page 2025-08-18 00:19:35 +08:00
javayhu
f468638f49 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-17 23:27:24 +08:00
javayhu
f1d02720d0 refactor: restructure credit distribution logic and enhance user credit handling 2025-08-17 23:26:10 +08:00
javayhu
35ddf5e08e Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-17 08:45:27 +08:00
javayhu
bade6b620e refactor: enhance error handling in credit balance retrieval 2025-08-16 23:08:16 +08:00
javayhu
d1928575b3 refactor: replace createSafeActionClient with userActionClient for improved session handling across multiple actions 2025-08-16 23:00:21 +08:00
javayhu
262228d6e9 feat: add session validation for admin access in getUsersAction 2025-08-16 22:03:01 +08:00
javayhu
57b92cfe85 feat: add script of listing users emails 2025-08-16 13:51:12 +08:00
javayhu
9f2fd58eb0 feat: optimize loading state in login and register forms 2025-08-16 10:48:58 +08:00
javayhu
1f7c38f9f5 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-16 08:19:56 +08:00
javayhu
9f71c9942a chore: optimize the colors of componnets in home page 2025-08-16 01:39:11 +08:00
javayhu
e99d6da45c chore: update blog card category bg 2025-08-16 00:39:43 +08:00
javayhu
63dd4e52fb Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 23:03:55 +08:00
javayhu
debbb5abf5 feat: support disable storage module 2025-08-15 22:51:48 +08:00
javayhu
c5dfaafe61 chore: optimize distributing credits when user sign up 2025-08-15 22:40:59 +08:00
javayhu
df3f3aa895 feat: support disable newsletter module 2025-08-15 22:39:29 +08:00
javayhu
a1ae6ca384 feat: support disable docs module 2025-08-15 22:23:28 +08:00
javayhu
866988d73c feat: support disable blog module 2025-08-15 22:20:15 +08:00
javayhu
46fd529390 refactor: add isDemo function 2025-08-15 22:10:01 +08:00
javayhu
fbb9a1b053 chore: update size of youtube video component 2025-08-15 20:25:37 +08:00
javayhu
200a9963f7 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 00:41:00 +08:00
javayhu
f6a2df402e fix: fix rate limit for newsletter subscription when sign up 2025-08-15 00:37:30 +08:00
javayhu
0da8f7d335 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 00:10:26 +08:00
javayhu
335c3b46d6 fix: fix build error in newsletter form validation 2025-08-15 00:06:28 +08:00
javayhu
004edeecea Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-14 23:14:32 +08:00
javayhu
f2b5bae866 feat: upgrade react-hook-form & zod & @hookform/resolvers
fix zod validation errors in form submission
2025-08-14 23:12:44 +08:00
javayhu
6bb12a2d86 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-11 07:41:56 +08:00
javayhu
a1b54d7518 chore: update env example 2025-08-11 07:41:29 +08:00
javayhu
97654d97ea Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-11 07:36:28 +08:00
javayhu
064576f48e refactor: streamline user payment retrieval by utilizing a subquery for latest active payments 2025-08-10 15:54:46 +08:00
javayhu
0be53d3251 feat: optimize credit distribution by batching user processing and enhancing database queries 2025-08-10 14:26:53 +08:00
javayhu
564efbd3e2 feat: increase maxDuration for API functions in vercel.json from 60 to 300 seconds 2025-08-10 14:02:49 +08:00
javayhu
2814f87578 feat: update user last refresh time and refine user selection criteria 2025-08-10 13:53:48 +08:00
javayhu
78f76f35b9 feat: add basic auth to distribute credits cron jobs 2025-08-10 11:48:53 +08:00
javayhu
f50f60443a
Merge pull request #79 from MkSaaSHQ/dev/cron-jobs
support cron jobs
2025-08-10 11:16:48 +08:00
javayhu
346d154604 feat: support cron jobs 2025-08-10 11:13:21 +08:00
javayhu
7985769871 refactor: remove inngest 2025-08-10 11:07:10 +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
197 changed files with 19248 additions and 5242 deletions

View File

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

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

View File

@ -12,6 +12,9 @@
".open-next/**",
".wrangler/**",
".cursor/**",
".claude/**",
".kiro/**",
".conductor/**",
".vscode/**",
".source/**",
"node_modules/**",
@ -23,11 +26,11 @@
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/components/ai-elements/*.tsx",
"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": {
@ -74,6 +77,9 @@
".open-next/**",
".wrangler/**",
".cursor/**",
".claude/**",
".conductor/**",
".kiro/**",
".vscode/**",
".source/**",
"node_modules/**",
@ -85,11 +91,11 @@
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/components/ai-elements/*.tsx",
"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

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

@ -8,13 +8,13 @@ NEXT_PUBLIC_BASE_URL="http://localhost:3000"
# -----------------------------------------------------------------------------
# Database
# https://mksaas.com/docs/database#setup
# https://mksaas.com/docs/database
# -----------------------------------------------------------------------------
DATABASE_URL=""
# -----------------------------------------------------------------------------
# Better Auth
# https://mksaas.com/docs/auth#setup
# https://mksaas.com/docs/auth
# Generate a random string for the secret key using `openssl rand -base64 32`
# -----------------------------------------------------------------------------
BETTER_AUTH_SECRET=""
@ -39,8 +39,8 @@ GOOGLE_CLIENT_SECRET=""
# -----------------------------------------------------------------------------
# Email / Newsletter (Resend)
# https://mksaas.com/docs/email#setup
# https://mksaas.com/docs/newsletter#setup
# https://mksaas.com/docs/email
# https://mksaas.com/docs/newsletter
# Get API key and audience id from https://resend.com
# -----------------------------------------------------------------------------
RESEND_API_KEY=""
@ -48,7 +48,7 @@ RESEND_AUDIENCE_ID=""
# -----------------------------------------------------------------------------
# Storage (Cloudflare R2 or S3-compatible service of your choice)
# https://mksaas.com/docs/storage#setup
# https://mksaas.com/docs/storage
# Cloudflare R2: https://www.cloudflare.com/developer-platform/products/r2
# -----------------------------------------------------------------------------
STORAGE_REGION="auto"
@ -60,7 +60,7 @@ STORAGE_PUBLIC_URL=""
# -----------------------------------------------------------------------------
# Payment (Stripe)
# https://mksaas.com/docs/payment#setup
# https://mksaas.com/docs/payment
# Get Stripe key and secret from https://dashboard.stripe.com
# -----------------------------------------------------------------------------
STRIPE_SECRET_KEY=""
@ -84,13 +84,16 @@ NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE=""
# Configurations
# -----------------------------------------------------------------------------
# Disable image optimization, check out next.config.ts for more details
# -----------------------------------------------------------------------------
DISABLE_IMAGE_OPTIMIZATION=false
# -----------------------------------------------------------------------------
# Run this website as demo website, in most cases, you should set this to false
# -----------------------------------------------------------------------------
NEXT_PUBLIC_DEMO_WEBSITE=false
# -----------------------------------------------------------------------------
# Analytics
# https://mksaas.com/docs/analytics#setup
# https://mksaas.com/docs/analytics
# -----------------------------------------------------------------------------
# Google Analytics (https://analytics.google.com)
# https://mksaas.com/docs/analytics#google
@ -156,27 +159,29 @@ NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""
# -----------------------------------------------------------------------------
# Captcha (Cloudflare Turnstile)
# https://mksaas.com/docs/captcha#setup
# https://mksaas.com/docs/captcha
# -----------------------------------------------------------------------------
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""
# -----------------------------------------------------------------------------
# Crisp
# https://mksaas.com/docs/chat#setup
# https://mksaas.com/docs/chat
# -----------------------------------------------------------------------------
NEXT_PUBLIC_CRISP_WEBSITE_ID=""
# -----------------------------------------------------------------------------
# Inngest
# https://mksaas.com/docs/jobs#setup
# Cron Jobs
# https://mksaas.com/docs/cronjobs
# -----------------------------------------------------------------------------
INNGEST_SIGNING_KEY=""
CRON_JOBS_USERNAME=""
CRON_JOBS_PASSWORD=""
# -----------------------------------------------------------------------------
# AI
# https://mksaas.com/docs/ai
# -----------------------------------------------------------------------------
AI_GATEWAY_API_KEY=""
FAL_API_KEY=""
FIREWORKS_API_KEY=""
OPENAI_API_KEY=""
@ -188,6 +193,5 @@ OPENROUTER_API_KEY=""
# -----------------------------------------------------------------------------
# Web Content Analyzer (Firecrawl)
# https://firecrawl.dev/
# Get API key from https://firecrawl.dev/app
# -----------------------------------------------------------------------------
FIRECRAWL_API_KEY=""

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": {
@ -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",
@ -588,6 +605,10 @@
"credits": {
"title": "Credits",
"description": "Manage your credit transactions",
"tabs": {
"balance": "Balance",
"transactions": "Transactions"
},
"balance": {
"title": "Credit Balance",
"description": "Your credit balance",
@ -597,9 +618,7 @@
"creditsAdded": "Credits have been added to your account",
"viewTransactions": "View Credit Transactions",
"retry": "Retry",
"subscriptionCredits": "{credits} credits from subscription this month",
"lifetimeCredits": "{credits} credits from lifetime plan this month",
"expiringCredits": "{credits} credits expiring on {date}"
"expiringCredits": "{credits} credits expiring in the next {days} days"
},
"packages": {
"title": "Credit Packages",
@ -1038,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"
}
}

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": {
@ -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": "您拥有所有高级功能的终身使用权限",
@ -588,6 +605,10 @@
"credits": {
"title": "积分",
"description": "管理您的积分交易",
"tabs": {
"balance": "积分余额",
"transactions": "交易记录"
},
"balance": {
"title": "积分余额",
"description": "您的积分余额",
@ -597,9 +618,7 @@
"creditsAdded": "积分已添加到您的账户",
"viewTransactions": "查看积分记录",
"retry": "重试",
"subscriptionCredits": "本月订阅获得 {credits} 积分",
"lifetimeCredits": "本月终身会员获得 {credits} 积分",
"expiringCredits": "{credits} 积分将在 {date} 过期"
"expiringCredits": "{credits} 积分将在 {days} 天内过期"
},
"packages": {
"title": "积分套餐",
@ -1038,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简单且毫不费力"
}
}

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
@ -48,6 +60,10 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'html.tailus.io',
},
{
protocol: 'https',
hostname: 'service.firecrawl.dev',
},
],
},
};
@ -66,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",
@ -15,6 +16,7 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"list-contacts": "tsx scripts/list-contacts.ts",
"list-users": "tsx scripts/list-users.ts",
"content": "fumadocs-mdx",
"email": "email dev --dir src/mail/templates --port 3333",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
@ -29,6 +31,7 @@
"@ai-sdk/fireworks": "^1.0.0",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.0",
"@ai-sdk/react": "^2.0.22",
"@ai-sdk/replicate": "^1.0.0",
"@base-ui-components/react": "1.0.0-beta.0",
"@better-fetch/fetch": "^1.1.18",
@ -36,7 +39,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^4.1.0",
"@hookform/resolvers": "^5.2.1",
"@marsidev/react-turnstile": "^1.1.0",
"@mendable/firecrawl-js": "^1.29.1",
"@next/third-parties": "^15.3.0",
@ -73,15 +76,17 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@react-email/components": "0.0.33",
"@react-email/render": "1.0.5",
"@stripe/stripe-js": "^5.6.0",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-query-devtools": "^5.85.5",
"@tanstack/react-table": "^8.21.2",
"@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",
@ -100,7 +105,6 @@
"fumadocs-core": "^15.6.7",
"fumadocs-mdx": "^11.7.3",
"fumadocs-ui": "^15.6.7",
"inngest": "^3.40.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.483.0",
"motion": "^12.4.3",
@ -108,14 +112,17 @@
"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",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-hook-form": "^7.62.0",
"react-remove-scroll": "^2.6.3",
"react-resizable-panels": "^2.1.7",
"react-syntax-highlighter": "^15.6.3",
"react-tweet": "^3.2.2",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.1",
@ -123,6 +130,7 @@
"s3mini": "^0.2.0",
"shiki": "^2.4.2",
"sonner": "^2.0.0",
"streamdown": "^1.0.12",
"stripe": "^17.6.0",
"swiper": "^11.2.5",
"tailwind-merge": "^3.0.2",
@ -130,24 +138,29 @@
"tw-animate-css": "^1.2.4",
"use-intl": "^3.26.5",
"use-media": "^1.5.0",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"zod": "^4.0.14",
"zod": "^4.0.17",
"zustand": "^5.0.3"
},
"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",
"@types/node": "^20.19.0",
"@types/pg": "^8.11.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"drizzle-kit": "^0.30.4",
"knip": "^5.61.2",
"postcss": "^8",
"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"
}
}

7207
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

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

24
scripts/list-users.ts Normal file
View File

@ -0,0 +1,24 @@
import dotenv from 'dotenv';
import { getDb } from '../src/db/index.js';
import { user } from '../src/db/schema.js';
dotenv.config();
export default async function listUsers() {
const db = await getDb();
try {
const users = await db.select({ email: user.email }).from(user);
// Extract emails from users
const emails: string[] = users.map((user) => user.email);
console.log(`Total users: ${emails.length}`);
// Output all emails joined with comma
console.log(emails.join(', '));
} catch (error) {
console.error('Error fetching users:', error);
}
}
listUsers();

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

@ -1,19 +1,16 @@
'use server';
import { userActionClient } from '@/lib/safe-action';
import { isSubscribed } from '@/newsletter';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),
});
// Create a safe action to check if a user is subscribed to the newsletter
export const checkNewsletterStatusAction = actionClient
export const checkNewsletterStatusAction = userActionClient
.schema(newsletterSchema)
.action(async ({ parsedInput: { email } }) => {
try {

View File

@ -1,45 +0,0 @@
'use server';
import { getWebContentAnalysisCost } from '@/ai/text/utils/web-content-analyzer-config';
import { getUserCredits, hasEnoughCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
const actionClient = createSafeActionClient();
/**
* Check if user has enough credits for web content analysis
*/
export const checkWebContentAnalysisCreditsAction = actionClient.action(
async () => {
const session = await getSession();
if (!session) {
console.warn(
'unauthorized request to check web content analysis credits'
);
return { success: false, error: 'Unauthorized' };
}
try {
const requiredCredits = getWebContentAnalysisCost();
const currentCredits = await getUserCredits(session.user.id);
const hasCredits = await hasEnoughCredits({
userId: session.user.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',
};
}
}
);

View File

@ -1,12 +1,10 @@
'use server';
import { consumeCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { z } from 'zod';
const actionClient = createSafeActionClient();
// consume credits schema
const consumeSchema = z.object({
amount: z.number().min(1),
@ -16,21 +14,17 @@ const consumeSchema = z.object({
/**
* Consume credits
*/
export const consumeCreditsAction = actionClient
export const consumeCreditsAction = userActionClient
.schema(consumeSchema)
.action(async ({ parsedInput }) => {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to consume credits');
return { success: false, error: 'Unauthorized' };
}
.action(async ({ parsedInput, ctx }) => {
const { amount, description } = parsedInput;
const currentUser = (ctx as { user: User }).user;
try {
await consumeCredits({
userId: session.user.id,
amount: parsedInput.amount,
description:
parsedInput.description || `Consume credits: ${parsedInput.amount}`,
userId: currentUser.id,
amount,
description: description || `Consume credits: ${amount}`,
});
return { success: true };
} catch (error) {

View File

@ -1,20 +1,17 @@
'use server';
import { websiteConfig } from '@/config/website';
import type { User } from '@/lib/auth-types';
import { findPlanByPlanId } from '@/lib/price-plan';
import { getSession } from '@/lib/server';
import { userActionClient } from '@/lib/safe-action';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCheckout } from '@/payment';
import type { CreateCheckoutParams } from '@/payment/types';
import { Routes } from '@/routes';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { cookies } from 'next/headers';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Checkout schema for validation
// metadata is optional, and may contain referral information if you need
const checkoutSchema = z.object({
@ -27,33 +24,11 @@ const checkoutSchema = z.object({
/**
* Create a checkout session for a price plan
*/
export const createCheckoutAction = actionClient
export const createCheckoutAction = userActionClient
.schema(checkoutSchema)
.action(async ({ parsedInput }) => {
const { userId, planId, priceId, metadata } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create checkout session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own checkout session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create checkout session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ parsedInput, ctx }) => {
const { planId, priceId, metadata } = parsedInput;
const currentUser = (ctx as { user: User }).user;
try {
// Get the current locale from the request
@ -71,8 +46,8 @@ export const createCheckoutAction = actionClient
// Add user id to metadata, so we can get it in the webhook event
const customMetadata: Record<string, string> = {
...metadata,
userId: session.user.id,
userName: session.user.name,
userId: currentUser.id,
userName: currentUser.name,
};
// https://datafa.st/docs/stripe-checkout-api
@ -94,7 +69,7 @@ export const createCheckoutAction = actionClient
const params: CreateCheckoutParams = {
planId,
priceId,
customerEmail: session.user.email,
customerEmail: currentUser.email,
metadata: customMetadata,
successUrl,
cancelUrl,

View File

@ -2,19 +2,16 @@
import { websiteConfig } from '@/config/website';
import { getCreditPackageById } from '@/credits/server';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCreditCheckout } from '@/payment';
import type { CreateCreditCheckoutParams } from '@/payment/types';
import { Routes } from '@/routes';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { cookies } from 'next/headers';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Credit checkout schema for validation
// metadata is optional, and may contain referral information if you need
const creditCheckoutSchema = z.object({
@ -27,33 +24,11 @@ const creditCheckoutSchema = z.object({
/**
* Create a checkout session for a credit package
*/
export const createCreditCheckoutSession = actionClient
export const createCreditCheckoutSession = userActionClient
.schema(creditCheckoutSchema)
.action(async ({ parsedInput }) => {
const { userId, packageId, priceId, metadata } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create credit checkout session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own checkout session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create credit checkout session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ parsedInput, ctx }) => {
const { packageId, priceId, metadata } = parsedInput;
const currentUser = (ctx as { user: User }).user;
try {
// Get the current locale from the request
@ -73,9 +48,9 @@ export const createCreditCheckoutSession = actionClient
...metadata,
type: 'credit_purchase',
packageId,
credits: creditPackage.credits.toString(),
userId: session.user.id,
userName: session.user.name,
credits: creditPackage.amount.toString(),
userId: currentUser.id,
userName: currentUser.name,
};
// https://datafa.st/docs/stripe-checkout-api
@ -90,15 +65,15 @@ export const createCreditCheckoutSession = actionClient
// Create checkout session with credit-specific URLs
const successUrl = getUrlWithLocale(
`${Routes.SettingsBilling}?credits_session_id={CHECKOUT_SESSION_ID}`,
`${Routes.SettingsCredits}?credits_session_id={CHECKOUT_SESSION_ID}`,
locale
);
const cancelUrl = getUrlWithLocale(Routes.SettingsBilling, locale);
const cancelUrl = getUrlWithLocale(Routes.SettingsCredits, locale);
const params: CreateCreditCheckoutParams = {
packageId,
priceId,
customerEmail: session.user.email,
customerEmail: currentUser.email,
metadata: customMetadata,
successUrl,
cancelUrl,

View File

@ -2,18 +2,15 @@
import { getDb } from '@/db';
import { user } from '@/db/schema';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCustomerPortal } from '@/payment';
import type { CreatePortalParams } from '@/payment/types';
import { eq } from 'drizzle-orm';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Portal schema for validation
const portalSchema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
@ -26,33 +23,11 @@ const portalSchema = z.object({
/**
* Create a customer portal session
*/
export const createPortalAction = actionClient
export const createPortalAction = userActionClient
.schema(portalSchema)
.action(async ({ parsedInput }) => {
const { userId, returnUrl } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create portal session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own portal session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create portal session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ parsedInput, ctx }) => {
const { returnUrl } = parsedInput;
const currentUser = (ctx as { user: User }).user;
try {
// Get the user's customer ID from the database
@ -60,11 +35,11 @@ export const createPortalAction = actionClient
const customerResult = await db
.select({ customerId: user.customerId })
.from(user)
.where(eq(user.id, session.user.id))
.where(eq(user.id, currentUser.id))
.limit(1);
if (customerResult.length <= 0 || !customerResult[0].customerId) {
console.error(`No customer found for user ${session.user.id}`);
console.error(`No customer found for user ${currentUser.id}`);
return {
success: false,
error: 'No customer found for user',

View File

@ -1,13 +1,10 @@
'use server';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getSubscriptions } from '@/payment';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Input schema
const schema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
@ -19,33 +16,10 @@ const schema = z.object({
* If the user has multiple subscriptions,
* it returns the most recent active or trialing one
*/
export const getActiveSubscriptionAction = actionClient
export const getActiveSubscriptionAction = userActionClient
.schema(schema)
.action(async ({ parsedInput }) => {
const { userId } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to get active subscription for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to check their own status unless they're admins
if (session.user.id !== userId && session.user.role !== 'admin') {
console.warn(
`current user ${session.user.id} is not authorized to get active subscription for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
// Check if Stripe environment variables are configured
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
@ -62,7 +36,7 @@ export const getActiveSubscriptionAction = actionClient
try {
// Find the user's most recent active subscription
const subscriptions = await getSubscriptions({
userId: session.user.id,
userId: currentUser.id,
});
// console.log('get user subscriptions:', subscriptions);
@ -76,16 +50,16 @@ export const getActiveSubscriptionAction = actionClient
// If found, use it
if (activeSubscription) {
console.log('find active subscription for userId:', session.user.id);
console.log('find active subscription for userId:', currentUser.id);
subscriptionData = activeSubscription;
} else {
console.log(
'no active subscription found for userId:',
session.user.id
currentUser.id
);
}
} else {
console.log('no subscriptions found for userId:', session.user.id);
console.log('no subscriptions found for userId:', currentUser.id);
}
return {

View File

@ -1,21 +1,27 @@
'use server';
import { getUserCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
const actionClient = createSafeActionClient();
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
/**
* Get current user's credits
*/
export const getCreditBalanceAction = actionClient.action(async () => {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to get credit balance');
return { success: false, error: 'Unauthorized' };
export const getCreditBalanceAction = userActionClient.action(
async ({ ctx }) => {
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',
};
}
}
const credits = await getUserCredits(session.user.id);
return { success: true, credits };
});
);

View File

@ -1,42 +1,30 @@
'use server';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
import { userActionClient } from '@/lib/safe-action';
import { addDays } from 'date-fns';
import { and, eq, gte, isNotNull, lte, sql, sum } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
const CREDITS_EXPIRATION_DAYS = 31;
const CREDITS_MONTHLY_DAYS = 31;
// Create a safe action client
const actionClient = createSafeActionClient();
import { and, eq, gt, gte, isNotNull, lte, sum } from 'drizzle-orm';
/**
* Get credit statistics for a user
*/
export const getCreditStatsAction = actionClient.action(async () => {
export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
try {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to get credit stats');
return {
success: false,
error: 'Unauthorized',
};
}
const currentUser = (ctx as { user: User }).user;
const userId = currentUser.id;
const db = await getDb();
const userId = session.user.id;
const now = new Date();
// Get credits expiring in the next 30 days
const expirationDaysFromNow = addDays(now, CREDITS_EXPIRATION_DAYS);
// Get credits expiring in the next CREDITS_EXPIRATION_DAYS days
const expirationDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS);
const expiringCredits = await db
// Get total credits expiring in the next 30 days
const expiringCreditsResult = await db
.select({
amount: sum(creditTransaction.remainingAmount),
earliestExpiration: sql<Date>`MIN(${creditTransaction.expirationDate})`,
totalAmount: sum(creditTransaction.remainingAmount),
})
.from(creditTransaction)
.where(
@ -44,56 +32,20 @@ export const getCreditStatsAction = actionClient.action(async () => {
eq(creditTransaction.userId, userId),
isNotNull(creditTransaction.expirationDate),
isNotNull(creditTransaction.remainingAmount),
gte(creditTransaction.remainingAmount, 1),
gt(creditTransaction.remainingAmount, 0),
lte(creditTransaction.expirationDate, expirationDaysFromNow),
gte(creditTransaction.expirationDate, new Date())
gte(creditTransaction.expirationDate, now)
)
);
// Get credits from subscription renewals (recent CREDITS_MONTHLY_DAYS days)
const monthlyRefreshDaysAgo = addDays(new Date(), -CREDITS_MONTHLY_DAYS);
const subscriptionCredits = await db
.select({
amount: sum(creditTransaction.amount),
})
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
eq(
creditTransaction.type,
CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL
),
gte(creditTransaction.createdAt, monthlyRefreshDaysAgo)
)
);
// Get credits from monthly lifetime distribution (recent CREDITS_MONTHLY_DAYS days)
const lifetimeCredits = await db
.select({
amount: sum(creditTransaction.amount),
})
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY),
gte(creditTransaction.createdAt, monthlyRefreshDaysAgo)
)
);
const totalExpiringCredits =
Number(expiringCreditsResult[0]?.totalAmount) || 0;
return {
success: true,
data: {
expiringCredits: {
amount: Number(expiringCredits[0]?.amount) || 0,
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
},
subscriptionCredits: {
amount: Number(subscriptionCredits[0]?.amount) || 0,
},
lifetimeCredits: {
amount: Number(lifetimeCredits[0]?.amount) || 0,
amount: totalExpiringCredits,
},
},
};

View File

@ -2,14 +2,11 @@
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getCreditTransactions parameters
const getCreditTransactionsSchema = z.object({
pageIndex: z.number().min(0).default(0),
@ -40,32 +37,39 @@ const sortFieldMap = {
} as const;
// Create a safe action for getting credit transactions
export const getCreditTransactionsAction = actionClient
export const getCreditTransactionsAction = userActionClient
.schema(getCreditTransactionsSchema)
.action(async ({ parsedInput }) => {
.action(async ({ parsedInput, ctx }) => {
try {
const session = await getSession();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}
const { pageIndex, pageSize, search, sorting } = parsedInput;
const currentUser = (ctx as { user: User }).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)
);
}
}
// search by type, amount, paymentId, description, and restrict to current user
const where = search
? and(
eq(creditTransaction.userId, session.user.id),
or(
ilike(creditTransaction.type, `%${search}%`),
ilike(creditTransaction.amount, `%${search}%`),
ilike(creditTransaction.remainingAmount, `%${search}%`),
ilike(creditTransaction.paymentId, `%${search}%`),
ilike(creditTransaction.description, `%${search}%`)
)
eq(creditTransaction.userId, currentUser.id),
or(...searchConditions)
)
: eq(creditTransaction.userId, session.user.id);
: eq(creditTransaction.userId, currentUser.id);
const offset = pageIndex * pageSize;

View File

@ -2,16 +2,13 @@
import { getDb } from '@/db';
import { payment } from '@/db/schema';
import type { User } from '@/lib/auth-types';
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
import { getSession } from '@/lib/server';
import { userActionClient } from '@/lib/safe-action';
import { PaymentTypes } from '@/payment/types';
import { and, eq } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Input schema
const schema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
@ -25,33 +22,11 @@ const schema = z.object({
* in order to do this, you have to update the logic to check the lifetime status,
* for example, just check the planId is `lifetime` or not.
*/
export const getLifetimeStatusAction = actionClient
export const getLifetimeStatusAction = userActionClient
.schema(schema)
.action(async ({ parsedInput }) => {
const { userId } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to get lifetime status for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to check their own status unless they're admins
if (session.user.id !== userId && session.user.role !== 'admin') {
console.warn(
`current user ${session.user.id} is not authorized to get lifetime status for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
const userId = currentUser.id;
try {
// Get lifetime plans

View File

@ -2,13 +2,11 @@
import { getDb } from '@/db';
import { user } from '@/db/schema';
import { isDemoWebsite } from '@/lib/demo';
import { adminActionClient } from '@/lib/safe-action';
import { asc, desc, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getUsers parameters
const getUsersSchema = z.object({
pageIndex: z.number().min(0).default(0),
@ -38,7 +36,7 @@ const sortFieldMap = {
} as const;
// Create a safe action for getting users
export const getUsersAction = actionClient
export const getUsersAction = adminActionClient
.schema(getUsersSchema)
.action(async ({ parsedInput }) => {
try {
@ -75,7 +73,8 @@ export const getUsersAction = actionClient
]);
// hide user data in demo website
if (process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true') {
const isDemo = isDemoWebsite();
if (isDemo) {
items = items.map((item) => ({
...item,
name: 'Demo User',

View File

@ -1,14 +1,11 @@
'use server';
import { websiteConfig } from '@/config/website';
import { actionClient } from '@/lib/safe-action';
import { sendEmail } from '@/mail';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
/**
* DOC: When using Zod for validation, how can I localize error messages?
* https://next-intl.dev/docs/environments/actions-metadata-route-handlers#server-actions

View File

@ -1,14 +1,11 @@
'use server';
import { actionClient } from '@/lib/safe-action';
import { sendEmail } from '@/mail';
import { subscribe } from '@/newsletter';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),

View File

@ -1,30 +1,18 @@
'use server';
import { getSession } from '@/lib/server';
import { userActionClient } from '@/lib/safe-action';
import { unsubscribe } from '@/newsletter';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),
});
// Create a safe action for newsletter unsubscription
export const unsubscribeNewsletterAction = actionClient
export const unsubscribeNewsletterAction = userActionClient
.schema(newsletterSchema)
.action(async ({ parsedInput: { email } }) => {
const session = await getSession();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}
try {
const unsubscribed = await unsubscribe(email);

View File

@ -1,12 +1,9 @@
'use server';
import { validateTurnstileToken } from '@/lib/captcha';
import { createSafeActionClient } from 'next-safe-action';
import { actionClient } from '@/lib/safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Captcha validation schema
const captchaSchema = z.object({
captchaToken: z.string().min(1, { error: 'Captcha token is required' }),

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

View File

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

View File

@ -1,51 +0,0 @@
'use client';
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
import { Button } from '@/components/ui/button';
import { useCredits } 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 { consumeCredits, hasEnoughCredits, isLoading } = useCredits();
const [loading, setLoading] = useState(false);
const handleConsume = async () => {
if (!hasEnoughCredits(CONSUME_CREDITS)) {
toast.error('Insufficient credits, please buy more credits.');
return;
}
setLoading(true);
const success = await consumeCredits(
CONSUME_CREDITS,
`AI Text Credit Consumption (${CONSUME_CREDITS} credits)`
);
setLoading(false);
if (success) {
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
} else {
toast.error('Failed to consume credits, please try again later.');
}
};
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={isLoading || loading}
className="w-full cursor-pointer"
>
<CoinsIcon className="size-4" />
<span>Consume {CONSUME_CREDITS} credits</span>
</Button>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,46 @@
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';
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' });
return constructMetadata({
title: pt('title') + ' | ' + t('title'),
description: pt('description'),
canonicalUrl: getUrlWithLocale('/ai/chat', locale),
});
}
export default async function AIChatPage() {
const t = await getTranslations('AIChatPage');
return (
<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>
{/* Chat Bot */}
<div className="max-w-6xl mx-auto">
<ChatBot />
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -1,4 +1,5 @@
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { isDemoWebsite } from '@/lib/demo';
import { getSession } from '@/lib/server';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
@ -9,7 +10,7 @@ interface UsersLayoutProps {
export default async function UsersLayout({ children }: UsersLayoutProps) {
// if is demo website, allow user to access admin and user pages, but data is fake
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const isDemo = isDemoWebsite();
// Check if user is admin
const session = await getSession();
if (!session || (session.user.role !== 'admin' && !isDemo)) {

View File

@ -1,5 +0,0 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,5 +0,0 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,5 +0,0 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,27 +1,14 @@
import BillingCard from '@/components/settings/billing/billing-card';
import CreditsBalanceCard from '@/components/settings/billing/credits-balance-card';
import { CreditPackages } from '@/components/settings/credits/credit-packages';
import { websiteConfig } from '@/config/website';
import { useMemo } from 'react';
/**
* Billing page, show billing information
*/
export default function BillingPage() {
// Memoize the credits enabled state to ensure consistency across renders
const creditsEnabled = useMemo(() => websiteConfig.credits.enableCredits, []);
return (
<div className="space-y-8">
{/* Billing and Credits Balance Cards */}
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<BillingCard />
{creditsEnabled && <CreditsBalanceCard />}
</div>
{/* Credit Packages */}
{creditsEnabled && (
<div className="w-full">
<CreditPackages />
</div>
)}
</div>
);
}

View File

@ -1,5 +0,0 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,10 +1,10 @@
import { CreditTransactionsPageClient } from '@/components/settings/credits/credit-transactions-page';
import CreditsPageClient from '@/components/settings/credits/credits-page-client';
import { websiteConfig } from '@/config/website';
import { Routes } from '@/routes';
import { redirect } from 'next/navigation';
/**
* Credits page, show credit transactions
* Credits page, show credit balance and transactions
*/
export default function CreditsPage() {
// If credits are disabled, redirect to billing page
@ -12,5 +12,5 @@ export default function CreditsPage() {
redirect(Routes.SettingsBilling);
}
return <CreditTransactionsPageClient />;
return <CreditsPageClient />;
}

View File

@ -1,5 +0,0 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,5 +0,0 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,20 +1,15 @@
import { UpdateAvatarCard } from '@/components/settings/profile/update-avatar-card';
import { UpdateNameCard } from '@/components/settings/profile/update-name-card';
import { websiteConfig } from '@/config/website';
export default function ProfilePage() {
const enableUpdateAvatar = websiteConfig.features.enableUpdateAvatar;
return (
<div className="flex flex-col gap-8">
{enableUpdateAvatar && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<UpdateAvatarCard />
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<UpdateNameCard />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<UpdateAvatarCard />
</div>
</div>
);
}

View File

@ -1,5 +0,0 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

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

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

View File

@ -1,8 +1,7 @@
'use client';
import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
import { CreditsProvider } from '@/components/layout/credits-provider';
import { PaymentProvider } from '@/components/layout/payment-provider';
import { QueryProvider } from '@/components/providers/query-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { websiteConfig } from '@/config/website';
import type { Translations } from 'fumadocs-ui/i18n';
@ -30,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
@ -54,21 +53,19 @@ export function Providers({ children, locale }: ProvidersProps) {
};
return (
<ThemeProvider
attribute="class"
defaultTheme={defaultMode}
enableSystem={true}
disableTransitionOnChange
>
<ActiveThemeProvider>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>
<PaymentProvider>
<CreditsProvider>{children}</CreditsProvider>
</PaymentProvider>
</TooltipProvider>
</RootProvider>
</ActiveThemeProvider>
</ThemeProvider>
<QueryProvider>
<ThemeProvider
attribute="class"
defaultTheme={defaultMode}
enableSystem={true}
disableTransitionOnChange
>
<ActiveThemeProvider>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
</ActiveThemeProvider>
</ThemeProvider>
</QueryProvider>
);
}

View File

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

26
src/app/api/chat/route.ts Normal file
View File

@ -0,0 +1,26 @@
import { type UIMessage, convertToModelMessages, streamText } from 'ai';
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {
const {
messages,
model,
webSearch,
}: { messages: UIMessage[]; model: string; webSearch: boolean } =
await req.json();
const result = streamText({
model: webSearch ? 'perplexity/sonar' : model,
messages: convertToModelMessages(messages),
system:
'You are a helpful assistant that can answer questions and help with tasks',
});
// send sources and reasoning back to the client
return result.toUIMessageStreamResponse({
sendSources: true,
sendReasoning: true,
});
}

View File

@ -0,0 +1,60 @@
import { distributeCreditsToAllUsers } from '@/credits/distribute';
import { NextResponse } from 'next/server';
// Basic authentication middleware
function validateBasicAuth(request: Request): boolean {
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Basic ')) {
return false;
}
// Extract credentials from Authorization header
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString(
'utf-8'
);
const [username, password] = credentials.split(':');
// Validate against environment variables
const expectedUsername = process.env.CRON_JOBS_USERNAME;
const expectedPassword = process.env.CRON_JOBS_PASSWORD;
if (!expectedUsername || !expectedPassword) {
console.error(
'Basic auth credentials not configured in environment variables'
);
return false;
}
return username === expectedUsername && password === expectedPassword;
}
/**
* distribute credits to all users daily
*/
export async function GET(request: Request) {
// Validate basic authentication
if (!validateBasicAuth(request)) {
console.error('distribute credits unauthorized');
return new NextResponse('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Secure Area"',
},
});
}
console.log('route: distribute credits start');
const { usersCount, processedCount, errorCount } =
await distributeCreditsToAllUsers();
console.log(
`route: distribute credits end, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}`
);
return NextResponse.json({
message: `distribute credits success, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}`,
usersCount,
processedCount,
errorCount,
});
}

View File

@ -1,20 +0,0 @@
import { inngest } from '@/inngest/client';
import { NextResponse } from 'next/server';
// Opt out of caching; every request should send a new event
export const dynamic = 'force-dynamic';
// Create a simple async Next.js API route handler
export async function GET() {
console.log('Send event to Inngest start');
// Send your event payload to Inngest
await inngest.send({
name: 'test/hello.world',
data: {
email: 'testUser@example.com',
},
});
console.log('Send event to Inngest end');
return NextResponse.json({ message: 'Event sent!' });
}

View File

@ -1,19 +0,0 @@
import { serve } from 'inngest/next';
import { inngest } from '../../../inngest/client';
import { distributeCreditsDaily, helloWorld } from '../../../inngest/functions';
/**
* Inngest route
*
* https://www.inngest.com/docs/getting-started/nextjs-quick-start
*
* Next.js Edge Functions hosted on Vercel can also stream responses back to Inngest,
* giving you a much higher request timeout of 15 minutes (up from 10 seconds on the Vercel Hobby plan!).
* To enable this, set your runtime to "edge" (see Quickstart for Using Edge Functions | Vercel Docs)
* and add the streaming: "allow" option to your serve handler:
* https://www.inngest.com/docs/learn/serving-inngest-functions#framework-next-js
*/
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [helloWorld, distributeCreditsDaily],
});

View File

@ -14,8 +14,6 @@ type Href = Parameters<typeof getLocalePathname>[0]['href'];
const staticRoutes = [
'/',
'/pricing',
'/blog',
'/docs',
'/about',
'/contact',
'/waitlist',
@ -25,6 +23,8 @@ const staticRoutes = [
'/cookie',
'/auth/login',
'/auth/register',
...(websiteConfig.blog.enable ? ['/blog'] : []),
...(websiteConfig.docs.enable ? ['/docs'] : []),
];
/**
@ -48,101 +48,106 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
})
);
// add categories
sitemapList.push(
...categorySource.getPages().flatMap((category) =>
routing.locales.map((locale) => ({
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
// add paginated blog list pages
routing.locales.forEach((locale) => {
const posts = blogSource
.getPages(locale)
.filter((post) => post.data.published);
const totalPages = Math.max(
1,
Math.ceil(posts.length / websiteConfig.blog.paginationSize)
// add blog related routes if enabled
if (websiteConfig.blog.enable) {
// add categories
sitemapList.push(
...categorySource.getPages().flatMap((category) =>
routing.locales.map((locale) => ({
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
// /blog/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(`/blog/page/${page}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
}
});
// add paginated category pages
routing.locales.forEach((locale) => {
const localeCategories = categorySource.getPages(locale);
localeCategories.forEach((category) => {
// posts in this category and locale
const postsInCategory = blogSource
// add paginated blog list pages
routing.locales.forEach((locale) => {
const posts = blogSource
.getPages(locale)
.filter((post) => post.data.published)
.filter((post) =>
post.data.categories.some((cat) => cat === category.slugs[0])
);
.filter((post) => post.data.published);
const totalPages = Math.max(
1,
Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
Math.ceil(posts.length / websiteConfig.blog.paginationSize)
);
// /blog/category/[slug] (first page)
sitemapList.push({
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
// /blog/category/[slug]/page/[page] (from 2)
// /blog/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(
`/blog/category/${category.slugs[0]}/page/${page}`,
locale
),
url: getUrl(`/blog/page/${page}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
}
});
});
// add posts (single post pages)
sitemapList.push(
...blogSource.getPages().flatMap((post) =>
routing.locales
.filter((locale) => post.locale === locale)
.map((locale) => ({
url: getUrl(`/blog/${post.slugs.join('/')}`, locale),
// add paginated category pages
routing.locales.forEach((locale) => {
const localeCategories = categorySource.getPages(locale);
localeCategories.forEach((category) => {
// posts in this category and locale
const postsInCategory = blogSource
.getPages(locale)
.filter((post) => post.data.published)
.filter((post) =>
post.data.categories.some((cat) => cat === category.slugs[0])
);
const totalPages = Math.max(
1,
Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
);
// /blog/category/[slug] (first page)
sitemapList.push({
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
// /blog/category/[slug]/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(
`/blog/category/${category.slugs[0]}/page/${page}`,
locale
),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
}
});
});
// add posts (single post pages)
sitemapList.push(
...blogSource.getPages().flatMap((post) =>
routing.locales
.filter((locale) => post.locale === locale)
.map((locale) => ({
url: getUrl(`/blog/${post.slugs.join('/')}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
}
// add docs related routes if enabled
if (websiteConfig.docs.enable) {
const docsParams = source.generateParams();
sitemapList.push(
...docsParams.flatMap((param) =>
routing.locales.map((locale) => ({
url: getUrl(`/docs/${param.slug.join('/')}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
// add docs
const docsParams = source.generateParams();
sitemapList.push(
...docsParams.flatMap((param) =>
routing.locales.map((locale) => ({
url: getUrl(`/docs/${param.slug.join('/')}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
)
);
}
return sitemapList;
}

View File

@ -6,7 +6,6 @@ import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
@ -21,12 +20,12 @@ import {
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { useIsMobile } from '@/hooks/use-mobile';
import { authClient } from '@/lib/auth-client';
import { useBanUser, useUnbanUser } from '@/hooks/use-users';
import type { User } from '@/lib/auth-types';
import { isDemoWebsite } from '@/lib/demo';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { cn } from '@/lib/utils';
import { useUsersStore } from '@/stores/users-store';
import {
CalendarIcon,
Loader2Icon,
@ -46,14 +45,16 @@ interface UserDetailViewerProps {
export function UserDetailViewer({ user }: UserDetailViewerProps) {
const t = useTranslations('Dashboard.admin.users');
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
// TanStack Query mutations
const banUserMutation = useBanUser();
const unbanUserMutation = useUnbanUser();
// show fake data in demo website
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const isDemo = isDemoWebsite();
const handleBan = async () => {
if (!banReason) {
@ -66,11 +67,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
return;
}
setIsLoading(true);
setError('');
try {
await authClient.admin.banUser({
await banUserMutation.mutateAsync({
userId: user.id,
banReason,
banExpiresIn: banExpiresAt
@ -82,15 +82,11 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
// Reset form
setBanReason('');
setBanExpiresAt(undefined);
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to ban user:', error);
setError(error.message || t('ban.error'));
toast.error(error.message || t('ban.error'));
} finally {
setIsLoading(false);
}
};
@ -100,24 +96,19 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
return;
}
setIsLoading(true);
setError('');
try {
await authClient.admin.unbanUser({
await unbanUserMutation.mutateAsync({
userId: user.id,
});
toast.success(t('unban.success'));
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to unban user:', error);
setError(error.message || t('unban.error'));
toast.error(error.message || t('unban.error'));
} finally {
setIsLoading(false);
}
};
@ -165,7 +156,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
{user.role === 'admin' ? t('admin') : t('user')}
</Badge>
{/* email verified */}
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{/* <Badge variant="outline" className="px-1.5 hover:bg-accent">
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
@ -174,7 +165,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
{user.emailVerified
? t('email.verified')
: t('email.unverified')}
</Badge>
</Badge> */}
{/* user banned */}
<div className="flex items-center gap-2">
@ -195,15 +186,23 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<span className="text-muted-foreground text-xs">
{t('columns.email')}:
</span>
<span
className="break-words cursor-pointer hover:bg-accent px-2 py-1 rounded border"
onClick={() => {
navigator.clipboard.writeText(user.email!);
toast.success(t('emailCopied'));
}}
>
{user.email}
</span>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
onClick={() => {
navigator.clipboard.writeText(user.email);
toast.success(t('emailCopied'));
}}
>
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
)}
{user.email}
</Badge>
</div>
</div>
)}
@ -255,10 +254,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<Button
variant="destructive"
onClick={handleUnban}
disabled={isLoading || isDemo}
disabled={unbanUserMutation.isPending || isDemo}
className="mt-4 cursor-pointer"
>
{isLoading && (
{unbanUserMutation.isPending && (
<Loader2Icon className="mr-2 size-4 animate-spin" />
)}
{t('unban.button')}
@ -314,10 +313,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<Button
type="submit"
variant="destructive"
disabled={isLoading || !banReason || isDemo}
disabled={banUserMutation.isPending || !banReason || isDemo}
className="mt-4 cursor-pointer"
>
{isLoading && (
{banUserMutation.isPending && (
<Loader2Icon className="mr-2 size-4 animate-spin" />
)}
{t('ban.button')}

View File

@ -1,74 +1,59 @@
'use client';
import { getUsersAction } from '@/actions/get-users';
import { UsersTable } from '@/components/admin/users-table';
import type { User } from '@/lib/auth-types';
import { useUsersStore } from '@/stores/users-store';
import { useUsers } from '@/hooks/use-users';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
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 [data, setData] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
const result = await getUsersAction({
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),
});
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
}
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error(t('error'));
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
const sorting: SortingState = useMemo(
() => [{ id: sortId, desc: Boolean(sortDesc) }],
[sortId, sortDesc]
);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
// page is already 0-based internally thanks to parseAsIndex
const { data, isLoading } = useUsers(page, pageSize, search, sorting);
return (
<>
<UsersTable
data={data}
total={total}
pageIndex={pageIndex}
pageSize={pageSize}
search={search}
loading={loading}
onSearch={setSearch}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}
onSortingChange={setSorting}
/>
</>
<UsersTable
data={data?.items || []}
total={data?.total || 0}
pageIndex={page}
pageSize={pageSize}
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,
});
}
}}
/>
);
}

View File

@ -27,6 +27,7 @@ import {
TableRow,
} from '@/components/ui/table';
import type { User } from '@/lib/auth-types';
import { isDemoWebsite } from '@/lib/demo';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react';
@ -58,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> {
@ -115,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;
@ -137,6 +154,7 @@ export function UsersTable({
pageIndex,
pageSize,
search,
sorting = [{ id: 'createdAt', desc: true }],
loading,
onSearch,
onPageChange,
@ -145,14 +163,11 @@ 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>({});
// show fake data in demo website
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const isDemo = isDemoWebsite();
// Map column IDs to translation keys
const columnIdToTranslationKey = {
@ -350,7 +365,6 @@ export function UsersTable({
},
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
setSorting(next);
onSortingChange?.(next);
},
onColumnFiltersChange: setColumnFilters,
@ -443,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}
@ -465,7 +484,7 @@ export function UsersTable({
colSpan={columns.length}
className="h-24 text-center"
>
{loading ? tTable('loading') : tTable('noResults')}
{tTable('noResults')}
</TableCell>
</TableRow>
)}

View File

@ -0,0 +1,65 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
export type ActionsProps = ComponentProps<'div'>;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
);
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const Action = ({
tooltip,
children,
label,
className,
variant = 'ghost',
size = 'sm',
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
'size-9 p-1.5 text-muted-foreground hover:text-foreground relative',
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View File

@ -0,0 +1,212 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
import { createContext, useContext, useEffect, useState } from 'react';
type BranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const BranchContext = createContext<BranchContextType | null>(null);
const useBranch = () => {
const context = useContext(BranchContext);
if (!context) {
throw new Error('Branch components must be used within Branch');
}
return context;
};
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const Branch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: BranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<BranchContext.Provider value={contextValue}>
<div
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
{...props}
/>
</BranchContext.Provider>
);
};
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden'
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const BranchSelector = ({
className,
from,
...props
}: BranchSelectorProps) => {
const { totalBranches } = useBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<div
className={cn(
'flex items-center gap-2 self-end px-10',
from === 'assistant' ? 'justify-start' : 'justify-end',
className
)}
{...props}
/>
);
};
export type BranchPreviousProps = ComponentProps<typeof Button>;
export const BranchPrevious = ({
className,
children,
...props
}: BranchPreviousProps) => {
const { goToPrevious, totalBranches } = useBranch();
return (
<Button
aria-label="Previous branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type BranchNextProps = ComponentProps<typeof Button>;
export const BranchNext = ({
className,
children,
...props
}: BranchNextProps) => {
const { goToNext, totalBranches } = useBranch();
return (
<Button
aria-label="Next branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { currentBranch, totalBranches } = useBranch();
return (
<span
className={cn(
'font-medium text-muted-foreground text-xs tabular-nums',
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</span>
);
};

View File

@ -0,0 +1,148 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CheckIcon, CopyIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
className
)}
{...props}
>
<div className="relative">
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneLight}
>
{code}
</SyntaxHighlighter>
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneDark}
>
{code}
</SyntaxHighlighter>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
onError?.(new Error('Clipboard API not available'));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn('shrink-0', className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View File

@ -0,0 +1,62 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ArrowDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-auto', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn('p-4', className)} {...props} />
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View File

@ -0,0 +1,24 @@
import { cn } from '@/lib/utils';
import type { Experimental_GeneratedImage } from 'ai';
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
alt?: string;
};
export const Image = ({
base64,
uint8Array,
mediaType,
...props
}: ImageProps) => (
<img
{...props}
alt={props.alt}
className={cn(
'h-auto max-w-full overflow-hidden rounded-md',
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>
);

View File

@ -0,0 +1,287 @@
'use client';
import { Badge } from '@/components/ui/badge';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@/components/ui/carousel';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { cn } from '@/lib/utils';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
export type InlineCitationProps = ComponentProps<'span'>;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn('group inline items-center gap-1', className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<'span'>;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn('transition-colors group-hover:bg-accent', className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn('ml-1 rounded-full', className)}
variant="secondary"
{...props}
>
{sources.length ? (
<>
{new URL(sources[0]).hostname}{' '}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
'unknown'
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn('w-full space-y-2 p-4 pl-8', className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
className
)}
{...props}
/>
);
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
export const InlineCitationCarouselIndex = ({
children,
className,
...props
}: InlineCitationCarouselIndexProps) => {
const api = useCarouselApi();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
return (
<div
className={cn(
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<'div'> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn('space-y-1', className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
className
)}
{...props}
>
{children}
</blockquote>
);

View File

@ -0,0 +1,96 @@
import { cn } from '@/lib/utils';
import type { HTMLAttributes } from 'react';
type LoaderIconProps = {
size?: number;
};
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: 'currentcolor' }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
'inline-flex animate-spin items-center justify-center',
className
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);

View File

@ -0,0 +1,62 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import type { ComponentProps, HTMLAttributes } from 'react';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full items-end justify-end gap-2 py-4',
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
'[&>div]:max-w-[80%]',
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
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-card group-[.is-assistant]:text-card-foreground',
'is-user:dark',
className
)}
{...props}
>
{children}
</div>
);
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
src: string;
name?: string;
};
export const MessageAvatar = ({
src,
name,
className,
...props
}: MessageAvatarProps) => (
<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

@ -0,0 +1,230 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { ChatStatus } from 'ai';
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
import type {
ComponentProps,
HTMLAttributes,
KeyboardEventHandler,
} from 'react';
import { Children } from 'react';
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
<form
className={cn(
'w-full divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
className
)}
{...props}
/>
);
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
minHeight?: number;
maxHeight?: number;
};
export const PromptInputTextarea = ({
onChange,
className,
placeholder = 'What would you like to know?',
minHeight = 48,
maxHeight = 164,
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === 'Enter') {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
// Allow newline
return;
}
// Submit on Enter (without Shift)
e.preventDefault();
const form = e.currentTarget.form;
if (form) {
form.requestSubmit();
}
}
};
return (
<Textarea
className={cn(
'w-full resize-none rounded-none border-none p-3 shadow-none outline-none ring-0',
'field-sizing-content max-h-[6lh] bg-transparent dark:bg-transparent',
'focus-visible:ring-0',
className
)}
name="message"
onChange={(e) => {
onChange?.(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
};
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputToolbar = ({
className,
...props
}: PromptInputToolbarProps) => (
<div
className={cn('flex items-center justify-between p-1', className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div
className={cn(
'flex items-center gap-1',
'[&_button:first-child]:rounded-bl-xl',
className
)}
{...props}
/>
);
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = 'ghost',
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
return (
<Button
className={cn(
'shrink-0 gap-1.5 rounded-lg',
variant === 'ghost' && 'text-muted-foreground',
newSize === 'default' && 'px-3',
className
)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
className,
variant = 'default',
size = 'icon',
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === 'submitted') {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === 'streaming') {
Icon = <SquareIcon className="size-4" />;
} else if (status === 'error') {
Icon = <XIcon className="size-4" />;
}
return (
<Button
className={cn('gap-1.5 rounded-lg', className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</Button>
);
};
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
<Select {...props} />
);
export type PromptInputModelSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputModelSelectTrigger = ({
className,
...props
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
className
)}
{...props}
/>
);
export type PromptInputModelSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputModelSelectContent = ({
className,
...props
}: PromptInputModelSelectContentProps) => (
<SelectContent className={cn(className)} {...props} />
);
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputModelSelectItem = ({
className,
...props
}: PromptInputModelSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputModelSelectValueProps = ComponentProps<
typeof SelectValue
>;
export const PromptInputModelSelectValue = ({
className,
...props
}: PromptInputModelSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);

View File

@ -0,0 +1,171 @@
'use client';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { createContext, memo, useContext, useEffect, useState } from 'react';
import { Response } from './response';
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number;
};
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error('Reasoning components must be used within Reasoning');
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: 0,
});
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
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 (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) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn('not-prose mb-4', className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const ReasoningTrigger = memo(
({ className, children, ...props }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
'flex items-center gap-2 text-muted-foreground text-sm',
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{isStreaming || duration === 0 ? (
<p>Thinking...</p>
) : (
<p>Thought for {duration} seconds</p>
)}
<ChevronDownIcon
className={cn(
'size-4 text-muted-foreground transition-transform',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
'mt-4 text-sm',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
>
<Response className="grid gap-2">{children}</Response>
</CollapsibleContent>
)
);
Reasoning.displayName = 'Reasoning';
ReasoningTrigger.displayName = 'ReasoningTrigger';
ReasoningContent.displayName = 'ReasoningContent';

View File

@ -0,0 +1,22 @@
'use client';
import { cn } from '@/lib/utils';
import { type ComponentProps, memo } from 'react';
import { Streamdown } from 'streamdown';
type ResponseProps = ComponentProps<typeof Streamdown>;
export const Response = memo(
({ className, ...props }: ResponseProps) => (
<Streamdown
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Response.displayName = 'Response';

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="flex items-center gap-2" {...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

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

@ -0,0 +1,56 @@
'use client';
import { Button } from '@/components/ui/button';
import {
ScrollArea,
ScrollBar,
} from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = 'outline',
size = 'sm',
children,
...props
}: SuggestionProps) => {
const handleClick = () => {
onClick?.(suggestion);
};
return (
<Button
className={cn('cursor-pointer rounded-full px-4', className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};

View File

@ -0,0 +1,94 @@
'use client';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, SearchIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
export type TaskItemFileProps = ComponentProps<'div'>;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
'inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs',
className
)}
{...props}
>
{children}
</div>
);
export type TaskItemProps = ComponentProps<'div'>;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
defaultOpen={defaultOpen}
{...props}
/>
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
{children ?? (
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
{children}
</div>
</CollapsibleContent>
);

View File

@ -0,0 +1,142 @@
'use client';
import { Badge } from '@/components/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type { ToolUIPart } from 'ai';
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { CodeBlock } from './code-block';
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn('not-prose mb-4 w-full rounded-md border', className)}
{...props}
/>
);
export type ToolHeaderProps = {
type: ToolUIPart['type'];
state: ToolUIPart['state'];
className?: string;
};
const getStatusBadge = (status: ToolUIPart['state']) => {
const labels = {
'input-streaming': 'Pending',
'input-available': 'Running',
'output-available': 'Completed',
'output-error': 'Error',
} as const;
const icons = {
'input-streaming': <CircleIcon className="size-4" />,
'input-available': <ClockIcon className="size-4 animate-pulse" />,
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
'output-error': <XCircleIcon className="size-4 text-red-600" />,
} as const;
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
};
export const ToolHeader = ({
className,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
'flex w-full items-center justify-between gap-4 p-3',
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{type}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<'div'> & {
input: ToolUIPart['input'];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<'div'> & {
output: ReactNode;
errorText: ToolUIPart['errorText'];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
return (
<div className={cn('space-y-2 p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? 'Error' : 'Result'}
</h4>
<div
className={cn(
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
errorText
? 'bg-destructive/10 text-destructive'
: 'bg-muted/50 text-foreground'
)}
>
{errorText && <div>{errorText}</div>}
{output && <div>{output}</div>}
</div>
</div>
);
};

View File

@ -0,0 +1,252 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
export type WebPreviewContextValue = {
url: string;
setUrl: (url: string) => void;
consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void;
};
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => {
const context = useContext(WebPreviewContext);
if (!context) {
throw new Error('WebPreview components must be used within a WebPreview');
}
return context;
};
export type WebPreviewProps = ComponentProps<'div'> & {
defaultUrl?: string;
onUrlChange?: (url: string) => void;
};
export const WebPreview = ({
className,
children,
defaultUrl = '',
onUrlChange,
...props
}: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl);
const [consoleOpen, setConsoleOpen] = useState(false);
const handleUrlChange = (newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
};
const contextValue: WebPreviewContextValue = {
url,
setUrl: handleUrlChange,
consoleOpen,
setConsoleOpen,
};
return (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
'flex size-full flex-col rounded-lg border bg-card',
className
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
);
};
export type WebPreviewNavigationProps = ComponentProps<'div'>;
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
className={cn('flex items-center gap-1 border-b p-2', className)}
{...props}
>
{children}
</div>
);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 p-0 hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({
value,
onChange,
onKeyDown,
...props
}: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview();
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
};
return (
<Input
className="h-8 flex-1 text-sm"
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? url}
{...props}
/>
);
};
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div className="flex-1">
<iframe
className={cn('size-full', className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
logs?: Array<{
level: 'log' | 'warn' | 'error';
message: string;
timestamp: Date;
}>;
};
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview();
return (
<Collapsible
className={cn('border-t bg-muted/50 font-mono text-sm', className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
variant="ghost"
>
Console
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180'
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'px-4 pb-4',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log, index) => (
<div
className={cn(
'text-xs',
log.level === 'error' && 'text-destructive',
log.level === 'warn' && 'text-yellow-600',
log.level === 'log' && 'text-foreground'
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{' '}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@ -17,14 +17,14 @@ 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';
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { Captcha } from '../shared/captcha';
@ -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);
@ -56,6 +56,7 @@ export const LoginForm = ({
const [success, setSuccess] = useState<string | undefined>('');
const [isPending, setIsPending] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const captchaRef = useRef<any>(null);
// Check if credential login is enabled
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
@ -92,9 +93,22 @@ export const LoginForm = ({
name: 'captchaToken',
});
// Function to reset captcha
const resetCaptcha = () => {
form.setValue('captchaToken', '');
// Try to reset the Turnstile widget if available
if (captchaRef.current && typeof captchaRef.current.reset === 'function') {
captchaRef.current.reset();
}
};
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
// Validate captcha token if turnstile is enabled and site key is available
if (captchaConfigured && values.captchaToken) {
setIsPending(true);
setError('');
setSuccess('');
const captchaResult = await validateCaptchaAction({
captchaToken: values.captchaToken,
});
@ -103,6 +117,8 @@ export const LoginForm = ({
console.error('login, captcha invalid:', values.captchaToken);
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
setError(errorMessage);
setIsPending(false);
resetCaptcha(); // Reset captcha on validation failure
return;
}
}
@ -135,6 +151,10 @@ export const LoginForm = ({
onError: (ctx) => {
console.error('login, error:', ctx.error);
setError(`${ctx.error.status}: ${ctx.error.message}`);
// Reset captcha on login error
if (captchaConfigured) {
resetCaptcha();
}
},
}
);
@ -233,6 +253,7 @@ export const LoginForm = ({
<FormSuccess message={success} />
{captchaConfigured && (
<Captcha
ref={captchaRef}
onSuccess={(token) => form.setValue('captchaToken', token)}
validationError={form.formState.errors.captchaToken?.message}
/>

View File

@ -16,13 +16,13 @@ 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';
import { useLocale, useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { Captcha } from '../shared/captcha';
@ -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);
@ -51,6 +51,7 @@ export const RegisterForm = ({
const [success, setSuccess] = useState<string | undefined>('');
const [isPending, setIsPending] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const captchaRef = useRef<any>(null);
// Check if credential login is enabled
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
@ -91,9 +92,22 @@ export const RegisterForm = ({
name: 'captchaToken',
});
// Function to reset captcha
const resetCaptcha = () => {
form.setValue('captchaToken', '');
// Try to reset the Turnstile widget if available
if (captchaRef.current && typeof captchaRef.current.reset === 'function') {
captchaRef.current.reset();
}
};
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
// Validate captcha token if turnstile is enabled and site key is available
if (captchaConfigured && values.captchaToken) {
setIsPending(true);
setError('');
setSuccess('');
const captchaResult = await validateCaptchaAction({
captchaToken: values.captchaToken,
});
@ -102,6 +116,8 @@ export const RegisterForm = ({
console.error('register, captcha invalid:', values.captchaToken);
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
setError(errorMessage);
setIsPending(false);
resetCaptcha(); // Reset captcha on validation failure
return;
}
}
@ -119,13 +135,13 @@ export const RegisterForm = ({
},
{
onRequest: (ctx) => {
console.log('register, request:', ctx.url);
// console.log('register, request:', ctx.url);
setIsPending(true);
setError('');
setSuccess('');
},
onResponse: (ctx) => {
console.log('register, response:', ctx.response);
// console.log('register, response:', ctx.response);
setIsPending(false);
},
onSuccess: (ctx) => {
@ -144,6 +160,10 @@ export const RegisterForm = ({
// sign up fail, display the error message
console.error('register, error:', ctx.error);
setError(`${ctx.error.status}: ${ctx.error.message}`);
// Reset captcha on registration error
if (captchaConfigured) {
resetCaptcha();
}
},
}
);
@ -243,6 +263,7 @@ export const RegisterForm = ({
<FormSuccess message={success} />
{captchaConfigured && (
<Captcha
ref={captchaRef}
onSuccess={(token) => form.setValue('captchaToken', token)}
validationError={form.formState.errors.captchaToken?.message}
/>

Some files were not shown because too many files have changed in this diff Show More