Merge remote-tracking branch 'origin/main' into cloudflare

This commit is contained in:
javayhu 2025-08-15 23:03:55 +08:00
commit 63dd4e52fb
19 changed files with 208 additions and 132 deletions

View File

@ -2,6 +2,7 @@
import { getDb } from '@/db';
import { user } from '@/db/schema';
import { isDemoWebsite } from '@/lib/demo';
import { asc, desc, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
@ -75,7 +76,8 @@ export const getUsersAction = actionClient
]);
// hide user data in demo website
if (process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true') {
const isDemo = isDemoWebsite();
if (isDemo) {
items = items.map((item) => ({
...item,
name: 'Demo User',

View File

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

View File

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

View File

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

View File

@ -23,6 +23,7 @@ import { Textarea } from '@/components/ui/textarea';
import { useIsMobile } from '@/hooks/use-mobile';
import { authClient } from '@/lib/auth-client';
import type { User } from '@/lib/auth-types';
import { isDemoWebsite } from '@/lib/demo';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { cn } from '@/lib/utils';
@ -53,7 +54,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
// show fake data in demo website
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const isDemo = isDemoWebsite();
const handleBan = async () => {
if (!banReason) {

View File

@ -27,6 +27,7 @@ import {
TableRow,
} from '@/components/ui/table';
import type { User } from '@/lib/auth-types';
import { isDemoWebsite } from '@/lib/demo';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react';
@ -152,7 +153,7 @@ export function UsersTable({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
// show fake data in demo website
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const isDemo = isDemoWebsite();
// Map column IDs to translation keys
const columnIdToTranslationKey = {

View File

@ -7,6 +7,7 @@ import {
} from '@/components/ui/breadcrumb';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { isDemoWebsite } from '@/lib/demo';
import React, { type ReactNode } from 'react';
import { CreditsBalanceButton } from '../layout/credits-balance-button';
import LocaleSwitcher from '../layout/locale-switcher';
@ -30,8 +31,7 @@ export function DashboardHeader({
breadcrumbs,
actions,
}: DashboardHeaderProps) {
// if is demo website, allow user to access admin and user pages, but data is fake
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const isDemo = isDemoWebsite();
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">

View File

@ -19,7 +19,7 @@ interface YoutubeVideoProps {
export const YoutubeVideo = ({
url,
width = 560,
height = 315,
height = 460,
}: YoutubeVideoProps) => {
return (
<div className="my-4">

View File

@ -10,7 +10,6 @@ import { LocaleLink } from '@/i18n/navigation';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import type React from 'react';
import { ThemeSelector } from './theme-selector';
export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
const t = useTranslations();
@ -46,7 +45,7 @@ export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
target="_blank"
rel="noreferrer"
aria-label={link.title}
className="border border-border inline-flex h-8 w-8 items-center
className="border border-border inline-flex h-8 w-8 items-center
justify-center rounded-full hover:bg-accent hover:text-accent-foreground"
>
<span className="sr-only">{link.title}</span>
@ -99,7 +98,6 @@ export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
</span>
<div className="flex items-center gap-x-4">
{/* <ThemeSelector /> */}
<ModeSwitcherHorizontal />
</div>
</Container>

View File

@ -1,10 +1,16 @@
'use client';
import { NewsletterForm } from '@/components/newsletter/newsletter-form';
import { websiteConfig } from '@/config/website';
import { useTranslations } from 'next-intl';
import { HeaderSection } from '../layout/header-section';
export function NewsletterCard() {
// show nothing if newsletter is disabled
if (!websiteConfig.newsletter.enable) {
return null;
}
const t = useTranslations('Newsletter');
return (

View File

@ -20,6 +20,7 @@ import {
FormLabel,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
@ -40,6 +41,11 @@ interface NewsletterFormCardProps {
* Allows users to toggle their newsletter subscription status
*/
export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
// show nothing if newsletter is disabled
if (!websiteConfig.newsletter.enable) {
return null;
}
const t = useTranslations('Dashboard.settings.notification');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>('');

View File

@ -11,6 +11,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { cn } from '@/lib/utils';
import { uploadFileFromBrowser } from '@/storage/client';
@ -27,6 +28,14 @@ interface UpdateAvatarCardProps {
* Update the user's avatar
*/
export function UpdateAvatarCard({ className }: UpdateAvatarCardProps) {
// show nothing if storage is disabled or update avatar is disabled
if (
!websiteConfig.storage.enable ||
!websiteConfig.features.enableUpdateAvatar
) {
return null;
}
const t = useTranslations('Dashboard.settings.profile');
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | undefined>('');

View File

@ -3,6 +3,7 @@
import { Routes } from '@/routes';
import type { NestedMenuItem } from '@/types';
import { useTranslations } from 'next-intl';
import { websiteConfig } from './website';
/**
* Get footer config with translations
@ -41,16 +42,24 @@ export function getFooterLinks(): NestedMenuItem[] {
{
title: t('resources.title'),
items: [
{
title: t('resources.items.blog'),
href: Routes.Blog,
external: false,
},
{
title: t('resources.items.docs'),
href: Routes.Docs,
external: false,
},
...(websiteConfig.blog.enable
? [
{
title: t('resources.items.blog'),
href: Routes.Blog,
external: false,
},
]
: []),
...(websiteConfig.docs.enable
? [
{
title: t('resources.items.docs'),
href: Routes.Docs,
external: false,
},
]
: []),
{
title: t('resources.items.changelog'),
href: Routes.Changelog,

View File

@ -34,6 +34,7 @@ import {
WandSparklesIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { websiteConfig } from './website';
/**
* Get navbar config with translations
@ -59,16 +60,24 @@ export function getNavbarLinks(): NestedMenuItem[] {
href: Routes.Pricing,
external: false,
},
{
title: t('blog.title'),
href: Routes.Blog,
external: false,
},
{
title: t('docs.title'),
href: Routes.Docs,
external: false,
},
...(websiteConfig.blog.enable
? [
{
title: t('blog.title'),
href: Routes.Blog,
external: false,
},
]
: []),
...(websiteConfig.docs.enable
? [
{
title: t('docs.title'),
href: Routes.Docs,
external: false,
},
]
: []),
{
title: t('ai.title'),
items: [

View File

@ -1,5 +1,6 @@
'use client';
import { isDemoWebsite } from '@/lib/demo';
import { Routes } from '@/routes';
import type { NestedMenuItem } from '@/types';
import {
@ -30,7 +31,7 @@ export function getSidebarLinks(): NestedMenuItem[] {
const t = useTranslations('Dashboard');
// if is demo website, allow user to access admin and user pages, but data is fake
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const isDemo = isDemoWebsite();
return [
{

View File

@ -34,12 +34,12 @@ export const websiteConfig: WebsiteConfig = {
},
features: {
enableDiscordWidget: false,
enableCrispChat: process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true',
enableUpgradeCard: true,
enableUpdateAvatar: true,
enableAffonsoAffiliate: false,
enablePromotekitAffiliate: false,
enableDatafastRevenueTrack: false,
enableCrispChat: process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true',
enableTurnstileCaptcha: process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true',
},
routes: {
@ -68,19 +68,25 @@ export const websiteConfig: WebsiteConfig = {
},
},
blog: {
enable: true,
paginationSize: 6,
relatedPostsSize: 3,
},
docs: {
enable: true,
},
mail: {
provider: 'resend',
fromEmail: 'MkSaaS <support@mksaas.com>',
supportEmail: 'MkSaaS <support@mksaas.com>',
},
newsletter: {
enable: true,
provider: 'resend',
autoSubscribeAfterSignUp: true,
},
storage: {
enable: true,
provider: 's3',
},
payment: {

View File

@ -164,7 +164,11 @@ export function getLocaleFromRequest(request?: Request): Locale {
async function onCreateUser(user: User) {
// Auto subscribe user to newsletter after sign up if enabled in website config
// Add a delay to avoid hitting Resend's 1 email per second limit
if (user.email && websiteConfig.newsletter.autoSubscribeAfterSignUp) {
if (
user.email &&
websiteConfig.newsletter.enable &&
websiteConfig.newsletter.autoSubscribeAfterSignUp
) {
// Delay newsletter subscription by 2 seconds to avoid rate limiting
// This ensures the email verification email is sent first
// Using 2 seconds instead of 1 to provide extra buffer for network delays
@ -184,6 +188,7 @@ async function onCreateUser(user: User) {
// Add register gift credits to the user if enabled in website config
if (
websiteConfig.credits.enableCredits &&
websiteConfig.credits.registerGiftCredits.enable &&
websiteConfig.credits.registerGiftCredits.credits > 0
) {
@ -199,21 +204,26 @@ async function onCreateUser(user: User) {
}
// Add free monthly credits to the user if enabled in website config
const pricePlans = await getAllPricePlans();
const freePlan = pricePlans.find((plan) => plan.isFree);
if (
freePlan?.credits?.enable &&
freePlan?.credits?.amount &&
freePlan?.credits?.amount > 0
websiteConfig.credits.enableCredits &&
websiteConfig.credits.enableForFreePlan
) {
try {
await addMonthlyFreeCredits(user.id);
const credits = freePlan.credits.amount;
console.log(
`added free monthly credits for user ${user.id}, credits: ${credits}`
);
} catch (error) {
console.error('Free monthly credits error:', error);
const pricePlans = await getAllPricePlans();
const freePlan = pricePlans.find((plan) => plan.isFree);
if (
freePlan?.credits?.enable &&
freePlan?.credits?.amount &&
freePlan?.credits?.amount > 0
) {
try {
await addMonthlyFreeCredits(user.id);
const credits = freePlan.credits.amount;
console.log(
`added free monthly credits for user ${user.id}, credits: ${credits}`
);
} catch (error) {
console.error('Free monthly credits error:', error);
}
}
}
}

6
src/lib/demo.ts Normal file
View File

@ -0,0 +1,6 @@
/**
* check if the website is a demo website
*/
export function isDemoWebsite() {
return process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
}

11
src/types/index.d.ts vendored
View File

@ -13,6 +13,7 @@ export type WebsiteConfig = {
auth: AuthConfig;
i18n: I18nConfig;
blog: BlogConfig;
docs: DocsConfig;
mail: MailConfig;
newsletter: NewsletterConfig;
storage: StorageConfig;
@ -111,10 +112,18 @@ export interface I18nConfig {
* Blog configuration
*/
export interface BlogConfig {
enable: boolean; // Whether to enable the blog
paginationSize: number; // Number of posts per page
relatedPostsSize: number; // Number of related posts to show
}
/**
* Docs configuration
*/
export interface DocsConfig {
enable: boolean; // Whether to enable the docs
}
/**
* Mail configuration
*/
@ -128,6 +137,7 @@ export interface MailConfig {
* Newsletter configuration
*/
export interface NewsletterConfig {
enable: boolean; // Whether to enable the newsletter
provider: 'resend'; // The newsletter provider, only resend is supported for now
autoSubscribeAfterSignUp?: boolean; // Whether to automatically subscribe users to the newsletter after sign up
}
@ -136,6 +146,7 @@ export interface NewsletterConfig {
* Storage configuration
*/
export interface StorageConfig {
enable: boolean; // Whether to enable the storage
provider: 's3'; // The storage provider, only s3 is supported for now
}