initial commit

This commit is contained in:
Harivansh Rathi 2024-11-25 19:28:40 -05:00
commit 9963e01acc
158 changed files with 48198 additions and 0 deletions

View file

@ -0,0 +1,16 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export default function CenteredLayout(props: { children: React.ReactNode }) {
const { userId } = auth();
if (userId) {
redirect('/dashboard');
}
return (
<div className="flex min-h-screen items-center justify-center">
{props.children}
</div>
);
}

View file

@ -0,0 +1,22 @@
import { SignIn } from '@clerk/nextjs';
import { getTranslations } from 'next-intl/server';
import { getI18nPath } from '@/utils/Helpers';
export async function generateMetadata(props: { params: { locale: string } }) {
const t = await getTranslations({
locale: props.params.locale,
namespace: 'SignIn',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
const SignInPage = (props: { params: { locale: string } }) => (
<SignIn path={getI18nPath('/sign-in', props.params.locale)} />
);
export default SignInPage;

View file

@ -0,0 +1,22 @@
import { SignUp } from '@clerk/nextjs';
import { getTranslations } from 'next-intl/server';
import { getI18nPath } from '@/utils/Helpers';
export async function generateMetadata(props: { params: { locale: string } }) {
const t = await getTranslations({
locale: props.params.locale,
namespace: 'SignUp',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
const SignUpPage = (props: { params: { locale: string } }) => (
<SignUp path={getI18nPath('/sign-up', props.params.locale)} />
);
export default SignUpPage;

View file

@ -0,0 +1,37 @@
'use client';
import { Analytics } from '@/components/features/Analytics';
export default function AnalyticsPage() {
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<div className="col-span-4">
<Analytics />
</div>
<div className="col-span-3">
<div className="rounded-lg border bg-card p-6">
<h3 className="text-xl font-semibold">Key Metrics</h3>
<div className="mt-4 space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Users</p>
<p className="text-2xl font-bold">12,345</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Active Sessions</p>
<p className="text-2xl font-bold">1,234</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Conversion Rate</p>
<p className="text-2xl font-bold">2.4%</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
'use client';
import { Calendar as CalendarIcon } from 'lucide-react';
import { useState } from 'react';
import { Calendar } from '@/components/ui/calendar';
const events = [
{
id: 1,
title: 'Team Meeting',
date: '2024-01-10',
time: '10:00 AM',
type: 'meeting',
},
{
id: 2,
title: 'Project Deadline',
date: '2024-01-15',
time: '5:00 PM',
type: 'deadline',
},
{
id: 3,
title: 'Client Presentation',
date: '2024-01-20',
time: '2:00 PM',
type: 'presentation',
},
];
export default function CalendarPage() {
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Calendar</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border bg-card p-6">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border"
/>
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Upcoming Events</h3>
<div className="space-y-4">
{events.map(event => (
<div
key={event.id}
className="flex items-center space-x-4 rounded-lg border p-4"
>
<CalendarIcon className="size-5 text-muted-foreground" />
<div className="flex-1">
<p className="text-sm font-medium">{event.title}</p>
<p className="text-xs text-muted-foreground">
{event.date}
{' at '}
{event.time}
</p>
</div>
<span className="text-xs capitalize text-muted-foreground">
{event.type}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,105 @@
'use client';
import { Mail, Star, Trash2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
const emails = [
{
id: 1,
sender: 'Alice Johnson',
avatar: '/avatars/05.png',
subject: 'Project Update: Q1 Goals',
preview: 'I wanted to share the latest updates on our Q1 goals and progress...',
time: '10:30 AM',
unread: true,
starred: false,
},
{
id: 2,
sender: 'Robert Smith',
avatar: '/avatars/06.png',
subject: 'Design Review Meeting',
preview: 'Let\'s schedule a design review meeting for the new feature...',
time: 'Yesterday',
unread: false,
starred: true,
},
{
id: 3,
sender: 'Emma Davis',
avatar: '/avatars/07.png',
subject: 'Client Feedback',
preview: 'The client has provided feedback on the latest deliverables...',
time: 'Yesterday',
unread: true,
starred: false,
},
{
id: 4,
sender: 'James Wilson',
avatar: '/avatars/08.png',
subject: 'Team Building Event',
preview: 'I\'m excited to announce our upcoming team building event...',
time: 'Dec 20',
unread: false,
starred: false,
},
];
export default function InboxPage() {
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Inbox</h2>
</div>
<div className="rounded-lg border bg-card">
{emails.map(email => (
<div
key={email.id}
className={`flex cursor-pointer items-center space-x-4 border-b p-4 hover:bg-muted/50 ${
email.unread ? 'bg-muted/30' : ''
}`}
>
<Avatar>
<AvatarImage src={email.avatar} alt={email.sender} />
<AvatarFallback>
{email.sender
.split(' ')
.map(n => n[0])
.join('')}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<p className={`text-sm ${email.unread ? 'font-semibold' : 'font-medium'}`}>
{email.sender}
</p>
<div className="flex items-center space-x-2">
<p className="text-xs text-muted-foreground">{email.time}</p>
{email.starred && (
<Star className="size-4 fill-primary text-primary" />
)}
</div>
</div>
<p className={`text-sm ${email.unread ? 'font-medium' : ''}`}>
{email.subject}
</p>
<p className="line-clamp-1 text-xs text-muted-foreground">
{email.preview}
</p>
</div>
<div className="flex items-center space-x-2">
<button className="rounded-full p-2 hover:bg-muted">
<Mail className="size-4 text-muted-foreground" />
</button>
<button className="rounded-full p-2 hover:bg-muted">
<Trash2 className="size-4 text-muted-foreground" />
</button>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,28 @@
'use client';
import dynamic from 'next/dynamic';
// Use dynamic import with ssr: false to avoid hydration issues
const Header = dynamic(() => import('@/components/layout/Header'), {
ssr: false,
});
const Sidebar = dynamic(() => import('@/components/layout/Sidebar'), {
ssr: false,
});
export default function DashboardLayout(props: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<Header />
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="w-full pt-16">
<div className="px-4 py-8 md:px-16">
{props.children}
</div>
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
const messages = [
{
id: 1,
sender: 'John Doe',
avatar: '/avatars/01.png',
message: 'Hey, can you review the latest design changes?',
time: '2 hours ago',
unread: true,
},
{
id: 2,
sender: 'Sarah Wilson',
avatar: '/avatars/02.png',
message: 'The client meeting went well. They loved the proposal!',
time: '4 hours ago',
unread: false,
},
{
id: 3,
sender: 'Michael Brown',
avatar: '/avatars/03.png',
message: 'Don\'t forget about the team meeting tomorrow at 10 AM',
time: '6 hours ago',
unread: true,
},
{
id: 4,
sender: 'Emily Davis',
avatar: '/avatars/04.png',
message: 'I\'ve updated the project timeline. Please check when you can.',
time: '1 day ago',
unread: false,
},
];
export default function MessagesPage() {
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Messages</h2>
</div>
<div className="rounded-lg border bg-card">
{messages.map(message => (
<div
key={message.id}
className={`flex items-center space-x-4 border-b p-4 ${
message.unread ? 'bg-muted/50' : ''
}`}
>
<Avatar>
<AvatarImage src={message.avatar} alt={message.sender} />
<AvatarFallback>
{message.sender
.split(' ')
.map(n => n[0])
.join('')}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{message.sender}</p>
<p className="text-xs text-muted-foreground">{message.time}</p>
</div>
<p className="text-sm text-muted-foreground">{message.message}</p>
</div>
{message.unread && (
<div className="size-2 rounded-full bg-primary"></div>
)}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
export async function generateMetadata(props: {
params: { locale: string };
}): Promise<Metadata> {
const t = await getTranslations({
locale: props.params.locale,
namespace: 'Dashboard',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}

View file

@ -0,0 +1,35 @@
import { OrganizationProfile } from '@clerk/nextjs';
import { useTranslations } from 'next-intl';
import { TitleBar } from '@/features/dashboard/TitleBar';
import { getI18nPath } from '@/utils/Helpers';
const OrganizationProfilePage = (props: { params: { locale: string } }) => {
const t = useTranslations('OrganizationProfile');
return (
<>
<TitleBar
title={t('title_bar')}
description={t('title_bar_description')}
/>
<OrganizationProfile
routing="path"
path={getI18nPath(
'/dashboard/organization-profile',
props.params.locale,
)}
afterLeaveOrganizationUrl="/onboarding/organization-selection"
appearance={{
elements: {
rootBox: 'w-full',
cardBox: 'w-full flex',
},
}}
/>
</>
);
};
export default OrganizationProfilePage;

View file

@ -0,0 +1,36 @@
import { Analytics } from '@/components/features/Analytics';
import { TeamMembers } from '@/components/features/TeamMembers';
export default function DashboardPage() {
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<h3 className="text-sm font-medium tracking-tight">Total Revenue</h3>
</div>
<div className="text-2xl font-bold">$15,231.89</div>
<p className="text-xs text-muted-foreground">+20.1% from last month</p>
</div>
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<h3 className="text-sm font-medium tracking-tight">Active Users</h3>
</div>
<div className="text-2xl font-bold">+2,350</div>
<p className="text-xs text-muted-foreground">+180.1% from last month</p>
</div>
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<h3 className="text-sm font-medium tracking-tight">Sales Today</h3>
</div>
<div className="text-2xl font-bold">+12,234</div>
<p className="text-xs text-muted-foreground">+19% from yesterday</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Analytics />
<TeamMembers />
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { UserProfile } from '@clerk/nextjs';
import { useTranslations } from 'next-intl';
import { TitleBar } from '@/features/dashboard/TitleBar';
import { getI18nPath } from '@/utils/Helpers';
const UserProfilePage = (props: { params: { locale: string } }) => {
const t = useTranslations('UserProfile');
return (
<>
<TitleBar
title={t('title_bar')}
description={t('title_bar_description')}
/>
<UserProfile
routing="path"
path={getI18nPath('/dashboard/user-profile', props.params.locale)}
appearance={{
elements: {
rootBox: 'w-full',
cardBox: 'w-full flex',
},
}}
/>
</>
);
};
export default UserProfilePage;

View file

@ -0,0 +1,45 @@
'use client';
import { enUS, frFR } from '@clerk/localizations';
import { ClerkProvider } from '@clerk/nextjs';
import { AppConfig } from '@/utils/AppConfig';
export default function AuthLayout(props: {
children: React.ReactNode;
params: { locale: string };
}) {
let clerkLocale = enUS;
let signInUrl = '/sign-in';
let signUpUrl = '/sign-up';
let dashboardUrl = '/dashboard';
let afterSignOutUrl = '/';
let orgProfileUrl = '/dashboard/organization-profile';
let createOrgUrl = '/dashboard/organization-profile/create-organization';
if (props.params.locale === 'fr') {
clerkLocale = frFR;
}
if (props.params.locale !== AppConfig.defaultLocale) {
signInUrl = `/${props.params.locale}${signInUrl}`;
signUpUrl = `/${props.params.locale}${signUpUrl}`;
dashboardUrl = `/${props.params.locale}${dashboardUrl}`;
afterSignOutUrl = `/${props.params.locale}${afterSignOutUrl}`;
orgProfileUrl = `/${props.params.locale}${orgProfileUrl}`;
createOrgUrl = `/${props.params.locale}${createOrgUrl}`;
}
return (
<ClerkProvider
localization={clerkLocale}
signInUrl={signInUrl}
signUpUrl={signUpUrl}
afterSignInUrl={dashboardUrl}
afterSignUpUrl={dashboardUrl}
afterSignOutUrl={afterSignOutUrl}
>
{props.children}
</ClerkProvider>
);
}

View file

@ -0,0 +1,29 @@
import { OrganizationList } from '@clerk/nextjs';
import { getTranslations } from 'next-intl/server';
export async function generateMetadata(props: { params: { locale: string } }) {
const t = await getTranslations({
locale: props.params.locale,
namespace: 'Dashboard',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
const OrganizationSelectionPage = () => (
<div className="flex min-h-screen items-center justify-center">
<OrganizationList
afterSelectOrganizationUrl="/dashboard"
afterCreateOrganizationUrl="/dashboard"
hidePersonal
skipInvitationScreen
/>
</div>
);
export const dynamic = 'force-dynamic';
export default OrganizationSelectionPage;

View file

@ -0,0 +1,41 @@
import { getTranslations, unstable_setRequestLocale } from 'next-intl/server';
import { CTA } from '@/templates/CTA';
import { FAQ } from '@/templates/FAQ';
import { Features } from '@/templates/Features';
import { Footer } from '@/templates/Footer';
import { Hero } from '@/templates/Hero';
import { Navbar } from '@/templates/Navbar';
import { Pricing } from '@/templates/Pricing';
import { Sponsors } from '@/templates/Sponsors';
export async function generateMetadata(props: { params: { locale: string } }) {
const t = await getTranslations({
locale: props.params.locale,
namespace: 'Index',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
const IndexPage = (props: { params: { locale: string } }) => {
unstable_setRequestLocale(props.params.locale);
return (
<>
<Navbar />
<Hero />
<Sponsors />
<Features />
<Pricing />
<FAQ />
<CTA />
<Footer />
</>
);
};
export default IndexPage;

View file

@ -0,0 +1,57 @@
import '@/styles/global.css';
import type { Metadata } from 'next';
import { NextIntlClientProvider, useMessages } from 'next-intl';
import { unstable_setRequestLocale } from 'next-intl/server';
import { AllLocales } from '@/utils/AppConfig';
export const metadata: Metadata = {
icons: [
{
rel: 'apple-touch-icon',
url: '/apple-touch-icon.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
url: '/favicon-32x32.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '16x16',
url: '/favicon-16x16.png',
},
{
rel: 'icon',
url: '/favicon.ico',
},
],
};
export function generateStaticParams() {
return AllLocales.map(locale => ({ locale }));
}
export default function RootLayout(props: {
children: React.ReactNode;
params: { locale: string };
}) {
unstable_setRequestLocale(props.params.locale);
const messages = useMessages();
return (
<html lang={props.params.locale} suppressHydrationWarning>
<body className="bg-background text-foreground antialiased" suppressHydrationWarning>
<NextIntlClientProvider
locale={props.params.locale}
messages={messages}
>
{props.children}
</NextIntlClientProvider>
</body>
</html>
);
}

26
src/app/global-error.tsx Normal file
View file

@ -0,0 +1,26 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
export default function GlobalError(props: {
error: Error & { digest?: string };
params: { locale: string };
}) {
useEffect(() => {
Sentry.captureException(props.error);
}, [props.error]);
return (
<html lang={props.params.locale}>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

13
src/app/robots.ts Normal file
View file

@ -0,0 +1,13 @@
import type { MetadataRoute } from 'next';
import { getBaseUrl } from '@/utils/Helpers';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: `${getBaseUrl()}/sitemap.xml`,
};
}

15
src/app/sitemap.ts Normal file
View file

@ -0,0 +1,15 @@
import type { MetadataRoute } from 'next';
import { getBaseUrl } from '@/utils/Helpers';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: `${getBaseUrl()}/`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.7,
},
// Add more URLs here
];
}

View file

@ -0,0 +1,23 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/utils/Helpers';
export const ActiveLink = (props: { href: string; children: React.ReactNode }) => {
const pathname = usePathname();
return (
<Link
href={props.href}
className={cn(
'px-3 py-2',
pathname.endsWith(props.href)
&& 'rounded-md bg-primary text-primary-foreground',
)}
>
{props.children}
</Link>
);
};

View file

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Background } from './Background';
const meta = {
title: 'Components/Background',
component: Background,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
} satisfies Meta<typeof Background>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultBackgroundWithChildren = {
args: {
children: <div>Children node</div>,
},
} satisfies Story;
export const RedBackgroundWithChildren = {
args: {
className: 'bg-red-500',
children: <div>Children node</div>,
},
} satisfies Story;

View file

@ -0,0 +1,10 @@
import { cn } from '@/utils/Helpers';
export const Background = (props: {
children: React.ReactNode;
className?: string;
}) => (
<div className={cn('w-full bg-secondary', props.className)}>
{props.children}
</div>
);

View file

@ -0,0 +1,22 @@
'use client';
import { ClerkProvider as BaseClerkProvider } from '@clerk/nextjs';
import { useLocale } from 'next-intl';
import * as React from 'react';
export const ClerkProvider = ({ children }: { children: React.ReactNode }) => {
const locale = useLocale();
return (
<BaseClerkProvider
localization={{ locale }}
signInUrl={`/${locale}/sign-in`}
signUpUrl={`/${locale}/sign-up`}
afterSignInUrl={`/${locale}/dashboard`}
afterSignUpUrl={`/${locale}/dashboard`}
afterSignOutUrl={`/${locale}`}
>
{children}
</BaseClerkProvider>
);
};

View file

@ -0,0 +1,12 @@
export const DemoBadge = () => (
<div className="fixed bottom-0 right-20 z-10">
<a
href="https://react-saas.com"
>
<div className="rounded-md bg-gray-900 px-3 py-2 font-semibold text-gray-100">
<span className="text-gray-500">Demo of</span>
{' SaaS Boilerplate'}
</div>
</a>
</div>
);

View file

@ -0,0 +1,55 @@
'use client';
import { useLocale } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { usePathname, useRouter } from '@/libs/i18nNavigation';
import { AppConfig } from '@/utils/AppConfig';
export const LocaleSwitcher = () => {
const router = useRouter();
const pathname = usePathname();
const locale = useLocale();
const handleChange = (value: string) => {
router.push(pathname, { locale: value });
router.refresh();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="p-2 focus-visible:ring-offset-0" variant="ghost" size="icon" aria-label="lang-switcher">
<svg
xmlns="http://www.w3.org/2000/svg"
className="size-6 stroke-current stroke-2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M3 12a9 9 0 1 0 18 0 9 9 0 0 0-18 0M3.6 9h16.8M3.6 15h16.8" />
<path d="M11.5 3a17 17 0 0 0 0 18M12.5 3a17 17 0 0 1 0 18" />
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup value={locale} onValueChange={handleChange}>
{AppConfig.locales.map(elt => (
<DropdownMenuRadioItem key={elt.id} value={elt.id}>
{elt.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};

View file

@ -0,0 +1,8 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { ToggleMenuButton } from './ToggleMenuButton';
describe('ToggleMenuButton', () => {
describe('onClick props', () => {
it('should call the callback when the user click on the button', async () => {
const handler = vi.fn();
render(<ToggleMenuButton onClick={handler} />);
const button = screen.getByRole('button');
await userEvent.click(button);
expect(handler).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,40 @@
import { type ForwardedRef, forwardRef } from 'react';
import { Button } from '@/components/ui/button';
/**
* A toggle button to show/hide component in small screen.
* @component
* @params props - Component props.
* @params props.onClick - Function to run when the button is clicked.
*/
const ToggleMenuButtonInternal = (
props: {
onClick?: () => void;
},
ref?: ForwardedRef<HTMLButtonElement>,
) => (
<Button
className="p-2 focus-visible:ring-offset-0"
variant="ghost"
ref={ref}
{...props}
>
<svg
className="size-6 stroke-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</Button>
);
const ToggleMenuButton = forwardRef(ToggleMenuButtonInternal);
export { ToggleMenuButton };

View file

@ -0,0 +1,107 @@
'use client';
import type { TooltipProps } from 'recharts';
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
type DataPoint = {
revenue: number;
date: string;
};
const data: DataPoint[] = [
{
revenue: 400,
date: 'Jan 1',
},
{
revenue: 300,
date: 'Jan 2',
},
{
revenue: 500,
date: 'Jan 3',
},
{
revenue: 700,
date: 'Jan 4',
},
{
revenue: 400,
date: 'Jan 5',
},
{
revenue: 600,
date: 'Jan 6',
},
];
const CustomTooltip = ({
active,
payload,
}: TooltipProps<ValueType, NameType>) => {
if (!active || !payload?.length) {
return null;
}
const value = payload[0]?.value;
if (typeof value !== 'number') {
return null;
}
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Revenue
</span>
<span className="font-bold text-muted-foreground">
$
{value}
</span>
</div>
</div>
</div>
);
};
export function Analytics() {
return (
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<h3 className="text-sm font-medium tracking-tight">Revenue over time</h3>
</div>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<XAxis
dataKey="date"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip content={CustomTooltip} />
<Line
type="monotone"
dataKey="revenue"
strokeWidth={2}
activeDot={{
r: 6,
style: { fill: 'var(--theme-primary)', opacity: 0.8 },
}}
style={
{
stroke: 'var(--theme-primary)',
opacity: 0.8,
} as React.CSSProperties
}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
type TeamMember = {
name: string;
email: string;
avatar?: string;
};
const teamMembers: TeamMember[] = [
{
name: 'Sofia Davis',
email: 'sofia.davis@example.com',
},
{
name: 'Jackson Lee',
email: 'jackson.lee@example.com',
},
{
name: 'Isabella Kim',
email: 'isabella.kim@example.com',
},
{
name: 'William Chen',
email: 'william.chen@example.com',
},
{
name: 'Emma Wilson',
email: 'emma.wilson@example.com',
},
];
export function TeamMembers() {
return (
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<h3 className="text-sm font-medium tracking-tight">Team Members</h3>
</div>
<div className="divide-y">
{teamMembers.map(member => (
<div
key={member.email}
className="flex items-center justify-between py-4"
>
<div className="flex items-center space-x-4">
<Avatar>
{member.avatar && (
<AvatarImage src={member.avatar} alt={member.name} />
)}
<AvatarFallback>
{member.name
.split(' ')
.map(n => n[0])
.join('')}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium leading-none">{member.name}</p>
<p className="text-sm text-muted-foreground">{member.email}</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
'use client';
import { UserButton } from '@clerk/nextjs';
import { Bell, Mail, Moon, Search, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
export default function Header() {
const { theme, setTheme } = useTheme();
return (
<header className="fixed top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-16 items-center px-4 md:px-6">
<div className="flex flex-1 items-center gap-4">
<div className="relative w-full max-w-sm">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<input
type="search"
placeholder="Search..."
className="flex h-9 w-full rounded-md border border-input bg-background px-8 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<kbd className="pointer-events-none absolute right-2.5 top-2.5 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>
K
</kbd>
</div>
</div>
<div className="flex items-center gap-4">
<button className="inline-flex size-10 items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50">
<Bell className="size-4" />
<span className="sr-only">Notifications</span>
</button>
<button className="inline-flex size-10 items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50">
<Mail className="size-4" />
<span className="sr-only">Messages</span>
</button>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="inline-flex size-10 items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</button>
<UserButton
appearance={{
elements: {
rootBox: 'h-10 w-10',
userButtonAvatarBox: 'w-10 h-10',
},
}}
/>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,75 @@
'use client';
import { OrganizationSwitcher } from '@clerk/nextjs';
import {
BarChart3,
Calendar,
CreditCard,
Home,
Inbox,
LayoutDashboard,
MessageSquare,
Settings,
Users,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLocale } from 'next-intl';
import { getI18nPath } from '@/utils/Helpers';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: Home },
{ name: 'Analytics', href: '/dashboard/analytics', icon: BarChart3 },
{ name: 'Team', href: '/dashboard/organization-profile/organization-members', icon: Users },
{ name: 'Messages', href: '/dashboard/messages', icon: MessageSquare },
{ name: 'Calendar', href: '/dashboard/calendar', icon: Calendar },
{ name: 'Inbox', href: '/dashboard/inbox', icon: Inbox },
{ name: 'Payments', href: '/dashboard/payments', icon: CreditCard },
{ name: 'Settings', href: '/dashboard/organization-profile', icon: Settings },
];
export default function Sidebar() {
const pathname = usePathname();
const locale = useLocale();
return (
<div className="flex h-full w-[250px] flex-col border-r bg-muted/10">
<div className="flex h-16 items-center gap-2 border-b px-6">
<LayoutDashboard className="size-6" />
<OrganizationSwitcher
organizationProfileMode="navigation"
organizationProfileUrl={getI18nPath('/dashboard/organization-profile', locale)}
afterCreateOrganizationUrl="/dashboard"
hidePersonal
appearance={{
elements: {
organizationSwitcherTrigger: 'font-semibold',
},
}}
/>
</div>
<div className="flex-1 overflow-auto py-2">
<nav className="grid items-start px-4 text-sm font-medium">
{navigation.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-foreground ${
pathname === item.href
? 'bg-muted/50 text-foreground'
: 'hover:bg-muted/30'
}`}
>
<Icon className="size-4" />
{item.name}
</Link>
);
})}
</nav>
</div>
</div>
);
}

View file

@ -0,0 +1,58 @@
'use client';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronRight } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/utils/Helpers';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-5 text-left text-lg font-medium transition-all hover:no-underline [&[data-state=open]>svg]:rotate-90',
className,
)}
{...props}
>
{children}
<ChevronRight className="size-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-base text-muted-foreground transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-5 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View file

@ -0,0 +1,48 @@
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from 'react';
import { cn } from '@/utils/cn';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarFallback, AvatarImage };

View file

@ -0,0 +1,16 @@
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/utils/Helpers';
import { badgeVariants } from './badgeVariants';
export type BadgeProps = {} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge };

View file

@ -0,0 +1,21 @@
import { cva } from 'class-variance-authority';
export const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);

View file

@ -0,0 +1,27 @@
import { Slot } from '@radix-ui/react-slot';
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/utils/Helpers';
import { buttonVariants } from './buttonVariants';
export type ButtonProps = {
asChild?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
export { Button };

View file

@ -0,0 +1,30 @@
import { cva } from 'class-variance-authority';
export const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);

View file

@ -0,0 +1,65 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import * as React from 'react';
import { DayPicker } from 'react-day-picker';
import { buttonVariants } from '@/components/ui/buttonVariants';
import { cn } from '@/utils/cn';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'size-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'size-9 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
}}
components={{
IconLeft: () => <ChevronLeft className="size-4" />,
IconRight: () => <ChevronRight className="size-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View file

@ -0,0 +1,84 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
};
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const t = useTranslations('DataTable');
return (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length
? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)
: (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{t('no_results')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,200 @@
'use client';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/utils/Helpers';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName
= DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName
= DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName
= DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

135
src/components/ui/form.tsx Normal file
View file

@ -0,0 +1,135 @@
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import { Controller, FormProvider } from 'react-hook-form';
import { Label } from '@/components/ui/label';
import { cn } from '@/utils/Helpers';
import { FormFieldContext, FormItemContext, useFormField } from './useFormField';
const Form = FormProvider;
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const formFieldName = React.useMemo(() => ({ name: props.name }), []);
return (
<FormFieldContext.Provider value={formFieldName}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
// eslint-disable-next-line react-hooks/exhaustive-deps
const formItemId = React.useMemo(() => ({ id }), []);
return (
<FormItemContext.Provider value={formItemId}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId }
= useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
};

View file

@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/utils/Helpers';
export type InputProps = {} & React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

View file

@ -0,0 +1,26 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/utils/Helpers';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View file

@ -0,0 +1,31 @@
'use client';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from 'react';
import { cn } from '@/utils/Helpers';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

117
src/components/ui/table.tsx Normal file
View file

@ -0,0 +1,117 @@
import * as React from 'react';
import { cn } from '@/utils/Helpers';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};

View file

@ -0,0 +1,30 @@
'use client';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import * as React from 'react';
import { cn } from '@/utils/Helpers';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View file

@ -0,0 +1,44 @@
import React from 'react';
import { type FieldPath, type FieldValues, useFormContext } from 'react-hook-form';
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
export const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
type FormItemContextValue = {
id: string;
};
export const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
export const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};

View file

@ -0,0 +1,23 @@
import { useTranslations } from 'next-intl';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
export const ProtectFallback = (props: { trigger: React.ReactNode }) => {
const t = useTranslations('ProtectFallback');
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>{props.trigger}</TooltipTrigger>
<TooltipContent align="center">
<p>{t('not_enough_permission')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View file

@ -0,0 +1,40 @@
import { useTranslations } from 'next-intl';
import React from 'react';
import type { BillingInterval } from '@/types/Subscription';
export const PricingCard = (props: {
planId: string;
price: number;
interval: BillingInterval;
button: React.ReactNode;
children: React.ReactNode;
}) => {
const t = useTranslations('PricingPlan');
return (
<div className="rounded-xl border border-border px-6 py-8 text-center">
<div className="text-lg font-semibold">
{t(`${props.planId}_plan_name`)}
</div>
<div className="mt-3 flex items-center justify-center">
<div className="text-5xl font-bold">
{`$${props.price}`}
</div>
<div className="ml-1 text-muted-foreground">
{`/ ${t(`plan_interval_${props.interval}`)}`}
</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{t(`${props.planId}_plan_description`)}
</div>
{props.button}
<ul className="mt-8 space-y-3">{props.children}</ul>
</div>
);
};

View file

@ -0,0 +1,16 @@
export const PricingFeature = (props: { children: React.ReactNode }) => (
<li className="flex items-center text-muted-foreground">
<svg
className="mr-1 size-6 stroke-current stroke-2 text-purple-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M5 12l5 5L20 7" />
</svg>
{props.children}
</li>
);

View file

@ -0,0 +1,51 @@
import { useTranslations } from 'next-intl';
import { PricingCard } from '@/features/billing/PricingCard';
import { PricingFeature } from '@/features/billing/PricingFeature';
import { PricingPlanList } from '@/utils/AppConfig';
export const PricingInformation = (props: {
buttonList: Record<string, React.ReactNode>;
}) => {
const t = useTranslations('PricingPlan');
return (
<div className="grid grid-cols-1 gap-x-6 gap-y-8 md:grid-cols-3">
{Object.values(PricingPlanList).map(plan => (
<PricingCard
key={plan.id}
planId={plan.id}
price={plan.price}
interval={plan.interval}
button={props.buttonList[plan.id]}
>
<PricingFeature>
{t('feature_team_member', {
number: plan.features.teamMember,
})}
</PricingFeature>
<PricingFeature>
{t('feature_website', {
number: plan.features.website,
})}
</PricingFeature>
<PricingFeature>
{t('feature_storage', {
number: plan.features.storage,
})}
</PricingFeature>
<PricingFeature>
{t('feature_transfer', {
number: plan.features.transfer,
})}
</PricingFeature>
<PricingFeature>{t('feature_email_support')}</PricingFeature>
</PricingCard>
))}
</div>
);
};

View file

@ -0,0 +1,118 @@
'use client';
import { OrganizationSwitcher, UserButton } from '@clerk/nextjs';
import Link from 'next/link';
import { useLocale } from 'next-intl';
import { ActiveLink } from '@/components/ActiveLink';
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
import { ToggleMenuButton } from '@/components/ToggleMenuButton';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { Logo } from '@/templates/Logo';
import { getI18nPath } from '@/utils/Helpers';
export const DashboardHeader = (props: {
menu: {
href: string;
label: string;
}[];
}) => {
const locale = useLocale();
return (
<>
<div className="flex items-center">
<Link href="/dashboard" className="max-sm:hidden">
<Logo />
</Link>
<svg
className="size-8 stroke-muted-foreground max-sm:hidden"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M17 5 7 19" />
</svg>
<OrganizationSwitcher
organizationProfileMode="navigation"
organizationProfileUrl={getI18nPath(
'/dashboard/organization-profile',
locale,
)}
afterCreateOrganizationUrl="/dashboard"
hidePersonal
skipInvitationScreen
appearance={{
elements: {
organizationSwitcherTrigger: 'max-w-28 sm:max-w-52',
},
}}
/>
<nav className="ml-3 max-lg:hidden">
<ul className="flex flex-row items-center gap-x-3 text-lg font-medium [&_a:hover]:opacity-100 [&_a]:opacity-75">
{props.menu.map(item => (
<li key={item.href}>
<ActiveLink href={item.href}>{item.label}</ActiveLink>
</li>
))}
</ul>
</nav>
</div>
<div>
<ul className="flex items-center gap-x-1.5 [&_li[data-fade]:hover]:opacity-100 [&_li[data-fade]]:opacity-60">
<li data-fade>
<div className="lg:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToggleMenuButton />
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.menu.map(item => (
<DropdownMenuItem key={item.href} asChild>
<Link href={item.href}>{item.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
{/* PRO: Dark mode toggle button */}
<li data-fade>
<LocaleSwitcher />
</li>
<li>
<Separator orientation="vertical" className="h-4" />
</li>
<li>
<UserButton
userProfileMode="navigation"
userProfileUrl="/dashboard/user-profile"
appearance={{
elements: {
rootBox: 'px-2 py-1.5',
},
}}
/>
</li>
</ul>
</div>
</>
);
};

View file

@ -0,0 +1,17 @@
export const DashboardSection = (props: {
title: string;
description: string;
children: React.ReactNode;
}) => (
<div className="rounded-md bg-card p-5">
<div className="max-w-3xl">
<div className="text-lg font-semibold">{props.title}</div>
<div className="mb-4 text-sm font-medium text-muted-foreground">
{props.description}
</div>
{props.children}
</div>
</div>
);

View file

@ -0,0 +1,23 @@
import React from 'react';
export const MessageState = (props: {
icon: React.ReactNode;
title: React.ReactNode;
description: React.ReactNode;
button: React.ReactNode;
}) => (
<div className="flex h-[600px] flex-col items-center justify-center rounded-md bg-card p-5">
<div className="size-16 rounded-full bg-muted p-3 [&_svg]:stroke-muted-foreground [&_svg]:stroke-2">
{props.icon}
</div>
<div className="mt-3 text-center">
<div className="text-xl font-semibold">{props.title}</div>
<div className="mt-1 text-sm font-medium text-muted-foreground">
{props.description}
</div>
<div className="mt-5">{props.button}</div>
</div>
</div>
);

View file

@ -0,0 +1,14 @@
export const TitleBar = (props: {
title: React.ReactNode;
description?: React.ReactNode;
}) => (
<div className="mb-8">
<div className="text-2xl font-semibold">{props.title}</div>
{props.description && (
<div className="text-sm font-medium text-muted-foreground">
{props.description}
</div>
)}
</div>
);

View file

@ -0,0 +1,17 @@
export const CTABanner = (props: {
title: string;
description: string;
buttons: React.ReactNode;
}) => (
<div className="rounded-xl bg-muted bg-gradient-to-br from-indigo-400 via-purple-400 to-pink-400 px-6 py-10 text-center">
<div className="text-3xl font-bold text-primary-foreground">
{props.title}
</div>
<div className="mt-2 text-lg font-medium text-muted">
{props.description}
</div>
<div className="mt-6">{props.buttons}</div>
</div>
);

View file

@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import messages from '@/locales/en.json';
import { CenteredFooter } from './CenteredFooter';
describe('CenteredFooter', () => {
describe('Render method', () => {
it('should have copyright information', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<CenteredFooter logo={null} name="" iconList={null} legalLinks={null}>
Random children
</CenteredFooter>
</NextIntlClientProvider>,
);
const copyright = screen.getByText(/© Copyright/);
expect(copyright).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,52 @@
import { useTranslations } from 'next-intl';
import React from 'react';
export const CenteredFooter = (props: {
logo: React.ReactNode;
name: string;
iconList: React.ReactNode;
legalLinks: React.ReactNode;
children: React.ReactNode;
}) => {
const t = useTranslations('Footer');
return (
<div className="flex flex-col items-center text-center">
{props.logo}
<ul className="mt-4 flex gap-x-8 text-lg max-sm:flex-col [&_a:hover]:opacity-100 [&_a]:opacity-60">
{props.children}
</ul>
<ul className="mt-4 flex flex-row gap-x-5 text-muted-foreground [&_svg:hover]:text-primary [&_svg:hover]:opacity-100 [&_svg]:size-5 [&_svg]:fill-current [&_svg]:opacity-60">
{props.iconList}
</ul>
<div className="mt-6 flex w-full items-center justify-between gap-y-2 border-t pt-3 text-sm text-muted-foreground max-md:flex-col">
<div>
{`© Copyright ${new Date().getFullYear()} ${props.name}. `}
{t.rich('designed_by', {
author: () => (
<a
className="text-blue-500 hover:text-blue-600"
href="https://creativedesignsguru.com"
>
Creative Designs Guru
</a>
),
})}
{/*
* PLEASE READ THIS SECTION
* I'm an indie maker with limited resources and funds, I'll really appreciate if you could have a link to my website.
* The link doesn't need to appear on every pages, one link on one page is enough.
* For example, in the `About` page. Thank you for your support, it'll mean a lot to me.
*/}
</div>
<ul className="flex gap-x-4 font-medium [&_a:hover]:opacity-100 [&_a]:opacity-60">
{props.legalLinks}
</ul>
</div>
</div>
);
};

View file

@ -0,0 +1,22 @@
export const CenteredHero = (props: {
banner: React.ReactNode;
title: React.ReactNode;
description: string;
buttons: React.ReactNode;
}) => (
<>
<div className="text-center">{props.banner}</div>
<div className="mt-3 text-center text-5xl font-bold tracking-tight">
{props.title}
</div>
<div className="mx-auto mt-5 max-w-screen-md text-center text-xl text-muted-foreground">
{props.description}
</div>
<div className="mt-8 flex justify-center gap-x-5 gap-y-3 max-sm:flex-col">
{props.buttons}
</div>
</>
);

View file

@ -0,0 +1,46 @@
'use client';
import Link from 'next/link';
import { ToggleMenuButton } from '@/components/ToggleMenuButton';
import { useMenu } from '@/hooks/UseMenu';
import { cn } from '@/utils/Helpers';
export const CenteredMenu = (props: {
logo: React.ReactNode;
children: React.ReactNode;
rightMenu: React.ReactNode;
}) => {
const { showMenu, handleToggleMenu } = useMenu();
const navClass = cn('max-lg:w-full max-lg:bg-secondary max-lg:p-5', {
'max-lg:hidden': !showMenu,
});
return (
<div className="flex flex-wrap items-center justify-between">
<Link href="/">{props.logo}</Link>
<div className="lg:hidden [&_button:hover]:opacity-100 [&_button]:opacity-60">
<ToggleMenuButton onClick={handleToggleMenu} />
</div>
<nav className={cn('rounded-t max-lg:mt-2', navClass)}>
<ul className="flex gap-x-6 gap-y-1 text-lg font-medium max-lg:flex-col [&_a:hover]:opacity-100 [&_a]:opacity-60 max-lg:[&_a]:inline-block max-lg:[&_a]:w-full">
{props.children}
</ul>
</nav>
<div
className={cn(
'rounded-b max-lg:border-t max-lg:border-border',
navClass,
)}
>
<ul className="flex flex-row items-center gap-x-1.5 text-lg font-medium [&_li[data-fade]:hover]:opacity-100 [&_li[data-fade]]:opacity-60">
{props.rightMenu}
</ul>
</div>
</div>
);
};

View file

@ -0,0 +1,17 @@
export const FeatureCard = (props: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) => (
<div className="rounded-xl border border-border bg-card p-5">
<div className="size-12 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-400 to-pink-400 p-2 [&_svg]:stroke-white [&_svg]:stroke-2">
{props.icon}
</div>
<div className="mt-2 text-lg font-bold">{props.title}</div>
<div className="my-3 w-8 border-t border-purple-400" />
<div className="mt-2 text-muted-foreground">{props.children}</div>
</div>
);

View file

@ -0,0 +1,11 @@
export const LogoCloud = (props: { text: string; children: React.ReactNode }) => (
<>
<div className="text-center text-xl font-medium text-muted-foreground">
{props.text}
</div>
<div className="mt-6 grid grid-cols-2 place-items-center gap-x-3 gap-y-6 md:grid-cols-4 md:gap-x-20 [&_a:hover]:opacity-100 [&_a]:opacity-60">
{props.children}
</div>
</>
);

View file

@ -0,0 +1,33 @@
import { cn } from '@/utils/Helpers';
export const Section = (props: {
children: React.ReactNode;
title?: string;
subtitle?: string;
description?: string;
className?: string;
}) => (
<div className={cn('px-3 py-16', props.className)}>
{(props.title || props.subtitle || props.description) && (
<div className="mx-auto mb-12 max-w-screen-md text-center">
{props.subtitle && (
<div className="bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 bg-clip-text text-sm font-bold text-transparent">
{props.subtitle}
</div>
)}
{props.title && (
<div className="mt-1 text-3xl font-bold">{props.title}</div>
)}
{props.description && (
<div className="mt-2 text-lg text-muted-foreground">
{props.description}
</div>
)}
</div>
)}
<div className="mx-auto max-w-screen-lg">{props.children}</div>
</div>
);

View file

@ -0,0 +1,5 @@
export const StickyBanner = (props: { children: React.ReactNode }) => (
<div className="sticky top-0 z-50 bg-primary p-4 text-center text-lg font-semibold text-primary-foreground [&_a:hover]:text-indigo-500 [&_a]:text-fuchsia-500">
{props.children}
</div>
);

View file

@ -0,0 +1,84 @@
/* eslint-disable react-dom/no-unsafe-target-blank */
import Image from 'next/image';
import { LogoCloud } from '@/features/landing/LogoCloud';
export const SponsorLogos = () => (
<LogoCloud text="Sponsored by">
<a
href="https://clerk.com?utm_source=github&utm_medium=sponsorship&utm_campaign=nextjs-boilerplate"
target="_blank"
rel="noopener"
>
<Image
src="/assets/images/clerk-logo-dark.png"
alt="Clerk logo dark"
className="dark:hidden"
width="128"
height="37"
/>
<Image
src="/assets/images/clerk-logo-white.png"
alt="Clerk logo light"
className="hidden dark:block"
width="128"
height="37"
/>
</a>
<a
href="https://l.crowdin.com/next-js"
target="_blank"
rel="noopener"
>
<Image
src="/assets/images/crowdin-dark.png"
alt="Crowdin logo dark"
className="dark:hidden"
width="128"
height="26"
/>
<Image
src="/assets/images/crowdin-white.png"
alt="Crowdin logo light"
className="hidden dark:block"
width="128"
height="26"
/>
</a>
<a
href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
target="_blank"
rel="noopener"
>
<Image
src="/assets/images/sentry-dark.png"
alt="Sentry logo dark"
className="dark:hidden"
width="128"
height="38"
/>
<Image
src="/assets/images/sentry-white.png"
alt="Sentry logo light"
className="hidden dark:block"
width="128"
height="38"
/>
</a>
<a
href="https://nextjs-boilerplate.com/pro-saas-starter-kit"
target="_blank"
rel="noopener"
>
<Image
src="/assets/images/nextjs-boilerplate-saas.png"
alt="Nextjs SaaS Boilerplate"
width="128"
height="30"
/>
</a>
</LogoCloud>
);

44
src/hooks/UseMenu.test.ts Normal file
View file

@ -0,0 +1,44 @@
import { act, renderHook } from '@testing-library/react';
import { useMenu } from './UseMenu';
describe('UseMenu', () => {
describe('Render hook', () => {
it('shouldn\'t show the menu by default', async () => {
const { result } = renderHook(() => useMenu());
expect(result.current.showMenu).toBeFalsy();
});
it('should make the menu visible by toggling the menu', () => {
const { result } = renderHook(() => useMenu());
act(() => {
result.current.handleToggleMenu();
});
expect(result.current.showMenu).toBeTruthy();
});
it('shouldn\'t make the menu visible after toggling and closing the menu', () => {
const { result } = renderHook(() => useMenu());
act(() => {
result.current.handleClose();
});
expect(result.current.showMenu).toBeFalsy();
});
it('shouldn\'t make the menu visible after toggling the menu twice', () => {
const { result } = renderHook(() => useMenu());
act(() => {
result.current.handleToggleMenu();
result.current.handleToggleMenu();
});
expect(result.current.showMenu).toBeFalsy();
});
});
});

19
src/hooks/UseMenu.ts Normal file
View file

@ -0,0 +1,19 @@
import { useState } from 'react';
/**
* React Hook to toggle element. Mostly used for responsive menu.
* @hook
*/
export const useMenu = () => {
const [showMenu, setShowMenu] = useState(false);
const handleToggleMenu = () => {
setShowMenu(prevState => !prevState);
};
const handleClose = () => {
setShowMenu(false);
};
return { showMenu, handleToggleMenu, handleClose };
};

37
src/instrumentation.ts Normal file
View file

@ -0,0 +1,37 @@
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Node.js Sentry configuration
Sentry.init({
// Sentry DSN
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Enable Spotlight in development
spotlight: process.env.NODE_ENV === 'development',
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
}
if (process.env.NEXT_RUNTIME === 'edge') {
// Edge Sentry configuration
Sentry.init({
// Sentry DSN
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Enable Spotlight in development
spotlight: process.env.NODE_ENV === 'development',
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
}
}

45
src/libs/DB.ts Normal file
View file

@ -0,0 +1,45 @@
import path from 'node:path';
import { PGlite } from '@electric-sql/pglite';
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator';
import { drizzle as drizzlePglite, type PgliteDatabase } from 'drizzle-orm/pglite';
import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator';
import { PHASE_PRODUCTION_BUILD } from 'next/dist/shared/lib/constants';
import { Client } from 'pg';
import * as schema from '@/models/Schema';
import { Env } from './Env';
let client;
let drizzle;
if (process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD && Env.DATABASE_URL) {
client = new Client({
connectionString: Env.DATABASE_URL,
});
await client.connect();
drizzle = drizzlePg(client, { schema });
await migratePg(drizzle, {
migrationsFolder: path.join(process.cwd(), 'migrations'),
});
} else {
// Stores the db connection in the global scope to prevent multiple instances due to hot reloading with Next.js
const global = globalThis as unknown as { client: PGlite; drizzle: PgliteDatabase<typeof schema> };
if (!global.client) {
global.client = new PGlite();
await global.client.waitReady;
global.drizzle = drizzlePglite(global.client, { schema });
}
drizzle = global.drizzle;
await migratePglite(global.drizzle, {
migrationsFolder: path.join(process.cwd(), 'migrations'),
});
}
export const db = drizzle;

38
src/libs/Env.ts Normal file
View file

@ -0,0 +1,38 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const Env = createEnv({
server: {
CLERK_SECRET_KEY: z.string().min(1),
DATABASE_URL: z.string().optional(),
LOGTAIL_SOURCE_TOKEN: z.string().optional(),
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
BILLING_PLAN_ENV: z.enum(['dev', 'test', 'prod']),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().optional(),
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string().min(1),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
},
shared: {
NODE_ENV: z.enum(['test', 'development', 'production']).optional(),
},
// You need to destructure all the keys manually
runtimeEnv: {
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
DATABASE_URL: process.env.DATABASE_URL,
LOGTAIL_SOURCE_TOKEN: process.env.LOGTAIL_SOURCE_TOKEN,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
BILLING_PLAN_ENV: process.env.BILLING_PLAN_ENV,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
NODE_ENV: process.env.NODE_ENV,
},
});

27
src/libs/Logger.ts Normal file
View file

@ -0,0 +1,27 @@
import logtail from '@logtail/pino';
import pino, { type DestinationStream } from 'pino';
import pretty from 'pino-pretty';
import { Env } from './Env';
let stream: DestinationStream;
if (Env.LOGTAIL_SOURCE_TOKEN) {
stream = pino.multistream([
await logtail({
sourceToken: Env.LOGTAIL_SOURCE_TOKEN,
options: {
sendLogsToBetterStack: true,
},
}),
{
stream: pretty(), // Prints logs to the console
},
]);
} else {
stream = pretty({
colorize: true,
});
}
export const logger = pino({ base: undefined }, stream);

26
src/libs/i18n.ts Normal file
View file

@ -0,0 +1,26 @@
import { notFound } from 'next/navigation';
import { getRequestConfig } from 'next-intl/server';
import { AllLocales } from '@/utils/AppConfig';
// NextJS Boilerplate uses Crowdin as the localization software.
// As a developer, you only need to take care of the English (or another default language) version.
// Other languages are automatically generated and handled by Crowdin.
// The localisation files are synced with Crowdin using GitHub Actions.
// By default, there are 3 ways to sync the message files:
// 1. Automatically sync on push to the `main` branch
// 2. Run manually the workflow on GitHub Actions
// 3. Every 24 hours at 5am, the workflow will run automatically
// Using internationalization in Server Components
export default getRequestConfig(async ({ locale }) => {
// Validate that the incoming `locale` parameter is valid
if (!AllLocales.includes(locale)) {
notFound();
}
return {
messages: (await import(`../locales/${locale}.json`)).default,
};
});

View file

@ -0,0 +1,8 @@
import { createSharedPathnamesNavigation } from 'next-intl/navigation';
import { AllLocales, AppConfig } from '@/utils/AppConfig';
export const { usePathname, useRouter } = createSharedPathnamesNavigation({
locales: AllLocales,
localePrefix: AppConfig.localePrefix,
});

166
src/locales/en.json Normal file
View file

@ -0,0 +1,166 @@
{
"Index": {
"meta_title": "SaaS Template - The perfect SaaS template to build and scale your business with ease.",
"meta_description": "A free and open-source landing page template for your SaaS business, built with React, TypeScript, Shadcn UI, and Tailwind CSS."
},
"Navbar": {
"sign_in": "Sign In",
"sign_up": "Sign Up",
"dashboard": "Dashboard",
"product": "Product",
"docs": "Docs",
"blog": "Blog",
"community": "Community",
"company": "Company"
},
"Hero": {
"follow_twitter": "Follow @Ixartz on Twitter",
"title": "The perfect <important>SaaS template</important> to build and scale your business with ease.",
"description": "A free and open-source landing page template for your SaaS business, built with React, TypeScript, Shadcn UI, and Tailwind CSS.",
"primary_button": "Get Started",
"secondary_button": "Star on GitHub"
},
"Sponsors": {
"title": "Sponsored by"
},
"Features": {
"section_subtitle": "Features",
"section_title": "Unlock the Full Potential of the SaaS Template",
"section_description": "A free and open-source landing page template for your SaaS business, built with React, TypeScript, Shadcn UI, and Tailwind CSS.",
"feature1_title": "Node.js",
"feature2_title": "React",
"feature3_title": "Tailwind CSS",
"feature4_title": "TypeScript",
"feature5_title": "Shadcn UI",
"feature6_title": "ESLint",
"feature_description": "A free and open-source landing page template for your SaaS business, built with React, TypeScript, Shadcn UI, and Tailwind CSS."
},
"Pricing": {
"section_subtitle": "Features",
"section_title": "Unlock the Full Potential of the SaaS Template",
"section_description": "A free and open-source landing page template for your SaaS business, built with React, TypeScript, Shadcn UI, and Tailwind CSS.",
"button_text": "Get Started"
},
"PricingPlan": {
"free_plan_name": "Free",
"premium_plan_name": "Premium",
"enterprise_plan_name": "Enterprise",
"free_plan_description": "For individuals",
"premium_plan_description": "For small teams",
"enterprise_plan_description": "For industry leaders",
"feature_team_member": "{number} Team Members",
"feature_website": "{number} Websites",
"feature_storage": "{number} GB Storage",
"feature_transfer": "{number} TB Transfer",
"feature_email_support": "Email Support",
"plan_interval_month": "month",
"plan_interval_year": "year",
"next_renew_date": "Your subscription renews on {date}"
},
"FAQ": {
"question": "Lorem ipsum dolor sit amet, consectetur adipiscing elit?",
"answer": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam aliquam enim dui, id consequat turpis ullamcorper ac. Mauris id quam dolor. Nullam eu egestas turpis. Proin risus elit, sollicitudin in mi a, accumsan euismod turpis. In euismod mi sed diam tristique hendrerit."
},
"CTA": {
"title": "You are ready?",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"button_text": "Star on GitHub"
},
"Footer": {
"product": "Product",
"docs": "Docs",
"blog": "Blog",
"community": "Community",
"company": "Company",
"terms_of_service": "Terms Of Service",
"privacy_policy": "Privacy Policy",
"designed_by": "Designed by <author></author>."
},
"ProtectFallback": {
"not_enough_permission": "You do not have the permissions to perform this action"
},
"SignIn": {
"meta_title": "Sign in",
"meta_description": "Seamlessly sign in to your account with our user-friendly login process."
},
"SignUp": {
"meta_title": "Sign up",
"meta_description": "Effortlessly create an account through our intuitive sign-up process."
},
"DashboardLayout": {
"home": "Home",
"todos": "Todos",
"members": "Members",
"billing": "Billing",
"settings": "Settings"
},
"Dashboard": {
"meta_title": "SaaS Template Dashboard",
"meta_description": "A free and open-source landing page template for your SaaS business, built with Next.js, TypeScript, Shadcn UI, and Tailwind CSS."
},
"DashboardIndex": {
"title_bar": "Dashboard",
"title_bar_description": "Welcome to your dashboard",
"message_state_title": "Let's get started",
"message_state_description": "You can customize this page by editing the file at <code>dashboard/page.tsx</code>",
"message_state_button": "Star on GitHub"
},
"UserProfile": {
"title_bar": "User Profile",
"title_bar_description": "View and manage your user profile"
},
"OrganizationProfile": {
"title_bar": "Organization Management",
"title_bar_description": "Manage your organization"
},
"Billing": {
"title_bar": "Billing",
"title_bar_description": "Manage your billing and subscription",
"current_section_title": "Current Plan",
"current_section_description": "Adjust your payment plan to best suit your requirements",
"manage_subscription_button": "Manage Subscription"
},
"BillingOptions": {
"current_plan": "Current Plan",
"upgrade_plan": "Get Started"
},
"CheckoutConfirmation": {
"title_bar": "Payment Confirmation",
"message_state_title": "Payment successful",
"message_state_description": "Your payment has been successfully processed. Thank you for your purchase!",
"message_state_button": "Go back to Billing"
},
"DataTable": {
"no_results": "No results."
},
"Todos": {
"title_bar": "Todo List",
"title_bar_description": "View and manage your todo list",
"add_todo_button": "New todo"
},
"TodoTableColumns": {
"open_menu": "Open menu",
"edit": "Edit",
"delete": "Delete",
"title_header": "Title",
"message_header": "Message",
"created_at_header": "Created at"
},
"AddTodo": {
"title_bar": "Add Todo",
"add_todo_section_title": "Create a new todo",
"add_todo_section_description": "Fill in the form below to create a new todo"
},
"EditTodo": {
"title_bar": "Edit todo",
"edit_todo_section_title": "Modify todo",
"edit_todo_section_description": "Fill in the form below to edit the todo"
},
"TodoForm": {
"title_label": "Title",
"title_description": "Enter a descriptive title for your todo.",
"message_title": "Message",
"message_description": "Enter a detailed message for your todo.",
"submit_button": "Submit"
}
}

165
src/locales/fr.json Normal file
View file

@ -0,0 +1,165 @@
{
"Index": {
"meta_title": "SaaS Template - Le template SaaS parfait pour construire et mettre à l'échelle votre entreprise en toute simplicité.",
"meta_description": "Un template gratuit et open-source de landing page pour votre entreprise SaaS, construit avec React, TypeScript, Shadcn UI et Tailwind CSS."
},
"Navbar": {
"sign_in": "Se connecter",
"sign_up": "S'inscrire",
"product": "Produit",
"docs": "Docs",
"blog": "Blog",
"community": "Communauté",
"company": "Entreprise"
},
"Hero": {
"follow_twitter": "Suivez @Ixartz sur Twitter",
"title": "Le parfait <important>SaaS template</important> pour construire et mettre à l'échelle votre entreprise en toute simplicité.",
"description": "Un template gratuit et open-source de landing page pour votre entreprise SaaS, construit avec React, TypeScript, Shadcn UI et Tailwind CSS.",
"primary_button": "Démarrer",
"secondary_button": "Mettez une étoile sur GitHub"
},
"Sponsors": {
"title": "Sponsorisé par"
},
"Features": {
"section_subtitle": "Fonctionnalités",
"section_title": "Débloquer le plein potentiel du SaaS Template",
"section_description": "Un template gratuit et open-source de landing page pour votre entreprise SaaS, construit avec React, TypeScript, Shadcn UI et Tailwind CSS.",
"feature1_title": "Node.js",
"feature2_title": "React",
"feature3_title": "Tailwind CSS",
"feature4_title": "TypeScript",
"feature5_title": "Shadcn UI",
"feature6_title": "ESLint",
"feature_description": "Un template gratuit et open-source de landing page pour votre entreprise SaaS, construit avec React, TypeScript, Shadcn UI et Tailwind CSS."
},
"Pricing": {
"section_subtitle": "Fonctionnalités",
"section_title": "Débloquer le plein potentiel du SaaS Template",
"section_description": "Un template gratuit et open-source de landing page pour votre entreprise SaaS, construit avec React, TypeScript, Shadcn UI et Tailwind CSS.",
"button_text": "Démarrer"
},
"PricingPlan": {
"free_plan_name": "Gratuit",
"premium_plan_name": "Premium",
"enterprise_plan_name": "Enterprise",
"free_plan_description": "Pour les particuliers",
"premium_plan_description": "Pour les petites équipes",
"enterprise_plan_description": "Pour les leaders de l'industrie",
"feature_team_member": "{number} membres",
"feature_website": "{number} sites internet",
"feature_storage": "{number} Go de stockage",
"feature_transfer": "{number} TB de transfert",
"feature_email_support": "Support par e-mail",
"plan_interval_month": "mois",
"plan_interval_year": "année",
"next_renew_date": "Votre abonnement sera renouvelé le {date}"
},
"FAQ": {
"question": "Lorem ipsum dolor sit amet, consectetur adipiscing elit?",
"answer": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam aliquam enim dui, id consequat turpis ullamcorper ac. Mauris id quam dolor. Nullam eu egestas turpis. Proin risus elit, sollicitudin in mi a, accumsan euismod turpis. In euismod mi sed diam tristique hendrerit."
},
"CTA": {
"title": "Vous êtes prêt?",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"button_text": "Mettez une étoile sur GitHub"
},
"Footer": {
"product": "Produit",
"docs": "Docs",
"blog": "Blog",
"community": "Communauté",
"company": "Entreprise",
"terms_of_service": "Conditions d'utilisation",
"privacy_policy": "Politique de confidentialité",
"designed_by": "Designé by <author></author>."
},
"ProtectFallback": {
"not_enough_permission": "Vous n'avez pas les permissions pour effectuer cette action"
},
"SignIn": {
"meta_title": "Se connecter",
"meta_description": "Connectez-vous à votre compte avec facilité."
},
"SignUp": {
"meta_title": "S'inscrire",
"meta_description": "Créez un compte sans effort grâce à notre processus d'inscription intuitif."
},
"DashboardLayout": {
"home": "Accueil",
"todos": "Todos",
"members": "Membres",
"billing": "Facturation",
"settings": "Réglages"
},
"Dashboard": {
"meta_title": "Tableau de bord du SaaS Template",
"meta_description": "Un template gratuit et open-source de landing page pour votre entreprise SaaS, construit avec Next.js, TypeScript, Shadcn UI et Tailwind CSS."
},
"DashboardIndex": {
"title_bar": "Tableau de bord",
"title_bar_description": "Bienvenue sur votre tableau de bord",
"message_state_title": "C'est parti",
"message_state_description": "Vous pouvez personnaliser cette page en modifiant le fichier dans <code>dashboard/page.tsx</code>",
"message_state_button": "Mettez une étoile sur GitHub"
},
"UserProfile": {
"title_bar": "Profil utilisateur",
"title_bar_description": "Afficher et gérer votre profil utilisateur"
},
"OrganizationProfile": {
"title_bar": "Gestion de lorganisation",
"title_bar_description": "Gérer votre organisation"
},
"Billing": {
"title_bar": "Facturation",
"title_bar_description": "Gérer votre facturation et votre abonnement",
"current_section_title": "Plan actuel",
"current_section_description": "Ajuster votre plan de paiement pour le mieux répondre à vos besoins",
"manage_subscription_button": "Gérer l'abonnement"
},
"BillingOptions": {
"current_plan": "Plan actuel",
"upgrade_plan": "Démarrer"
},
"CheckoutConfirmation": {
"title_bar": "Confirmation du paiement",
"message_state_title": "Paiement accepté",
"message_state_description": "Votre paiement a été traité avec succès. Merci pour votre achat !",
"message_state_button": "Revenir à la facturation"
},
"DataTable": {
"no_results": "Aucun résultat."
},
"Todos": {
"title_bar": "Liste de Todos",
"title_bar_description": "Afficher et gérer votre liste de todos",
"add_todo_button": "Nouveau todo"
},
"TodoTableColumns": {
"open_menu": "Ouvrir le menu",
"edit": "Modifier",
"delete": "Supprimer",
"title_header": "Titre",
"message_header": "Message",
"created_at_header": "Créé le"
},
"AddTodo": {
"title_bar": "Ajouter une todo",
"add_todo_section_title": "Créer une nouvelle todo",
"add_todo_section_description": "Remplissez le formulaire ci-dessous pour créer une nouvelle todo"
},
"EditTodo": {
"title_bar": "Editer le todo",
"edit_todo_section_title": "Modifier le todo",
"edit_todo_section_description": "Remplissez le formulaire ci-dessous pour modifier la todo"
},
"TodoForm": {
"title_label": "Titre",
"title_description": "Entrez un titre descriptif pour votre todo.",
"message_title": "Message",
"message_description": "Entrez un message détaillé pour votre todo.",
"submit_button": "Envoyer"
}
}

73
src/middleware.ts Normal file
View file

@ -0,0 +1,73 @@
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import {
type NextFetchEvent,
type NextRequest,
NextResponse,
} from 'next/server';
import createMiddleware from 'next-intl/middleware';
import { AllLocales, AppConfig } from './utils/AppConfig';
const intlMiddleware = createMiddleware({
locales: AllLocales,
localePrefix: AppConfig.localePrefix,
defaultLocale: AppConfig.defaultLocale,
});
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/:locale/dashboard(.*)',
'/onboarding(.*)',
'/:locale/onboarding(.*)',
'/api(.*)',
'/:locale/api(.*)',
]);
export default function middleware(
request: NextRequest,
event: NextFetchEvent,
) {
if (
request.nextUrl.pathname.includes('/sign-in')
|| request.nextUrl.pathname.includes('/sign-up')
|| isProtectedRoute(request)
) {
return clerkMiddleware((auth, req) => {
const authObj = auth();
if (isProtectedRoute(req)) {
const locale
= req.nextUrl.pathname.match(/(\/.*)\/dashboard/)?.at(1) ?? '';
const signInUrl = new URL(`${locale}/sign-in`, req.url);
authObj.protect({
// `unauthenticatedUrl` is needed to avoid error: "Unable to find `next-intl` locale because the middleware didn't run on this request"
unauthenticatedUrl: signInUrl.toString(),
});
}
if (
authObj.userId
&& !authObj.orgId
&& req.nextUrl.pathname.includes('/dashboard')
&& !req.nextUrl.pathname.endsWith('/organization-selection')
) {
const orgSelection = new URL(
'/onboarding/organization-selection',
req.url,
);
return NextResponse.redirect(orgSelection);
}
return intlMiddleware(req);
})(request, event);
}
return intlMiddleware(request);
}
export const config = {
matcher: ['/((?!.+\\.[\\w]+$|_next|monitoring).*)', '/', '/(api|trpc)(.*)'], // Also exclude tunnelRoute used in Sentry from the matcher
};

57
src/models/Schema.ts Normal file
View file

@ -0,0 +1,57 @@
import {
bigint,
pgTable,
serial,
text,
timestamp,
uniqueIndex,
} from 'drizzle-orm/pg-core';
// This file defines the structure of your database tables using the Drizzle ORM.
// To modify the database schema:
// 1. Update this file with your desired changes.
// 2. Generate a new migration by running: `npm run db:generate`
// The generated migration file will reflect your schema changes.
// The migration is automatically applied during the next database interaction,
// so there's no need to run it manually or restart the Next.js server.
export const organizationSchema = pgTable(
'organization',
{
id: text('id').primaryKey(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
stripeSubscriptionPriceId: text('stripe_subscription_price_id'),
stripeSubscriptionStatus: text('stripe_subscription_status'),
stripeSubscriptionCurrentPeriodEnd: bigint(
'stripe_subscription_current_period_end',
{ mode: 'number' },
),
updatedAt: timestamp('updated_at', { mode: 'date' })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
},
(table) => {
return {
stripeCustomerIdIdx: uniqueIndex('stripe_customer_id_idx').on(
table.stripeCustomerId,
),
};
},
);
export const todoSchema = pgTable('todo', {
id: serial('id').primaryKey(),
ownerId: text('owner_id').notNull(),
title: text('title').notNull(),
message: text('message').notNull(),
updatedAt: timestamp('updated_at', { mode: 'date' })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
});

73
src/styles/global.css Normal file
View file

@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
}

28
src/templates/CTA.tsx Normal file
View file

@ -0,0 +1,28 @@
import { GitHubLogoIcon } from '@radix-ui/react-icons';
import { useTranslations } from 'next-intl';
import { buttonVariants } from '@/components/ui/buttonVariants';
import { CTABanner } from '@/features/landing/CTABanner';
import { Section } from '@/features/landing/Section';
export const CTA = () => {
const t = useTranslations('CTA');
return (
<Section>
<CTABanner
title={t('title')}
description={t('description')}
buttons={(
<a
className={buttonVariants({ variant: 'outline', size: 'lg' })}
href="https://github.com/ixartz/SaaS-Boilerplate"
>
<GitHubLogoIcon className="mr-2 size-5" />
{t('button_text')}
</a>
)}
/>
</Section>
);
};

View file

@ -0,0 +1,11 @@
import Link from 'next/link';
import { StickyBanner } from '@/features/landing/StickyBanner';
export const DemoBanner = () => (
<StickyBanner>
Live Demo of SaaS Boilerplate -
{' '}
<Link href="/sign-up">Explore the User Dashboard</Link>
</StickyBanner>
);

44
src/templates/FAQ.tsx Normal file
View file

@ -0,0 +1,44 @@
import { useTranslations } from 'next-intl';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Section } from '@/features/landing/Section';
export const FAQ = () => {
const t = useTranslations('FAQ');
return (
<Section>
<Accordion type="multiple" className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>{t('question')}</AccordionTrigger>
<AccordionContent>{t('answer')}</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>{t('question')}</AccordionTrigger>
<AccordionContent>{t('answer')}</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>{t('question')}</AccordionTrigger>
<AccordionContent>{t('answer')}</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4">
<AccordionTrigger>{t('question')}</AccordionTrigger>
<AccordionContent>{t('answer')}</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5">
<AccordionTrigger>{t('question')}</AccordionTrigger>
<AccordionContent>{t('answer')}</AccordionContent>
</AccordionItem>
<AccordionItem value="item-6">
<AccordionTrigger>{t('question')}</AccordionTrigger>
<AccordionContent>{t('answer')}</AccordionContent>
</AccordionItem>
</Accordion>
</Section>
);
};

135
src/templates/Features.tsx Normal file
View file

@ -0,0 +1,135 @@
import { useTranslations } from 'next-intl';
import { Background } from '@/components/Background';
import { FeatureCard } from '@/features/landing/FeatureCard';
import { Section } from '@/features/landing/Section';
export const Features = () => {
const t = useTranslations('Features');
return (
<Background>
<Section
subtitle={t('section_subtitle')}
title={t('section_title')}
description={t('section_description')}
>
<div className="grid grid-cols-1 gap-x-3 gap-y-8 md:grid-cols-3">
<FeatureCard
icon={(
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
)}
title={t('feature1_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={(
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
)}
title={t('feature2_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={(
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
)}
title={t('feature3_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={(
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
)}
title={t('feature4_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={(
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
)}
title={t('feature5_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={(
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
)}
title={t('feature6_title')}
>
{t('feature_description')}
</FeatureCard>
</div>
</Section>
</Background>
);
};

110
src/templates/Footer.tsx Normal file
View file

@ -0,0 +1,110 @@
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { CenteredFooter } from '@/features/landing/CenteredFooter';
import { Section } from '@/features/landing/Section';
import { AppConfig } from '@/utils/AppConfig';
import { Logo } from './Logo';
export const Footer = () => {
const t = useTranslations('Footer');
return (
<Section className="pb-16 pt-0">
<CenteredFooter
logo={<Logo />}
name={AppConfig.name}
iconList={(
<>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23.998 12c0-6.628-5.372-12-11.999-12C5.372 0 0 5.372 0 12c0 5.988 4.388 10.952 10.124 11.852v-8.384H7.078v-3.469h3.046V9.356c0-3.008 1.792-4.669 4.532-4.669 1.313 0 2.686.234 2.686.234v2.953H15.83c-1.49 0-1.955.925-1.955 1.874V12h3.328l-.532 3.469h-2.796v8.384c5.736-.9 10.124-5.864 10.124-11.853z" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23.954 4.569a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.691 8.094 4.066 6.13 1.64 3.161a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.061a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.937 4.937 0 004.604 3.417 9.868 9.868 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.054 0 13.999-7.496 13.999-13.986 0-.209 0-.42-.015-.63a9.936 9.936 0 002.46-2.548l-.047-.02z" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M11.585 5.267c1.834 0 3.558.811 4.824 2.08v.004c0-.609.41-1.068.979-1.068h.145c.891 0 1.073.842 1.073 1.109l.005 9.475c-.063.621.64.941 1.029.543 1.521-1.564 3.342-8.038-.946-11.79-3.996-3.497-9.357-2.921-12.209-.955-3.031 2.091-4.971 6.718-3.086 11.064 2.054 4.74 7.931 6.152 11.424 4.744 1.769-.715 2.586 1.676.749 2.457-2.776 1.184-10.502 1.064-14.11-5.188C-.977 13.521-.847 6.093 5.62 2.245 10.567-.698 17.09.117 21.022 4.224c4.111 4.294 3.872 12.334-.139 15.461-1.816 1.42-4.516.037-4.498-2.031l-.019-.678c-1.265 1.256-2.948 1.988-4.782 1.988-3.625 0-6.813-3.189-6.813-6.812 0-3.659 3.189-6.885 6.814-6.885zm4.561 6.623c-.137-2.653-2.106-4.249-4.484-4.249h-.09c-2.745 0-4.268 2.159-4.268 4.61 0 2.747 1.842 4.481 4.256 4.481 2.693 0 4.464-1.973 4.592-4.306l-.006-.536z" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.199 24C19.199 13.467 10.533 4.8 0 4.8V0c13.165 0 24 10.835 24 24h-4.801zM3.291 17.415a3.3 3.3 0 013.293 3.295A3.303 3.303 0 013.283 24C1.47 24 0 22.526 0 20.71s1.475-3.294 3.291-3.295zM15.909 24h-4.665c0-6.169-5.075-11.245-11.244-11.245V8.09c8.727 0 15.909 7.184 15.909 15.91z" />
</svg>
</Link>
</li>
</>
)}
legalLinks={(
<>
<li>
<Link href="/sign-up">{t('terms_of_service')}</Link>
</li>
<li>
<Link href="/sign-up">{t('privacy_policy')}</Link>
</li>
</>
)}
>
<li>
<Link href="/sign-up">{t('product')}</Link>
</li>
<li>
<Link href="/sign-up">{t('docs')}</Link>
</li>
<li>
<Link href="/sign-up">{t('blog')}</Link>
</li>
<li>
<Link href="/sign-up">{t('community')}</Link>
</li>
<li>
<Link href="/sign-up">{t('company')}</Link>
</li>
</CenteredFooter>
</Section>
);
};

56
src/templates/Hero.tsx Normal file
View file

@ -0,0 +1,56 @@
import { GitHubLogoIcon, TwitterLogoIcon } from '@radix-ui/react-icons';
import { useTranslations } from 'next-intl';
import { badgeVariants } from '@/components/ui/badgeVariants';
import { buttonVariants } from '@/components/ui/buttonVariants';
import { CenteredHero } from '@/features/landing/CenteredHero';
import { Section } from '@/features/landing/Section';
export const Hero = () => {
const t = useTranslations('Hero');
return (
<Section className="py-36">
<CenteredHero
banner={(
<a
className={badgeVariants()}
href="https://twitter.com/ixartz"
target="_blank"
rel="noopener noreferrer"
>
<TwitterLogoIcon className="mr-1 size-5" />
{' '}
{t('follow_twitter')}
</a>
)}
title={t.rich('title', {
important: chunks => (
<span className="bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 bg-clip-text text-transparent">
{chunks}
</span>
),
})}
description={t('description')}
buttons={(
<>
<a
className={buttonVariants({ size: 'lg' })}
href="https://github.com/ixartz/SaaS-Boilerplate"
>
{t('primary_button')}
</a>
<a
className={buttonVariants({ variant: 'outline', size: 'lg' })}
href="https://github.com/ixartz/SaaS-Boilerplate"
>
<GitHubLogoIcon className="mr-2 size-5" />
{t('secondary_button')}
</a>
</>
)}
/>
</Section>
);
};

23
src/templates/Logo.tsx Normal file
View file

@ -0,0 +1,23 @@
import { AppConfig } from '@/utils/AppConfig';
export const Logo = (props: {
isTextHidden?: boolean;
}) => (
<div className="flex items-center text-xl font-semibold">
<svg
className="mr-1 size-8 stroke-current stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<rect x="3" y="12" width="6" height="8" rx="1" />
<rect x="9" y="8" width="6" height="12" rx="1" />
<rect x="15" y="4" width="6" height="16" rx="1" />
<path d="M4 20h14" />
</svg>
{!props.isTextHidden && AppConfig.name}
</div>
);

56
src/templates/Navbar.tsx Normal file
View file

@ -0,0 +1,56 @@
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
import { buttonVariants } from '@/components/ui/buttonVariants';
import { CenteredMenu } from '@/features/landing/CenteredMenu';
import { Section } from '@/features/landing/Section';
import { Logo } from './Logo';
export const Navbar = () => {
const t = useTranslations('Navbar');
return (
<Section className="px-3 py-6">
<CenteredMenu
logo={<Logo />}
rightMenu={(
<>
<li data-fade>
<LocaleSwitcher />
</li>
<li className="ml-1 mr-2.5" data-fade>
<Link href="/sign-in">{t('sign_in')}</Link>
</li>
<li>
<Link className={buttonVariants()} href="/dashboard">
{t('dashboard')}
</Link>
</li>
</>
)}
>
<li>
<Link href="/sign-up">{t('product')}</Link>
</li>
<li>
<Link href="/sign-up">{t('docs')}</Link>
</li>
<li>
<Link href="/sign-up">{t('blog')}</Link>
</li>
<li>
<Link href="/sign-up">{t('community')}</Link>
</li>
<li>
<Link href="/sign-up">{t('company')}</Link>
</li>
</CenteredMenu>
</Section>
);
};

57
src/templates/Pricing.tsx Normal file
View file

@ -0,0 +1,57 @@
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { buttonVariants } from '@/components/ui/buttonVariants';
import { PricingInformation } from '@/features/billing/PricingInformation';
import { Section } from '@/features/landing/Section';
import { PLAN_ID } from '@/utils/AppConfig';
export const Pricing = () => {
const t = useTranslations('Pricing');
return (
<Section
subtitle={t('section_subtitle')}
title={t('section_title')}
description={t('section_description')}
>
<PricingInformation
buttonList={{
[PLAN_ID.FREE]: (
<Link
className={buttonVariants({
size: 'sm',
className: 'mt-5 w-full',
})}
href="/sign-up"
>
{t('button_text')}
</Link>
),
[PLAN_ID.PREMIUM]: (
<Link
className={buttonVariants({
size: 'sm',
className: 'mt-5 w-full',
})}
href="/sign-up"
>
{t('button_text')}
</Link>
),
[PLAN_ID.ENTERPRISE]: (
<Link
className={buttonVariants({
size: 'sm',
className: 'mt-5 w-full',
})}
href="/sign-up"
>
{t('button_text')}
</Link>
),
}}
/>
</Section>
);
};

View file

@ -0,0 +1,8 @@
import { Section } from '@/features/landing/Section';
import { SponsorLogos } from '@/features/sponsors/SponsorLogos';
export const Sponsors = () => (
<Section>
<SponsorLogos />
</Section>
);

14
src/types/Auth.ts Normal file
View file

@ -0,0 +1,14 @@
import type { EnumValues } from './Enum';
export const ORG_ROLE = {
ADMIN: 'org:admin',
MEMBER: 'org:member',
} as const;
export type OrgRole = EnumValues<typeof ORG_ROLE>;
export const ORG_PERMISSION = {
// Add Organization Permissions here
} as const;
export type OrgPermission = EnumValues<typeof ORG_PERMISSION>;

1
src/types/Enum.ts Normal file
View file

@ -0,0 +1 @@
export type EnumValues<Type> = Type[keyof Type];

55
src/types/Subscription.ts Normal file
View file

@ -0,0 +1,55 @@
import type { PLAN_ID } from '@/utils/AppConfig';
import type { EnumValues } from './Enum';
export type PlanId = EnumValues<typeof PLAN_ID>;
export const BILLING_INTERVAL = {
MONTH: 'month',
YEAR: 'year',
} as const;
export type BillingInterval = EnumValues<typeof BILLING_INTERVAL>;
export const SUBSCRIPTION_STATUS = {
ACTIVE: 'active',
PENDING: 'pending',
} as const;
// PricingPlan is currently only used for Pricing section of the landing page.
// If you need a real Stripe subscription payment with checkout page, customer portal, webhook, etc.
// You can check out the Next.js Boilerplate Pro at: https://nextjs-boilerplate.com/pro-saas-starter-kit
// On top of that, you'll get access to real example of SaaS application with Next.js, TypeScript, Tailwind CSS, and more.
// You can find a live demo at: https://pro-demo.nextjs-boilerplate.com
export type PricingPlan = {
id: PlanId;
price: number;
interval: BillingInterval;
testPriceId: string; // Use for testing
devPriceId: string;
prodPriceId: string;
features: {
teamMember: number;
website: number;
storage: number;
transfer: number;
};
};
export type IStripeSubscription = {
stripeSubscriptionId: string | null;
stripeSubscriptionPriceId: string | null;
stripeSubscriptionStatus: string | null;
stripeSubscriptionCurrentPeriodEnd: number | null;
};
export type PlanDetails =
| {
isPaid: true;
plan: PricingPlan;
stripeDetails: IStripeSubscription;
} | {
isPaid: false;
plan: PricingPlan;
stripeDetails?: undefined;
};

15
src/types/global.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
import type { OrgPermission, OrgRole } from '@/types/Auth';
// Use type safe message keys with `next-intl`
type Messages = typeof import('../locales/en.json');
// eslint-disable-next-line ts/consistent-type-definitions
declare interface IntlMessages extends Messages {}
declare global {
// eslint-disable-next-line ts/consistent-type-definitions
interface ClerkAuthorization {
permission: OrgPermission;
role: OrgRole;
}
}

74
src/utils/AppConfig.ts Normal file
View file

@ -0,0 +1,74 @@
import type { LocalePrefix } from 'node_modules/next-intl/dist/types/src/routing/types';
import { BILLING_INTERVAL, type PricingPlan } from '@/types/Subscription';
const localePrefix: LocalePrefix = 'as-needed';
// FIXME: Update this configuration file based on your project information
export const AppConfig = {
name: 'SaaS Template',
locales: [
{
id: 'en',
name: 'English',
},
{ id: 'fr', name: 'Français' },
],
defaultLocale: 'en',
localePrefix,
};
export const AllLocales = AppConfig.locales.map(locale => locale.id);
export const PLAN_ID = {
FREE: 'free',
PREMIUM: 'premium',
ENTERPRISE: 'enterprise',
} as const;
export const PricingPlanList: Record<string, PricingPlan> = {
[PLAN_ID.FREE]: {
id: PLAN_ID.FREE,
price: 0,
interval: BILLING_INTERVAL.MONTH,
testPriceId: '',
devPriceId: '',
prodPriceId: '',
features: {
teamMember: 2,
website: 2,
storage: 2,
transfer: 2,
},
},
[PLAN_ID.PREMIUM]: {
id: PLAN_ID.PREMIUM,
price: 79,
interval: BILLING_INTERVAL.MONTH,
testPriceId: 'price_premium_test', // Use for testing
// FIXME: Update the price ID, you can create it after running `npm run stripe:setup-price`
devPriceId: 'price_1PNksvKOp3DEwzQlGOXO7YBK',
prodPriceId: '',
features: {
teamMember: 5,
website: 5,
storage: 5,
transfer: 5,
},
},
[PLAN_ID.ENTERPRISE]: {
id: PLAN_ID.ENTERPRISE,
price: 199,
interval: BILLING_INTERVAL.MONTH,
testPriceId: 'price_enterprise_test', // Use for testing
// FIXME: Update the price ID, you can create it after running `npm run stripe:setup-price`
devPriceId: 'price_1PNksvKOp3DEwzQli9IvXzgb',
prodPriceId: 'price_123',
features: {
teamMember: 100,
website: 100,
storage: 100,
transfer: 100,
},
},
};

20
src/utils/Helpers.test.ts Normal file
View file

@ -0,0 +1,20 @@
import { AppConfig } from './AppConfig';
import { getI18nPath } from './Helpers';
describe('Helpers', () => {
describe('getI18nPath function', () => {
it('should not change the path for default language', () => {
const url = '/random-url';
const locale = AppConfig.defaultLocale;
expect(getI18nPath(url, locale)).toBe(url);
});
it('should prepend the locale to the path for non-default language', () => {
const url = '/random-url';
const locale = 'fr';
expect(getI18nPath(url, locale)).toMatch(/^\/fr/);
});
});
});

37
src/utils/Helpers.ts Normal file
View file

@ -0,0 +1,37 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { AppConfig } from './AppConfig';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const MILLISECONDS_IN_ONE_DAY = 86_400_000;
export const getBaseUrl = () => {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
if (
process.env.VERCEL_ENV === 'production'
&& process.env.VERCEL_PROJECT_PRODUCTION_URL
) {
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
}
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
}
return 'http://localhost:3000';
};
export const getI18nPath = (url: string, locale: string) => {
if (locale === AppConfig.defaultLocale) {
return url;
}
return `/${locale}${url}`;
};

6
src/utils/cn.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}