Compare commits

..

No commits in common. "cloudflare" and "dev/blog-premium" have entirely different histories.

49 changed files with 257 additions and 12200 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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 变得更加有用!

View File

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

View File

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

View File

@ -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": "您拥有所有高级功能的终身使用权限",

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ import { Input } from '@/components/ui/input';
import { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { 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);

View File

@ -16,7 +16,7 @@ import {
import { Input } from '@/components/ui/input';
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { 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);

View File

@ -6,7 +6,7 @@ import { GoogleIcon } from '@/components/icons/google';
import { Button } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,8 +4,8 @@ import Container from '@/components/layout/container';
import { Logo } from '@/components/layout/logo';
import { ModeSwitcherHorizontal } from '@/components/layout/mode-switcher-horizontal';
import BuiltWithButton from '@/components/shared/built-with-button';
import { 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)}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,7 +35,7 @@ export function getUrlWithLocale(url: string, locale?: Locale | null): string {
* Input: http://localhost:3000/api/auth/reset-password/token?callbackURL=/auth/reset-password
* Output: http://localhost:3000/api/auth/reset-password/token?callbackURL=/zh/auth/reset-password
*
* 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

View File

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

View File

@ -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": []
}