Compare commits
No commits in common. "cloudflare" and "dev/blog-premium" have entirely different histories.
cloudflare
...
dev/blog-p
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
|
||||
}
|
||||
}
|
||||
|
7483
cloudflare-env.d.ts
vendored
7483
cloudflare-env.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@ -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
|
@ -282,7 +282,20 @@
|
||||
"all": "All",
|
||||
"noPostsFound": "No posts found",
|
||||
"allPosts": "All Posts",
|
||||
"morePosts": "More Posts"
|
||||
"morePosts": "More Posts",
|
||||
"premiumContent": {
|
||||
"title": "Unlock Premium Content",
|
||||
"description": "Subscribe to our Pro plan to access all premium articles and exclusive content.",
|
||||
"upgradeCta": "Upgrade Now",
|
||||
"benefit1": "All premium articles",
|
||||
"benefit2": "Exclusive content",
|
||||
"benefit3": "Cancel anytime",
|
||||
"signIn": "Sign In",
|
||||
"loginRequired": "Sign in to continue reading",
|
||||
"loginDescription": "This is a premium article. Sign in to your account to access the full content.",
|
||||
"checkingAccess": "Checking access...",
|
||||
"loadingContent": "Loading full content..."
|
||||
}
|
||||
},
|
||||
"DocsPage": {
|
||||
"toc": "Table of Contents",
|
||||
@ -293,20 +306,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 +592,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",
|
||||
|
@ -282,7 +282,20 @@
|
||||
"all": "全部",
|
||||
"noPostsFound": "没有找到文章",
|
||||
"allPosts": "全部文章",
|
||||
"morePosts": "更多文章"
|
||||
"morePosts": "更多文章",
|
||||
"premiumContent": {
|
||||
"title": "解锁付费内容",
|
||||
"description": "订阅我们的付费计划,访问所有付费文章和独家内容。",
|
||||
"upgradeCta": "立即升级",
|
||||
"benefit1": "所有文章",
|
||||
"benefit2": "独家内容",
|
||||
"benefit3": "随时取消",
|
||||
"signIn": "登录",
|
||||
"loginRequired": "登录以继续阅读",
|
||||
"loginDescription": "这是一篇付费文章,请登录您的账户以访问完整内容。",
|
||||
"checkingAccess": "检查阅读权限...",
|
||||
"loadingContent": "加载完整内容..."
|
||||
}
|
||||
},
|
||||
"DocsPage": {
|
||||
"toc": "目录",
|
||||
@ -293,20 +306,8 @@
|
||||
"nextPage": "下一页",
|
||||
"chooseLanguage": "选择语言",
|
||||
"title": "MkSaaS文档",
|
||||
"homepage": "首页"
|
||||
},
|
||||
"PremiumContent": {
|
||||
"title": "解锁付费内容",
|
||||
"description": "订阅我们的付费计划,访问所有付费内容和独家内容。",
|
||||
"upgradeCta": "立即升级",
|
||||
"benefit1": "所有内容",
|
||||
"benefit2": "独家内容",
|
||||
"benefit3": "随时取消",
|
||||
"signIn": "登录",
|
||||
"loginRequired": "登录以继续阅读",
|
||||
"loginDescription": "这是一篇付费内容,请登录您的账户以访问完整内容。",
|
||||
"checkingAccess": "检查阅读权限...",
|
||||
"loadingContent": "加载完整内容..."
|
||||
"homepage": "首页",
|
||||
"blog": "博客"
|
||||
},
|
||||
"Marketing": {
|
||||
"navbar": {
|
||||
@ -591,7 +592,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: {
|
||||
|
@ -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>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import AllPostsButton from '@/components/blog/all-posts-button';
|
||||
import BlogGrid from '@/components/blog/blog-grid';
|
||||
import { PremiumBadge } from '@/components/blog/premium-badge';
|
||||
import { PremiumGuard } from '@/components/blog/premium-guard';
|
||||
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';
|
||||
|
@ -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,8 +3,8 @@ 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';
|
||||
import { PremiumBadge } from './premium-badge';
|
||||
|
||||
interface BlogCardProps {
|
||||
locale: string;
|
||||
|
@ -35,7 +35,8 @@ export function PremiumBadge({
|
||||
variant={variant}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 font-medium',
|
||||
'bg-orange-400 text-white border-0',
|
||||
'bg-gradient-to-r from-amber-500 to-orange-500',
|
||||
'text-white border-0 hover:from-amber-600 hover:to-orange-600',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
@ -30,7 +30,7 @@ export function PremiumGuard({
|
||||
className,
|
||||
}: PremiumGuardProps) {
|
||||
// All hooks must be called unconditionally at the top
|
||||
const t = useTranslations('PremiumContent');
|
||||
const t = useTranslations('BlogPage');
|
||||
const pathname = useLocalePathname();
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
|
||||
@ -76,7 +76,7 @@ export function PremiumGuard({
|
||||
</div>
|
||||
|
||||
{/* Enhanced login prompt for server-side blocked content */}
|
||||
<div className="mt-8">
|
||||
<div className="mt-16">
|
||||
<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">
|
||||
@ -85,17 +85,17 @@ export function PremiumGuard({
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t('loginRequired')}
|
||||
{t('premiumContent.loginRequired')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t('loginDescription')}
|
||||
{t('premiumContent.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')}
|
||||
{t('premiumContent.signIn')}
|
||||
</Button>
|
||||
</LoginWrapper>
|
||||
</div>
|
||||
@ -115,7 +115,7 @@ export function PremiumGuard({
|
||||
</div>
|
||||
|
||||
{/* Enhanced login prompt */}
|
||||
<div className="mt-8">
|
||||
<div className="mt-16">
|
||||
<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">
|
||||
@ -123,16 +123,18 @@ export function PremiumGuard({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold">{t('loginRequired')}</h3>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t('premiumContent.loginRequired')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t('loginDescription')}
|
||||
{t('premiumContent.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')}
|
||||
{t('premiumContent.signIn')}
|
||||
</Button>
|
||||
</LoginWrapper>
|
||||
</div>
|
||||
@ -152,7 +154,7 @@ export function PremiumGuard({
|
||||
{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>
|
||||
<span>{t('premiumContent.checkingAccess')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -168,7 +170,7 @@ export function PremiumGuard({
|
||||
</div>
|
||||
|
||||
{/* Inline subscription banner for logged-in non-members */}
|
||||
<div className="mt-8">
|
||||
<div className="mt-16">
|
||||
<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">
|
||||
@ -177,19 +179,18 @@ export function PremiumGuard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{t('title')}</h3>
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
{t('premiumContent.title')}
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
{t('description')}
|
||||
{t('premiumContent.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')}
|
||||
<LocaleLink href="/pricing">
|
||||
{t('premiumContent.upgradeCta')}
|
||||
<ArrowRightIcon className="ml-2 size-4" />
|
||||
</LocaleLink>
|
||||
</Button>
|
||||
@ -198,15 +199,15 @@ export function PremiumGuard({
|
||||
<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')}
|
||||
{t('premiumContent.benefit1')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 text-primary" />
|
||||
{t('benefit2')}
|
||||
{t('premiumContent.benefit2')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="size-4 text-primary" />
|
||||
{t('benefit3')}
|
||||
{t('premiumContent.benefit3')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
@ -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,7 @@
|
||||
import { PremiumContent } from '@/components/blog/premium-content';
|
||||
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';
|
||||
|
@ -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,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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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