mirror of
https://github.com/harivansh-afk/Saas-Teamspace-2.git
synced 2026-04-20 01:00:32 +00:00
initial commit
This commit is contained in:
commit
9963e01acc
158 changed files with 48198 additions and 0 deletions
16
src/app/[locale]/(auth)/(center)/layout.tsx
Normal file
16
src/app/[locale]/(auth)/(center)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
37
src/app/[locale]/(auth)/dashboard/analytics/page.tsx
Normal file
37
src/app/[locale]/(auth)/dashboard/analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/app/[locale]/(auth)/dashboard/calendar/page.tsx
Normal file
76
src/app/[locale]/(auth)/dashboard/calendar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/app/[locale]/(auth)/dashboard/inbox/page.tsx
Normal file
105
src/app/[locale]/(auth)/dashboard/inbox/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/[locale]/(auth)/dashboard/layout.tsx
Normal file
28
src/app/[locale]/(auth)/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/app/[locale]/(auth)/dashboard/messages/page.tsx
Normal file
78
src/app/[locale]/(auth)/dashboard/messages/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/[locale]/(auth)/dashboard/metadata.ts
Normal file
16
src/app/[locale]/(auth)/dashboard/metadata.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
36
src/app/[locale]/(auth)/dashboard/page.tsx
Normal file
36
src/app/[locale]/(auth)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
45
src/app/[locale]/(auth)/layout.tsx
Normal file
45
src/app/[locale]/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
41
src/app/[locale]/(unauth)/page.tsx
Normal file
41
src/app/[locale]/(unauth)/page.tsx
Normal 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;
|
||||
57
src/app/[locale]/layout.tsx
Normal file
57
src/app/[locale]/layout.tsx
Normal 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
26
src/app/global-error.tsx
Normal 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
13
src/app/robots.ts
Normal 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
15
src/app/sitemap.ts
Normal 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
|
||||
];
|
||||
}
|
||||
23
src/components/ActiveLink.tsx
Normal file
23
src/components/ActiveLink.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
src/components/Background.stories.tsx
Normal file
28
src/components/Background.stories.tsx
Normal 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;
|
||||
10
src/components/Background.tsx
Normal file
10
src/components/Background.tsx
Normal 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>
|
||||
);
|
||||
22
src/components/ClerkProvider.tsx
Normal file
22
src/components/ClerkProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
12
src/components/DemoBadge.tsx
Normal file
12
src/components/DemoBadge.tsx
Normal 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>
|
||||
);
|
||||
55
src/components/LocaleSwitcher.tsx
Normal file
55
src/components/LocaleSwitcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
src/components/ThemeProvider.tsx
Normal file
8
src/components/ThemeProvider.tsx
Normal 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>;
|
||||
}
|
||||
19
src/components/ToggleMenuButton.test.tsx
Normal file
19
src/components/ToggleMenuButton.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/components/ToggleMenuButton.tsx
Normal file
40
src/components/ToggleMenuButton.tsx
Normal 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 };
|
||||
107
src/components/features/Analytics.tsx
Normal file
107
src/components/features/Analytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/features/TeamMembers.tsx
Normal file
66
src/components/features/TeamMembers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/layout/Header.tsx
Normal file
56
src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/layout/Sidebar.tsx
Normal file
75
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/components/ui/accordion.tsx
Normal file
58
src/components/ui/accordion.tsx
Normal 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 };
|
||||
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal 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 };
|
||||
16
src/components/ui/badge.tsx
Normal file
16
src/components/ui/badge.tsx
Normal 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 };
|
||||
21
src/components/ui/badgeVariants.ts
Normal file
21
src/components/ui/badgeVariants.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
27
src/components/ui/button.tsx
Normal file
27
src/components/ui/button.tsx
Normal 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 };
|
||||
30
src/components/ui/buttonVariants.ts
Normal file
30
src/components/ui/buttonVariants.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
65
src/components/ui/calendar.tsx
Normal file
65
src/components/ui/calendar.tsx
Normal 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 };
|
||||
84
src/components/ui/data-table.tsx
Normal file
84
src/components/ui/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/components/ui/dropdown-menu.tsx
Normal file
200
src/components/ui/dropdown-menu.tsx
Normal 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
135
src/components/ui/form.tsx
Normal 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,
|
||||
};
|
||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal 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 };
|
||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal 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 };
|
||||
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal 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
117
src/components/ui/table.tsx
Normal 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,
|
||||
};
|
||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal 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 };
|
||||
44
src/components/ui/useFormField.ts
Normal file
44
src/components/ui/useFormField.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
23
src/features/auth/ProtectFallback.tsx
Normal file
23
src/features/auth/ProtectFallback.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
src/features/billing/PricingCard.tsx
Normal file
40
src/features/billing/PricingCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
src/features/billing/PricingFeature.tsx
Normal file
16
src/features/billing/PricingFeature.tsx
Normal 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>
|
||||
);
|
||||
51
src/features/billing/PricingInformation.tsx
Normal file
51
src/features/billing/PricingInformation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
118
src/features/dashboard/DashboardHeader.tsx
Normal file
118
src/features/dashboard/DashboardHeader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
17
src/features/dashboard/DashboardSection.tsx
Normal file
17
src/features/dashboard/DashboardSection.tsx
Normal 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>
|
||||
);
|
||||
23
src/features/dashboard/MessageState.tsx
Normal file
23
src/features/dashboard/MessageState.tsx
Normal 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>
|
||||
);
|
||||
14
src/features/dashboard/TitleBar.tsx
Normal file
14
src/features/dashboard/TitleBar.tsx
Normal 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>
|
||||
);
|
||||
17
src/features/landing/CTABanner.tsx
Normal file
17
src/features/landing/CTABanner.tsx
Normal 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>
|
||||
);
|
||||
24
src/features/landing/CenteredFooter.test.tsx
Normal file
24
src/features/landing/CenteredFooter.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
52
src/features/landing/CenteredFooter.tsx
Normal file
52
src/features/landing/CenteredFooter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
src/features/landing/CenteredHero.tsx
Normal file
22
src/features/landing/CenteredHero.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
46
src/features/landing/CenteredMenu.tsx
Normal file
46
src/features/landing/CenteredMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
src/features/landing/FeatureCard.tsx
Normal file
17
src/features/landing/FeatureCard.tsx
Normal 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>
|
||||
);
|
||||
11
src/features/landing/LogoCloud.tsx
Normal file
11
src/features/landing/LogoCloud.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
33
src/features/landing/Section.tsx
Normal file
33
src/features/landing/Section.tsx
Normal 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>
|
||||
);
|
||||
5
src/features/landing/StickyBanner.tsx
Normal file
5
src/features/landing/StickyBanner.tsx
Normal 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>
|
||||
);
|
||||
84
src/features/sponsors/SponsorLogos.tsx
Normal file
84
src/features/sponsors/SponsorLogos.tsx
Normal 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
44
src/hooks/UseMenu.test.ts
Normal 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
19
src/hooks/UseMenu.ts
Normal 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
37
src/instrumentation.ts
Normal 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
45
src/libs/DB.ts
Normal 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
38
src/libs/Env.ts
Normal 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
27
src/libs/Logger.ts
Normal 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
26
src/libs/i18n.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
8
src/libs/i18nNavigation.ts
Normal file
8
src/libs/i18nNavigation.ts
Normal 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
166
src/locales/en.json
Normal 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
165
src/locales/fr.json
Normal 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 l’organisation",
|
||||
"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
73
src/middleware.ts
Normal 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
57
src/models/Schema.ts
Normal 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
73
src/styles/global.css
Normal 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
28
src/templates/CTA.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
src/templates/DemoBanner.tsx
Normal file
11
src/templates/DemoBanner.tsx
Normal 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
44
src/templates/FAQ.tsx
Normal 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
135
src/templates/Features.tsx
Normal 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
110
src/templates/Footer.tsx
Normal 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
56
src/templates/Hero.tsx
Normal 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
23
src/templates/Logo.tsx
Normal 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
56
src/templates/Navbar.tsx
Normal 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
57
src/templates/Pricing.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
src/templates/Sponsors.tsx
Normal file
8
src/templates/Sponsors.tsx
Normal 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
14
src/types/Auth.ts
Normal 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
1
src/types/Enum.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type EnumValues<Type> = Type[keyof Type];
|
||||
55
src/types/Subscription.ts
Normal file
55
src/types/Subscription.ts
Normal 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
15
src/types/global.d.ts
vendored
Normal 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
74
src/utils/AppConfig.ts
Normal 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
20
src/utils/Helpers.test.ts
Normal 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
37
src/utils/Helpers.ts
Normal 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
6
src/utils/cn.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue