feat: add premium content feature with related components and configuration

This commit is contained in:
javayhu 2025-08-31 01:12:56 +08:00
parent 9aeb59dff2
commit 66d7dd3259
8 changed files with 385 additions and 1 deletions

View File

@ -0,0 +1,67 @@
---
title: "Premium: What is Fumadocs"
description: "Introducing Fumadocs, a docs framework that you can break."
date: "2025-08-30"
published: true
premium: true
categories: ["development", "nextjs"]
author: "fox"
image: "/images/blog/post-1.png"
---
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**.
## Philosophy
**Less Abstraction:** Fumadocs expects you to write code and cooperate with the rest of your software.
While most frameworks are configured with a configuration file, they usually lack flexibility when you hope to tune its details.
You can't control how they render the page nor the internal logic. Fumadocs shows you how the app works, instead of a single configuration file.
**Next.js Fundamentals:** It gives you the utilities and a good-looking UI.
You are still using features of Next.js App Router, like **Static Site Generation**. There is nothing new for Next.js developers, so you can use it with confidence.
<PremiumContent>
**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.
## Why Fumadocs
Fumadocs is designed with flexibility in mind.
You can use `fumadocs-core` as a headless UI library and bring your own styles.
Fumadocs MDX is also a useful library to handle MDX content in Next.js. It also includes:
- Many built-in components.
- Typescript Twoslash, OpenAPI, and Math (KaTeX) integrations.
- Fast and optimized by default, natively built on App Router.
- Tight integration with Next.js, you can add it to an existing Next.js project easily.
You can read [Comparisons](/docs/comparisons) if you're interested.
### Documentation
Fumadocs focuses on **authoring experience**, it provides a beautiful theme and many docs automation tools.
It helps you to iterate your codebase faster while never leaving your docs behind.
You can take this site as an example of docs site built with Fumadocs.
### Blog sites
Since Next.js is already a powerful framework, most features can be implemented with **just Next.js**.
Fumadocs provides additional tooling for Next.js, including syntax highlighting, document search, and a default theme (Fumadocs UI).
It helps you to avoid reinventing the wheels.
## When to use Fumadocs
For most of the web applications, vanilla React.js is no longer enough.
Nowadays, we also wish to have a blog, a showcase page, a FAQ page, etc. With a
fancy UI that's breathtaking, in these cases, Fumadocs can help you build the
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

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

View File

@ -94,6 +94,7 @@ export const blog = defineCollections({
image: z.string(),
date: z.string().date(),
published: z.boolean().default(true),
premium: z.boolean().optional(),
categories: z.array(z.string()),
author: z.string(),
}),

View File

