Compare commits
No commits in common. "cloudflare" and "dev/nuqs" have entirely different histories.
cloudflare
...
dev/nuqs
@ -1,6 +1,5 @@
|
||||
.cursor
|
||||
.claude
|
||||
.conductor
|
||||
.kiro
|
||||
.github
|
||||
.next
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,9 +41,6 @@ certificates
|
||||
# claude code
|
||||
.claude
|
||||
|
||||
# conductor
|
||||
.conductor
|
||||
|
||||
# kiro
|
||||
.kiro
|
||||
|
||||
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@ -24,12 +24,6 @@
|
||||
".next": true,
|
||||
".source": true,
|
||||
".wrangler": true,
|
||||
".open-next": true,
|
||||
".vscode": true,
|
||||
".cursor": true,
|
||||
".claude": true,
|
||||
".conductor": true,
|
||||
".kiro": true,
|
||||
".github": true
|
||||
".open-next": true
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,6 @@
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".kiro/**",
|
||||
".conductor/**",
|
||||
".vscode/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
@ -78,7 +77,6 @@
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".conductor/**",
|
||||
".kiro/**",
|
||||
".vscode/**",
|
||||
".source/**",
|
||||
|
7483
cloudflare-env.d.ts
vendored
7483
cloudflare-env.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@ -1,56 +0,0 @@
|
||||
---
|
||||
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>
|
@ -1,56 +0,0 @@
|
||||
---
|
||||
title: "测试专用付费文章"
|
||||
description: "这是一篇测试专用付费文章。"
|
||||
date: "2025-08-30"
|
||||
published: true
|
||||
premium: true
|
||||
categories: ["product"]
|
||||
author: "fox"
|
||||
image: "/images/blog/post-7.png"
|
||||
---
|
||||
|
||||
这是一篇测试专用的付费文章。
|
||||
|
||||
如果你不是付费用户,你可以阅读这篇文章的这部分内容。
|
||||
|
||||
但如果你想阅读剩下的内容,你需要成为一个付费用户。
|
||||
|
||||
你可以点击 "登录" 按钮来以免费用户的身份登录。
|
||||
|
||||
然后你可以点击 "立即升级" 按钮来升级到付费计划。
|
||||
|
||||
<Callout type="warn">
|
||||
不用担心,你实际上不需要支付任何费用,因为我们处于 Stripe 的沙盒环境中。
|
||||
</Callout>
|
||||
|
||||
你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。
|
||||
|
||||
```
|
||||
Card number: 4242 4242 4242 4242
|
||||
Exp: 12/34
|
||||
CVV: 567
|
||||
```
|
||||
|
||||
之后,你可以返回这篇博客文章,然后你可以阅读剩下的内容。
|
||||
|
||||
更多详情,请参考文档:[博客](https://mksaas.com/docs/blog)。
|
||||
|
||||
现在剩下的内容是付费内容。
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
<Callout type="info">
|
||||
这是付费内容部分的开始。
|
||||
</Callout>
|
||||
|
||||
这是付费内容部分。
|
||||
|
||||
你可以阅读这篇内容,只要你是一个付费用户。
|
||||
|
||||
请不要分享这篇文章给其他人。
|
||||
|
||||
<Callout type="info">
|
||||
这是付费内容部分的结束。
|
||||
</Callout>
|
||||
|
||||
</PremiumContent>
|
@ -2,7 +2,6 @@
|
||||
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**.
|
||||
@ -19,8 +18,6 @@ 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.
|
||||
@ -59,5 +56,3 @@ docs easier, with less boilerplate.
|
||||
Fumadocs is maintained by Fuma and many contributors, with care on the maintainability of codebase.
|
||||
While we don't aim to offer every functionality people wanted, we're more focused on making basic features perfect and well-maintained.
|
||||
You can also help Fumadocs to be more useful by contributing!
|
||||
|
||||
</PremiumContent>
|
||||
|
@ -2,7 +2,6 @@
|
||||
title: 什么是 Fumadocs
|
||||
description: 介绍 Fumadocs,一个可以打破常规的文档框架
|
||||
icon: CircleHelp
|
||||
premium: true
|
||||
---
|
||||
|
||||
Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体验,一个不固执己见的文档框架,**一个你可以"打破"的"框架"**。
|
||||
@ -19,8 +18,6 @@ Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体
|
||||
**对 UI 有自己的看法:** Fumadocs UI(默认主题)提供的唯一东西是**用户界面**。UI 的设计理念是提供更好的移动响应性和用户体验。
|
||||
相反,我们使用受 Shadcn UI 启发的更灵活的方法 — [Fumadocs CLI](/docs/cli),这样我们可以快速迭代设计,并欢迎更多关于 UI 的反馈。
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
## 为什么选择 Fumadocs
|
||||
|
||||
Fumadocs 的设计考虑了灵活性。
|
||||
@ -56,6 +53,4 @@ Fumadocs 为 Next.js 提供了额外的工具,包括语法高亮、文档搜
|
||||
|
||||
Fumadocs 由 Fuma 和许多贡献者维护,关注代码库的可维护性。
|
||||
虽然我们不打算提供人们想要的每一项功能,但我们更专注于使基本功能完美且维护良好。
|
||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
||||
|
||||
</PremiumContent>
|
||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
@ -1 +0,0 @@
|
||||
NEXTJS_ENV=development
|
@ -5,7 +5,6 @@
|
||||
"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",
|
||||
@ -293,20 +292,8 @@
|
||||
"nextPage": "Next",
|
||||
"chooseLanguage": "Select language",
|
||||
"title": "MkSaaS Docs",
|
||||
"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..."
|
||||
"homepage": "Homepage",
|
||||
"blog": "Blog"
|
||||
},
|
||||
"Marketing": {
|
||||
"navbar": {
|
||||
@ -591,7 +578,7 @@
|
||||
},
|
||||
"price": "Price:",
|
||||
"periodStartDate": "Period start date:",
|
||||
"periodEndDate": "Period end date:",
|
||||
"nextBillingDate": "Next billing date:",
|
||||
"trialEnds": "Trial ends:",
|
||||
"freePlanMessage": "You are currently on the free plan with limited features",
|
||||
"lifetimeMessage": "You have lifetime access to all premium features",
|
||||
|
@ -5,7 +5,6 @@
|
||||
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
|
||||
},
|
||||
"Common": {
|
||||
"premium": "付费文章",
|
||||
"login": "登录",
|
||||
"logout": "退出",
|
||||
"signUp": "注册",
|
||||
@ -293,20 +292,8 @@
|
||||
"nextPage": "下一页",
|
||||
"chooseLanguage": "选择语言",
|
||||
"title": "MkSaaS文档",
|
||||
"homepage": "首页"
|
||||
},
|
||||
"PremiumContent": {
|
||||
"title": "解锁付费内容",
|
||||
"description": "订阅我们的付费计划,访问所有付费内容和独家内容。",
|
||||
"upgradeCta": "立即升级",
|
||||
"benefit1": "所有内容",
|
||||
"benefit2": "独家内容",
|
||||
"benefit3": "随时取消",
|
||||
"signIn": "登录",
|
||||
"loginRequired": "登录以继续阅读",
|
||||
"loginDescription": "这是一篇付费内容,请登录您的账户以访问完整内容。",
|
||||
"checkingAccess": "检查阅读权限...",
|
||||
"loadingContent": "加载完整内容..."
|
||||
"homepage": "首页",
|
||||
"blog": "博客"
|
||||
},
|
||||
"Marketing": {
|
||||
"navbar": {
|
||||
@ -591,7 +578,7 @@
|
||||
},
|
||||
"price": "价格:",
|
||||
"periodStartDate": "周期开始日期:",
|
||||
"periodEndDate": "周期结束日期:",
|
||||
"nextBillingDate": "下次账单日期:",
|
||||
"trialEnds": "试用结束日期:",
|
||||
"freePlanMessage": "您当前使用的是功能有限的免费方案",
|
||||
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
|
||||
|
@ -18,18 +18,6 @@ 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
|
||||
@ -82,9 +70,3 @@ 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();
|
||||
|
@ -1,6 +0,0 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
|
||||
});
|
@ -4,7 +4,6 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"cf-dev": "next dev -p 8787",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
@ -112,7 +111,6 @@
|
||||
"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",
|
||||
@ -145,7 +143,6 @@
|
||||
},
|
||||
"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",
|
||||
@ -160,7 +157,6 @@
|
||||
"react-email": "3.0.7",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3",
|
||||
"wrangler": "^4.28.1"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
4299
pnpm-lock.yaml
generated
4299
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
/_next/static/*
|
||||
Cache-Control: public,max-age=31536000,immutable
|
@ -15,7 +15,6 @@ export const docs = defineDocs({
|
||||
schema: frontmatterSchema.extend({
|
||||
preview: z.string().optional(),
|
||||
index: z.boolean().default(false),
|
||||
premium: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
meta: {
|
||||
@ -86,7 +85,7 @@ export const category = defineCollections({
|
||||
/**
|
||||
* Blog posts
|
||||
*
|
||||
* title is required, but description is optional in frontmatter
|
||||
* dtitle is required, but description is optional in frontmatter
|
||||
*/
|
||||
export const blog = defineCollections({
|
||||
type: 'doc',
|
||||
@ -95,7 +94,6 @@ 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(),
|
||||
}),
|
||||
|
@ -70,7 +70,7 @@ export default function ChatBot() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto p-6 relative size-full h-screen rounded-lg bg-muted">
|
||||
<div className="mx-auto p-6 relative size-full h-screen rounded-lg bg-muted/50">
|
||||
<div className="flex flex-col h-full">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent>
|
||||
|
@ -2,14 +2,10 @@ 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,
|
||||
@ -17,7 +13,6 @@ 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';
|
||||
@ -26,6 +21,7 @@ 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,
|
||||
@ -87,8 +83,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { date, title, description, image, author, categories, premium } =
|
||||
post.data;
|
||||
const { date, title, description, image, author, categories } = post.data;
|
||||
const publishDate = formatDate(new Date(date));
|
||||
|
||||
const blogAuthor = authorSource.getPage([author], locale);
|
||||
@ -96,13 +91,6 @@ 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
|
||||
@ -133,7 +121,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* blog post date and premium badge */}
|
||||
{/* blog post date */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
||||
@ -141,8 +129,6 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
{publishDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{premium && <PremiumBadge size="sm" />}
|
||||
</div>
|
||||
|
||||
{/* blog post title */}
|
||||
@ -155,14 +141,8 @@ 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">
|
||||
<PremiumGuard
|
||||
isPremium={!!premium}
|
||||
canAccess={hasPremiumAccess}
|
||||
className="max-w-none"
|
||||
>
|
||||
<MDX components={getMDXComponents()} />
|
||||
</PremiumGuard>
|
||||
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
|
||||
<MDX components={getMDXComponents()} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start my-16">
|
||||
|
@ -1,7 +1,5 @@
|
||||
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,
|
||||
@ -9,8 +7,6 @@ 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';
|
||||
@ -90,14 +86,6 @@ 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;
|
||||
|
||||
@ -110,54 +98,44 @@ 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 */}
|
||||
<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,
|
||||
});
|
||||
<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>
|
||||
);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</PremiumGuard>
|
||||
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>
|
||||
);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
|
@ -34,13 +34,12 @@ export const MessageContent = ({
|
||||
className={cn(
|
||||
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
|
||||
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
|
||||
'group-[.is-assistant]:bg-card group-[.is-assistant]:text-card-foreground',
|
||||
'is-user:dark',
|
||||
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="is-user:dark">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -55,7 +54,10 @@ export const MessageAvatar = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageAvatarProps) => (
|
||||
<Avatar className={cn('size-8 ring-1 ring-border', className)} {...props}>
|
||||
<Avatar
|
||||
className={cn('size-8 ring ring-1 ring-border', className)}
|
||||
{...props}
|
||||
>
|
||||
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
|
||||
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
@ -38,14 +38,13 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
@ -71,22 +70,23 @@ export const Reasoning = memo(
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.round((Date.now() - startTime) / MS_IN_S));
|
||||
setDuration(Math.round((Date.now() - startTime) / 1000));
|
||||
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);
|
||||
}
|
||||
if (isStreaming && !isOpen) {
|
||||
setIsOpen(true);
|
||||
} else if (!isStreaming && isOpen && !defaultOpen && !hasAutoClosedRef) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosedRef(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@ -110,10 +110,19 @@ export const Reasoning = memo(
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
({
|
||||
className,
|
||||
title = 'Reasoning',
|
||||
children,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
|
@ -1,74 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BookIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type SourcesProps = ComponentProps<'div'>;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4 text-primary text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<'a'>;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
@ -50,7 +50,7 @@ const getStatusBadge = (status: ToolUIPart['state']) => {
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
<Badge className="rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
|
@ -17,7 +17,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@ -45,10 +45,10 @@ export const LoginForm = ({
|
||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||
const locale = useLocale();
|
||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
||||
// console.log('login form, propCallbackUrl', propCallbackUrl);
|
||||
// console.log('login form, paramCallbackUrl', paramCallbackUrl);
|
||||
// console.log('login form, defaultCallbackUrl', defaultCallbackUrl);
|
||||
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||
DEFAULT_LOGIN_REDIRECT,
|
||||
locale
|
||||
);
|
||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||
console.log('login form, callbackUrl', callbackUrl);
|
||||
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
||||
@ -40,10 +40,10 @@ export const RegisterForm = ({
|
||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||
const locale = useLocale();
|
||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
||||
// console.log('register form, propCallbackUrl', propCallbackUrl);
|
||||
// console.log('register form, paramCallbackUrl', paramCallbackUrl);
|
||||
// console.log('register form, defaultCallbackUrl', defaultCallbackUrl);
|
||||
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||
DEFAULT_LOGIN_REDIRECT,
|
||||
locale
|
||||
);
|
||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||
console.log('register form, callbackUrl', callbackUrl);
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { GoogleIcon } from '@/components/icons/google';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
@ -37,7 +37,10 @@ export const SocialLoginButton = ({
|
||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||
const locale = useLocale();
|
||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
||||
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||
DEFAULT_LOGIN_REDIRECT,
|
||||
locale
|
||||
);
|
||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||
const [isLoading, setIsLoading] = useState<'google' | 'github' | null>(null);
|
||||
console.log('social login button, callbackUrl', callbackUrl);
|
||||
|
@ -3,7 +3,6 @@ import { LocaleLink } from '@/i18n/navigation';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { type BlogType, authorSource, categorySource } from '@/lib/source';
|
||||
import Image from 'next/image';
|
||||
import { PremiumBadge } from '../premium/premium-badge';
|
||||
import BlogImage from './blog-image';
|
||||
|
||||
interface BlogCardProps {
|
||||
@ -31,13 +30,6 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
|
||||
title={title || 'image for blog post'}
|
||||
/>
|
||||
|
||||
{/* Premium badge - top right */}
|
||||
{post.data.premium && (
|
||||
<div className="absolute top-2 right-2 z-20">
|
||||
<PremiumBadge size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* categories */}
|
||||
{blogCategories && blogCategories.length > 0 && (
|
||||
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300 z-20">
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useSidebarLinks } from '@/config/sidebar-config';
|
||||
import { getSidebarLinks } from '@/config/sidebar-config';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Routes } from '@/routes';
|
||||
@ -35,7 +35,7 @@ export function DashboardSidebar({
|
||||
const { state } = useSidebar();
|
||||
// console.log('sidebar currentUser:', currentUser);
|
||||
|
||||
const sidebarLinks = useSidebarLinks();
|
||||
const sidebarLinks = getSidebarLinks();
|
||||
const filteredSidebarLinks = sidebarLinks.filter((link) => {
|
||||
if (link.authorizeOnly) {
|
||||
return link.authorizeOnly.includes(currentUser?.role || '');
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { ImageWrapper } from '@/components/docs/image-wrapper';
|
||||
import { Wrapper } from '@/components/docs/wrapper';
|
||||
import { YoutubeVideo } from '@/components/docs/youtube-video';
|
||||
import { PremiumContent } from '@/components/premium/premium-content';
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { File, Files, Folder } from 'fumadocs-ui/components/files';
|
||||
@ -24,7 +23,6 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
||||
...LucideIcons,
|
||||
// ...((await import('lucide-react')) as unknown as MDXComponents),
|
||||
YoutubeVideo,
|
||||
PremiumContent,
|
||||
Tabs,
|
||||
Tab,
|
||||
TypeTable,
|
||||
|
@ -4,8 +4,8 @@ import Container from '@/components/layout/container';
|
||||
import { Logo } from '@/components/layout/logo';
|
||||
import { ModeSwitcherHorizontal } from '@/components/layout/mode-switcher-horizontal';
|
||||
import BuiltWithButton from '@/components/shared/built-with-button';
|
||||
import { useFooterLinks } from '@/config/footer-config';
|
||||
import { useSocialLinks } from '@/config/social-config';
|
||||
import { getFooterLinks } from '@/config/footer-config';
|
||||
import { getSocialLinks } from '@/config/social-config';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -13,8 +13,8 @@ import type React from 'react';
|
||||
|
||||
export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
|
||||
const t = useTranslations();
|
||||
const footerLinks = useFooterLinks();
|
||||
const socialLinks = useSocialLinks();
|
||||
const footerLinks = getFooterLinks();
|
||||
const socialLinks = getSocialLinks();
|
||||
|
||||
return (
|
||||
<footer className={cn('border-t', className)}>
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { useNavbarLinks } from '@/config/navbar-config';
|
||||
import { getNavbarLinks } from '@/config/navbar-config';
|
||||
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -146,7 +146,7 @@ interface MainMobileMenuProps {
|
||||
function MainMobileMenu({ userLoggedIn, onLinkClicked }: MainMobileMenuProps) {
|
||||
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
|
||||
const t = useTranslations();
|
||||
const menuLinks = useNavbarLinks();
|
||||
const menuLinks = getNavbarLinks();
|
||||
const localePathname = useLocalePathname();
|
||||
|
||||
return (
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import { useNavbarLinks } from '@/config/navbar-config';
|
||||
import { getNavbarLinks } from '@/config/navbar-config';
|
||||
import { useScroll } from '@/hooks/use-scroll';
|
||||
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
@ -44,7 +44,7 @@ const customNavigationMenuTriggerStyle = cn(
|
||||
export function Navbar({ scroll }: NavBarProps) {
|
||||
const t = useTranslations();
|
||||
const scrolled = useScroll(50);
|
||||
const menuLinks = useNavbarLinks();
|
||||
const menuLinks = getNavbarLinks();
|
||||
const localePathname = useLocalePathname();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer';
|
||||
import { useAvatarLinks } from '@/config/avatar-config';
|
||||
import { getAvatarLinks } from '@/config/avatar-config';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import type { User } from 'better-auth';
|
||||
@ -25,7 +25,7 @@ interface UserButtonProps {
|
||||
|
||||
export function UserButtonMobile({ user }: UserButtonProps) {
|
||||
const t = useTranslations();
|
||||
const avatarLinks = useAvatarLinks();
|
||||
const avatarLinks = getAvatarLinks();
|
||||
const localeRouter = useLocaleRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeDrawer = () => {
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAvatarLinks } from '@/config/avatar-config';
|
||||
import { getAvatarLinks } from '@/config/avatar-config';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
@ -25,7 +25,7 @@ interface UserButtonProps {
|
||||
|
||||
export function UserButton({ user }: UserButtonProps) {
|
||||
const t = useTranslations();
|
||||
const avatarLinks = useAvatarLinks();
|
||||
const avatarLinks = getAvatarLinks();
|
||||
const localeRouter = useLocaleRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleSignOut = async () => {
|
||||
|
@ -1,47 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CrownIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface PremiumBadgeProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'outline' | 'secondary';
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
}
|
||||
|
||||
export function PremiumBadge({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
}: PremiumBadgeProps) {
|
||||
const t = useTranslations('Common');
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs h-5',
|
||||
default: 'text-xs h-6',
|
||||
lg: 'text-sm h-7',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'size-3',
|
||||
default: 'size-3',
|
||||
lg: 'size-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 font-medium',
|
||||
'bg-orange-400 text-white border-0',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CrownIcon className={iconSizes[size]} />
|
||||
{t('premium')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PremiumContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side Premium Content component
|
||||
* Note: This component now serves as a fallback for client-side rendering.
|
||||
* The main security filtering happens server-side in PremiumGuard component.
|
||||
*/
|
||||
export function PremiumContent({ children }: PremiumContentProps) {
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: paymentData } = useCurrentPlan(currentUser?.id);
|
||||
|
||||
// Determine if user has premium access
|
||||
const hasPremiumAccess =
|
||||
paymentData?.currentPlan &&
|
||||
(paymentData.currentPlan.isLifetime || !paymentData.currentPlan.isFree);
|
||||
|
||||
// Only show content if user has premium access
|
||||
// This is a client-side fallback - main security is handled server-side
|
||||
if (!currentUser || !hasPremiumAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="premium-content-section">{children}</div>;
|
||||
}
|
@ -1,227 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { LoginWrapper } from '@/components/auth/login-wrapper';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircleIcon,
|
||||
CrownIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PremiumGuardProps {
|
||||
children: ReactNode;
|
||||
isPremium: boolean;
|
||||
canAccess?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PremiumGuard({
|
||||
children,
|
||||
isPremium,
|
||||
canAccess,
|
||||
className,
|
||||
}: PremiumGuardProps) {
|
||||
// All hooks must be called unconditionally at the top
|
||||
const t = useTranslations('PremiumContent');
|
||||
const pathname = useLocalePathname();
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
|
||||
currentUser?.id
|
||||
);
|
||||
|
||||
// For non-premium articles, show content immediately with no extra processing
|
||||
if (!isPremium) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if user has premium access
|
||||
const hasPremiumAccess =
|
||||
paymentData?.currentPlan &&
|
||||
(!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime);
|
||||
|
||||
// If server-side check has already determined access, use that
|
||||
if (canAccess !== undefined) {
|
||||
// Server has determined the user has access
|
||||
if (canAccess) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Server determined no access, show appropriate message
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{/* Show partial content before protection */}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Enhanced login prompt for server-side blocked content */}
|
||||
<div className="mt-8">
|
||||
<div className="w-full p-12 rounded-lg bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
|
||||
<div className="flex flex-col items-center justify-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<LockIcon className="size-8 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t('loginRequired')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t('loginDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LoginWrapper mode="modal" asChild callbackUrl={pathname}>
|
||||
<Button size="lg" className="min-w-[160px] cursor-pointer">
|
||||
<LockIcon className="mr-2 size-4" />
|
||||
{t('signIn')}
|
||||
</Button>
|
||||
</LoginWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If user is not logged in
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Enhanced login prompt */}
|
||||
<div className="mt-8">
|
||||
<div className="w-full p-12 rounded-lg bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
|
||||
<div className="flex flex-col items-center justify-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<LockIcon className="size-8 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold">{t('loginRequired')}</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t('loginDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LoginWrapper mode="modal" asChild callbackUrl={pathname}>
|
||||
<Button size="lg" className="min-w-[160px] cursor-pointer">
|
||||
<LockIcon className="mr-2 size-4" />
|
||||
{t('signIn')}
|
||||
</Button>
|
||||
</LoginWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If payment data is still loading
|
||||
if (isLoadingPayment) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
{isLoadingPayment && (
|
||||
<div className="mt-8 flex items-center justify-center text-primary font-semibold">
|
||||
<Loader2Icon className="size-5 animate-spin mr-2" />
|
||||
<span>{t('checkingAccess')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If user doesn't have premium access
|
||||
if (!hasPremiumAccess) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Inline subscription banner for logged-in non-members */}
|
||||
<div className="mt-8">
|
||||
<Card className="bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<CrownIcon className="size-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{t('title')}</h3>
|
||||
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
{t('description')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
|
||||
<Button asChild size="lg" className="min-w-[160px]">
|
||||
<LocaleLink
|
||||
href="/pricing"
|
||||
className="text-white no-underline hover:text-white/90"
|
||||
>
|
||||
{t('upgradeCta')}
|
||||
<ArrowRightIcon className="ml-2 size-4" />
|
||||
</LocaleLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 text-primary" />
|
||||
{t('benefit1')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 text-primary" />
|
||||
{t('benefit2')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 text-primary" />
|
||||
{t('benefit3')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show full content for premium users
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { usePricePlans } from '@/config/price-config';
|
||||
import { getPricePlans } from '@/config/price-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
PaymentTypes,
|
||||
@ -36,7 +36,7 @@ export function PricingTable({
|
||||
const [interval, setInterval] = useState<PlanInterval>(PlanIntervals.MONTH);
|
||||
|
||||
// Get price plans with translations
|
||||
const pricePlans = usePricePlans();
|
||||
const pricePlans = getPricePlans();
|
||||
const plans = Object.values(pricePlans);
|
||||
|
||||
// Current plan ID for comparison
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { usePricePlans } from '@/config/price-config';
|
||||
import { getPricePlans } from '@/config/price-config';
|
||||
import { useMounted } from '@/hooks/use-mounted';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
@ -50,7 +50,7 @@ export default function BillingCard() {
|
||||
const subscription = paymentData?.subscription;
|
||||
|
||||
// Get price plans with translations - must be called here to maintain hook order
|
||||
const pricePlans = usePricePlans();
|
||||
const pricePlans = getPricePlans();
|
||||
const plans = Object.values(pricePlans);
|
||||
|
||||
// Convert current plan to a plan with translations
|
||||
@ -60,21 +60,23 @@ export default function BillingCard() {
|
||||
const isFreePlan = currentPlanWithTranslations?.isFree || false;
|
||||
const isLifetimeMember = currentPlanWithTranslations?.isLifetime || false;
|
||||
|
||||
// Get subscription price details
|
||||
const currentPrice =
|
||||
subscription &&
|
||||
currentPlanWithTranslations?.prices.find(
|
||||
(price) => price.priceId === subscription?.priceId
|
||||
);
|
||||
|
||||
// Get current period start date
|
||||
const currentPeriodStart = subscription?.currentPeriodStart
|
||||
? formatDate(subscription.currentPeriodStart)
|
||||
: null;
|
||||
|
||||
// Get current period end date
|
||||
const currentPeriodEnd = subscription?.currentPeriodEnd
|
||||
// Format next billing date if subscription is active
|
||||
const nextBillingDate = subscription?.currentPeriodEnd
|
||||
? formatDate(subscription.currentPeriodEnd)
|
||||
: null;
|
||||
|
||||
// Get current trial end date
|
||||
const trialEndDate = subscription?.trialEndDate
|
||||
? formatDate(subscription.trialEndDate)
|
||||
: null;
|
||||
|
||||
// Retry payment data fetching
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch payment info');
|
||||
@ -227,25 +229,36 @@ export default function BillingCard() {
|
||||
)}
|
||||
|
||||
{/* Subscription plan message */}
|
||||
{subscription && (
|
||||
{subscription && currentPrice && (
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
{/* <div>
|
||||
{t('price')}{' '}
|
||||
{formatPrice(currentPrice.amount, currentPrice.currency)} /{' '}
|
||||
{currentPrice.interval === PlanIntervals.MONTH
|
||||
? t('interval.month')
|
||||
: currentPrice.interval === PlanIntervals.YEAR
|
||||
? t('interval.year')
|
||||
: t('interval.oneTime')}
|
||||
</div> */}
|
||||
|
||||
{currentPeriodStart && (
|
||||
<div className="text-muted-foreground">
|
||||
{t('periodStartDate')} {currentPeriodStart}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPeriodEnd && (
|
||||
{nextBillingDate && (
|
||||
<div className="text-muted-foreground">
|
||||
{t('periodEndDate')} {currentPeriodEnd}
|
||||
{t('nextBillingDate')} {nextBillingDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscription.status === 'trialing' && trialEndDate && (
|
||||
<div className="text-amber-600">
|
||||
{t('trialEnds')} {trialEndDate}
|
||||
</div>
|
||||
)}
|
||||
{subscription.status === 'trialing' &&
|
||||
subscription.currentPeriodEnd && (
|
||||
<div className="text-amber-600">
|
||||
{t('trialEnds')} {formatDate(subscription.currentPeriodEnd)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
@ -8,10 +8,11 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useCreditPackages } from '@/config/credits-config';
|
||||
import { getCreditPackages } from '@/config/credits-config';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { formatPrice } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
|
||||
@ -31,27 +32,15 @@ export function CreditPackages() {
|
||||
|
||||
// Get current user and payment info
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
|
||||
currentUser?.id
|
||||
);
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: paymentData } = useCurrentPlan(session?.user?.id);
|
||||
const currentPlan = paymentData?.currentPlan;
|
||||
|
||||
// Get credit packages with translations - must be called here to maintain hook order
|
||||
// This function contains useTranslations hook, so it must be called before any conditional returns
|
||||
const creditPackages = Object.values(useCreditPackages()).filter(
|
||||
const creditPackages = Object.values(getCreditPackages()).filter(
|
||||
(pkg) => !pkg.disabled && pkg.price.priceId
|
||||
);
|
||||
|
||||
// Don't render anything while loading to prevent flash
|
||||
if (isLoadingPayment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render anything if we don't have payment data yet
|
||||
if (!paymentData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user is on free plan and enablePackagesForFreePlan is false
|
||||
const isFreePlan = currentPlan?.isFree === true;
|
||||
|
||||
|
@ -4,33 +4,34 @@ import { CreditTransactionsTable } from '@/components/settings/credits/credit-tr
|
||||
import { useCreditTransactions } from '@/hooks/use-credits';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface CreditTransactionsProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
sorting: SortingState;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
onSearch: (search: string) => void;
|
||||
onSortingChange: (sorting: SortingState) => void;
|
||||
}
|
||||
import {
|
||||
parseAsIndex,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Credit transactions component
|
||||
*/
|
||||
export function CreditTransactions({
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onSearch,
|
||||
onSortingChange,
|
||||
}: CreditTransactionsProps) {
|
||||
export function CreditTransactions() {
|
||||
const t = useTranslations('Dashboard.settings.credits.transactions');
|
||||
|
||||
const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] =
|
||||
useQueryStates({
|
||||
page: parseAsIndex.withDefault(0), // 0-based internally, 1-based in URL
|
||||
pageSize: parseAsInteger.withDefault(10),
|
||||
search: parseAsString.withDefault(''),
|
||||
sortId: parseAsString.withDefault('createdAt'),
|
||||
sortDesc: parseAsInteger.withDefault(1),
|
||||
});
|
||||
|
||||
const sorting: SortingState = useMemo(
|
||||
() => [{ id: sortId, desc: Boolean(sortDesc) }],
|
||||
[sortId, sortDesc]
|
||||
);
|
||||
|
||||
const { data, isLoading } = useCreditTransactions(
|
||||
page,
|
||||
pageSize,
|
||||
@ -47,10 +48,19 @@ export function CreditTransactions({
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
loading={isLoading}
|
||||
onSearch={onSearch}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
onSortingChange={onSortingChange}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -4,16 +4,8 @@ import { CreditPackages } from '@/components/settings/credits/credit-packages';
|
||||
import { CreditTransactions } from '@/components/settings/credits/credit-transactions';
|
||||
import CreditsBalanceCard from '@/components/settings/credits/credits-balance-card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
parseAsIndex,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringLiteral,
|
||||
useQueryStates,
|
||||
} from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
|
||||
/**
|
||||
* Credits page client, show credit balance and transactions
|
||||
@ -21,47 +13,24 @@ import { useMemo } from 'react';
|
||||
export default function CreditsPageClient() {
|
||||
const t = useTranslations('Dashboard.settings.credits');
|
||||
|
||||
// Manage all URL states in the parent component
|
||||
const [{ tab, page, pageSize, search, sortId, sortDesc }, setQueryStates] =
|
||||
useQueryStates({
|
||||
tab: parseAsStringLiteral(['balance', 'transactions']).withDefault(
|
||||
'balance'
|
||||
),
|
||||
// Transaction-specific parameters
|
||||
page: parseAsIndex.withDefault(0),
|
||||
pageSize: parseAsInteger.withDefault(10),
|
||||
search: parseAsString.withDefault(''),
|
||||
sortId: parseAsString.withDefault('createdAt'),
|
||||
sortDesc: parseAsInteger.withDefault(1),
|
||||
});
|
||||
|
||||
const sorting: SortingState = useMemo(
|
||||
() => [{ id: sortId, desc: Boolean(sortDesc) }],
|
||||
[sortId, sortDesc]
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringLiteral(['balance', 'transactions']).withDefault('balance')
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === 'balance' || value === 'transactions') {
|
||||
if (value === 'balance') {
|
||||
// When switching to balance tab, clear transaction parameters
|
||||
setQueryStates({
|
||||
tab: value,
|
||||
page: null,
|
||||
pageSize: null,
|
||||
search: null,
|
||||
sortId: null,
|
||||
sortDesc: null,
|
||||
});
|
||||
} else {
|
||||
// When switching to transactions tab, just set the tab
|
||||
setQueryStates({ tab: value });
|
||||
}
|
||||
setActiveTab(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Tabs value={tab} onValueChange={handleTabChange} className="w-full">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="">
|
||||
<TabsTrigger value="balance">{t('tabs.balance')}</TabsTrigger>
|
||||
<TabsTrigger value="transactions">
|
||||
@ -83,29 +52,7 @@ export default function CreditsPageClient() {
|
||||
|
||||
<TabsContent value="transactions" className="mt-4">
|
||||
{/* Credit Transactions */}
|
||||
<CreditTransactions
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
onPageChange={(newPageIndex) =>
|
||||
setQueryStates({ page: newPageIndex })
|
||||
}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setQueryStates({ pageSize: newPageSize, page: 0 })
|
||||
}
|
||||
onSearch={(newSearch) =>
|
||||
setQueryStates({ search: newSearch, page: 0 })
|
||||
}
|
||||
onSortingChange={(newSorting) => {
|
||||
if (newSorting.length > 0) {
|
||||
setQueryStates({
|
||||
sortId: newSorting[0].id,
|
||||
sortDesc: newSorting[0].desc ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CreditTransactions />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
@ -19,7 +19,7 @@ import { useTranslations } from 'next-intl';
|
||||
*
|
||||
* @returns The avatar config with translated titles
|
||||
*/
|
||||
export function useAvatarLinks(): MenuItem[] {
|
||||
export function getAvatarLinks(): MenuItem[] {
|
||||
const t = useTranslations('Marketing.avatar');
|
||||
|
||||
return [
|
||||
|
@ -16,7 +16,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The credit packages with translated content
|
||||
*/
|
||||
export function useCreditPackages(): Record<string, CreditPackage> {
|
||||
export function getCreditPackages(): Record<string, CreditPackage> {
|
||||
const t = useTranslations('CreditPackages');
|
||||
const creditConfig = websiteConfig.credits;
|
||||
const packages: Record<string, CreditPackage> = {};
|
||||
|
@ -15,7 +15,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The footer config with translated titles
|
||||
*/
|
||||
export function useFooterLinks(): NestedMenuItem[] {
|
||||
export function getFooterLinks(): NestedMenuItem[] {
|
||||
const t = useTranslations('Marketing.footer');
|
||||
|
||||
return [
|
||||
|
@ -47,7 +47,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The navbar config with translated titles and descriptions
|
||||
*/
|
||||
export function useNavbarLinks(): NestedMenuItem[] {
|
||||
export function getNavbarLinks(): NestedMenuItem[] {
|
||||
const t = useTranslations('Marketing.navbar');
|
||||
|
||||
return [
|
||||
|
@ -16,7 +16,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The price plans with translated content
|
||||
*/
|
||||
export function usePricePlans(): Record<string, PricePlan> {
|
||||
export function getPricePlans(): Record<string, PricePlan> {
|
||||
const t = useTranslations('PricePlans');
|
||||
const priceConfig = websiteConfig.price;
|
||||
const plans: Record<string, PricePlan> = {};
|
||||
|
@ -27,7 +27,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The sidebar config with translated titles and descriptions
|
||||
*/
|
||||
export function useSidebarLinks(): NestedMenuItem[] {
|
||||
export function getSidebarLinks(): NestedMenuItem[] {
|
||||
const t = useTranslations('Dashboard');
|
||||
|
||||
// if is demo website, allow user to access admin and user pages, but data is fake
|
||||
|
@ -25,7 +25,7 @@ import { websiteConfig } from './website';
|
||||
*
|
||||
* @returns The social config
|
||||
*/
|
||||
export function useSocialLinks(): MenuItem[] {
|
||||
export function getSocialLinks(): MenuItem[] {
|
||||
const socialLinks: MenuItem[] = [];
|
||||
|
||||
if (websiteConfig.metadata.social?.github) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useCreditPackages } from '@/config/credits-config';
|
||||
import { getCreditPackages } from '@/config/credits-config';
|
||||
import type { CreditPackage } from './types';
|
||||
|
||||
/**
|
||||
@ -6,7 +6,7 @@ import type { CreditPackage } from './types';
|
||||
* @returns Credit packages
|
||||
*/
|
||||
export function getCreditPackagesInClient(): CreditPackage[] {
|
||||
return Object.values(useCreditPackages());
|
||||
return Object.values(getCreditPackages());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2,23 +2,17 @@
|
||||
* Connect to PostgreSQL Database (Supabase/Neon/Local PostgreSQL)
|
||||
* https://orm.drizzle.team/docs/tutorials/drizzle-with-supabase
|
||||
*/
|
||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
// https://opennext.js.org/cloudflare/howtos/db#postgresql
|
||||
export async function getDb() {
|
||||
if (db) return db;
|
||||
const { env } = await getCloudflareContext({ async: true });
|
||||
const pool = new Pool({
|
||||
connectionString: env.HYPERDRIVE.connectionString,
|
||||
// You don't want to reuse the same connection for multiple requests
|
||||
maxUses: 1,
|
||||
});
|
||||
db = drizzle({ client: pool, schema });
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const client = postgres(connectionString, { prepare: false });
|
||||
db = drizzle(client, { schema });
|
||||
return db;
|
||||
}
|
||||
|
||||
|
@ -1,89 +0,0 @@
|
||||
import { getDb } from '@/db';
|
||||
import { payment } from '@/db/schema';
|
||||
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
|
||||
import { PaymentTypes } from '@/payment/types';
|
||||
import { and, eq, gt, isNull, or } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Check premium access for a specific user ID
|
||||
*
|
||||
* This function combines the logic from getLifetimeStatusAction and getActiveSubscriptionAction
|
||||
* but optimizes it for a single database query to check premium access.
|
||||
*/
|
||||
export async function checkPremiumAccess(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// Get lifetime plan IDs for efficient checking
|
||||
const plans = getAllPricePlans();
|
||||
const lifetimePlanIds = plans
|
||||
.filter((plan) => plan.isLifetime)
|
||||
.map((plan) => plan.id);
|
||||
|
||||
// Single optimized query to check both lifetime and active subscriptions
|
||||
const result = await db
|
||||
.select({
|
||||
id: payment.id,
|
||||
priceId: payment.priceId,
|
||||
type: payment.type,
|
||||
status: payment.status,
|
||||
periodEnd: payment.periodEnd,
|
||||
cancelAtPeriodEnd: payment.cancelAtPeriodEnd,
|
||||
})
|
||||
.from(payment)
|
||||
.where(
|
||||
and(
|
||||
eq(payment.userId, userId),
|
||||
or(
|
||||
// Check for completed lifetime payments
|
||||
and(
|
||||
eq(payment.type, PaymentTypes.ONE_TIME),
|
||||
eq(payment.status, 'completed')
|
||||
),
|
||||
// Check for active subscriptions that haven't expired
|
||||
and(
|
||||
eq(payment.type, PaymentTypes.SUBSCRIPTION),
|
||||
eq(payment.status, 'active'),
|
||||
or(
|
||||
// Either period hasn't ended yet
|
||||
gt(payment.periodEnd, new Date()),
|
||||
// Or period end is null (ongoing subscription)
|
||||
isNull(payment.periodEnd)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any payment grants premium access
|
||||
return result.some((p) => {
|
||||
// For one-time payments, check if it's a lifetime plan
|
||||
if (p.type === PaymentTypes.ONE_TIME && p.status === 'completed') {
|
||||
const plan = findPlanByPriceId(p.priceId);
|
||||
return plan && lifetimePlanIds.includes(plan.id);
|
||||
}
|
||||
|
||||
// For subscriptions, check if they're active and not expired
|
||||
if (p.type === PaymentTypes.SUBSCRIPTION && p.status === 'active') {
|
||||
// If periodEnd is null, it's an ongoing subscription
|
||||
if (!p.periodEnd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the subscription period hasn't ended yet
|
||||
const now = new Date();
|
||||
const periodEnd = new Date(p.periodEnd);
|
||||
return periodEnd > now;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking premium access for user:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
@ -35,7 +35,7 @@ export function getUrlWithLocale(url: string, locale?: Locale | null): string {
|
||||
* Input: http://localhost:3000/api/auth/reset-password/token?callbackURL=/auth/reset-password
|
||||
* Output: http://localhost:3000/api/auth/reset-password/token?callbackURL=/zh/auth/reset-password
|
||||
*
|
||||
* Input: http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/dashboard
|
||||
* http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/dashboard
|
||||
* Output: http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/zh/dashboard
|
||||
*
|
||||
* @param url - The original URL with callbackURL parameter
|
||||
|
@ -57,13 +57,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
// Initialize Stripe without specifying apiVersion to use default/latest version
|
||||
// https://opennext.js.org/cloudflare/howtos/stripeAPI
|
||||
// When creating a Stripe object, the default http client implementation is based on
|
||||
// node:https which is not implemented on Workers.
|
||||
this.stripe = new Stripe(apiKey, {
|
||||
// Cloudflare Workers use the Fetch API for their API requests.
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
});
|
||||
this.stripe = new Stripe(apiKey);
|
||||
this.webhookSecret = webhookSecret;
|
||||
}
|
||||
|
||||
@ -543,9 +537,6 @@ export class StripeProvider implements PaymentProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
const periodStart = this.getPeriodStart(stripeSubscription);
|
||||
const periodEnd = this.getPeriodEnd(stripeSubscription);
|
||||
|
||||
// create fields
|
||||
const createFields: any = {
|
||||
id: randomUUID(),
|
||||
@ -558,8 +549,12 @@ export class StripeProvider implements PaymentProvider {
|
||||
status: this.mapSubscriptionStatusToPaymentStatus(
|
||||
stripeSubscription.status
|
||||
),
|
||||
periodStart: periodStart,
|
||||
periodEnd: periodEnd,
|
||||
periodStart: stripeSubscription.current_period_start
|
||||
? new Date(stripeSubscription.current_period_start * 1000)
|
||||
: null,
|
||||
periodEnd: stripeSubscription.current_period_end
|
||||
? new Date(stripeSubscription.current_period_end * 1000)
|
||||
: null,
|
||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
||||
trialStart: stripeSubscription.trial_start
|
||||
? new Date(stripeSubscription.trial_start * 1000)
|
||||
@ -619,8 +614,12 @@ export class StripeProvider implements PaymentProvider {
|
||||
.limit(1);
|
||||
|
||||
// get new period start and end
|
||||
const newPeriodStart = this.getPeriodStart(stripeSubscription);
|
||||
const newPeriodEnd = this.getPeriodEnd(stripeSubscription);
|
||||
const newPeriodStart = stripeSubscription.current_period_start
|
||||
? new Date(stripeSubscription.current_period_start * 1000)
|
||||
: undefined;
|
||||
const newPeriodEnd = stripeSubscription.current_period_end
|
||||
? new Date(stripeSubscription.current_period_end * 1000)
|
||||
: undefined;
|
||||
|
||||
// Check if this is a renewal (period has changed and subscription is active)
|
||||
const isRenewal =
|
||||
@ -973,24 +972,4 @@ export class StripeProvider implements PaymentProvider {
|
||||
// Default to auto to let Stripe detect the language
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
private getPeriodStart(subscription: Stripe.Subscription): Date | undefined {
|
||||
const s: any = subscription as any;
|
||||
const startUnix =
|
||||
s.current_period_start ??
|
||||
s?.items?.data?.[0]?.current_period_start ??
|
||||
undefined;
|
||||
return typeof startUnix === 'number'
|
||||
? new Date(startUnix * 1000)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private getPeriodEnd(subscription: Stripe.Subscription): Date | undefined {
|
||||
const s: any = subscription as any;
|
||||
const endUnix =
|
||||
s.current_period_end ??
|
||||
s?.items?.data?.[0]?.current_period_end ??
|
||||
undefined;
|
||||
return typeof endUnix === 'number' ? new Date(endUnix * 1000) : undefined;
|
||||
}
|
||||
}
|
||||
|
@ -1,84 +0,0 @@
|
||||
/**
|
||||
* For more details on how to configure Wrangler, refer to:
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/
|
||||
*/
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "mksaas-template",
|
||||
"compatibility_date": "2024-12-30",
|
||||
"compatibility_flags": [
|
||||
// Enable Node.js API
|
||||
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag
|
||||
"nodejs_compat",
|
||||
// This also enables nodejs_compat_v2 as long as compatibility date is 2024-09-23 or later.
|
||||
// https://developers.cloudflare.com/workers/configuration/compatibility-dates/#nodejs-compatibility-flag
|
||||
// Enable improved Node.js API with polyfills and native code
|
||||
// see https://blog.cloudflare.com/zh-cn/more-npm-packages-on-cloudflare-workers-combining-polyfills-and-native-code/
|
||||
// "nodejs_compat_v2",
|
||||
// Enable auto-populating process.env
|
||||
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-auto-populating-processenv
|
||||
"nodejs_compat_populate_process_env",
|
||||
// Allow to fetch URLs in your app
|
||||
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
// Minification helps to keep the Worker bundle size down and improve start up time.
|
||||
"minify": true,
|
||||
|
||||
// Enables Workers Trace Events Logpush for a Worker
|
||||
"logpush": true,
|
||||
|
||||
// https://developers.cloudflare.com/workers/wrangler/configuration/#top-level-only-keys
|
||||
// Whether Wrangler should keep variables configured in the dashboard on deploy
|
||||
"keep_vars": true,
|
||||
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
"directory": ".open-next/assets"
|
||||
},
|
||||
|
||||
// https://developers.cloudflare.com/workers/wrangler/configuration/#observability
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
/**
|
||||
* Smart Placement
|
||||
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
||||
*/
|
||||
// "placement": { "mode": "smart" },
|
||||
|
||||
/**
|
||||
* Bindings
|
||||
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
|
||||
* databases, object storage, AI inference, real-time communication and more.
|
||||
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hyperdrive
|
||||
* https://opennext.js.org/cloudflare/howtos/db#hyperdrive-example
|
||||
* https://developers.cloudflare.com/workers/tutorials/postgres/#8-use-hyperdrive-to-accelerate-queries
|
||||
*/
|
||||
"hyperdrive": [
|
||||
{
|
||||
"binding": "HYPERDRIVE",
|
||||
"id": "8ba4508b28cf42f987f3533c1f09433c",
|
||||
"localConnectionString": "postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
}
|
||||
],
|
||||
|
||||
/**
|
||||
* Environment Variables
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
|
||||
*/
|
||||
"vars": {},
|
||||
|
||||
/**
|
||||
* Note: Use secrets to store sensitive data.
|
||||
* https://developers.cloudflare.com/workers/configuration/secrets/
|
||||
*/
|
||||
|
||||
"kv_namespaces": []
|
||||
}
|
Loading…
Reference in New Issue
Block a user