Merge pull request #9 from MkSaaSHQ/email

optimize intl and layouts
This commit is contained in:
javayhu 2025-03-08 18:40:53 +08:00 committed by GitHub
commit 711537c732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1022 additions and 2005 deletions

View File

@ -1,4 +1,9 @@
{
"Common": {
"login": "Log in",
"logout": "Log out",
"signUp": "Sign up"
},
"HomePage": {
"title": "next-intl example"
},
@ -19,7 +24,7 @@
"email": "Email",
"password": "Password",
"signIn": "Sign In",
"signUp": "Don't have an account? Sign up",
"signUpHint": "Don't have an account? Sign up",
"forgotPassword": "Forgot Password?",
"signInWithGoogle": "Sign In with Google",
"signInWithGitHub": "Sign In with GitHub",
@ -35,7 +40,7 @@
"email": "Email",
"password": "Password",
"signUp": "Sign Up",
"signIn": "Already have an account? Sign in",
"signInHint": "Already have an account? Sign in",
"checkEmail": "Please check your email inbox"
},
"forgotPassword": {
@ -61,11 +66,121 @@
"BlogPage": {
"title": "Blog",
"subtitle": "Latest news and updates from our team",
"publisher": "Publisher",
"author": "Author",
"categories": "Categories",
"tableOfContents": "Table of Contents",
"all": "All",
"noPostsFound": "No posts found"
"noPostsFound": "No posts found",
"allPosts": "All Posts"
},
"Marketing": {
"menu": {
"features": {
"title": "Features"
},
"pricing": {
"title": "Pricing"
},
"blog": {
"title": "Blog"
},
"ai": {
"title": "AI",
"items": {
"text": {
"title": "AI Text",
"description": "Show how to use AI to write stunning text"
},
"image": {
"title": "AI Image",
"description": "Show how to use AI to generate beautiful images"
},
"video": {
"title": "AI Video",
"description": "Show how to use AI to generate amazing videos"
},
"audio": {
"title": "AI Audio",
"description": "Show how to use AI to generate wonderful audio"
}
}
},
"pages": {
"title": "Pages",
"items": {
"about": {
"title": "About",
"description": "Learn more about our company, mission, and values"
},
"contact": {
"title": "Contact",
"description": "Get in touch with our team for support or inquiries"
},
"waitlist": {
"title": "Waitlist",
"description": "Join our waitlist for latest news and updates"
},
"changelog": {
"title": "Changelog",
"description": "See the latest updates to our products"
},
"roadmap": {
"title": "Roadmap",
"description": "Explore our future plans and upcoming features"
},
"cookiePolicy": {
"title": "Cookie Policy",
"description": "Information about how we use cookies on our website"
},
"privacyPolicy": {
"title": "Privacy Policy",
"description": "Details about how we protect and handle your data"
},
"termsOfService": {
"title": "Terms of Service",
"description": "The legal agreement between you and our company"
}
}
}
},
"footer": {
"product": {
"title": "Product",
"items": {
"features": "Features",
"pricing": "Pricing",
"faq": "FAQ"
}
},
"resources": {
"title": "Resources",
"items": {
"blog": "Blog",
"changelog": "Changelog",
"roadmap": "Roadmap"
}
},
"company": {
"title": "Company",
"items": {
"about": "About",
"contact": "Contact",
"waitlist": "Waitlist"
}
},
"legal": {
"title": "Legal",
"items": {
"cookiePolicy": "Cookie Policy",
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service"
}
}
},
"avatar": {
"dashboard": "Dashboard",
"settings": "Settings"
}
},
"mail": {
"common": {

View File

@ -1,10 +1,15 @@
{
"Common": {
"login": "登录",
"logout": "退出",
"signUp": "注册"
},
"HomePage": {
"title": "next-intl 示例"
},
"NotFoundPage": {
"title": "404",
"message": "抱歉,您正在寻找的页面不存在",
"message": "抱歉,您正在寻找的页面不存在",
"backToHome": "返回首页"
},
"ErrorPage": {
@ -19,7 +24,7 @@
"email": "邮箱",
"password": "密码",
"signIn": "登录",
"signUp": "没有账号?注册",
"signUpHint": "没有账号?注册",
"forgotPassword": "忘记密码?",
"signInWithGoogle": "使用 Google 登录",
"signInWithGitHub": "使用 GitHub 登录",
@ -35,7 +40,7 @@
"email": "邮箱",
"password": "密码",
"signUp": "注册",
"signIn": "已经有账号?登录",
"signInHint": "已经有账号?登录",
"checkEmail": "请检查您的邮箱"
},
"forgotPassword": {
@ -53,7 +58,7 @@
},
"error": {
"title": "哎呀!出错了!",
"tryAgain": "请重试",
"tryAgain": "请重试",
"backToLogin": "返回登录",
"checkEmail": "请检查您的邮箱"
}
@ -61,16 +66,127 @@
"BlogPage": {
"title": "博客",
"subtitle": "来自我们的团队最新新闻和更新",
"publisher": "发布者",
"author": "作者",
"categories": "分类",
"tableOfContents": "目录",
"all": "全部",
"noPostsFound": "没有找到文章"
"noPostsFound": "没有找到文章",
"allPosts": "全部文章"
},
"Marketing": {
"menu": {
"features": {
"title": "功能"
},
"pricing": {
"title": "价格"
},
"blog": {
"title": "博客"
},
"ai": {
"title": "人工智能",
"items": {
"text": {
"title": "AI 文本",
"description": "展示如何使用 AI 生成精彩文本"
},
"image": {
"title": "AI 图像",
"description": "展示如何使用 AI 生成精美图像"
},
"video": {
"title": "AI 视频",
"description": "展示如何使用 AI 生成惊人视频"
},
"audio": {
"title": "AI 音频",
"description": "展示如何使用 AI 生成动听音频"
}
}
},
"pages": {
"title": "演示页面",
"items": {
"about": {
"title": "关于我们",
"description": "了解更多关于我们的公司、使命和价值观"
},
"contact": {
"title": "联系我们",
"description": "与我们的团队联系,以获取支持或咨询"
},
"waitlist": {
"title": "邮件列表",
"description": "加入我们的邮件列表,获取最新消息和更新"
},
"changelog": {
"title": "更新日志",
"description": "查看我们产品的更新历史,查看最新动态"
},
"roadmap": {
"title": "路线图",
"description": "探索我们的未来计划和即将推出的功能"
},
"cookiePolicy": {
"title": "Cookie 政策",
"description": "关于我们如何在网站上使用 Cookie 的信息"
},
"privacyPolicy": {
"title": "隐私政策",
"description": "关于我们将如何保护和处理您在网站上的数据"
},
"termsOfService": {
"title": "服务条款",
"description": "关于您与我们公司之间的法律协议和条款"
}
}
}
},
"footer": {
"product": {
"title": "产品",
"items": {
"features": "功能",
"pricing": "价格",
"faq": "常见问题"
}
},
"resources": {
"title": "资源",
"items": {
"blog": "博客",
"changelog": "更新日志",
"roadmap": "路线图"
}
},
"company": {
"title": "公司",
"items": {
"about": "关于我们",
"contact": "联系我们",
"waitlist": "邮件列表"
}
},
"legal": {
"title": "法律",
"items": {
"cookiePolicy": "Cookie 政策",
"privacyPolicy": "隐私政策",
"termsOfService": "服务条款"
}
}
},
"avatar": {
"dashboard": "工作台",
"settings": "设置",
"logout": "退出"
}
},
"mail": {
"common": {
"team": "{name} 团队",
"copyright": "版权所有 {year}"
"copyright": "版权所有 {year} All Rights Reserved."
},
"verifyEmail": {
"title": "你好, {name}.",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@ -27,19 +27,19 @@ export default async function HomePage(props: HomePageProps) {
{/* <Features /> */}
<FeaturesSection />
{/* <FeaturesSection /> */}
<ContentSection />
{/* <ContentSection /> */}
<Pricing />
<FAQs />
{/* <FAQs />
<WallOfLoveSection />
<WallOfLoveSection /> */}
<StatsSection />
{/* <StatsSection />
<CallToAction />
<CallToAction /> */}
</div>
</>
);

View File

@ -1,16 +1,17 @@
import AllPostsButton from '@/components/blog/all-posts-button';
import { BlogToc } from '@/components/blog/blog-toc';
import { Mdx } from '@/components/marketing/blog/mdx-component';
import { Mdx } from '@/components/shared/mdx-component';
import { LocaleLink } from '@/i18n/navigation';
import { getTableOfContents } from '@/lib/toc';
import { getBaseUrl } from '@/lib/urls/get-base-url';
import { getLocaleDate } from '@/lib/utils';
import { estimateReadingTime, getLocaleDate } from '@/lib/utils';
import type { NextPageProps } from '@/types/next-page-props';
import { allPosts } from 'content-collections';
import { CalendarIcon, ClockIcon } from 'lucide-react';
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import '@/styles/mdx.css';
@ -35,28 +36,28 @@ async function getBlogPostFromParams(props: NextPageProps) {
if (!params) {
return null;
}
const locale = params.locale as string;
const slug =
(Array.isArray(params.slug) ? params.slug?.join('/') : params.slug) || '';
// Find post with matching slug and locale
const post = allPosts.find(
(post) =>
(post.slugAsParams === slug || (!slug && post.slugAsParams === 'index')) &&
(post) =>
(post.slugAsParams === slug || (!slug && post.slugAsParams === 'index')) &&
post.locale === locale
);
if (!post) {
// If no post found with the current locale, try to find one with the default locale
const defaultPost = allPosts.find(
(post) =>
(post) =>
(post.slugAsParams === slug || (!slug && post.slugAsParams === 'index'))
);
return defaultPost;
}
return post;
}
@ -113,6 +114,18 @@ export default async function BlogPostPage(props: NextPageProps) {
)}
</div>
{/* blog post date */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{date}</p>
</div>
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{estimateReadingTime(post.body.raw)}</p>
</div>
</div>
{/* blog post title */}
<h1 className="text-3xl font-bold">{post.title}</h1>
@ -135,9 +148,9 @@ export default async function BlogPostPage(props: NextPageProps) {
<div className="space-y-4 lg:sticky lg:top-24">
{/* author info */}
<div className="bg-muted/50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t("publisher")}</h2>
<h2 className="text-lg font-semibold mb-4">{t("author")}</h2>
<div className="flex items-center gap-4">
<div className="relative h-12 w-12 flex-shrink-0">
<div className="relative h-8 w-8 flex-shrink-0">
{post.author?.avatar && (
<Image
src={post.author.avatar}
@ -147,11 +160,7 @@ export default async function BlogPostPage(props: NextPageProps) {
/>
)}
</div>
<div>
<span>{post.author?.name}</span>
<p className="text-sm text-muted-foreground">{date}</p>
</div>
<span className="line-clamp-1">{post.author?.name}</span>
</div>
</div>
@ -164,7 +173,7 @@ export default async function BlogPostPage(props: NextPageProps) {
<li key={category.slug}>
<LocaleLink
href={`/blog/category/${category.slug}`}
className="text-sm link-underline"
className="text-sm link-underline-animation"
>
{category.name}
</LocaleLink>

View File

@ -1,11 +1,10 @@
import { marketingConfig } from "@/config/marketing";
import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/marketing/navbar";
import { Navbar } from "@/components/layout/navbar";
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col min-h-screen">
<Navbar scroll={true} config={marketingConfig} />
<Navbar scroll={true} />
<main className="flex-1">{children}</main>
<Footer />
</div>

View File

@ -1,8 +1,5 @@
import { fontSourceSans, fontSourceSerif4 } from "@/assets/fonts";
import { Footer } from '@/components/layout/footer';
import { Navbar } from '@/components/marketing/navbar';
import { TailwindIndicator } from '@/components/tailwind-indicator';
import { marketingConfig } from '@/config/marketing';
import { routing } from '@/i18n/routing';
import { cn } from "@/lib/utils";
import { GeistMono } from "geist/font/mono";

View File

@ -78,7 +78,7 @@ export const LoginForm = ({ className }: { className?: string }) => {
return (
<AuthCard
headerLabel={t("welcomeBack")}
bottomButtonLabel={t("signUp")}
bottomButtonLabel={t("signUpHint")}
bottomButtonHref={`${Routes.Register}`}
showSocialLoginButton
className={cn("border-none", className)}

View File

@ -79,7 +79,7 @@ export const RegisterForm = () => {
return (
<AuthCard
headerLabel={t("createAccount")}
bottomButtonLabel={t("signIn")}
bottomButtonLabel={t("signInHint")}
bottomButtonHref={`${Routes.Login}`}
showSocialLoginButton
className="border-none"

View File

@ -1,10 +1,12 @@
"use client";
import { Button } from "@/components/ui/button";
import { ArrowLeftIcon } from "lucide-react";
import { LocaleLink } from "@/i18n/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export default function AllPostsButton() {
const t = useTranslations("BlogPage");
return (
<Button
size="lg"
@ -13,11 +15,10 @@ export default function AllPostsButton() {
asChild
>
<LocaleLink href="/blog">
<ArrowLeftIcon
className="w-5 h-5
transition-transform duration-200 group-hover:-translate-x-1"
<ArrowLeftIcon className="w-5 h-5
transition-transform duration-200 group-hover:-translate-x-1"
/>
<span>All Posts</span>
<span>{t("allPosts")}</span>
</LocaleLink>
</Button>
);

View File

@ -1,12 +1,19 @@
"use client";
import FilterItemMobile from "@/components/shared/filter-item-mobile";
import {
Drawer,
DrawerContent,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Category } from "content-collections";
import { LayoutListIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import { useState } from "react";
import { Drawer } from "vaul";
import FilterItemMobile from "@/components/shared/filter-item-mobile";
import { useTranslations } from "next-intl";
export type BlogCategoryListMobileProps = {
categoryList: Category[];
@ -27,10 +34,10 @@ export function BlogCategoryListMobile({
};
return (
<Drawer.Root open={open} onClose={closeDrawer}>
<Drawer.Trigger
<Drawer open={open} onClose={closeDrawer}>
<DrawerTrigger
onClick={() => setOpen(true)}
className="flex items-center w-full p-3 border-y text-foreground/90"
className="flex items-center w-full p-4 border-y text-foreground/90"
>
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
@ -41,19 +48,12 @@ export function BlogCategoryListMobile({
{selectedCategory?.name ? `${selectedCategory?.name}` : t("all")}
</span>
</div>
</Drawer.Trigger>
<Drawer.Overlay
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm"
onClick={closeDrawer}
/>
<Drawer.Portal>
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 overflow-hidden rounded-t-[10px] border bg-background">
<Drawer.Title className="sr-only">{t("categories")}</Drawer.Title>
<div className="sticky top-0 z-20 flex w-full items-center justify-center bg-inherit">
<div className="my-3 h-1.5 w-16 rounded-full bg-muted-foreground/20" />
</div>
<ul className="mb-14 w-full p-3 text-muted-foreground">
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay className="fixed inset-0 z-40 bg-background/50" />
<DrawerContent className="fixed inset-x-0 bottom-0 z-50 mt-24 overflow-hidden rounded-t-[10px] border bg-background">
<DrawerTitle className="sr-only">{t("categories")}</DrawerTitle>
<ul className="mb-14 w-full p-4 text-muted-foreground">
<FilterItemMobile
title={t("all")}
href="/blog"
@ -71,9 +71,8 @@ export function BlogCategoryListMobile({
/>
))}
</ul>
</Drawer.Content>
<Drawer.Overlay />
</Drawer.Portal>
</Drawer.Root>
</DrawerContent>
</DrawerPortal>
</Drawer>
);
}

View File

@ -1,78 +0,0 @@
import * as React from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
import { Mdx } from '@/components/marketing/blog/mdx-component';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { getInitials } from '@/lib/utils';
import { Post } from 'content-collections';
type BlogPostProps = {
post: Post;
};
export function BlogPost({ post }: BlogPostProps): React.JSX.Element {
console.log(post);
console.log(post.author);
return (
<div className="border-b">
<div className="container mx-auto flex max-w-3xl flex-col space-y-4 py-20">
<div className="mx-auto w-full min-w-0">
<Link
href="/blog"
className="group mb-12 flex items-center space-x-1 text-base leading-none text-foreground duration-200"
>
<span className="transition-transform group-hover:-translate-x-0.5">
</span>
<span>All posts</span>
</Link>
<div className="space-y-8">
<div className="flex flex-row items-center justify-between gap-4 text-base text-muted-foreground">
<span className="flex flex-row items-center gap-2">
{post.categories.map((c) => c?.name).join(', ')}
</span>
<span className="flex flex-row items-center gap-2">
<time dateTime={post.date}>
{format(post.date, 'dd MMM yyyy')}
</time>
</span>
</div>
<h1 className="font-heading text-3xl font-semibold tracking-tighter xl:text-5xl">
{post.title}
</h1>
<p className="text-lg text-muted-foreground">{post.description}</p>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<Avatar className="relative size-7 flex-none rounded-full">
<AvatarImage
src={post.author?.avatar}
alt="avatar"
/>
<AvatarFallback className="size-7 text-[10px]">
{getInitials(post.author?.name ?? '')}
</AvatarFallback>
</Avatar>
<span>{post.author?.name ?? ''}</span>
</div>
<div>{estimateReadingTime(post.body.raw)}</div>
</div>
</div>
</div>
</div>
<Separator />
<div className="container mx-auto flex max-w-3xl py-20">
<Mdx code={post.body.code} />
</div>
</div>
);
}
function estimateReadingTime(
text: string,
wordsPerMinute: number = 250
): string {
const words = text.trim().split(/\s+/).length;
const minutes = Math.ceil(words / wordsPerMinute);
return minutes === 1 ? '1 minute read' : `${minutes} minutes read`;
}

View File

@ -1,78 +0,0 @@
import * as React from 'react';
import Link from 'next/link';
import { allPosts } from 'content-collections';
import { format, isBefore } from 'date-fns';
import { ArrowRightIcon } from 'lucide-react';
import { GridSection } from '@/components/marketing/fragments/grid-section';
import { SiteHeading } from '@/components/marketing/fragments/site-heading';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { FillRemainingSpace } from '@/components/fill-remaining-space';
import { getBaseUrl } from '@/lib/urls/get-base-url';
import { getInitials } from '@/lib/utils';
import { useLocale } from 'next-intl';
export function BlogPosts(): React.JSX.Element {
const locale = useLocale();
// Filter posts by current locale
const localizedPosts = allPosts
.filter(post => post.published && post.locale === locale)
.slice()
.sort((a, b) => (isBefore(a.date, b.date) ? 1 : -1));
return (
<GridSection>
<div className="container space-y-20 py-20">
<SiteHeading
badge="Blog Posts"
title="Insights & News"
description="Learn more about our products and the latest news."
/>
<div className="grid gap-x-12 gap-y-6 divide-y md:grid-cols-2 md:gap-x-6 md:divide-none xl:grid-cols-3">
{localizedPosts.map((post, index) => (
<Link
key={index}
href={`${getBaseUrl()}${post.slug}`}
className="md:duration-2000 flex h-full flex-col space-y-4 text-clip border-dashed py-6 md:rounded-2xl md:px-6 md:shadow md:transition-shadow md:hover:shadow-xl dark:md:bg-accent/40 dark:md:hover:bg-accent"
>
<div className="flex flex-row items-center justify-between text-muted-foreground">
<span className="text-sm">{post.categories.map((c) => c?.name).join(', ')}</span>
<time
className="text-sm"
dateTime={post.date}
>
{format(post.date, 'dd MMM yyyy')}
</time>
</div>
<h2 className="text-lg font-semibold md:mb-4 md:text-xl lg:mb-6">
{post.title}
</h2>
<p className="line-clamp-3 text-muted-foreground md:mb-4 lg:mb-6">
{post.description}
</p>
<FillRemainingSpace />
<div className="flex flex-1 shrink flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<Avatar className="relative size-7 flex-none rounded-full">
<AvatarImage
src={post.author?.avatar}
alt="avatar"
/>
<AvatarFallback className="size-7 text-[10px]">
{getInitials(post.author?.name ?? '')}
</AvatarFallback>
</Avatar>
<span className="text-sm">{post.author?.name ?? ''}</span>
</div>
<div className="group flex items-center gap-2 text-sm duration-200 hover:underline">
Read more
<ArrowRightIcon className="size-4 shrink-0 transition-transform group-hover:translate-x-0.5" />
</div>
</div>
</Link>
))}
</div>
</div>
</GridSection>
);
}

View File

@ -1,20 +1,21 @@
"use client";
import { Icons } from "@/components/icons/icons";
import { footerConfig } from "@/config/footer";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
import { useTheme } from "next-themes";
import Link from "next/link";
import type * as React from "react";
import Container from "@/components/container";
import { ThemeSwitcherHorizontal } from "@/components/layout/theme-switcher-horizontal";
import { Logo } from "@/components/logo";
import BuiltWithButton from "@/components/shared/built-with-button";
import { ThemeSwitcherHorizontal } from "@/components/layout/theme-switcher-horizontal";
import { createTranslator, getFooterLinks, SOCIAL_LINKS } from "@/config/marketing";
import { siteConfig } from "@/config/site";
import { LocaleLink } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import React from "react";
export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
const { theme } = useTheme();
const t = useTranslations();
const translator = createTranslator(t);
const footerLinks = getFooterLinks(translator);
return (
<footer className={cn("border-t", className)}>
<Container className="px-4">
@ -35,61 +36,19 @@ export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
{/* social links */}
<div className="flex items-center gap-4 py-2">
<div className="flex items-center gap-2">
{siteConfig.mail && (
<Link
href={`mailto:${siteConfig.mail}`}
{SOCIAL_LINKS.map((link) => (
<a
key={link.title}
href={link.href || "#"}
target="_blank"
rel="noreferrer"
aria-label="Email"
aria-label={link.title}
className="border border-border inline-flex h-8 w-8 items-center justify-center rounded-full hover:bg-accent hover:text-accent-foreground"
>
<Icons.email className="size-4" aria-hidden="true" />
</Link>
)}
{siteConfig.links.github && (
<Link
href={siteConfig.links.github}
target="_blank"
rel="noreferrer"
aria-label="GitHub"
className="border border-border inline-flex h-8 w-8 items-center justify-center rounded-full hover:bg-accent hover:text-accent-foreground"
>
<Icons.github className="size-4" aria-hidden="true" />
</Link>
)}
{siteConfig.links.twitter && (
<Link
href={siteConfig.links.twitter}
target="_blank"
rel="noreferrer"
aria-label="Twitter"
className="border border-border inline-flex h-8 w-8 items-center justify-center rounded-full hover:bg-accent hover:text-accent-foreground"
>
<Icons.twitter className="size-4" aria-hidden="true" />
</Link>
)}
{siteConfig.links.bluesky && (
<Link
href={siteConfig.links.bluesky}
target="_blank"
rel="noreferrer"
aria-label="Bluesky"
className="border border-border inline-flex h-8 w-8 items-center justify-center rounded-full hover:bg-accent hover:text-accent-foreground"
>
<Icons.bluesky className="size-4" aria-hidden="true" />
</Link>
)}
{siteConfig.links.youtube && (
<Link
href={siteConfig.links.youtube}
target="_blank"
rel="noreferrer"
aria-label="YouTube"
className="border border-border inline-flex h-8 w-8 items-center justify-center rounded-full hover:bg-accent hover:text-accent-foreground"
>
<Icons.youtube className="size-4" aria-hidden="true" />
</Link>
)}
<span className="sr-only">{link.title}</span>
{link.icon ? link.icon : null}
</a>
))}
</div>
</div>
@ -98,7 +57,8 @@ export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
</div>
</div>
{footerConfig.links.map((section) => (
{/* footer links */}
{footerLinks.map((section) => (
<div
key={section.title}
className="col-span-1 md:col-span-1 items-start"
@ -108,16 +68,16 @@ export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
</span>
<ul className="mt-4 list-inside space-y-3">
{section.items?.map(
(link) =>
link.href && (
<li key={link.title}>
<Link
href={link.href}
target={link.external ? "_blank" : undefined}
(item) =>
item.href && (
<li key={item.title}>
<LocaleLink
href={item.href || "#"}
target={item.external ? "_blank" : undefined}
className="text-sm text-muted-foreground hover:text-primary"
>
{link.title}
</Link>
{item.title}
</LocaleLink>
</li>
),
)}
@ -130,7 +90,7 @@ export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
<div className="border-t py-8">
<Container className="px-4 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
&copy; {new Date().getFullYear()} All Rights Reserved.
&copy; {new Date().getFullYear()} {siteConfig.name} All Rights Reserved.
</span>
<ThemeSwitcherHorizontal />

View File

@ -74,7 +74,7 @@ export default function LocaleSelector() {
{routing.locales.map((cur) => (
<SelectItem key={cur} value={cur} className="cursor-pointer flex items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-lg">{LOCALE_LIST[cur].flag}</span>
<span className="text-md">{LOCALE_LIST[cur].flag}</span>
<span>{LOCALE_LIST[cur].name}</span>
</div>
</SelectItem>

View File

@ -1,25 +1,27 @@
'use client';
import LocaleSelector from '@/components/layout/locale-selector';
import { ThemeSwitcherHorizontal } from '@/components/layout/theme-switcher-horizontal';
import { Logo } from '@/components/logo';
import { DOCS_LINKS, MENU_LINKS } from '@/components/marketing/marketing-links';
import { Button, buttonVariants } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible';
import { createTranslator, getMenuLinks } from '@/config/marketing';
import { siteConfig } from '@/config/site';
import { Routes } from '@/routes';
import { LocaleLink } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { Portal } from '@radix-ui/react-portal';
import { ArrowUpRightIcon, ChevronDown, ChevronUp, MenuIcon, X } from 'lucide-react';
import Link from 'next/link';
import { ArrowUpRightIcon, ChevronDownIcon, ChevronUpIcon, MenuIcon, XIcon } from 'lucide-react';
import { useTranslations } from "next-intl";
import { usePathname } from 'next/navigation';
import * as React from 'react';
import { RemoveScroll } from 'react-remove-scroll';
import { ThemeSwitcherHorizontal } from '@/components/layout/theme-switcher-horizontal';
import LocaleSelector from '@/components/layout/locale-selector';
import { LocaleLink } from '@/i18n/navigation';
import { UserButton } from './user-button';
export function NavbarMobile({
className,
@ -27,7 +29,8 @@ export function NavbarMobile({
}: React.HTMLAttributes<HTMLDivElement>) {
const [open, setOpen] = React.useState<boolean>(false);
const pathname = usePathname();
const isDocs = pathname.startsWith('/docs');
const { data: session, error } = authClient.useSession();
const user = session?.user;
React.useEffect(() => {
const handleRouteChangeStart = () => {
@ -70,31 +73,32 @@ export function NavbarMobile({
</LocaleLink>
{/* navbar right shows menu icon */}
<Button
variant="ghost"
size="icon"
aria-expanded={open}
aria-label="Toggle Mobile Menu"
onClick={handleToggleMobileMenu}
className="flex aspect-square h-fit select-none items-center justify-center rounded-md border"
>
{open ? (
<X className="size-8" />
) : (
<MenuIcon className="size-8" />
)}
</Button>
<div className="flex items-center gap-4">
{/* show user button if user is logged in */}
{user ? <UserButton /> : null}
<Button
variant="ghost"
size="icon"
aria-expanded={open}
aria-label="Toggle Mobile Menu"
onClick={handleToggleMobileMenu}
className="flex aspect-square h-fit select-none items-center justify-center rounded-md border"
>
{open ? (
<XIcon className="size-8" />
) : (
<MenuIcon className="size-8" />
)}
</Button>
</div>
</div>
{/* mobile menu */}
{open && (
<Portal asChild>
<RemoveScroll allowPinchZoom enabled>
{isDocs ? (
<DocsMobileMenu onLinkClicked={handleToggleMobileMenu} />
) : (
<MainMobileMenu onLinkClicked={handleToggleMobileMenu} />
)}
<MainMobileMenu onLinkClicked={handleToggleMobileMenu} />
</RemoveScroll>
</Portal>
)}
@ -108,6 +112,10 @@ interface MainMobileMenuProps {
function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const t = useTranslations();
const translator = createTranslator(t);
const menuLinks = getMenuLinks(translator);
const commonTranslations = useTranslations("Common");
return (
<div className="fixed inset-0 z-50 mt-[72px] overflow-y-auto bg-background backdrop-blur-md animate-in fade-in-0">
<div className="flex size-full flex-col items-start space-y-4 p-4">
@ -124,7 +132,7 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
'w-full'
)}
>
Log in
{commonTranslations("login")}
</LocaleLink>
<LocaleLink
href={Routes.Register}
@ -137,13 +145,13 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
)}
onClick={onLinkClicked}
>
Sign up
{commonTranslations("signUp")}
</LocaleLink>
</div>
{/* main menu */}
<ul className="w-full">
{MENU_LINKS.map((item) => (
{menuLinks.map((item) => (
<li key={item.title} className="py-2">
{item.items ? (
<Collapsible
@ -165,17 +173,17 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
{item.title}
</span>
{expanded[item.title.toLowerCase()] ? (
<ChevronUp className="size-4" />
<ChevronUpIcon className="size-4" />
) : (
<ChevronDown className="size-4" />
<ChevronDownIcon className="size-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-2 pl-4">
<ul className="mt-2 pl-4 space-y-2">
{item.items.map((subItem) => (
<li key={subItem.title}>
<Link
<LocaleLink
href={subItem.href || '#'}
target={subItem.external ? '_blank' : undefined}
rel={
@ -190,7 +198,7 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
onClick={onLinkClicked}
>
<div className="flex size-8 shrink-0 items-center justify-center text-muted-foreground transition-colors group-hover:text-foreground">
{subItem.icon}
{subItem.icon ? subItem.icon : null}
</div>
<div className="flex-1">
<span className="text-sm font-medium">
@ -205,14 +213,14 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
{subItem.external && (
<ArrowUpRightIcon className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</Link>
</LocaleLink>
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
) : (
<Link
<LocaleLink
href={item.href || '#'}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
@ -223,7 +231,7 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
onClick={onLinkClicked}
>
<span className="text-base">{item.title}</span>
</Link>
</LocaleLink>
)}
</li>
))}
@ -238,77 +246,3 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
</div>
);
}
interface DocsMobileMenuProps {
onLinkClicked: () => void;
};
function DocsMobileMenu({
onLinkClicked
}: DocsMobileMenuProps): React.JSX.Element {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
return (
<div className="fixed inset-0 z-50 mt-[69px] overflow-y-auto bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/60 animate-in fade-in-0">
<div className="flex size-full flex-col items-start space-y-3 p-4">
<ul className="w-full">
{DOCS_LINKS.map((item) => (
<li
key={item.title}
className="py-2"
>
<Collapsible
open={expanded[item.title.toLowerCase()]}
onOpenChange={(isOpen) =>
setExpanded((prev) => ({
...prev,
[item.title.toLowerCase()]: isOpen
}))
}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="flex h-9 w-full items-center justify-between px-4 text-left"
>
<div className="flex flex-row items-center gap-2 text-base font-medium">
{item.icon}
{item.title}
</div>
{expanded[item.title.toLowerCase()] ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-2 pl-4">
{item.items.map((subItem) => (
<li key={subItem.title}>
<Link
href={subItem.href || '#'}
className={cn(
buttonVariants({ variant: 'ghost' }),
'm-0 h-auto w-full justify-start gap-4 p-1.5 text-sm font-medium'
)}
onClick={onLinkClicked}
>
{subItem.title}
</Link>
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
</li>
))}
</ul>
<div className="flex w-full items-center justify-between gap-2 border-y border-border/40 py-4">
<LocaleSelector />
<ThemeSwitcherHorizontal />
</div>
</div>
</div>
);
}

View File

@ -1,11 +1,13 @@
"use client";
'use client';
import { LoginWrapper } from "@/components/auth/login-button";
import Container from "@/components/container";
import { Icons } from "@/components/icons/icons";
import { UserButton } from "@/components/layout/user-button";
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import { LoginWrapper } from '@/components/auth/login-button';
import Container from '@/components/container';
import LocaleSelector from '@/components/layout/locale-selector';
import { NavbarMobile } from '@/components/layout/navbar-mobile';
import { ThemeSwitcher } from '@/components/layout/theme-switcher';
import { UserButton } from '@/components/layout/user-button';
import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button';
import {
NavigationMenu,
NavigationMenuContent,
@ -13,274 +15,182 @@ import {
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { siteConfig } from "@/config/site";
navigationMenuTriggerStyle
} from '@/components/ui/navigation-menu';
import { createTranslator, getMenuLinks } from '@/config/marketing';
import { siteConfig } from '@/config/site';
import { useScroll } from "@/hooks/use-scroll";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import type { DashboardConfig, MarketingConfig, NestedNavItem } from "@/types";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@radix-ui/react-accordion";
import { MenuIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { LocaleLink } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { ArrowUpRightIcon } from 'lucide-react';
import { useTranslations } from "next-intl";
import { usePathname } from 'next/navigation';
interface NavBarProps {
scroll?: boolean;
config: DashboardConfig | MarketingConfig;
}
export function Navbar({ scroll = false, config }: NavBarProps) {
const customNavigationMenuTriggerStyle = cn(
navigationMenuTriggerStyle(),
"bg-transparent hover:bg-transparent hover:text-primary focus:bg-transparent focus:text-primary data-[active]:bg-transparent data-[active]:text-primary data-[state=open]:bg-transparent data-[state=open]:text-primary relative data-[active]:font-bold dark:text-gray-400 dark:hover:text-gray-300 dark:data-[active]:text-white"
);
export function Navbar({ scroll }: NavBarProps) {
const scrolled = useScroll(50);
const { data: session, error } = authClient.useSession();
const user = session?.user;
console.log(`Navbar, user:`, user);
const t = useTranslations();
const translator = createTranslator(t);
const menuLinks = getMenuLinks(translator);
const commonTranslations = useTranslations("Common");
// console.log(`Navbar, user:`, user);
const pathname = usePathname();
// console.log(`Navbar, pathname: ${pathname}`);
const menus = config.menus;
const isMenuActive = (href: string) => {
if (href === "/") {
return pathname === "/";
}
// console.log(`Navbar, href: ${href}, pathname: ${pathname}`);
return pathname.startsWith(href);
};
const [open, setOpen] = useState(false);
// prevent body scroll when modal is open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "auto";
}
}, [open]);
return (
<div className="sticky top-0 z-40 w-full">
{/* Desktop View */}
<header
className={cn(
"hidden md:flex justify-center bg-background/60 backdrop-blur-xl transition-all",
scroll ? (scrolled ? "border-b" : "bg-transparent") : "border-b",
)}
>
<Container className="flex h-16 items-center px-4">
{/* navbar left show logo and links */}
<div className="flex items-center gap-6 md:gap-10">
{/* logo */}
<a href="/" className="flex items-center space-x-2">
<section className={cn(
"sticky inset-x-0 top-0 z-40 py-4 transition-all duration-300",
scroll ? (
scrolled
? "bg-background/80 backdrop-blur-md border-b supports-[backdrop-filter]:bg-background/60"
: "bg-background"
) : "border-b bg-background"
)}>
<Container className="px-4">
{/* desktop navbar */}
<nav className="hidden lg:flex">
{/* logo and name */}
<div className="flex items-center">
<LocaleLink href="/" className="flex items-center space-x-2">
<Logo />
<span className="text-xl font-bold">{siteConfig.name}</span>
</a>
<span className="text-xl font-semibold">{siteConfig.name}</span>
</LocaleLink>
</div>
{/* links */}
<div className="flex-1 flex justify-center">
{menus && menus.length > 0 ? (
<NavigationMenu>
<NavigationMenuList>
{menus.map((item) => renderMenuItem(item))}
</NavigationMenuList>
</NavigationMenu>
) : null}
{/* menu links */}
<div className="flex-1 flex items-center justify-center space-x-2">
<NavigationMenu className="relative">
<NavigationMenuList className="flex items-center">
{menuLinks.map((item, index) =>
item.items ? (
<NavigationMenuItem key={index} className="relative">
<NavigationMenuTrigger
data-active={
item.items.some((subItem) =>
subItem.href && pathname.startsWith(subItem.href)
) ? "true" : undefined
}
className={cn(
customNavigationMenuTriggerStyle,
"data-[active]:text-primary data-[active]:font-bold dark:data-[active]:text-white"
)}
>
{item.title}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] gap-4 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
{item.items.map((subItem, subIndex) => (
<li key={subIndex}>
<NavigationMenuLink asChild>
<LocaleLink
href={subItem.href || '#'}
target={subItem.external ? '_blank' : undefined}
rel={
subItem.external
? 'noopener noreferrer'
: undefined
}
className="group flex select-none flex-row items-center gap-4 rounded-md p-2 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
>
<div className="flex size-8 shrink-0 items-center justify-center text-muted-foreground transition-colors group-hover:text-foreground">
{subItem.icon ? subItem.icon : null}
</div>
<div className="flex-1">
<div className="text-sm font-medium">
{subItem.title}
</div>
{subItem.description && (
<div className="text-sm text-muted-foreground">
{subItem.description}
</div>
)}
</div>
{subItem.external && (
<ArrowUpRightIcon className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</LocaleLink>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
) : (
<NavigationMenuItem key={index}>
<NavigationMenuLink
asChild
active={item.href && pathname.startsWith(item.href) ? true : undefined}
className={cn(
customNavigationMenuTriggerStyle,
"data-[active]:text-primary data-[active]:font-bold dark:data-[active]:text-white"
)}
>
<LocaleLink
href={item.href || '#'}
target={item.external ? '_blank' : undefined}
rel={
item.external ? 'noopener noreferrer' : undefined
}
>
{item.title}
</LocaleLink>
</NavigationMenuLink>
</NavigationMenuItem>
)
)}
</NavigationMenuList>
</NavigationMenu>
</div>
{/* navbar right show sign in or account */}
{/* navbar right show sign in or user */}
<div className="flex items-center gap-x-4">
{user ? (
<div className="flex items-center">
<UserButton />
</div>
) : (
<LoginWrapper mode="modal" asChild>
<Button
className="flex gap-2"
variant="default"
size="sm"
>
<span>Login</span>
{/* <ArrowRightIcon className="size-4" /> */}
</Button>
</LoginWrapper>
)}
</div>
</Container>
</header>
{/* Mobile View */}
<header className="md:hidden flex justify-center bg-background/60 backdrop-blur-xl transition-all">
<div className="w-full px-4 h-16 flex items-center justify-between">
{/* mobile navbar left show menu icon when closed & show sheet when menu is open */}
<div className="flex items-center gap-x-4">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-9 shrink-0"
>
<MenuIcon className="size-5" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="overflow-y-auto">
<SheetHeader>
<SheetTitle>
<a href="/"
className="flex items-center space-x-2"
onClick={() => setOpen(false)}
>
<Logo />
<span className="text-xl font-bold">{siteConfig.name}</span>
</a>
</SheetTitle>
</SheetHeader>
<div className="my-6 flex flex-col gap-6">
<Accordion
type="single"
collapsible
className="flex w-full flex-col gap-4"
<div className="flex items-center gap-x-4">
<LoginWrapper mode="modal" asChild>
<Button
variant="outline"
size="sm"
>
{menus.map((item) => renderMobileMenuItem(item))}
</Accordion>
</div>
</SheetContent>
</Sheet>
{commonTranslations("login")}
</Button>
</LoginWrapper>
{/* logo */}
<a href="/"
className="flex items-center space-x-2"
onClick={() => setOpen(false)}
>
<Logo className="size-8" />
<span className="text-xl font-bold">{siteConfig.name}</span>
</a>
</div>
{/* mobile navbar right show sign in or user button */}
<div className="flex items-center gap-x-4">
{user ? (
<div className="flex items-center">
<UserButton />
</div>
) : (
<LoginWrapper mode="redirect" asChild>
<Button
className="flex gap-2"
variant="default"
size="sm"
asChild
>
<span>Login</span>
{/* <ArrowRightIcon className="size-4" /> */}
<LocaleLink href={Routes.Register}>
{commonTranslations("signUp")}
</LocaleLink>
</Button>
</LoginWrapper>
</div>
)}
<ThemeSwitcher />
<LocaleSelector />
</div>
</div>
</header>
</div>
</nav>
{/* mobile navbar */}
<NavbarMobile className="lg:hidden" />
</Container>
</section>
);
}
const renderMenuItem = (item: NestedNavItem) => {
if (item.items) {
return (
<NavigationMenuItem key={item.title} className="text-muted-foreground">
<NavigationMenuTrigger className="text-base">
{item.title}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="w-80 p-4">
<NavigationMenuLink>
{item.items.map((subItem) => {
const CustomMenuIcon = Icons[subItem.icon || "arrowRight"];
return (
<li key={subItem.title}>
<a
className="flex items-center select-none gap-4 rounded-md p-4 leading-none no-underline outline-none transition-colors hover:bg-muted hover:text-accent-foreground"
href={subItem.href}
>
{subItem.icon && <CustomMenuIcon className="size-4 shrink-0" />}
<div>
<div className="text-base text-foreground/60 hover:text-foreground">
{subItem.title}
</div>
</div>
</a>
</li>
);
})}
</NavigationMenuLink>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
);
}
return (
<NavigationMenuItem key={item.title}>
<Link
href={item.disabled ? "#" : item.href || "#"}
target={item.external ? "_blank" : ""}
className={cn(
navigationMenuTriggerStyle(),
"px-4 bg-transparent focus:bg-transparent text-base",
"text-foreground/60 hover:text-foreground",
item.disabled && "cursor-not-allowed opacity-80"
)}
>
{item.title}
</Link>
</NavigationMenuItem>
);
};
const renderMobileMenuItem = (item: NestedNavItem) => {
console.log(`renderMobileMenuItem, item:`, item, `, items:`, item.items);
if (item.items) {
return (
<AccordionItem key={item.title} value={item.title} className="border-b-0">
<AccordionTrigger className="py-0 hover:no-underline">
{item.title}
</AccordionTrigger>
<AccordionContent className="mt-2">
{item.items.map((subItem) => {
const CustomMenuIcon = Icons[subItem.icon || "arrowRight"];
return (
<Link
key={subItem.title}
className="flex select-none gap-4 rounded-md p-3 leading-none outline-none transition-colors hover:bg-muted hover:text-accent-foreground"
href={subItem.disabled ? "#" : subItem.href}
>
{subItem.icon && <CustomMenuIcon className="size-4 shrink-0" />}
<div>
<div className="text-sm font-semibold">{subItem.title}</div>
</div>
</Link>
);
})}
</AccordionContent>
</AccordionItem>
);
}
const CustomMenuIcon = Icons[item.icon || "arrowRight"];
return (
<Link
key={item.title}
href={item.disabled ? "#" : item.href || "#"}
target={item.external ? "_blank" : ""}
className="flex items-center rounded-md gap-2 p-2 text-sm font-medium hover:bg-muted text-muted-foreground hover:text-foreground"
>
{item.icon && <CustomMenuIcon className="size-4 shrink-0" />}
{item.title}
</Link>
);
};

View File

@ -19,7 +19,7 @@ export function ThemeSwitcher() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="px-0">
<Button variant="ghost" size="icon" className="p-2 border border-border rounded-full text-sm">
<SunIcon className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>

View File

@ -1,15 +1,14 @@
"use client";
import { Icons } from "@/components/icons/icons";
import { UserAvatar } from "@/components/shared/user-avatar";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
DropdownMenu,
@ -18,32 +17,28 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { userButtonConfig } from "@/config/user-button";
import { createTranslator, getAvatarLinks } from "@/config/marketing";
import { useMediaQuery } from "@/hooks/use-media-query";
import { LogOutIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { LocaleLink, useLocaleRouter } from "@/i18n/navigation";
import { authClient } from "@/lib/auth-client";
import { useTranslations } from "next-intl";
import { LogOutIcon } from "lucide-react";
import { useState } from "react";
export function UserButton() {
const { data: session, error } = authClient.useSession();
const user = session?.user;
// console.log('UserButton, user:', user);
// if (error) {
// console.error("UserButton, error:", error);
// return (
// <div className="size-8 animate-pulse rounded-full border bg-muted" />
// );
// }
const isAdmin = user?.role === "admin";
const t = useTranslations();
const translator = createTranslator(t);
const avatarLinks = getAvatarLinks(translator);
const commonTranslations = useTranslations("Common");
const handleSignOut = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
console.log("sign out success");
router.push("/");
localeRouter.push("/");
},
onError: (error) => {
console.error("sign out error:", error);
@ -53,14 +48,14 @@ export function UserButton() {
});
};
const router = useRouter();
const localeRouter = useLocaleRouter();
const [open, setOpen] = useState(false);
const closeDrawer = () => {
setOpen(false);
};
const { isMobile } = useMediaQuery();
// Mobile View, use Drawer
if (isMobile) {
return (
@ -69,12 +64,13 @@ export function UserButton() {
<UserAvatar
name={user?.name || undefined}
image={user?.image || undefined}
className="size-8 border"
className="size-10 border"
/>
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay className="fixed inset-0 z-40 bg-background/50" />
<DrawerContent className="fixed inset-x-0 bottom-0 z-50 mt-24 overflow-hidden rounded-t-[10px] border bg-background px-3 text-sm">
<DrawerContent className="fixed inset-x-0 bottom-0 z-50 mt-24
overflow-hidden rounded-t-[10px] border bg-background px-3 text-sm">
<DrawerHeader>
<DrawerTitle />
</DrawerHeader>
@ -82,7 +78,7 @@ export function UserButton() {
<UserAvatar
name={user?.name || undefined}
image={user?.image || undefined}
className="size-8 border"
className="size-10 border"
/>
<div className="flex flex-col">
{user?.name && <p className="font-medium">{user.name}</p>}
@ -95,38 +91,21 @@ export function UserButton() {
</div>
<ul className="mb-14 mt-1 w-full text-muted-foreground">
{userButtonConfig.menus.map((item) => {
const Icon = Icons[item.icon || "arrowRight"];
return (
<li
key={item.href}
className="rounded-lg text-foreground hover:bg-muted"
>
<a href={item.href}
onClick={closeDrawer}
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<Icon className="size-4" />
<p className="text-sm">{item.title}</p>
</a>
</li>
);
})}
{isAdmin && (
{avatarLinks.map((item) => (
<li
key='admin'
key={item.title}
className="rounded-lg text-foreground hover:bg-muted"
>
<a href="/admin"
<LocaleLink
href={item.href || "#"}
onClick={closeDrawer}
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<Icons.admin className="size-4" />
<p className="text-sm">Admin</p>
</a>
{item.icon ? item.icon : null}
<p className="text-sm">{item.title}</p>
</LocaleLink>
</li>
)}
))}
<li
key="logout"
@ -141,7 +120,7 @@ export function UserButton() {
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<LogOutIcon className="size-4" />
<p className="text-sm">Log out</p>
<p className="text-sm">{commonTranslations("logout")}</p>
</a>
</li>
</ul>
@ -158,7 +137,7 @@ export function UserButton() {
<UserAvatar
name={user?.name || undefined}
image={user?.image || undefined}
className="size-8 border"
className="size-10 border"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -174,40 +153,22 @@ export function UserButton() {
</div>
<DropdownMenuSeparator />
{userButtonConfig.menus.map((item) => {
const Icon = Icons[item.icon || "arrowRight"];
return (
<DropdownMenuItem
key={item.href}
asChild
className="cursor-pointer"
onClick={() => {
router.push(item.href);
}}
>
<div className="flex items-center space-x-2.5">
<Icon className="size-4" />
<p className="text-sm">{item.title}</p>
</div>
</DropdownMenuItem>
);
})}
{isAdmin && (
{avatarLinks.map((item) => (
<DropdownMenuItem
key="admin"
asChild
key={item.title}
className="cursor-pointer"
onClick={() => {
router.push("/admin");
if (item.href) {
localeRouter.push(item.href);
}
}}
>
<div className="flex items-center space-x-2.5">
<Icons.admin className="size-4" />
<p className="text-sm">Admin</p>
{item.icon ? item.icon : null}
<p className="text-sm">{item.title}</p>
</div>
</DropdownMenuItem>
)}
))}
<DropdownMenuSeparator />
<DropdownMenuItem
@ -220,7 +181,7 @@ export function UserButton() {
>
<div className="flex items-center space-x-2.5">
<LogOutIcon className="size-4" />
<p className="text-sm">Log out</p>
<p className="text-sm">{commonTranslations("logout")}</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@ -1,30 +0,0 @@
import * as React from 'react';
import { ComponentProps } from 'react';
import {
Alert,
AlertDescription,
AlertTitle
} from '@/components/ui/alert';
type CalloutProps = ComponentProps<typeof Alert> & {
icon?: string;
title?: string;
};
/**
* TODO: update
*/
export function Callout({
title,
children,
icon,
...props
}: CalloutProps): React.JSX.Element {
return (
<Alert {...props}>
{icon && <span className="mr-4 text-2xl">{icon}</span>}
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}

View File

@ -1,39 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export type GridSectionProps = React.HtmlHTMLAttributes<HTMLDivElement> & {
hideVerticalGridLines?: boolean;
hideBottomGridLine?: boolean;
containerProps?: React.HtmlHTMLAttributes<HTMLDivElement>;
};
/**
* TODO: remove
*/
export function GridSection({
children,
hideVerticalGridLines,
hideBottomGridLine,
containerProps: { className = '', ...containerProps } = {},
...other
}: GridSectionProps): React.JSX.Element {
return (
<section {...other}>
<div
className={cn('px-2 sm:container', className)}
{...containerProps}
>
<div className="relative grid">
{!hideVerticalGridLines && (
<>
<div className="absolute inset-y-0 block w-px bg-border" />
<div className="absolute inset-y-0 right-0 w-px bg-border" />
</>
)}
{children}
</div>
</div>
{!hideBottomGridLine && <div className="h-px w-full bg-border" />}
</section>
);
}

View File

@ -1,38 +0,0 @@
import * as React from 'react';
import { Badge } from '@/components/ui/badge';
export type SiteHeadingProps = {
badge?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
};
/**
* TODO: remove
*/
export function SiteHeading({
badge,
title,
description
}: SiteHeadingProps): React.JSX.Element {
return (
<div className="mx-auto flex max-w-5xl flex-col items-center gap-6 text-center">
{badge && (
<Badge
variant="outline"
className="h-8 rounded-full px-3 text-sm font-medium shadow-sm"
>
{badge}
</Badge>
)}
{title && (
<h1 className="text-pretty text-5xl font-bold lg:text-6xl">{title}</h1>
)}
{description && (
<p className="text-lg text-muted-foreground lg:text-xl">
{description}
</p>
)}
</div>
);
}

View File

@ -1,194 +0,0 @@
import * as React from 'react';
import { CubeIcon, PaperPlaneIcon } from '@radix-ui/react-icons';
import {
BookIcon,
BookOpenIcon,
CircuitBoardIcon,
CuboidIcon,
FileBarChartIcon,
LayoutIcon,
PlayIcon
} from 'lucide-react';
import { Routes } from '@/routes';
import { FacebookIcon } from '@/components/icons/facebook';
import { InstagramIcon } from '@/components/icons/instagram';
import { LinkedInIcon } from '@/components/icons/linkedin';
import { TikTokIcon } from '@/components/icons/tiktok';
import { XTwitterIcon } from '@/components/icons/x';
export const MENU_LINKS = [
{
title: 'Features',
href: Routes.Pricing,
external: false
},
{
title: 'Pricing',
href: Routes.Pricing,
external: false
},
{
title: 'Blog',
href: Routes.Blog,
external: false
},
// {
// title: 'Docs',
// href: Routes.Docs,
// external: false
// },
{
title: 'AI',
items: [
{
title: 'Features',
description: 'Short description here',
icon: <CubeIcon className="size-5 shrink-0" />,
href: Routes.Features,
external: false
},
{
title: 'Pricing',
description: 'Short description here',
icon: <PlayIcon className="size-5 shrink-0" />,
href: Routes.Pricing,
external: false
},
{
title: 'FAQ',
description: 'Short description here',
icon: <LayoutIcon className="size-5 shrink-0" />,
href: Routes.FAQ,
external: false
},
{
title: 'Roadmap',
description: 'Short description here',
icon: <FileBarChartIcon className="size-5 shrink-0" />,
href: Routes.Roadmap,
external: true
}
]
},
{
title: 'Pages',
items: [
{
title: 'About',
description: 'Short description here',
icon: <PaperPlaneIcon className="size-5 shrink-0" />,
href: Routes.About,
external: false
},
{
title: 'Contact',
description: 'Short description here',
icon: <PaperPlaneIcon className="size-5 shrink-0" />,
href: Routes.Contact,
external: false
},
{
title: 'Waitlist',
description: 'Short description here',
icon: <BookOpenIcon className="size-5 shrink-0" />,
href: Routes.Waitlist,
external: false
}
]
},
];
export const FOOTER_LINKS = [
{
title: 'Product',
links: [
{ name: 'Feature 1', href: '#', external: false },
{ name: 'Feature 2', href: '#', external: false },
{ name: 'Feature 3', href: '#', external: false },
{ name: 'Feature 4', href: '#', external: false },
{ name: 'Feature 5', href: '#', external: false }
]
},
{
title: 'Resources',
links: [
{ name: 'Contact', href: Routes.Contact, external: false },
{ name: 'Roadmap', href: Routes.Roadmap, external: true },
{ name: 'Docs', href: Routes.Docs, external: false }
]
},
{
title: 'About',
links: [
{ name: 'Story', href: Routes.Story, external: false },
{ name: 'Blog', href: Routes.Blog, external: false },
{ name: 'Careers', href: Routes.Careers, external: false }
]
},
{
title: 'Legal',
links: [
{ name: 'Terms of Use', href: Routes.TermsOfService, external: false },
{ name: 'Privacy Policy', href: Routes.PrivacyPolicy, external: false },
{ name: 'Cookie Policy', href: Routes.CookiePolicy, external: false }
]
}
];
export const SOCIAL_LINKS = [
{
name: 'X (formerly Twitter)',
href: '#',
icon: <XTwitterIcon className="size-4 shrink-0" />
},
{
name: 'LinkedIn',
href: '#',
icon: <LinkedInIcon className="size-4 shrink-0" />
},
{
name: 'Facebook',
href: '#',
icon: <FacebookIcon className="size-4 shrink-0" />
},
{
name: 'Instagram',
href: '#',
icon: <InstagramIcon className="size-4 shrink-0" />
},
{
name: 'TikTok',
href: '#',
icon: <TikTokIcon className="size-4 shrink-0" />
}
];
export const DOCS_LINKS = [
{
title: 'Getting Started',
icon: <CuboidIcon className="size-4 shrink-0 text-muted-foreground" />,
items: [
{
title: 'Introduction',
href: '/docs',
items: []
},
{
title: 'Dependencies',
href: '/docs/dependencies',
items: []
}
]
},
{
title: 'Guides',
icon: <BookIcon className="size-4 shrink-0 text-muted-foreground" />,
items: [
{
title: 'Using MDX',
href: '/docs/using-mdx',
items: []
}
]
}
];

View File

@ -1,194 +0,0 @@
'use client';
import { LoginWrapper } from '@/components/auth/login-button';
import Container from '@/components/container';
import { ThemeSwitcher } from '@/components/layout/theme-switcher';
import { UserButton } from '@/components/layout/user-button';
import { Logo } from '@/components/logo';
import { MENU_LINKS } from '@/components/marketing/marketing-links';
import { NavbarMobile } from '@/components/marketing/navbar-mobile';
import { Button } from '@/components/ui/button';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle
} from '@/components/ui/navigation-menu';
import { siteConfig } from '@/config/site';
import { useScroll } from "@/hooks/use-scroll";
import { authClient } from '@/lib/auth-client';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { MarketingConfig } from '@/types';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ArrowUpRightIcon } from 'lucide-react';
import LocaleSelector from '@/components/layout/locale-selector';
import { LocaleLink } from '@/i18n/navigation';
interface NavBarProps {
scroll?: boolean;
config: MarketingConfig;
}
const customNavigationMenuTriggerStyle = cn(
navigationMenuTriggerStyle(),
"bg-transparent hover:bg-transparent hover:text-primary focus:bg-transparent focus:text-primary data-[active]:bg-transparent data-[active]:text-primary data-[state=open]:bg-transparent data-[state=open]:text-primary relative data-[active]:font-bold dark:text-gray-400 dark:hover:text-gray-300 dark:data-[active]:text-white"
);
export function Navbar({ scroll, config }: NavBarProps) {
const scrolled = useScroll(50);
const { data: session, error } = authClient.useSession();
const user = session?.user;
// console.log(`Navbar, user:`, user);
const pathname = usePathname();
return (
<section className={cn(
"sticky inset-x-0 top-0 z-40 py-4 transition-all duration-300",
scroll ? (
scrolled
? "bg-background/80 backdrop-blur-md border-b supports-[backdrop-filter]:bg-background/60"
: "bg-background"
) : "border-b bg-background"
)}>
<Container className="px-4">
{/* desktop navbar */}
<nav className="hidden lg:flex">
{/* logo and name */}
<div className="flex items-center">
<LocaleLink href="/" className="flex items-center space-x-2">
<Logo />
<span className="text-xl font-semibold">{siteConfig.name}</span>
</LocaleLink>
</div>
{/* menu links */}
<div className="flex-1 flex items-center justify-center space-x-2">
<NavigationMenu className="relative">
<NavigationMenuList className="flex items-center">
{MENU_LINKS.map((item, index) =>
item.items ? (
<NavigationMenuItem key={index} className="relative">
<NavigationMenuTrigger
data-active={
item.items.some((subItem) =>
pathname.startsWith(subItem.href)
) ? "true" : undefined
}
className={cn(
customNavigationMenuTriggerStyle,
"data-[active]:text-primary data-[active]:font-bold dark:data-[active]:text-white"
)}
>
{item.title}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="w-96 list-none p-2">
{item.items.map((subItem, subIndex) => (
<li key={subIndex}>
<NavigationMenuLink asChild>
<Link
href={subItem.href || '#'}
target={ subItem.external ? '_blank' : undefined }
rel={
subItem.external
? 'noopener noreferrer'
: undefined
}
className="group flex select-none flex-row items-center gap-4 rounded-md p-2 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
>
<div className="flex size-8 shrink-0 items-center justify-center text-muted-foreground transition-colors group-hover:text-foreground">
{subItem.icon}
</div>
<div className="flex-1">
<div className="text-sm font-medium">
{subItem.title}
</div>
{subItem.description && (
<div className="text-sm text-muted-foreground">
{subItem.description}
</div>
)}
</div>
{subItem.external && (
<ArrowUpRightIcon className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</Link>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
) : (
<NavigationMenuItem key={index}>
<NavigationMenuLink
asChild
active={pathname.startsWith(item.href)}
className={cn(
customNavigationMenuTriggerStyle,
"data-[active]:text-primary data-[active]:font-bold dark:data-[active]:text-white"
)}
>
<Link
href={item.href || '#'}
target={item.external ? '_blank' : undefined}
rel={
item.external ? 'noopener noreferrer' : undefined
}
>
{item.title}
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
)
)}
</NavigationMenuList>
</NavigationMenu>
</div>
{/* navbar right show sign in or user */}
<div className="flex items-center gap-x-4">
{user ? (
<div className="flex items-center">
<UserButton />
</div>
) : (
<div className="flex items-center gap-x-4">
<LoginWrapper mode="modal" asChild>
<Button
variant="outline"
size="sm"
>
<span>Log in</span>
</Button>
</LoginWrapper>
<Button
variant="default"
size="sm"
asChild
>
<LocaleLink href={Routes.Register}>
Sign up
</LocaleLink>
</Button>
</div>
)}
<LocaleSelector />
<ThemeSwitcher />
</div>
</nav>
{/* mobile navbar */}
<NavbarMobile className="lg:hidden" />
</Container>
</section>
);
}

View File

@ -0,0 +1,86 @@
import {
AlertTriangle,
Ban,
CircleAlert,
CircleCheckBig,
FileText,
Info,
Lightbulb,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface CalloutProps {
twClass?: string;
children?: React.ReactNode;
type?: keyof typeof dataCallout;
}
const dataCallout = {
default: {
icon: Info,
classes:
"border-zinc-200 bg-gray-50 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200",
},
danger: {
icon: CircleAlert,
classes:
"border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200",
},
error: {
icon: Ban,
classes:
"border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200",
},
idea: {
icon: Lightbulb,
classes:
"border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
},
info: {
icon: Info,
classes:
"border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
},
note: {
icon: FileText,
classes:
"border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
},
success: {
icon: CircleCheckBig,
classes:
"border-green-200 bg-green-50 text-green-800 dark:bg-green-400/20 dark:text-green-300",
},
warning: {
icon: AlertTriangle,
classes:
"border-orange-200 bg-orange-50 text-orange-800 dark:bg-orange-400/20 dark:text-orange-300",
},
};
export function Callout({
children,
twClass,
type = "default",
...props
}: CalloutProps) {
const { icon: Icon, classes } = dataCallout[type];
return (
<div>
{/* <div
className={cn(
"mt-6 flex items-start space-x-3 rounded-lg border px-4 py-3 text-[15.6px] dark:border-none",
classes,
twClass,
)}
{...props}
>
<div className="mt-1 shrink-0">
<Icon className="size-5" />
</div>
<div className="[&>p]:my-0">{children}</div>
</div> */}
</div>
);
}

View File

@ -1,86 +1,30 @@
import * as React from 'react';
import { ComponentProps } from 'react';
import {
AlertTriangle,
Ban,
CircleAlert,
CircleCheckBig,
FileText,
Info,
Lightbulb,
} from "lucide-react";
Alert,
AlertDescription,
AlertTitle
} from '@/components/ui/alert';
import { cn } from "@/lib/utils";
interface CalloutProps {
twClass?: string;
children?: React.ReactNode;
type?: keyof typeof dataCallout;
}
const dataCallout = {
default: {
icon: Info,
classes:
"border-zinc-200 bg-gray-50 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200",
},
danger: {
icon: CircleAlert,
classes:
"border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200",
},
error: {
icon: Ban,
classes:
"border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200",
},
idea: {
icon: Lightbulb,
classes:
"border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
},
info: {
icon: Info,
classes:
"border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
},
note: {
icon: FileText,
classes:
"border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
},
success: {
icon: CircleCheckBig,
classes:
"border-green-200 bg-green-50 text-green-800 dark:bg-green-400/20 dark:text-green-300",
},
warning: {
icon: AlertTriangle,
classes:
"border-orange-200 bg-orange-50 text-orange-800 dark:bg-orange-400/20 dark:text-orange-300",
},
type CalloutProps = ComponentProps<typeof Alert> & {
icon?: string;
title?: string;
};
/**
* TODO: update
*/
export function Callout({
title,
children,
twClass,
type = "default",
icon,
...props
}: CalloutProps) {
const { icon: Icon, classes } = dataCallout[type];
}: CalloutProps): React.JSX.Element {
return (
<div>
{/* <div
className={cn(
"mt-6 flex items-start space-x-3 rounded-lg border px-4 py-3 text-[15.6px] dark:border-none",
classes,
twClass,
)}
{...props}
>
<div className="mt-1 shrink-0">
<Icon className="size-5" />
</div>
<div className="[&>p]:my-0">{children}</div>
</div> */}
</div>
<Alert {...props}>
{icon && <span className="mr-4 text-2xl">{icon}</span>}
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}

View File

@ -4,7 +4,7 @@ import * as React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useMDXComponent } from '@content-collections/mdx/react';
import { Callout } from '@/components/marketing/blog/callout';
import { Callout } from '@/components/shared/callout';
import {
Accordion,
AccordionContent,

View File

@ -1,39 +0,0 @@
import type { FooterConfig } from "@/types";
import { Routes } from "@/routes";
export const footerConfig: FooterConfig = {
links: [
{
title: "Product",
items: [
{ title: "Features", href: Routes.Features },
{ title: "Pricing", href: Routes.Pricing },
{ title: "FAQ", href: Routes.FAQ },
],
},
{
title: "Resources",
items: [
{ title: "Blog", href: Routes.Blog },
{ title: "Changelog", href: Routes.Changelog },
{ title: "Roadmap", href: Routes.Roadmap },
],
},
{
title: "Company",
items: [
{ title: "Waitlist", href: Routes.Waitlist },
{ title: "About Us", href: Routes.About },
{ title: "Contact Us", href: Routes.Contact },
],
},
{
title: "Legal",
items: [
{ title: "Cookie Policy", href: Routes.CookiePolicy },
{ title: "Privacy Policy", href: Routes.PrivacyPolicy },
{ title: "Terms of Service", href: Routes.TermsOfService },
],
},
],
};

View File

@ -1,28 +0,0 @@
import type { HeroConfig } from "@/types";
export const heroConfig: HeroConfig = {
title: {
first: "Launch",
second: "AI SaaS websites in minutes",
},
subtitle:
"The best AI SaaS boilerplate, packed with AI, Payment, Blog, Authentication, Newsletter, SEO, Themes and more.",
label: {
text: "🎁 Special Gift - 50% OFF 🎁",
href: "/#pricing",
icon: "gift",
external: false,
},
primaryButton: {
text: "Get Now",
href: "/#pricing",
icon: "rocket",
variant: "default",
},
secondaryButton: {
text: "Live Demo",
href: "https://demo.mksaas.com",
icon: "eye",
variant: "outline",
},
};

View File

@ -1,501 +0,0 @@
import type { FeatureLdg, InfoLdg, MkdirsInfoLdg, PoweredLdg, TestimonialType } from "@/types";
export const homeFeatures: MkdirsInfoLdg[] = [
{
title: "Feature Rich Directory",
description: "Everything you need to run a directory website.",
images: ["/images/feature-item-detail-page.png", "/images/feature-search-page.png", "/images/feature-filter.png", "/images/feature-filter-category.png"],
list: [
{
title: "Advanced Search",
description:
"Search by keywords from name, description, or rich text content.",
icon: "search",
},
{
title: "Filterable and Sortable",
description:
"Filter items by categories, tags, or sort them by latest, or name.",
icon: "filter",
},
{
title: "Detailed Item Page",
description:
"Each item has a detailed page with rich text content (Markdown).",
icon: "blog",
},
],
button: {
text: "Explore the demo directory",
icon: "eye",
href: "https://demo.mkdirs.com",
variant: "default",
},
},
{
title: "Sanity CMS Integrated",
description: "Deeply integrated with the world-class headless CMS, Sanity.",
images: ["/images/feature-sanity-item-content.png", "/images/feature-sanity-item-image.png", "/images/feature-sanity-item-desc.png", "/images/feature-sanity-category.png"],
list: [
{
title: "Content Management",
description:
"Manage items, categories, tags, blogs, users, and more in Sanity Studio.",
icon: "dashboard",
},
{
title: "Fully Customizable",
description:
"Tailor Sanity Studio with versatile plugins to meet your needs.",
icon: "wrench",
},
{
title: "No Database or Storage Setup",
description:
"Sanity integration eliminates database and storage configuration.",
icon: "database",
},
],
button: {
text: "Contact us to preview the Sanity Studio",
icon: "dashboard",
href: "mailto:support@mkdirs.com",
variant: "default",
},
},
{
title: "Secure Authentication",
description: "Built-in secure authentication system, powered by Auth.js.",
images: ["/images/feature-account-register.png", "/images/feature-account-login.png", "/images/feature-account.png", "/images/feature-account-forget-password.png"],
list: [
{
title: "Email/Password Login",
description: "Email verification and password reset are supported.",
icon: "email",
},
{
title: "Google or GitHub Login",
description:
"Supports Google/GitHub login, easy to add more social logins.",
icon: "githubLucide",
},
{
title: "User Account Settings",
description:
"Users can update their name or link or password in settings.",
icon: "shieldCheck",
},
],
button: {
text: "Explore the Login Page",
icon: "user",
href: "https://demo.mkdirs.com/login",
variant: "default",
},
},
{
title: "Built-in Submission",
description: "Free and paid submissions, easily monetize your directory.",
images: ["/images/feature-submit.png", "/images/feature-submit-payment.png", "/images/feature-submit-publish.png", "/images/feature-submit-dashboard.png", "/images/feature-submit-edit.png", "/images/feature-sanity-item-desc.png"],
list: [
{
title: "Submission Form (AI Suppported)",
description:
"AI Autofill supported, rich text editing and image uploads.",
icon: "submit",
},
{
title: "Paid Submission",
description:
"Paid submission with Stripe, easy to monetize your directory.",
icon: "money",
},
{
title: "Sponsor Ad Submission",
description:
"Show sponsor ad in the listing page and detail page once paid.",
icon: "sponsor",
},
],
button: {
text: "Explore the Submission Workflow",
icon: "submit",
href: "https://demo.mkdirs.com/submit",
variant: "default",
},
},
{
title: "Blog",
description: "Blog system, easy to share your content.",
images: ["/images/feature-blog.png", "/images/feature-blog-category.png", "/images/feature-blog-detail.png", "/images/feature-blog-image.png", "/images/feature-blog-more.png", "/images/feature-blog-sanity.png"],
list: [
{
title: "Blog System",
description: "Blog with categories, authors and rich text content.",
icon: "newspaper",
},
{
title: "Blog Categories",
description:
"Blog with categories, easy to organize your blog posts.",
icon: "category",
},
{
title: "Rich Text Content",
description:
"Supports Image and Code blocks, easy to write blog posts.",
icon: "notebook",
},
],
button: {
text: "Explore the Blog Page",
icon: "blog",
href: "https://demo.mkdirs.com/blog",
variant: "default",
},
},
{
title: "Email",
description: "React email templates, easy to send emails.",
images: ["/images/feature-email-newsletter.png", "/images/feature-email-example.png", "/images/feature-email-preview.png", "/images/feature-email-preview-submission.png"],
list: [
{
title: "Email Templates",
description:
"Built-in emails, automatically send emails to the user and admin.",
icon: "mailbox",
},
{
title: "Customizable Email",
description:
"Support for customizing and previewing email templates.",
icon: "email",
},
{
title: "Newsletter Subscription",
description: "Support for newsletter subscription and unsubscription.",
icon: "mailcheck",
},
],
button: {
text: "Explore the Newsletter subscription",
icon: "mailbox",
href: "https://demo.mkdirs.com/#newsletter",
variant: "default",
},
},
{
title: "Layouts and Components",
description: "Built-in layouts and components, easy to customize your directory website.",
images: ["/images/feature-layout-1.png", "/images/feature-layout-2.png", "/images/feature-layout-3.png", "/images/feature-layout-4.png"],
list: [
{
title: "Layouts",
description:
"Multiple pre-built page layouts to showcase your directory.",
icon: "layout",
},
{
title: "Components",
description:
"Ready-to-use UI components for search, filters, cards, and more.",
icon: "component",
},
{
title: "Item Cards",
description:
"Flexible item cards with both icon and image layouts.",
icon: "grid",
},
],
button: {
text: "Explore the demo directory",
icon: "mailbox",
href: "https://demo.mkdirs.com/home2",
variant: "default",
},
},
{
title: "Docs and Videos",
description: "Comprehensive documentation and video tutorials to help you get started.",
images: ["/images/feature-docs-en.png", "/images/feature-docs-cn.png", "/images/feature-video-home.png", "/images/feature-video-playlist.png"],
list: [
{
title: "Documentation",
description:
"Comprehensive documentation to help you get started.",
icon: "docs",
},
{
title: "Video Tutorials",
description:
"High-quality video tutorials to help you get started.",
icon: "video",
},
{
title: "Multiple Languages",
description:
"Docs and videos are available in English and Chinese.",
icon: "globe",
},
],
button: {
text: "Explore the Documentation",
icon: "mailbox",
href: "https://docs.mkdirs.com",
variant: "default",
},
},
{
title: "SEO Optimization",
description: "SEO optimized, including OG metadata and auto-generated sitemap.",
images: ["/images/feature-seo.png", "/images/feature-seo-item.png", "/images/feature-seo-item-image.png", "/images/feature-seo-item-heading.png", "/images/feature-seo-blog.png"] ,
list: [
{
title: "SEO Metadata",
description:
"Built-in SEO metadata for all pages (especially items and blogs).",
icon: "blog",
},
{
title: "Open Graph",
description: "Built-in Open Graph metadata for social media sharing.",
icon: "image",
},
{
title: "Auto-generated Sitemap",
description: "Auto-generated sitemap for search engines.",
icon: "map",
},
],
button: {
text: "View the performance results",
icon: "chartNoAxes",
href: "https://pagespeed.web.dev/analysis/https-demo-mkdirs-com/egj0638v8m?form_factor=desktop",
variant: "default",
},
},
{
title: "Batteries Included",
description: "Dark mode, responsive design, and customizable theme.",
images: ["/images/feature-ui-theme.png", "/images/feature-ui-theme-blue.png", "/images/feature-ui-dark.png", "/images/feature-ui-dark-item.png", "/images/feature-ui-responsive.png"] ,
list: [
{
title: "Customizable Theme",
description: "Customize the theme to match your brand and style.",
icon: "palette",
},
{
title: "Dark Mode & Responsive",
description:
"Supports dark mode and responsive design.",
icon: "image",
},
{
title: "Built-in Analytics",
description: "Supports Google Analytics and OpenPanel Analytics.",
icon: "chartLine",
},
],
button: {
text: "Explore the demo directory",
icon: "eye",
href: "https://demo.mkdirs.com",
variant: "default",
},
},
];
export const powereds: PoweredLdg[] = [
{
title: "Next.js",
description: "Full stack React framework for production.",
link: "https://nextjs.org/",
icon: "nextjs",
},
{
title: "Auth.js",
description: "Open source authentication library for Next.js.",
link: "https://authjs.dev/",
icon: "nextjs",
},
{
title: "Shadcn UI",
description: "Components for building modern websites.",
link: "https://ui.shadcn.com/",
icon: "shadcnui",
},
{
title: "Tailwind CSS",
description: "CSS framework for rapid UI development.",
link: "https://tailwindcss.com/",
icon: "tailwindcss",
},
{
title: "Sanity",
description: "Headless CMS for modern websites.",
link: "https://www.sanity.io/",
icon: "nextjs",
},
{
title: "Resend",
description: "Modern email service for developers.",
link: "https://resend.com/",
icon: "resend",
},
{
title: "Stripe",
description: "Best and most secure online payment service.",
link: "https://stripe.com/",
icon: "stripe",
},
{
title: "Vercel AI SDK",
description: "The open source AI Toolkit for TypeScript.",
link: "https://sdk.vercel.ai/",
icon: "vercel",
},
];
export const infos: InfoLdg[] = [
{
title: "Empower your projects",
description:
"Unlock the full potential of your projects with our open-source SaaS platform. Collaborate seamlessly, innovate effortlessly, and scale limitlessly.",
image: "/og.png",
list: [
{
title: "Collaborative",
description: "Work together with your team members in real-time.",
icon: "settings",
},
{
title: "Innovative",
description: "Stay ahead of the curve with access constant updates.",
icon: "settings",
},
{
title: "Scalable",
description:
"Our platform offers the scalability needed to adapt to your needs.",
icon: "search",
},
],
},
{
title: "Seamless Integration",
description:
"Integrate our open-source SaaS seamlessly into your existing workflows. Effortlessly connect with your favorite tools and services for a streamlined experience.",
image: "/og.png",
list: [
{
title: "Flexible",
description:
"Customize your integrations to fit your unique requirements.",
icon: "settings",
},
{
title: "Efficient",
description: "Streamline your processes and reducing manual effort.",
icon: "search",
},
{
title: "Reliable",
description:
"Rely on our robust infrastructure and comprehensive documentation.",
icon: "settings",
},
],
},
];
export const features: FeatureLdg[] = [
{
title: "Feature 1",
description:
"Amet praesentium deserunt ex commodi tempore fuga voluptatem. Sit, sapiente.",
link: "/",
icon: "settings",
},
{
title: "Feature 2",
description:
"Amet praesentium deserunt ex commodi tempore fuga voluptatem. Sit, sapiente.",
link: "/",
icon: "settings",
},
{
title: "Feature 3",
description:
"Amet praesentium deserunt ex commodi tempore fuga voluptatem. Sit, sapiente.",
link: "/",
icon: "settings",
},
{
title: "Feature 4",
description:
"Amet praesentium deserunt ex commodi tempore fuga voluptatem. Sit, sapiente.",
link: "/",
icon: "settings",
},
{
title: "Feature 5",
description:
"Amet praesentium deserunt ex commodi tempore fuga voluptatem. Sit, sapiente.",
link: "/",
icon: "settings",
},
{
title: "Feature 6",
description:
"Amet praesentium deserunt ex commodi tempore fuga voluptatem. Sit, sapiente.",
link: "/",
icon: "settings",
},
];
// The documentation is clear and concise, making it easy to navigate through the setup process.
export const testimonials: TestimonialType[] = [
{
name: "Tom Anderson",
job: "Niche Blogger",
image: "https://randomuser.me/api/portraits/men/9.jpg",
review:
"This directory website template has revolutionized how I present my curated content. It's intuitive, visually appealing, and has significantly improved my site's organization. My readers can now easily find the resources they need. Highly recommended for any content curator!",
},
{
name: "Mike Johnson",
job: "Local Business Owner",
image: "https://randomuser.me/api/portraits/men/4.jpg",
review:
"As a small business owner, I needed an efficient way to showcase local services. This template made creating a town directory website a piece of cake. It's user-friendly, looks professional, and has boosted community engagement significantly.",
},
{
name: "Carlos Mendoza",
job: "Tech Review Blogger",
image: "https://randomuser.me/api/portraits/men/18.jpg",
review:
"I used this template to build a comprehensive tech product directory, and I'm impressed with the results. The category system is flexible, and the search function works flawlessly. It's helped me organize my reviews in a way that's much more accessible to my readers.",
},
{
name: "Ryan Zhang",
job: "Affiliate Marketer",
image: "https://randomuser.me/api/portraits/men/32.jpg",
review:
"I've tried several directory templates, but this one is a cut above. It's SEO-friendly, mobile-responsive, and a breeze to customize. My affiliate links are now organized beautifully, leading to increased click-through rates. It's become an essential tool in my marketing arsenal.",
},
{
name: "Ahmed Hassan",
job: "Online Course Creator",
image: "https://randomuser.me/api/portraits/men/19.jpg",
review:
"This directory website template has been perfect for organizing my online courses and resources. The clean layout and easy navigation have received praise from my students. It's made managing and presenting my educational content so much easier.",
},
{
name: "Daniel Lee",
job: "Freelance Web Designer",
image: "https://randomuser.me/api/portraits/men/7.jpg",
review:
"As a web designer, I appreciate the thought put into this template. It's a solid foundation that I can easily customize for clients needing directory sites. The code is clean, well-documented, and saves me tons of time on each project. A real asset to my business!",
},
];

View File

@ -1,43 +0,0 @@
import type { MarketingConfig } from "@/types";
export const marketingConfig: MarketingConfig = {
menus: [
{
title: "Features",
href: "/#features",
icon: "features",
},
{
title: "Pricing",
href: "/#pricing",
icon: "pricing",
},
{
title: "Blog",
href: "/blog",
icon: "blog",
},
{
title: "Pages",
href: "#",
icon: "about",
items: [
{
title: "Waitlist",
href: "/waitlist",
icon: "about",
},
{
title: "Contact",
href: "/contact",
icon: "about",
},
{
title: "About",
href: "/about",
icon: "about",
},
],
},
],
};

277
src/config/marketing.tsx Normal file
View File

@ -0,0 +1,277 @@
import { BlueskyIcon } from '@/components/icons/bluesky';
import { FacebookIcon } from '@/components/icons/facebook';
import { GitHubIcon } from '@/components/icons/github';
import { InstagramIcon } from '@/components/icons/instagram';
import { LinkedInIcon } from '@/components/icons/linkedin';
import { TikTokIcon } from '@/components/icons/tiktok';
import { TwitterIcon } from '@/components/icons/twitter';
import { YouTubeIcon } from '@/components/icons/youtube';
import { Routes } from '@/routes';
import { MenuItem, NestedMenuItem } from '@/types';
import { DashboardIcon } from '@radix-ui/react-icons';
import {
AudioLinesIcon,
CookieIcon,
FileTextIcon,
FilmIcon,
ImageIcon,
InfoIcon,
ListChecksIcon,
MailboxIcon,
MailIcon,
SettingsIcon,
ShieldIcon,
SquareKanbanIcon,
SquarePenIcon
} from 'lucide-react';
type TranslationFunction = (key: string, ...args: any[]) => string;
/**
* Creates a translation function that works with our menu functions
* @param t - The next-intl translation function
* @returns A translation function that accepts string keys
*/
export function createTranslator(t: any): TranslationFunction {
return (key: string) => {
try {
// @ts-ignore - We know this is a valid key because we've defined it in our messages
return t(key);
} catch (error) {
console.error(`Translation key not found: ${key}`);
return key.split('.').pop() || key;
}
};
}
/**
* Get menu links with translations
* @param t - The translation function
* @returns The menu links with translated titles and descriptions
*/
export function getMenuLinks(t: TranslationFunction): NestedMenuItem[] {
return [
{
title: t('Marketing.menu.features.title'),
href: Routes.Features,
external: false
},
{
title: t('Marketing.menu.pricing.title'),
href: Routes.Pricing,
external: false
},
{
title: t('Marketing.menu.blog.title'),
href: Routes.Blog,
external: false
},
{
title: t('Marketing.menu.ai.title'),
items: [
{
title: t('Marketing.menu.ai.items.text.title'),
description: t('Marketing.menu.ai.items.text.description'),
icon: <SquarePenIcon className="size-5 shrink-0" />,
href: Routes.AIText,
external: false
},
{
title: t('Marketing.menu.ai.items.image.title'),
description: t('Marketing.menu.ai.items.image.description'),
icon: <ImageIcon className="size-5 shrink-0" />,
href: Routes.AIImage,
external: false
},
{
title: t('Marketing.menu.ai.items.video.title'),
description: t('Marketing.menu.ai.items.video.description'),
icon: <FilmIcon className="size-5 shrink-0" />,
href: Routes.AIVideo,
external: false
},
{
title: t('Marketing.menu.ai.items.audio.title'),
description: t('Marketing.menu.ai.items.audio.description'),
icon: <AudioLinesIcon className="size-5 shrink-0" />,
href: Routes.AIAudio,
external: false
}
]
},
{
title: t('Marketing.menu.pages.title'),
items: [
{
title: t('Marketing.menu.pages.items.about.title'),
description: t('Marketing.menu.pages.items.about.description'),
icon: <InfoIcon className="size-5 shrink-0" />,
href: Routes.About,
external: false
},
{
title: t('Marketing.menu.pages.items.contact.title'),
description: t('Marketing.menu.pages.items.contact.description'),
icon: <MailIcon className="size-5 shrink-0" />,
href: Routes.Contact,
external: false
},
{
title: t('Marketing.menu.pages.items.waitlist.title'),
description: t('Marketing.menu.pages.items.waitlist.description'),
icon: <MailboxIcon className="size-5 shrink-0" />,
href: Routes.Waitlist,
external: false
},
{
title: t('Marketing.menu.pages.items.changelog.title'),
description: t('Marketing.menu.pages.items.changelog.description'),
icon: <ListChecksIcon className="size-5 shrink-0" />,
href: Routes.Changelog,
external: false
},
{
title: t('Marketing.menu.pages.items.roadmap.title'),
description: t('Marketing.menu.pages.items.roadmap.description'),
icon: <SquareKanbanIcon className="size-5 shrink-0" />,
href: Routes.Roadmap,
external: true
},
{
title: t('Marketing.menu.pages.items.cookiePolicy.title'),
description: t('Marketing.menu.pages.items.cookiePolicy.description'),
icon: <CookieIcon className="size-5 shrink-0" />,
href: Routes.CookiePolicy,
external: false
},
{
title: t('Marketing.menu.pages.items.privacyPolicy.title'),
description: t('Marketing.menu.pages.items.privacyPolicy.description'),
icon: <ShieldIcon className="size-5 shrink-0" />,
href: Routes.PrivacyPolicy,
external: false
},
{
title: t('Marketing.menu.pages.items.termsOfService.title'),
description: t('Marketing.menu.pages.items.termsOfService.description'),
icon: <FileTextIcon className="size-5 shrink-0" />,
href: Routes.TermsOfService,
external: false
}
]
},
];
}
/**
* Get footer links with translations
* @param t - The translation function
* @returns The footer links with translated titles
*/
export function getFooterLinks(t: TranslationFunction): NestedMenuItem[] {
return [
{
title: t('Marketing.footer.product.title'),
items: [
{ title: t('Marketing.footer.product.items.features'), href: Routes.Features, external: false },
{ title: t('Marketing.footer.product.items.pricing'), href: Routes.Pricing, external: false },
{ title: t('Marketing.footer.product.items.faq'), href: Routes.FAQ, external: false },
]
},
{
title: t('Marketing.footer.resources.title'),
items: [
{ title: t('Marketing.footer.resources.items.blog'), href: Routes.Blog, external: false },
{ title: t('Marketing.footer.resources.items.changelog'), href: Routes.Changelog, external: false },
{ title: t('Marketing.footer.resources.items.roadmap'), href: Routes.Roadmap, external: true },
]
},
{
title: t('Marketing.footer.company.title'),
items: [
{ title: t('Marketing.footer.company.items.about'), href: Routes.About, external: false },
{ title: t('Marketing.footer.company.items.contact'), href: Routes.Contact, external: false },
{ title: t('Marketing.footer.company.items.waitlist'), href: Routes.Waitlist, external: false }
]
},
{
title: t('Marketing.footer.legal.title'),
items: [
{ title: t('Marketing.footer.legal.items.cookiePolicy'), href: Routes.CookiePolicy, external: false },
{ title: t('Marketing.footer.legal.items.privacyPolicy'), href: Routes.PrivacyPolicy, external: false },
{ title: t('Marketing.footer.legal.items.termsOfService'), href: Routes.TermsOfService, external: false },
]
}
];
}
/**
* list all the social links here, you can delete the ones that are not needed
*/
export const SOCIAL_LINKS: MenuItem[] = [
{
title: 'Email',
href: 'mailto:mksaas@gmail.com',
icon: <MailIcon className="size-4 shrink-0" />
},
{
title: 'GitHub',
href: 'https://github.com/MkSaaSHQ',
icon: <GitHubIcon className="size-4 shrink-0" />
},
{
title: 'Twitter',
href: 'https://twitter.com/mksaas',
icon: <TwitterIcon className="size-4 shrink-0" />
},
{
title: 'Bluesky',
href: 'https://bsky.app/profile/mksaas.com',
icon: <BlueskyIcon className="size-4 shrink-0" />
},
{
title: 'YouTube',
href: 'https://www.youtube.com/@MkSaaSHQ',
icon: <YouTubeIcon className="size-4 shrink-0" />
},
{
title: 'LinkedIn',
href: 'https://www.linkedin.com/company/mksaas',
icon: <LinkedInIcon className="size-4 shrink-0" />
},
{
title: 'Facebook',
href: 'https://www.facebook.com/mksaas',
icon: <FacebookIcon className="size-4 shrink-0" />
},
{
title: 'Instagram',
href: 'https://www.instagram.com/mksaas',
icon: <InstagramIcon className="size-4 shrink-0" />
},
{
title: 'TikTok',
href: 'https://www.tiktok.com/@mksaas',
icon: <TikTokIcon className="size-4 shrink-0" />
}
];
/**
* Get avatar links with translations
* @param t - The translation function
* @returns The avatar links with translated titles
*/
export function getAvatarLinks(t: TranslationFunction): MenuItem[] {
return [
{
title: t('Marketing.avatar.dashboard'),
href: Routes.Dashboard,
icon: <DashboardIcon className="size-4 shrink-0" />
},
{
title: t('Marketing.avatar.settings'),
href: Routes.Settings,
icon: <SettingsIcon className="size-4 shrink-0" />
}
];
}

View File

@ -1,7 +1,6 @@
import { getBaseUrl } from "@/lib/urls/get-base-url";
import type { SiteConfig } from "@/types";
const SITE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
export const siteConfig: SiteConfig = {
name: "MkSaaS",
title: "MkSaaS - The Best AI SaaS Boilerplate",
@ -17,17 +16,7 @@ export const siteConfig: SiteConfig = {
"SaaS Website Builder",
],
author: "MkSaaS",
url: SITE_URL ?? "",
image: `${SITE_URL}/og.png`,
url: getBaseUrl(),
image: `${getBaseUrl()}/og.png`,
mail: "support@mksaas.com",
links: {
twitter: "https://x.com/javay_hu",
bluesky: "https://bsky.app/profile/javayhu.com",
github: "https://github.com/MkSaaSHQ",
youtube: "https://www.youtube.com/@MkSaaSHQ",
docs: "https://docs.mksaas.com",
demo: "https://demo.mksaas.com",
studio: "https://demo.mksaas.com/studio",
showcase: "https://mksaas.com/showcase",
},
};

View File

@ -1,16 +0,0 @@
import type { UserButtonConfig } from "@/types";
export const userButtonConfig: UserButtonConfig = {
menus: [
{
title: "Dashboard",
href: "/dashboard",
icon: "dashboard",
},
{
title: "Settings",
href: "/settings",
icon: "settings",
}
],
};

View File

@ -72,10 +72,26 @@ export function getLocaleDate(input: string | number): string {
* @param request - The request to get the locale from
* @returns The locale from the request or the default locale
*/
export const getLocaleFromRequest = (request?: Request) => {
export function getLocaleFromRequest(request?: Request): Locale {
const cookies = parseCookies(request?.headers.get("cookie") ?? "");
return (
(cookies[LOCALE_COOKIE_NAME] as Locale) ??
routing.defaultLocale
);
};
};
/**
* Estimates the reading time of a text
*
* @param text - The text to estimate the reading time of
* @param wordsPerMinute - The number of words per minute to use for the estimate
* @returns The estimated reading time
*/
export function estimateReadingTime(
text: string,
wordsPerMinute: number = 250
): string {
const words = text.trim().split(/\s+/).length;
const minutes = Math.ceil(words / wordsPerMinute);
return minutes === 1 ? '1 minute read' : `${minutes} minutes read`;
}

View File

@ -3,6 +3,7 @@
*/
export enum Routes {
Root = '/',
DefaultLoginRedirect = '/dashboard',
Features = '/#features',
Pricing = '/#pricing',
@ -13,15 +14,12 @@ export enum Routes {
CookiePolicy = '/cookie-policy',
Blog = '/blog',
Docs = '/docs',
Changelog = '/changelog',
Roadmap = 'https://mksaas.canny.io',
Roadmap = 'https://mksaas.featurebase.app',
About = '/about',
Contact = '/contact',
Waitlist = '/waitlist',
Story = '/story',
Careers = '/careers',
Login = '/auth/login',
Register = '/auth/register',
@ -29,48 +27,13 @@ export enum Routes {
ForgotPassword = '/auth/forgot-password',
ResetPassword = '/auth/reset-password',
Auth = '/auth',
Logout = '/auth/logout',
Totp = '/auth/totp',
RecoveryCode = '/auth/recovery-code',
ChangeEmail = '/auth/change-email',
ChangeEmailRequest = '/auth/change-email/request',
ChangeEmailInvalid = '/auth/change-email/invalid',
ChangeEmailExpired = '/auth/change-email/expired',
// ForgotPassword = '/auth/forgot-password',
ForgotPasswordSuccess = '/auth/forgot-password/success',
// ResetPassword = '/auth/reset-password',
ResetPasswordRequest = '/auth/reset-password/request',
ResetPasswordExpired = '/auth/reset-password/expired',
ResetPasswordSuccess = '/auth/reset-password/success',
VerifyEmail = '/auth/verify-email',
VerifyEmailRequest = '/auth/verify-email/request',
VerifyEmailExpired = '/auth/verify-email/expired',
VerifyEmailSuccess = '/auth/verify-email/success',
Dashboard = '/dashboard',
Home = '/dashboard/home',
Contacts = '/dashboard/contacts',
Settings = '/dashboard/settings',
Account = '/dashboard/settings/account',
Profile = '/dashboard/settings/account/profile',
Security = '/dashboard/settings/account/security',
Notifications = '/dashboard/settings/account/notifications',
Organization = '/dashboard/settings/organization',
OrganizationInformation = '/dashboard/settings/organization/information',
Members = '/dashboard/settings/organization/members',
Billing = '/dashboard/settings/organization/billing',
Developers = '/dashboard/settings/organization/developers',
Invitations = '/invitations',
InvitationRequest = '/invitations/request',
InvitationAlreadyAccepted = '/invitations/already-accepted',
InvitationRevoked = '/invitations/revoked',
InvitationLogOutToAccept = '/invitations/log-out-to-accept',
Onboarding = '/onboarding',
DefaultLoginRedirect = '/dashboard',
AIText = '/dashboard/features/ai-text',
AIImage = '/dashboard/features/ai-image',
AIVideo = '/dashboard/features/ai-video',
AIAudio = '/dashboard/features/ai-audio',
}
/**
@ -81,14 +44,11 @@ export enum Routes {
export const publicRoutes = [
"/",
// blog
"/blog(/.*)?",
// pages
"/blog(/.*)?",
"/terms-of-service(/.*)?",
"/privacy-policy(/.*)?",
"/cookie-policy(/.*)?",
"/about(/.*)?",
"/contact(/.*)?",
"/waitlist(/.*)?",

View File

@ -50,6 +50,11 @@
}
@layer base {
html {
/* prevent the layout from shifting when the scrollbar appears or disappears. */
scrollbar-gutter: stable;
}
* {
@apply border-border outline-ring/50;
}
@ -57,3 +62,10 @@
@apply bg-background text-foreground;
}
}
@layer utilities {
.link-underline-animation {
@apply relative underline underline-offset-4 decoration-transparent decoration-2
hover:decoration-primary hover:text-primary transition-all duration-300 ease-in-out;
}
}

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

@ -1,4 +1,5 @@
import type { Icons } from "@/components/icons/icons";
import type { ReactNode } from "react";
/**
* utm parameters
@ -14,18 +15,27 @@ export type SiteConfig = {
url: string;
image: string;
mail: string;
links: {
github?: string;
twitter?: string;
bluesky?: string;
youtube?: string;
docs?: string;
demo?: string;
studio?: string;
showcase?: string;
};
};
export type MenuItem = {
title: string;
description?: string;
icon?: ReactNode;
href?: string;
external?: boolean;
};
export type NestedMenuItem = {
title: string;
description?: string;
icon?: ReactNode;
href?: string;
external?: boolean;
items?: MenuItem[];
};
// marketing config //
export type HeroConfig = {
title: {
first: string;
@ -163,4 +173,4 @@ export type TestimonialType = {
job: string;
image: string;
review: string;
};
};