@ -4,6 +4,7 @@ import { formatDate } from '@/lib/formatter';
import { type BlogType, authorSource, categorySource } from '@/lib/source';
import Image from 'next/image';
import BlogImage from './blog-image';
import { PremiumBadge } from './premium-badge';
interface BlogCardProps {
locale: string;
@ -30,6 +31,13 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
title={title || 'image for blog post'}
/>
{/* Premium badge - top right */}
{post.data.premium && (
<div className="absolute top-2 right-2 z-20">
<PremiumBadge size="sm" />
</div>
)}
{/* categories */}
{blogCategories && blogCategories.length > 0 && (
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300 z-20">

View File

@ -0,0 +1,45 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { CrownIcon } from 'lucide-react';
interface PremiumBadgeProps {
className?: string;
variant?: 'default' | 'outline' | 'secondary';
size?: 'sm' | 'default' | 'lg';
}
export function PremiumBadge({
className,
variant = 'default',
size = 'default',
}: PremiumBadgeProps) {
const sizeClasses = {
sm: 'text-xs h-5',
default: 'text-xs h-6',
lg: 'text-sm h-7',
};
const iconSizes = {
sm: 'size-3',
default: 'size-3',
lg: 'size-4',
};
return (
<Badge
variant={variant}
className={cn(
'inline-flex items-center gap-1 font-medium',
'bg-gradient-to-r from-amber-500 to-orange-500',
'text-white border-0 hover:from-amber-600 hover:to-orange-600',
sizeClasses[size],
className
)}
>
<CrownIcon className={iconSizes[size]} />
Premium
</Badge>
);
}

View File

@ -0,0 +1,18 @@
'use client';
import { useCurrentUser } from '@/hooks/use-current-user';
import { useCurrentPlan } from '@/hooks/use-payment';
import type { ReactNode } from 'react';
interface PremiumContentProps {
children: ReactNode;
}
/**
* This component will now rely on server-side filtering
* The <PremiumContent> tags will be removed server-side for non-premium users
* This component only serves as a marker for premium sections
*/
export function PremiumContent({ children }: PremiumContentProps) {
return <div className="premium-content-section">{children}</div>;
}

View File

@ -0,0 +1,230 @@
'use client';
import { LoginWrapper } from '@/components/auth/login-wrapper';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCurrentUser } from '@/hooks/use-current-user';
import { useCurrentPlan } from '@/hooks/use-payment';
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
import {
ArrowRightIcon,
CheckCircleIcon,
CrownIcon,
Loader2Icon,
LockIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import type { ReactNode } from 'react';
interface PremiumGuardProps {
children: ReactNode;
isPremium: boolean;
canAccess?: boolean;
className?: string;
}
export function PremiumGuard({
children,
isPremium,
canAccess,
className,
}: PremiumGuardProps) {
// For non-premium articles, show content immediately with no extra processing
if (!isPremium) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
</div>
);
}
const t = useTranslations('BlogPage');
const pathname = useLocalePathname();
// If server-side check has already determined access, use that
if (canAccess !== undefined) {
// Server has determined the user has access
if (canAccess) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
</div>
);
}
// Server determined no access, show appropriate message
const currentUser = useCurrentUser();
if (!currentUser) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{/* Show partial content before protection */}
{children}
</div>
{/* Enhanced login prompt for server-side blocked content */}
<div className="mt-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">
<LockIcon className="size-8 text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold">
{t('premiumContent.loginRequired')}
</h3>
<p className="text-muted-foreground max-w-md">
{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('premiumContent.signIn')}
</Button>
</LoginWrapper>
</div>
</div>
</div>
</div>
);
}
}
// Fallback to client-side check when server-side check is not available
const currentUser = useCurrentUser();
const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
currentUser?.id
);
// Determine if user has premium access
const hasPremiumAccess =
paymentData?.currentPlan &&
(!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime);
// If user is not logged in
if (!currentUser) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
{/* Enhanced login prompt */}
<div className="mt-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">
<LockIcon className="size-8 text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold">
{t('premiumContent.loginRequired')}
</h3>
<p className="text-muted-foreground max-w-md">
{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('premiumContent.signIn')}
</Button>
</LoginWrapper>
</div>
</div>
</div>
</div>
);
}
// If payment data is still loading
if (isLoadingPayment) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
{isLoadingPayment && (
<div className="mt-8 flex items-center justify-center text-primary font-semibold">
<Loader2Icon className="size-5 animate-spin mr-2" />
<span>{t('premiumContent.checkingAccess')}</span>
</div>
)}
</div>
);
}
// If user doesn't have premium access
if (!hasPremiumAccess) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
{/* Inline subscription banner for logged-in non-members */}
<div className="mt-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">
<div className="p-4 rounded-full bg-primary/10">
<CrownIcon className="size-8 text-primary" />
</div>
</div>
<h3 className="text-xl font-semibold mb-2">
{t('premiumContent.title')}
</h3>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
{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">
{t('premiumContent.upgradeCta')}
<ArrowRightIcon className="ml-2 size-4" />
</LocaleLink>
</Button>
</div>
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-2">
<CheckCircleIcon className="size-4 text-primary" />
{t('premiumContent.benefit1')}
</span>
<span className="flex items-center gap-2">
<CheckCircleIcon className="size-4 text-primary" />
{t('premiumContent.benefit2')}
</span>
<span className="flex items-center gap-2">
<CheckCircleIcon className="size-4 text-primary" />
{t('premiumContent.benefit3')}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
// Show full content for premium users
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
</div>
);
}

View File

@ -1,3 +1,4 @@
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';
@ -23,6 +24,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
...LucideIcons,
// ...((await import('lucide-react')) as unknown as MDXComponents),
YoutubeVideo,
PremiumContent,
Tabs,
Tab,
TypeTable,