B2B-88: add starter kit structure and elements
This commit is contained in:
27
.aiignore
Normal file
27
.aiignore
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# An .aiignore file follows the same syntax as a .gitignore file.
|
||||||
|
# .gitignore documentation: https://git-scm.com/docs/gitignore
|
||||||
|
|
||||||
|
# you can ignore files
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# or folders
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
|
||||||
|
.cursor
|
||||||
|
.cursorignore
|
||||||
|
database.types.ts
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
web/supabase/migrations
|
||||||
|
pnpm-lock.yaml
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.zed
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
.windsurfrules
|
||||||
12
.cursorignore
Normal file
12
.cursorignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
database.types.ts
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
web/supabase/migrations
|
||||||
|
pnpm-lock.yaml
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.zed
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
.windsurfrules
|
||||||
27
.env.development
Normal file
27
.env.development
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# This file is used to define environment variables for the development environment.
|
||||||
|
# These values are only used when running the app in development mode.
|
||||||
|
|
||||||
|
# SUPABASE
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||||
|
|
||||||
|
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
|
||||||
|
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
|
||||||
|
|
||||||
|
# EMAILS
|
||||||
|
EMAIL_SENDER="Makerkit <admin@makerkit.dev>"
|
||||||
|
EMAIL_PORT=54325
|
||||||
|
EMAIL_HOST=localhost
|
||||||
|
EMAIL_TLS=false
|
||||||
|
EMAIL_USER=user
|
||||||
|
EMAIL_PASSWORD=password
|
||||||
|
|
||||||
|
# CONTACT FORM
|
||||||
|
CONTACT_EMAIL=test@makerkit.dev
|
||||||
|
|
||||||
|
# STRIPE
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
|
# MAILER
|
||||||
|
MAILER_PROVIDER=nodemailer
|
||||||
12
.env.production
Normal file
12
.env.production
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# PRODUCTION ENVIRONMENT VARIABLES
|
||||||
|
|
||||||
|
## DO NOT ADD VARS HERE UNLESS THEY ARE PUBLIC OR NOT SENSITIVE
|
||||||
|
## THIS ENV IS USED FOR PRODUCTION AND IS COMMITED TO THE REPO
|
||||||
|
## AVOID PLACING SENSITIVE DATA IN THIS FILE.
|
||||||
|
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
||||||
|
|
||||||
|
# SUPABASE
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
|
||||||
|
# STRIPE
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
22
.env.test
Normal file
22
.env.test
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# TEST ENVIRONMENT VARIABLES
|
||||||
|
NEXT_PUBLIC_CI=true
|
||||||
|
|
||||||
|
# SUPABASE
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||||
|
|
||||||
|
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
|
||||||
|
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
|
||||||
|
|
||||||
|
EMAIL_SENDER=test@makerkit.dev
|
||||||
|
EMAIL_PORT=54325
|
||||||
|
EMAIL_HOST=localhost
|
||||||
|
EMAIL_TLS=false
|
||||||
|
EMAIL_USER=user
|
||||||
|
EMAIL_PASSWORD=password
|
||||||
|
|
||||||
|
# STRIPE
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
||||||
|
|
||||||
|
CONTACT_EMAIL=test@makerkit.dev
|
||||||
10
.npmrc
Normal file
10
.npmrc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
peer-legacy-deps=true
|
||||||
|
dedupe-peer-dependents=true
|
||||||
|
use-lockfile-v6=true
|
||||||
|
resolution-mode=highest
|
||||||
|
package-manager-strict=true
|
||||||
|
public-hoist-pattern[]=*i18next*
|
||||||
|
public-hoist-pattern[]=*eslint*
|
||||||
|
public-hoist-pattern[]=*prettier*
|
||||||
|
public-hoist-pattern[]=*require-in-the-middle*
|
||||||
|
public-hoist-pattern[]=*import-in-the-middle*
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
database.types.ts
|
||||||
|
playwright-report
|
||||||
|
*.hbs
|
||||||
225
.windsurfrules
Normal file
225
.windsurfrules
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Makerkit Guidelines
|
||||||
|
|
||||||
|
## Project Stack
|
||||||
|
- Framework: Next.js 15 App Router, TypeScript, React, Node.js
|
||||||
|
- Backend: Supabase with Postgres
|
||||||
|
- UI: Shadcn UI, Tailwind CSS
|
||||||
|
- Key libraries: React Hook Form, React Query, Zod, Lucide React
|
||||||
|
- Focus: Code clarity, Readability, Best practices, Maintainability
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
/apps/web/
|
||||||
|
/app
|
||||||
|
/home # protected routes
|
||||||
|
/(user) # user workspace
|
||||||
|
/[account] # team workspace
|
||||||
|
/(marketing) # marketing pages
|
||||||
|
/auth # auth pages
|
||||||
|
/components # global components
|
||||||
|
/config # global config
|
||||||
|
/lib # global utils
|
||||||
|
/content # markdoc content
|
||||||
|
/supabase # supabase root
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
1. Server Components
|
||||||
|
- Use Supabase Client directly via `getSupabaseServerClient`
|
||||||
|
- Handle errors with proper boundaries
|
||||||
|
- Example:
|
||||||
|
```tsx
|
||||||
|
async function ServerComponent() {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data, error } = await client.from('notes').select('*');
|
||||||
|
if (error) return <ErrorComponent error={error} />;
|
||||||
|
return <ClientComponent data={data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Client Components
|
||||||
|
- Use React Query for data fetching
|
||||||
|
- Implement proper loading states
|
||||||
|
- Example:
|
||||||
|
```tsx
|
||||||
|
function useNotes() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['notes'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await fetch('/api/notes');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { data, isLoading };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Actions
|
||||||
|
- Name files as "server-actions.ts" in `_lib/server` folder
|
||||||
|
- Export with "Action" suffix
|
||||||
|
- Use `enhanceAction` with proper typing
|
||||||
|
- Example:
|
||||||
|
```tsx
|
||||||
|
export const createNoteAction = enhanceAction(
|
||||||
|
async function (data, user) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { error } = await client
|
||||||
|
.from('notes')
|
||||||
|
.insert({ ...data, user_id: user.id });
|
||||||
|
if (error) throw error;
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: true,
|
||||||
|
schema: NoteSchema,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Handlers
|
||||||
|
|
||||||
|
- Use `enhanceRouteHandler` to wrap route handlers
|
||||||
|
- Use Route Handlers when data fetching from Client Components
|
||||||
|
|
||||||
|
## Database & Security
|
||||||
|
|
||||||
|
### RLS Policies
|
||||||
|
- Strive to create a safe, robust, secure and consistent database schema
|
||||||
|
- Always consider the compromises you need to make and explain them so I can make an educated decision. Follow up with the considerations make and explain them.
|
||||||
|
- Enable RLS by default and propose the required RLS policies
|
||||||
|
- `public.accounts` are the root tables for the application
|
||||||
|
- Implement cascading deletes when appropriate
|
||||||
|
- Ensure strong consistency considering triggers and constraints
|
||||||
|
- Always use Postgres schemas explicitly (e.g., `public.accounts`)
|
||||||
|
|
||||||
|
## Forms Pattern
|
||||||
|
|
||||||
|
### 1. Schema Definition
|
||||||
|
```tsx
|
||||||
|
// schema/note.schema.ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const NoteSchema = z.object({
|
||||||
|
title: z.string().min(1).max(100),
|
||||||
|
content: z.string().min(1),
|
||||||
|
category: z.enum(['work', 'personal']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Form Component
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
export function NoteForm() {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(NoteSchema),
|
||||||
|
defaultValues: { title: '', content: '', category: 'personal' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: z.infer<typeof NoteSchema>) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await createNoteAction(data);
|
||||||
|
form.reset();
|
||||||
|
} catch (error) {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField name="title" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
{/* Other fields */}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Consider logging asynchronous requests in server code using the `@kit/shared/logger`
|
||||||
|
- Handle promises and async/await gracefully
|
||||||
|
- Consider the unhappy path and handle errors appropriately
|
||||||
|
|
||||||
|
### Structured Logging
|
||||||
|
```tsx
|
||||||
|
const ctx = {
|
||||||
|
name: 'create-note',
|
||||||
|
userId: user.id,
|
||||||
|
noteId: note.id
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(ctx, 'Creating new note...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createNote();
|
||||||
|
logger.info(ctx, 'Note created successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(ctx, 'Failed to create note', { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context Management
|
||||||
|
|
||||||
|
In client components, we can use the `useUserWorkspace` hook to access the user's workspace data.
|
||||||
|
|
||||||
|
### Personal Account
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
function PersonalDashboard() {
|
||||||
|
const { workspace, user } = useUserWorkspace();
|
||||||
|
if (!workspace) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome, {user.email}</h1>
|
||||||
|
<SubscriptionStatus status={workspace.subscription_status} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Team Account
|
||||||
|
In client components, we can use the `useTeamAccountWorkspace` hook to access the team account's workspace data. It only works under the `/home/[account]` route.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
function TeamDashboard() {
|
||||||
|
const { account, user } = useTeamAccountWorkspace();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{account.name}</h1>
|
||||||
|
<RoleDisplay role={account.role} />
|
||||||
|
<PermissionsList permissions={account.permissions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
- Reusable UI components are defined in the "packages/ui" package named "@kit/ui".
|
||||||
|
- By exporting the component from the "exports" field, we can import it using the "@kit/ui/{component-name}" format.
|
||||||
|
|
||||||
|
## Creating Pages
|
||||||
|
|
||||||
|
When creating new pages ensure:
|
||||||
|
- The page is exported using `withI18n(Page)` to enable i18n.
|
||||||
|
- The page has the required and correct metadata using the `metadata` or `generateMetadata` function.
|
||||||
|
- Don't worry about authentication, it's handled in the middleware.
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { forgotPasswordAction } from "@/app/actions";
|
|
||||||
import { FormMessage, Message } from "@/components/form-message";
|
|
||||||
import { SubmitButton } from "@/components/submit-button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { SmtpMessage } from "../smtp-message";
|
|
||||||
|
|
||||||
export default async function ForgotPassword(props: {
|
|
||||||
searchParams: Promise<Message>;
|
|
||||||
}) {
|
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<form className="flex-1 flex flex-col w-full gap-2 text-foreground [&>input]:mb-6 min-w-64 max-w-64 mx-auto">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-medium">Reset Password</h1>
|
|
||||||
<p className="text-sm text-secondary-foreground">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link className="text-primary underline" href="/sign-in">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 [&>input]:mb-3 mt-8">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input name="email" placeholder="you@example.com" required />
|
|
||||||
<SubmitButton formAction={forgotPasswordAction}>
|
|
||||||
Reset Password
|
|
||||||
</SubmitButton>
|
|
||||||
<FormMessage message={searchParams} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<SmtpMessage />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export default async function Layout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl flex flex-col gap-12 items-start">{children}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { signInAction } from "@/app/actions";
|
|
||||||
import { FormMessage, Message } from "@/components/form-message";
|
|
||||||
import { SubmitButton } from "@/components/submit-button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default async function Login(props: { searchParams: Promise<Message> }) {
|
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
return (
|
|
||||||
<form className="flex-1 flex flex-col min-w-64">
|
|
||||||
<h1 className="text-2xl font-medium">Sign in</h1>
|
|
||||||
<p className="text-sm text-foreground">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<Link className="text-foreground font-medium underline" href="/sign-up">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2 [&>input]:mb-3 mt-8">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input name="email" placeholder="you@example.com" required />
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Link
|
|
||||||
className="text-xs text-foreground underline"
|
|
||||||
href="/forgot-password"
|
|
||||||
>
|
|
||||||
Forgot Password?
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Your password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<SubmitButton pendingText="Signing In..." formAction={signInAction}>
|
|
||||||
Sign in
|
|
||||||
</SubmitButton>
|
|
||||||
<FormMessage message={searchParams} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { signUpAction } from "@/app/actions";
|
|
||||||
import { FormMessage, Message } from "@/components/form-message";
|
|
||||||
import { SubmitButton } from "@/components/submit-button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { SmtpMessage } from "../smtp-message";
|
|
||||||
|
|
||||||
export default async function Signup(props: {
|
|
||||||
searchParams: Promise<Message>;
|
|
||||||
}) {
|
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
if ("message" in searchParams) {
|
|
||||||
return (
|
|
||||||
<div className="w-full flex-1 flex items-center h-screen sm:max-w-md justify-center gap-2 p-4">
|
|
||||||
<FormMessage message={searchParams} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<form className="flex flex-col min-w-64 max-w-64 mx-auto">
|
|
||||||
<h1 className="text-2xl font-medium">Sign up</h1>
|
|
||||||
<p className="text-sm text text-foreground">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link className="text-primary font-medium underline" href="/sign-in">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2 [&>input]:mb-3 mt-8">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input name="email" placeholder="you@example.com" required />
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Your password"
|
|
||||||
minLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<SubmitButton formAction={signUpAction} pendingText="Signing up...">
|
|
||||||
Sign up
|
|
||||||
</SubmitButton>
|
|
||||||
<FormMessage message={searchParams} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<SmtpMessage />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ArrowUpRight, InfoIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export function SmtpMessage() {
|
|
||||||
return (
|
|
||||||
<div className="bg-muted/50 px-5 py-3 border rounded-md flex gap-4">
|
|
||||||
<InfoIcon size={16} className="mt-0.5" />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<small className="text-sm text-secondary-foreground">
|
|
||||||
<strong> Note:</strong> Emails are rate limited. Enable Custom SMTP to
|
|
||||||
increase the rate limit.
|
|
||||||
</small>
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href="https://supabase.com/docs/guides/auth/auth-smtp"
|
|
||||||
target="_blank"
|
|
||||||
className="text-primary/50 hover:text-primary flex items-center text-sm gap-1"
|
|
||||||
>
|
|
||||||
Learn more <ArrowUpRight size={14} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
30
app/(marketing)/(legal)/cookie-policy/page.tsx
Normal file
30
app/(marketing)/(legal)/cookie-policy/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('marketing:cookiePolicy'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function CookiePolicyPage() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SitePageHeader
|
||||||
|
title={t(`marketing:cookiePolicy`)}
|
||||||
|
subtitle={t(`marketing:cookiePolicyDescription`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'container mx-auto py-8'}>
|
||||||
|
<div>Your terms of service content here</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(CookiePolicyPage);
|
||||||
30
app/(marketing)/(legal)/privacy-policy/page.tsx
Normal file
30
app/(marketing)/(legal)/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('marketing:privacyPolicy'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function PrivacyPolicyPage() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SitePageHeader
|
||||||
|
title={t('marketing:privacyPolicy')}
|
||||||
|
subtitle={t('marketing:privacyPolicyDescription')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'container mx-auto py-8'}>
|
||||||
|
<div>Your terms of service content here</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(PrivacyPolicyPage);
|
||||||
30
app/(marketing)/(legal)/terms-of-service/page.tsx
Normal file
30
app/(marketing)/(legal)/terms-of-service/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('marketing:termsOfService'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function TermsOfServicePage() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SitePageHeader
|
||||||
|
title={t(`marketing:termsOfService`)}
|
||||||
|
subtitle={t(`marketing:termsOfServiceDescription`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'container mx-auto py-8'}>
|
||||||
|
<div>Your terms of service content here</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(TermsOfServicePage);
|
||||||
58
app/(marketing)/_components/site-footer.tsx
Normal file
58
app/(marketing)/_components/site-footer.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Footer } from '@kit/ui/marketing';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
import appConfig from '~/config/app.config';
|
||||||
|
|
||||||
|
export function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<Footer
|
||||||
|
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
|
||||||
|
description={<Trans i18nKey="marketing:footerDescription" />}
|
||||||
|
copyright={
|
||||||
|
<Trans
|
||||||
|
i18nKey="marketing:copyright"
|
||||||
|
values={{
|
||||||
|
product: appConfig.name,
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
heading: <Trans i18nKey="marketing:about" />,
|
||||||
|
links: [
|
||||||
|
{ href: '/blog', label: <Trans i18nKey="marketing:blog" /> },
|
||||||
|
{ href: '/contact', label: <Trans i18nKey="marketing:contact" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: <Trans i18nKey="marketing:product" />,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: '/docs',
|
||||||
|
label: <Trans i18nKey="marketing:documentation" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: <Trans i18nKey="marketing:legal" />,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: '/terms-of-service',
|
||||||
|
label: <Trans i18nKey="marketing:termsOfService" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/privacy-policy',
|
||||||
|
label: <Trans i18nKey="marketing:privacyPolicy" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/cookie-policy',
|
||||||
|
label: <Trans i18nKey="marketing:cookiePolicy" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
app/(marketing)/_components/site-header-account-section.tsx
Normal file
88
app/(marketing)/_components/site-header-account-section.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||||
|
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||||
|
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
|
const ModeToggle = dynamic(() =>
|
||||||
|
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||||
|
default: mod.ModeToggle,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
home: pathsConfig.app.home,
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = {
|
||||||
|
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteHeaderAccountSection() {
|
||||||
|
const session = useSession();
|
||||||
|
const signOut = useSignOut();
|
||||||
|
|
||||||
|
if (session.data) {
|
||||||
|
return (
|
||||||
|
<PersonalAccountDropdown
|
||||||
|
showProfileName={false}
|
||||||
|
paths={paths}
|
||||||
|
features={features}
|
||||||
|
user={session.data.user}
|
||||||
|
signOutRequested={() => signOut.mutateAsync()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthButtons />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthButtons() {
|
||||||
|
return (
|
||||||
|
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
|
||||||
|
<div className={'hidden md:flex'}>
|
||||||
|
<If condition={features.enableThemeToggle}>
|
||||||
|
<ModeToggle />
|
||||||
|
</If>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex gap-x-2.5'}>
|
||||||
|
<Button className={'hidden md:block'} asChild variant={'ghost'}>
|
||||||
|
<Link href={pathsConfig.auth.signIn}>
|
||||||
|
<Trans i18nKey={'auth:signIn'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||||
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
|
<Trans i18nKey={'auth:signUp'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSession() {
|
||||||
|
const client = useSupabase();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['session'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await client.auth.getSession();
|
||||||
|
|
||||||
|
return data.session;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
app/(marketing)/_components/site-header.tsx
Normal file
16
app/(marketing)/_components/site-header.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Header } from '@kit/ui/marketing';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
|
import { SiteHeaderAccountSection } from './site-header-account-section';
|
||||||
|
import { SiteNavigation } from './site-navigation';
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
return (
|
||||||
|
<Header
|
||||||
|
logo={<AppLogo />}
|
||||||
|
navigation={<SiteNavigation />}
|
||||||
|
actions={<SiteHeaderAccountSection />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/(marketing)/_components/site-navigation-item.tsx
Normal file
37
app/(marketing)/_components/site-navigation-item.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { NavigationMenuItem } from '@kit/ui/navigation-menu';
|
||||||
|
import { cn, isRouteActive } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
const getClassName = (path: string, currentPathName: string) => {
|
||||||
|
const isActive = isRouteActive(path, currentPathName);
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
`inline-flex w-max text-sm font-medium transition-colors duration-300`,
|
||||||
|
{
|
||||||
|
'dark:text-gray-300 dark:hover:text-white': !isActive,
|
||||||
|
'text-current dark:text-white': isActive,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteNavigationItem({
|
||||||
|
path,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
path: string;
|
||||||
|
}>) {
|
||||||
|
const currentPathName = usePathname();
|
||||||
|
const className = getClassName(path, currentPathName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationMenuItem key={path}>
|
||||||
|
<Link className={className} href={path} as={path} prefetch={true}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
app/(marketing)/_components/site-navigation.tsx
Normal file
87
app/(marketing)/_components/site-navigation.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@kit/ui/dropdown-menu';
|
||||||
|
import { NavigationMenu, NavigationMenuList } from '@kit/ui/navigation-menu';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { SiteNavigationItem } from './site-navigation-item';
|
||||||
|
|
||||||
|
const links = {
|
||||||
|
Blog: {
|
||||||
|
label: 'marketing:blog',
|
||||||
|
path: '/blog',
|
||||||
|
},
|
||||||
|
Docs: {
|
||||||
|
label: 'marketing:documentation',
|
||||||
|
path: '/docs',
|
||||||
|
},
|
||||||
|
Pricing: {
|
||||||
|
label: 'marketing:pricing',
|
||||||
|
path: '/pricing',
|
||||||
|
},
|
||||||
|
FAQ: {
|
||||||
|
label: 'marketing:faq',
|
||||||
|
path: '/faq',
|
||||||
|
},
|
||||||
|
Contact: {
|
||||||
|
label: 'marketing:contact',
|
||||||
|
path: '/contact',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteNavigation() {
|
||||||
|
const NavItems = Object.values(links).map((item) => {
|
||||||
|
return (
|
||||||
|
<SiteNavigationItem key={item.path} path={item.path}>
|
||||||
|
<Trans i18nKey={item.label} />
|
||||||
|
</SiteNavigationItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'hidden items-center justify-center md:flex'}>
|
||||||
|
<NavigationMenu>
|
||||||
|
<NavigationMenuList className={'gap-x-2.5'}>
|
||||||
|
{NavItems}
|
||||||
|
</NavigationMenuList>
|
||||||
|
</NavigationMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex justify-start sm:items-center md:hidden'}>
|
||||||
|
<MobileDropdown />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileDropdown() {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger aria-label={'Open Menu'}>
|
||||||
|
<Menu className={'h-8 w-8'} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className={'w-full'}>
|
||||||
|
{Object.values(links).map((item) => {
|
||||||
|
const className = 'flex w-full h-full items-center';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={item.path} asChild>
|
||||||
|
<Link className={className} href={item.path}>
|
||||||
|
<Trans i18nKey={item.label} />
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/(marketing)/_components/site-page-header.tsx
Normal file
37
app/(marketing)/_components/site-page-header.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
export function SitePageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
container = true,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
container?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const containerClass = container ? 'container' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('border-b py-8 xl:py-10 2xl:py-12', className)}>
|
||||||
|
<div className={cn('flex flex-col gap-y-3 lg:gap-y-4', containerClass)}>
|
||||||
|
<h1
|
||||||
|
className={
|
||||||
|
'font-heading text-3xl font-medium tracking-tighter xl:text-5xl dark:text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
className={
|
||||||
|
'text-muted-foreground text-lg tracking-tight 2xl:text-2xl'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
app/(marketing)/blog/[slug]/page.tsx
Normal file
78
app/(marketing)/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { createCmsClient } from '@kit/cms';
|
||||||
|
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import { Post } from '../../blog/_components/post';
|
||||||
|
|
||||||
|
interface BlogPageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPostBySlug = cache(postLoader);
|
||||||
|
|
||||||
|
async function postLoader(slug: string) {
|
||||||
|
const client = await createCmsClient();
|
||||||
|
|
||||||
|
return client.getContentItemBySlug({ slug, collection: 'posts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: BlogPageProps): Promise<Metadata> {
|
||||||
|
const slug = (await params).slug;
|
||||||
|
const post = await getPostBySlug(slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, publishedAt, description, image } = post;
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type: 'article',
|
||||||
|
publishedTime: publishedAt,
|
||||||
|
url: post.url,
|
||||||
|
images: image
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
url: image,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: image ? [image] : [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function BlogPost({ params }: BlogPageProps) {
|
||||||
|
const slug = (await params).slug;
|
||||||
|
const post = await getPostBySlug(slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'container sm:max-w-none sm:p-0'}>
|
||||||
|
<Post post={post} content={post.content} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(BlogPost);
|
||||||
58
app/(marketing)/blog/_components/blog-pagination.tsx
Normal file
58
app/(marketing)/blog/_components/blog-pagination.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export function BlogPagination(props: {
|
||||||
|
currentPage: number;
|
||||||
|
canGoToNextPage: boolean;
|
||||||
|
canGoToPreviousPage: boolean;
|
||||||
|
}) {
|
||||||
|
const navigate = useGoToPage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex items-center space-x-2'}>
|
||||||
|
<If condition={props.canGoToPreviousPage}>
|
||||||
|
<Button
|
||||||
|
variant={'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(props.currentPage - 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft className={'mr-2 h-4'} />
|
||||||
|
<Trans i18nKey={'marketing:blogPaginationPrevious'} />
|
||||||
|
</Button>
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<If condition={props.canGoToNextPage}>
|
||||||
|
<Button
|
||||||
|
variant={'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(props.currentPage + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans i18nKey={'marketing:blogPaginationNext'} />
|
||||||
|
<ArrowRight className={'ml-2 h-4'} />
|
||||||
|
</Button>
|
||||||
|
</If>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGoToPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const path = usePathname();
|
||||||
|
|
||||||
|
return (page: number) => {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(path + '?' + searchParams.toString());
|
||||||
|
};
|
||||||
|
}
|
||||||
28
app/(marketing)/blog/_components/cover-image.tsx
Normal file
28
app/(marketing)/blog/_components/cover-image.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
src: string;
|
||||||
|
preloadImage?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CoverImage({ title, src, preloadImage, className }: Props) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
className={cn(
|
||||||
|
'block rounded-xl object-cover duration-250' +
|
||||||
|
' transition-all hover:opacity-90',
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
src={src}
|
||||||
|
priority={preloadImage}
|
||||||
|
alt={`Cover Image for ${title}`}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/(marketing)/blog/_components/date-formatter.tsx
Normal file
11
app/(marketing)/blog/_components/date-formatter.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dateString: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DateFormatter = ({ dateString }: Props) => {
|
||||||
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
|
return <time dateTime={dateString}>{format(date, 'PP')}</time>;
|
||||||
|
};
|
||||||
9
app/(marketing)/blog/_components/draft-post-badge.tsx
Normal file
9
app/(marketing)/blog/_components/draft-post-badge.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function DraftPostBadge({ children }: React.PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<span className="dark:text-dark-800 rounded-md bg-yellow-200 px-4 py-2 font-semibold">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
app/(marketing)/blog/_components/post-header.tsx
Normal file
50
app/(marketing)/blog/_components/post-header.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Cms } from '@kit/cms';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
import { CoverImage } from './cover-image';
|
||||||
|
import { DateFormatter } from './date-formatter';
|
||||||
|
|
||||||
|
export function PostHeader({ post }: { post: Cms.ContentItem }) {
|
||||||
|
const { title, publishedAt, description, image } = post;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-1 flex-col'}>
|
||||||
|
<div className={cn('border-b py-8')}>
|
||||||
|
<div className={'mx-auto flex max-w-3xl flex-col space-y-4'}>
|
||||||
|
<h1
|
||||||
|
className={
|
||||||
|
'font-heading text-3xl font-semibold tracking-tighter xl:text-5xl dark:text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className={'text-muted-foreground'}>
|
||||||
|
<DateFormatter dateString={publishedAt} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
className={'text-muted-foreground text-base xl:text-lg'}
|
||||||
|
dangerouslySetInnerHTML={{ __html: description ?? '' }}
|
||||||
|
></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<If condition={image}>
|
||||||
|
{(imageUrl) => (
|
||||||
|
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-3xl justify-center">
|
||||||
|
<CoverImage
|
||||||
|
preloadImage
|
||||||
|
className="rounded-md"
|
||||||
|
title={title}
|
||||||
|
src={imageUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</If>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
app/(marketing)/blog/_components/post-preview.tsx
Normal file
65
app/(marketing)/blog/_components/post-preview.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Cms } from '@kit/cms';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
|
||||||
|
import { CoverImage } from '~/(marketing)/blog/_components/cover-image';
|
||||||
|
import { DateFormatter } from '~/(marketing)/blog/_components/date-formatter';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
post: Cms.ContentItem;
|
||||||
|
preloadImage?: boolean;
|
||||||
|
imageHeight?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE_HEIGHT = 250;
|
||||||
|
|
||||||
|
export function PostPreview({
|
||||||
|
post,
|
||||||
|
preloadImage,
|
||||||
|
imageHeight,
|
||||||
|
}: React.PropsWithChildren<Props>) {
|
||||||
|
const { title, image, publishedAt, description } = post;
|
||||||
|
const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT;
|
||||||
|
|
||||||
|
const slug = `/blog/${post.slug}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="transition-shadow-sm flex flex-col gap-y-4 rounded-lg duration-500">
|
||||||
|
<If condition={image}>
|
||||||
|
{(imageUrl) => (
|
||||||
|
<div className="relative mb-2 w-full" style={{ height }}>
|
||||||
|
<Link href={slug}>
|
||||||
|
<CoverImage
|
||||||
|
preloadImage={preloadImage}
|
||||||
|
title={title}
|
||||||
|
src={imageUrl}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<div className={'flex flex-col space-y-4 px-1'}>
|
||||||
|
<div className={'flex flex-col space-y-2'}>
|
||||||
|
<h2 className="text-xl leading-snug font-semibold tracking-tight">
|
||||||
|
<Link href={slug} className="hover:underline">
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center gap-x-3 text-sm">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<DateFormatter dateString={publishedAt} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-muted-foreground mb-4 text-sm leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{ __html: description ?? '' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/(marketing)/blog/_components/post.tsx
Normal file
24
app/(marketing)/blog/_components/post.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Cms } from '@kit/cms';
|
||||||
|
import { ContentRenderer } from '@kit/cms';
|
||||||
|
|
||||||
|
import { PostHeader } from './post-header';
|
||||||
|
|
||||||
|
export function Post({
|
||||||
|
post,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
post: Cms.ContentItem;
|
||||||
|
content: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PostHeader post={post} />
|
||||||
|
|
||||||
|
<div className={'mx-auto flex max-w-3xl flex-col space-y-6 py-8'}>
|
||||||
|
<article className="markdoc">
|
||||||
|
<ContentRenderer content={content} />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
app/(marketing)/blog/page.tsx
Normal file
105
app/(marketing)/blog/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { createCmsClient } from '@kit/cms';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
// local imports
|
||||||
|
import { SitePageHeader } from '../_components/site-page-header';
|
||||||
|
import { BlogPagination } from './_components/blog-pagination';
|
||||||
|
import { PostPreview } from './_components/post-preview';
|
||||||
|
|
||||||
|
interface BlogPageProps {
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('marketing:blog'),
|
||||||
|
description: t('marketing:blogSubtitle'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContentItems = cache(
|
||||||
|
async (language: string | undefined, limit: number, offset: number) => {
|
||||||
|
const client = await createCmsClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await client.getContentItems({
|
||||||
|
collection: 'posts',
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
language,
|
||||||
|
content: false,
|
||||||
|
sortBy: 'publishedAt',
|
||||||
|
sortDirection: 'desc',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to load blog posts');
|
||||||
|
|
||||||
|
return { total: 0, items: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function BlogPage(props: BlogPageProps) {
|
||||||
|
const { t, resolvedLanguage: language } = await createI18nServerInstance();
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
|
||||||
|
const page = searchParams.page ? parseInt(searchParams.page) : 0;
|
||||||
|
const limit = 10;
|
||||||
|
const offset = page * limit;
|
||||||
|
|
||||||
|
const { total, items: posts } = await getContentItems(
|
||||||
|
language,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SitePageHeader
|
||||||
|
title={t('marketing:blog')}
|
||||||
|
subtitle={t('marketing:blogSubtitle')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'container flex flex-col space-y-6 py-12'}>
|
||||||
|
<If
|
||||||
|
condition={posts.length > 0}
|
||||||
|
fallback={<Trans i18nKey="marketing:noPosts" />}
|
||||||
|
>
|
||||||
|
<PostsGridList>
|
||||||
|
{posts.map((post, idx) => {
|
||||||
|
return <PostPreview key={idx} post={post} />;
|
||||||
|
})}
|
||||||
|
</PostsGridList>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<BlogPagination
|
||||||
|
currentPage={page}
|
||||||
|
canGoToNextPage={offset + limit < total}
|
||||||
|
canGoToPreviousPage={page > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</If>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(BlogPage);
|
||||||
|
|
||||||
|
function PostsGridList({ children }: React.PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-y-8 md:grid-cols-2 md:gap-x-8 md:gap-y-12 lg:grid-cols-3 lg:gap-x-12">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
app/(marketing)/contact/_components/contact-form.tsx
Normal file
161
app/(marketing)/contact/_components/contact-form.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@kit/ui/form';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Textarea } from '@kit/ui/textarea';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { ContactEmailSchema } from '~/(marketing)/contact/_lib/contact-email.schema';
|
||||||
|
import { sendContactEmail } from '~/(marketing)/contact/_lib/server/server-actions';
|
||||||
|
|
||||||
|
export function ContactForm() {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [state, setState] = useState({
|
||||||
|
success: false,
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ContactEmailSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.success) {
|
||||||
|
return <SuccessAlert />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error) {
|
||||||
|
return <ErrorAlert />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className={'flex flex-col space-y-4'}
|
||||||
|
onSubmit={form.handleSubmit((data) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await sendContactEmail(data);
|
||||||
|
|
||||||
|
setState({ success: true, error: false });
|
||||||
|
} catch {
|
||||||
|
setState({ error: true, success: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name={'name'}
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans i18nKey={'marketing:contactName'} />
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input maxLength={200} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name={'email'}
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans i18nKey={'marketing:contactEmail'} />
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input type={'email'} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name={'message'}
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans i18nKey={'marketing:contactMessage'} />
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
className={'min-h-36'}
|
||||||
|
maxLength={5000}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button disabled={pending} type={'submit'}>
|
||||||
|
<Trans i18nKey={'marketing:sendMessage'} />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessAlert() {
|
||||||
|
return (
|
||||||
|
<Alert variant={'success'}>
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans i18nKey={'marketing:contactSuccess'} />
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans i18nKey={'marketing:contactSuccessDescription'} />
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorAlert() {
|
||||||
|
return (
|
||||||
|
<Alert variant={'destructive'}>
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans i18nKey={'marketing:contactError'} />
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans i18nKey={'marketing:contactErrorDescription'} />
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
app/(marketing)/contact/_lib/contact-email.schema.ts
Normal file
7
app/(marketing)/contact/_lib/contact-email.schema.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ContactEmailSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
email: z.string().email(),
|
||||||
|
message: z.string().min(1).max(5000),
|
||||||
|
});
|
||||||
51
app/(marketing)/contact/_lib/server/server-actions.ts
Normal file
51
app/(marketing)/contact/_lib/server/server-actions.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getMailer } from '@kit/mailers';
|
||||||
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
|
|
||||||
|
import { ContactEmailSchema } from '../contact-email.schema';
|
||||||
|
|
||||||
|
const contactEmail = z
|
||||||
|
.string({
|
||||||
|
description: `The email where you want to receive the contact form submissions.`,
|
||||||
|
required_error:
|
||||||
|
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
|
||||||
|
})
|
||||||
|
.parse(process.env.CONTACT_EMAIL);
|
||||||
|
|
||||||
|
const emailFrom = z
|
||||||
|
.string({
|
||||||
|
description: `The email sending address.`,
|
||||||
|
required_error:
|
||||||
|
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
|
||||||
|
})
|
||||||
|
.parse(process.env.EMAIL_SENDER);
|
||||||
|
|
||||||
|
export const sendContactEmail = enhanceAction(
|
||||||
|
async (data) => {
|
||||||
|
const mailer = await getMailer();
|
||||||
|
|
||||||
|
await mailer.sendEmail({
|
||||||
|
to: contactEmail,
|
||||||
|
from: emailFrom,
|
||||||
|
subject: 'Contact Form Submission',
|
||||||
|
html: `
|
||||||
|
<p>
|
||||||
|
You have received a new contact form submission.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Name: ${data.name}</p>
|
||||||
|
<p>Email: ${data.email}</p>
|
||||||
|
<p>Message: ${data.message}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schema: ContactEmailSchema,
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
54
app/(marketing)/contact/page.tsx
Normal file
54
app/(marketing)/contact/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||||
|
import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('marketing:contact'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ContactPage() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SitePageHeader
|
||||||
|
title={t(`marketing:contact`)}
|
||||||
|
subtitle={t(`marketing:contactDescription`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'container mx-auto'}>
|
||||||
|
<div
|
||||||
|
className={'flex flex-1 flex-col items-center justify-center py-12'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex w-full max-w-lg flex-col space-y-4 rounded-lg border p-8'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Heading level={3}>
|
||||||
|
<Trans i18nKey={'marketing:contactHeading'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground'}>
|
||||||
|
<Trans i18nKey={'marketing:contactSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(ContactPage);
|
||||||
90
app/(marketing)/docs/[...slug]/page.tsx
Normal file
90
app/(marketing)/docs/[...slug]/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ContentRenderer, createCmsClient } from '@kit/cms';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { Separator } from '@kit/ui/separator';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
// local imports
|
||||||
|
import { DocsCards } from '../_components/docs-cards';
|
||||||
|
import { DocsTableOfContents } from '../_components/docs-table-of-contents';
|
||||||
|
import { extractHeadingsFromJSX } from '../_lib/utils';
|
||||||
|
|
||||||
|
const getPageBySlug = cache(pageLoader);
|
||||||
|
|
||||||
|
interface DocumentationPageProps {
|
||||||
|
params: Promise<{ slug: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageLoader(slug: string) {
|
||||||
|
const client = await createCmsClient();
|
||||||
|
|
||||||
|
return client.getContentItemBySlug({ slug, collection: 'documentation' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ params }: DocumentationPageProps) => {
|
||||||
|
const slug = (await params).slug.join('/');
|
||||||
|
const page = await getPageBySlug(slug);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description } = page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function DocumentationPage({ params }: DocumentationPageProps) {
|
||||||
|
const slug = (await params).slug.join('/');
|
||||||
|
const page = await getPageBySlug(slug);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = page?.description ?? '';
|
||||||
|
|
||||||
|
const headings = extractHeadingsFromJSX(
|
||||||
|
page.content as {
|
||||||
|
props: { children: React.ReactElement[] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-1 flex-col gap-y-4 overflow-y-hidden py-5'}>
|
||||||
|
<div className={'flex overflow-y-hidden'}>
|
||||||
|
<article className={cn('gap-y-12 overflow-y-auto px-6')}>
|
||||||
|
<section className={'flex flex-col gap-y-2.5'}>
|
||||||
|
<h1 className={'text-foreground text-3xl font-semibold'}>
|
||||||
|
{page.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h2 className={'text-muted-foreground text-lg'}>{description}</h2>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={'markdoc'}>
|
||||||
|
<ContentRenderer content={page.content} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<DocsTableOfContents data={headings} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<If condition={page.children.length > 0}>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DocsCards cards={page.children ?? []} />
|
||||||
|
</If>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(DocumentationPage);
|
||||||
53
app/(marketing)/docs/_components/docs-card.tsx
Normal file
53
app/(marketing)/docs/_components/docs-card.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export function DocsCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
link,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
link: { url: string; label?: string };
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div
|
||||||
|
className={`bg-background flex grow flex-col gap-y-2 border p-6 ${link ? 'rounded-t-lg border-b-0' : 'rounded-lg'}`}
|
||||||
|
>
|
||||||
|
<h3 className="mt-0 text-lg font-semibold hover:underline dark:text-white">
|
||||||
|
<Link href={link.url}>{title}</Link>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<p dangerouslySetInnerHTML={{ __html: subtitle }}></p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children && <div className="text-sm">{children}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{link && (
|
||||||
|
<div className="bg-muted/50 rounded-b-lg border p-6 py-4">
|
||||||
|
<Link
|
||||||
|
className={
|
||||||
|
'flex items-center space-x-2 text-sm font-medium hover:underline'
|
||||||
|
}
|
||||||
|
href={link.url}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{link.label ?? <Trans i18nKey={'marketing:readMore'} />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ChevronRight className={'h-4'} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/(marketing)/docs/_components/docs-cards.tsx
Normal file
24
app/(marketing)/docs/_components/docs-cards.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Cms } from '@kit/cms';
|
||||||
|
|
||||||
|
import { DocsCard } from './docs-card';
|
||||||
|
|
||||||
|
export function DocsCards({ cards }: { cards: Cms.ContentItem[] }) {
|
||||||
|
const cardsSortedByOrder = [...cards].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'grid grid-cols-1 gap-6 lg:grid-cols-2'}>
|
||||||
|
{cardsSortedByOrder.map((item) => {
|
||||||
|
return (
|
||||||
|
<DocsCard
|
||||||
|
key={item.title}
|
||||||
|
title={item.title}
|
||||||
|
subtitle={item.description}
|
||||||
|
link={{
|
||||||
|
url: `/docs/${item.slug}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/(marketing)/docs/_components/docs-nav-link.tsx
Normal file
39
app/(marketing)/docs/_components/docs-nav-link.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/shadcn-sidebar';
|
||||||
|
import { cn, isRouteActive } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
export function DocsNavLink({
|
||||||
|
label,
|
||||||
|
url,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ label: string; url: string }>) {
|
||||||
|
const currentPath = usePathname();
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const isCurrent = isRouteActive(url, currentPath, true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={isCurrent}
|
||||||
|
className={cn('transition-background font-normal!', {
|
||||||
|
'text-secondary-foreground font-bold': isCurrent,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Link href={url}>
|
||||||
|
<span ref={ref} className="block max-w-full truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Cms } from '@kit/cms';
|
||||||
|
import { Collapsible } from '@kit/ui/collapsible';
|
||||||
|
import { isRouteActive } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
export function DocsNavigationCollapsible(
|
||||||
|
props: React.PropsWithChildren<{
|
||||||
|
node: Cms.ContentItem;
|
||||||
|
prefix: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const currentPath = usePathname();
|
||||||
|
const prefix = props.prefix;
|
||||||
|
|
||||||
|
const isChildActive = props.node.children.some((child) =>
|
||||||
|
isRouteActive(prefix + '/' + child.url, currentPath, false),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
className={'group/collapsible'}
|
||||||
|
defaultOpen={isChildActive ? true : !props.node.collapsed}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
app/(marketing)/docs/_components/docs-navigation.tsx
Normal file
143
app/(marketing)/docs/_components/docs-navigation.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Cms } from '@kit/cms';
|
||||||
|
import { CollapsibleContent, CollapsibleTrigger } from '@kit/ui/collapsible';
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
} from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
|
import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link';
|
||||||
|
import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible';
|
||||||
|
|
||||||
|
import { FloatingDocumentationNavigation } from './floating-docs-navigation';
|
||||||
|
|
||||||
|
function Node({
|
||||||
|
node,
|
||||||
|
level,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
node: Cms.ContentItem;
|
||||||
|
level: number;
|
||||||
|
prefix: string;
|
||||||
|
}) {
|
||||||
|
const url = `${prefix}/${node.slug}`;
|
||||||
|
const label = node.label ? node.label : node.title;
|
||||||
|
|
||||||
|
const Container = (props: React.PropsWithChildren) => {
|
||||||
|
if (node.collapsible) {
|
||||||
|
return (
|
||||||
|
<DocsNavigationCollapsible node={node} prefix={prefix}>
|
||||||
|
{props.children}
|
||||||
|
</DocsNavigationCollapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.children;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContentContainer = (props: React.PropsWithChildren) => {
|
||||||
|
if (node.collapsible) {
|
||||||
|
return <CollapsibleContent>{props.children}</CollapsibleContent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.children;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Trigger = () => {
|
||||||
|
if (node.collapsible) {
|
||||||
|
return (
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton>
|
||||||
|
{label}
|
||||||
|
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DocsNavLink label={label} url={url} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Trigger />
|
||||||
|
|
||||||
|
<ContentContainer>
|
||||||
|
<Tree pages={node.children ?? []} level={level + 1} prefix={prefix} />
|
||||||
|
</ContentContainer>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tree({
|
||||||
|
pages,
|
||||||
|
level,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
pages: Cms.ContentItem[];
|
||||||
|
level: number;
|
||||||
|
prefix: string;
|
||||||
|
}) {
|
||||||
|
if (level === 0) {
|
||||||
|
return pages.map((treeNode, index) => (
|
||||||
|
<Node key={index} node={treeNode} level={level} prefix={prefix} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{pages.map((treeNode, index) => (
|
||||||
|
<Node key={index} node={treeNode} level={level} prefix={prefix} />
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsNavigation({
|
||||||
|
pages,
|
||||||
|
prefix = '/docs',
|
||||||
|
}: {
|
||||||
|
pages: Cms.ContentItem[];
|
||||||
|
prefix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sidebar
|
||||||
|
variant={'ghost'}
|
||||||
|
className={'sticky z-1 mt-4 max-h-full overflow-y-auto'}
|
||||||
|
>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<Tree pages={pages} level={0} prefix={prefix} />
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
<div className={'lg:hidden'}>
|
||||||
|
<FloatingDocumentationNavigation>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<Tree pages={pages} level={0} prefix={prefix} />
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</FloatingDocumentationNavigation>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/(marketing)/docs/_components/docs-page-link.tsx
Normal file
45
app/(marketing)/docs/_components/docs-page-link.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
export function DocsPageLink({
|
||||||
|
page,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
page: {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
before?: React.ReactNode;
|
||||||
|
after?: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={cn(
|
||||||
|
`ring-muted hover:ring-primary flex w-full items-center space-x-8 rounded-xl p-6 font-medium text-current ring-2 transition-all`,
|
||||||
|
{
|
||||||
|
'justify-start': before,
|
||||||
|
'justify-end self-end': after,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
href={page.url}
|
||||||
|
>
|
||||||
|
<If condition={before}>{(node) => <>{node}</>}</If>
|
||||||
|
|
||||||
|
<span className={'flex flex-col space-y-1.5'}>
|
||||||
|
<span
|
||||||
|
className={'text-muted-foreground text-xs font-semibold uppercase'}
|
||||||
|
>
|
||||||
|
{before ? `Previous` : ``}
|
||||||
|
{after ? `Next` : ``}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>{page.title}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<If condition={after}>{(node) => <>{node}</>}</If>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/(marketing)/docs/_components/docs-table-of-contents.tsx
Normal file
51
app/(marketing)/docs/_components/docs-table-of-contents.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
text: string;
|
||||||
|
level: number;
|
||||||
|
href: string;
|
||||||
|
children: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsTableOfContents(props: { data: NavItem[] }) {
|
||||||
|
const navData = props.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background sticky inset-y-0 hidden h-svh max-h-full min-w-[14em] border-l p-4 lg:block">
|
||||||
|
<ol
|
||||||
|
role="list"
|
||||||
|
className="relative text-sm text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{navData.map((item) => (
|
||||||
|
<li key={item.href} className="group/item relative mt-3 first:mt-0">
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
className="block transition-colors **:[font:inherit] hover:text-gray-950 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</a>
|
||||||
|
{item.children && (
|
||||||
|
<ol role="list" className="relative mt-3 pl-4">
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<li
|
||||||
|
key={child.href}
|
||||||
|
className="group/subitem relative mt-3 first:mt-0"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={child.href}
|
||||||
|
className="block transition-colors **:[font:inherit] hover:text-gray-950 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{child.text}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isBrowser } from '@kit/shared/utils';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
|
||||||
|
export function FloatingDocumentationNavigation(
|
||||||
|
props: React.PropsWithChildren,
|
||||||
|
) {
|
||||||
|
const activePath = usePathname();
|
||||||
|
|
||||||
|
const body = useMemo(() => {
|
||||||
|
return isBrowser() ? document.body : null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const enableScrolling = (element: HTMLElement) =>
|
||||||
|
(element.style.overflowY = '');
|
||||||
|
|
||||||
|
const disableScrolling = (element: HTMLElement) =>
|
||||||
|
(element.style.overflowY = 'hidden');
|
||||||
|
|
||||||
|
// enable/disable body scrolling when the docs are toggled
|
||||||
|
useEffect(() => {
|
||||||
|
if (!body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
disableScrolling(body);
|
||||||
|
} else {
|
||||||
|
enableScrolling(body);
|
||||||
|
}
|
||||||
|
}, [isVisible, body]);
|
||||||
|
|
||||||
|
// hide docs when navigating to another page
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
}, [activePath]);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
setIsVisible(!isVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<If condition={isVisible}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'fixed top-0 left-0 z-10 h-screen w-full p-4' +
|
||||||
|
' dark:bg-background flex flex-col space-y-4 overflow-auto bg-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={'fixed right-5 bottom-5 z-10 h-16 w-16 rounded-full'}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Menu className={'h-8'} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
app/(marketing)/docs/_lib/server/docs.loader.ts
Normal file
31
app/(marketing)/docs/_lib/server/docs.loader.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { createCmsClient } from '@kit/cms';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getDocs
|
||||||
|
* @description Load the documentation pages.
|
||||||
|
* @param language
|
||||||
|
*/
|
||||||
|
export const getDocs = cache(docsLoader);
|
||||||
|
|
||||||
|
async function docsLoader(language: string | undefined) {
|
||||||
|
const cms = await createCmsClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await cms.getContentItems({
|
||||||
|
collection: 'documentation',
|
||||||
|
language,
|
||||||
|
limit: Infinity,
|
||||||
|
content: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.items;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to load documentation pages');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/(marketing)/docs/_lib/utils.ts
Normal file
146
app/(marketing)/docs/_lib/utils.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Cms } from '@kit/cms';
|
||||||
|
|
||||||
|
interface HeadingNode {
|
||||||
|
text: string;
|
||||||
|
level: number;
|
||||||
|
href: string;
|
||||||
|
children: HeadingNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name buildDocumentationTree
|
||||||
|
* @description Build a tree structure for the documentation pages.
|
||||||
|
* @param pages
|
||||||
|
*/
|
||||||
|
export function buildDocumentationTree(pages: Cms.ContentItem[]) {
|
||||||
|
const tree: Cms.ContentItem[] = [];
|
||||||
|
|
||||||
|
pages.forEach((page) => {
|
||||||
|
if (page.parentId) {
|
||||||
|
const parent = pages.find((item) => item.slug === page.parentId);
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parent.children) {
|
||||||
|
parent.children = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children.push(page);
|
||||||
|
|
||||||
|
// sort children by order
|
||||||
|
parent.children.sort((a, b) => a.order - b.order);
|
||||||
|
} else {
|
||||||
|
tree.push(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tree.sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name extractHeadingsFromJSX
|
||||||
|
* @description Extract headings from JSX. This is used to generate the table of contents for the documentation pages.
|
||||||
|
* @param jsx
|
||||||
|
*/
|
||||||
|
export function extractHeadingsFromJSX(jsx: {
|
||||||
|
props: { children: React.ReactElement[] };
|
||||||
|
}) {
|
||||||
|
const headings: HeadingNode[] = [];
|
||||||
|
let currentH2: HeadingNode | null = null;
|
||||||
|
|
||||||
|
function getTextContent(
|
||||||
|
children: React.ReactElement[] | string | React.ReactElement,
|
||||||
|
): string {
|
||||||
|
try {
|
||||||
|
if (typeof children === 'string') {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
return children.map((child) => getTextContent(child)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
children.props as {
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
).children
|
||||||
|
) {
|
||||||
|
return getTextContent(
|
||||||
|
(children.props as { children: React.ReactElement }).children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsx.props.children.forEach((node) => {
|
||||||
|
if (!node || typeof node !== 'object' || !('type' in node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeType = node.type as string;
|
||||||
|
|
||||||
|
const text = getTextContent(
|
||||||
|
(
|
||||||
|
node.props as {
|
||||||
|
children: React.ReactElement[];
|
||||||
|
}
|
||||||
|
).children,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nodeType === 'h1') {
|
||||||
|
const slug = generateSlug(text);
|
||||||
|
|
||||||
|
headings.push({
|
||||||
|
text,
|
||||||
|
level: 1,
|
||||||
|
href: `#${slug}`,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
} else if (nodeType === 'h2') {
|
||||||
|
const slug = generateSlug(text);
|
||||||
|
|
||||||
|
currentH2 = {
|
||||||
|
text,
|
||||||
|
level: 2,
|
||||||
|
href: `#${slug}`,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (headings.length > 0) {
|
||||||
|
headings[headings.length - 1]!.children.push(currentH2);
|
||||||
|
} else {
|
||||||
|
headings.push(currentH2);
|
||||||
|
}
|
||||||
|
} else if (nodeType === 'h3' && currentH2) {
|
||||||
|
const slug = generateSlug(text);
|
||||||
|
|
||||||
|
currentH2.children.push({
|
||||||
|
text,
|
||||||
|
level: 3,
|
||||||
|
href: `#${slug}`,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return headings;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSlug(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
27
app/(marketing)/docs/layout.tsx
Normal file
27
app/(marketing)/docs/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
|
||||||
|
// local imports
|
||||||
|
import { DocsNavigation } from './_components/docs-navigation';
|
||||||
|
import { getDocs } from './_lib/server/docs.loader';
|
||||||
|
import { buildDocumentationTree } from './_lib/utils';
|
||||||
|
|
||||||
|
async function DocsLayout({ children }: React.PropsWithChildren) {
|
||||||
|
const { resolvedLanguage } = await createI18nServerInstance();
|
||||||
|
const docs = await getDocs(resolvedLanguage);
|
||||||
|
const tree = buildDocumentationTree(docs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider
|
||||||
|
style={{ '--sidebar-width': '18em' } as React.CSSProperties}
|
||||||
|
className={'h-[calc(100vh-72px)] overflow-y-hidden lg:container'}
|
||||||
|
>
|
||||||
|
<DocsNavigation pages={tree} />
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocsLayout;
|
||||||
3
app/(marketing)/docs/loading.tsx
Normal file
3
app/(marketing)/docs/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||||
|
|
||||||
|
export default GlobalLoader;
|
||||||
39
app/(marketing)/docs/page.tsx
Normal file
39
app/(marketing)/docs/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import { SitePageHeader } from '../_components/site-page-header';
|
||||||
|
import { DocsCards } from './_components/docs-cards';
|
||||||
|
import { getDocs } from './_lib/server/docs.loader';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('marketing:documentation'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function DocsPage() {
|
||||||
|
const { t, resolvedLanguage } = await createI18nServerInstance();
|
||||||
|
const items = await getDocs(resolvedLanguage);
|
||||||
|
|
||||||
|
// Filter out any docs that have a parentId, as these are children of other docs
|
||||||
|
const cards = items.filter((item) => !item.parentId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-col gap-y-6 xl:gap-y-10'}>
|
||||||
|
<SitePageHeader
|
||||||
|
title={t('marketing:documentation')}
|
||||||
|
subtitle={t('marketing:documentationSubtitle')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'flex flex-col items-center'}>
|
||||||
|
<div className={'container mx-auto max-w-5xl'}>
|
||||||
|
<DocsCards cards={cards} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(DocsPage);
|
||||||
143
app/(marketing)/faq/page.tsx
Normal file
143
app/(marketing)/faq/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { ArrowRight, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('marketing:faq'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function FAQPage() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
// replace this content with translations
|
||||||
|
const faqItems = [
|
||||||
|
{
|
||||||
|
// or: t('marketing:faq.question1')
|
||||||
|
question: `Do you offer a free trial?`,
|
||||||
|
// or: t('marketing:faq.answer1')
|
||||||
|
answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `Can I cancel my subscription?`,
|
||||||
|
answer: `You can cancel your subscription at any time. You can do this from your account settings.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `Where can I find my invoices?`,
|
||||||
|
answer: `You can find your invoices in your account settings.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `What payment methods do you accept?`,
|
||||||
|
answer: `We accept all major credit cards and PayPal.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `Can I upgrade or downgrade my plan?`,
|
||||||
|
answer: `Yes, you can upgrade or downgrade your plan at any time. You can do this from your account settings.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `Do you offer discounts for non-profits?`,
|
||||||
|
answer: `Yes, we offer a 50% discount for non-profits. Please contact us to learn more.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const structuredData = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: faqItems.map((item) => {
|
||||||
|
return {
|
||||||
|
'@type': 'Question',
|
||||||
|
name: item.question,
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: item.answer,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
key={'ld:json'}
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'flex flex-col space-y-4 xl:space-y-8'}>
|
||||||
|
<SitePageHeader
|
||||||
|
title={t('marketing:faq')}
|
||||||
|
subtitle={t('marketing:faqSubtitle')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'container flex flex-col space-y-8 pb-16'}>
|
||||||
|
<div className="flex w-full max-w-xl flex-col">
|
||||||
|
{faqItems.map((item, index) => {
|
||||||
|
return <FaqItem key={index} item={item} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button asChild variant={'outline'}>
|
||||||
|
<Link href={'/contact'}>
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey={'marketing:contactFaq'} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ArrowRight className={'ml-2 w-4'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(FAQPage);
|
||||||
|
|
||||||
|
function FaqItem({
|
||||||
|
item,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
item: {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<details className={'group border-b px-2 py-4 last:border-b-transparent'}>
|
||||||
|
<summary
|
||||||
|
className={
|
||||||
|
'flex items-center justify-between hover:cursor-pointer hover:underline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className={
|
||||||
|
'hover:underline-none cursor-pointer font-sans font-medium'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans i18nKey={item.question} defaults={item.question} />
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ChevronDown
|
||||||
|
className={'h-5 transition duration-300 group-open:-rotate-180'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className={'text-muted-foreground flex flex-col gap-y-3 py-1'}>
|
||||||
|
<Trans i18nKey={item.answer} defaults={item.answer} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
app/(marketing)/layout.tsx
Normal file
17
app/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { SiteFooter } from '~/(marketing)/_components/site-footer';
|
||||||
|
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
function SiteLayout(props: React.PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<div className={'flex min-h-[100vh] flex-col'}>
|
||||||
|
<SiteHeader />
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(SiteLayout);
|
||||||
3
app/(marketing)/loading.tsx
Normal file
3
app/(marketing)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||||
|
|
||||||
|
export default GlobalLoader;
|
||||||
182
app/(marketing)/page.tsx
Normal file
182
app/(marketing)/page.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { ArrowRightIcon, LayoutDashboard } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PricingTable } from '@kit/billing-gateway/marketing';
|
||||||
|
import {
|
||||||
|
CtaButton,
|
||||||
|
FeatureCard,
|
||||||
|
FeatureGrid,
|
||||||
|
FeatureShowcase,
|
||||||
|
FeatureShowcaseIconContainer,
|
||||||
|
Hero,
|
||||||
|
Pill,
|
||||||
|
PillActionButton,
|
||||||
|
SecondaryHero,
|
||||||
|
} from '@kit/ui/marketing';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import billingConfig from '~/config/billing.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
return (
|
||||||
|
<div className={'mt-4 flex flex-col space-y-24 py-14'}>
|
||||||
|
<div className={'container mx-auto'}>
|
||||||
|
<Hero
|
||||||
|
pill={
|
||||||
|
<Pill label={'New'}>
|
||||||
|
<span>The SaaS Starter Kit for ambitious developers</span>
|
||||||
|
<PillActionButton asChild>
|
||||||
|
<Link href={'/auth/sign-up'}>
|
||||||
|
<ArrowRightIcon className={'h-4 w-4'} />
|
||||||
|
</Link>
|
||||||
|
</PillActionButton>
|
||||||
|
</Pill>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<span>The ultimate SaaS Starter</span>
|
||||||
|
<span>for your next project</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
<span>
|
||||||
|
Build and Ship a SaaS faster than ever before with the next-gen
|
||||||
|
SaaS Starter Kit. Ship your SaaS in days, not months.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
cta={<MainCallToActionButton />}
|
||||||
|
image={
|
||||||
|
<Image
|
||||||
|
priority
|
||||||
|
className={
|
||||||
|
'dark:border-primary/10 rounded-xl border border-gray-200'
|
||||||
|
}
|
||||||
|
width={3558}
|
||||||
|
height={2222}
|
||||||
|
src={`/images/dashboard.webp`}
|
||||||
|
alt={`App Image`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'container 2 mx-auto'}>
|
||||||
|
<div
|
||||||
|
className={'flex flex-col space-y-16 xl:space-y-32 2xl:space-y-36'}
|
||||||
|
>
|
||||||
|
<FeatureShowcase
|
||||||
|
heading={
|
||||||
|
<>
|
||||||
|
<b className="font-medium tracking-tighter dark:text-white">
|
||||||
|
The ultimate SaaS Starter Kit
|
||||||
|
</b>
|
||||||
|
.{' '}
|
||||||
|
<span className="text-muted-foreground font-normal tracking-tighter">
|
||||||
|
Unleash your creativity and build your SaaS faster than ever
|
||||||
|
with Makerkit.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
<FeatureShowcaseIconContainer>
|
||||||
|
<LayoutDashboard className="h-5" />
|
||||||
|
<span>All-in-one solution</span>
|
||||||
|
</FeatureShowcaseIconContainer>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FeatureGrid>
|
||||||
|
<FeatureCard
|
||||||
|
className={'relative col-span-1 overflow-hidden'}
|
||||||
|
label={'Beautiful Dashboard'}
|
||||||
|
description={`Makerkit provides a beautiful dashboard to manage your SaaS business.`}
|
||||||
|
></FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard
|
||||||
|
className={'relative col-span-1 w-full overflow-hidden'}
|
||||||
|
label={'Authentication'}
|
||||||
|
description={`Makerkit provides a variety of providers to allow your users to sign in.`}
|
||||||
|
></FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard
|
||||||
|
className={'relative col-span-1 overflow-hidden'}
|
||||||
|
label={'Multi Tenancy'}
|
||||||
|
description={`Multi tenant memberships for your SaaS business.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeatureCard
|
||||||
|
className={'relative col-span-1 overflow-hidden md:col-span-2'}
|
||||||
|
label={'Billing'}
|
||||||
|
description={`Makerkit supports multiple payment gateways to charge your customers.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeatureCard
|
||||||
|
className={'relative col-span-1 overflow-hidden'}
|
||||||
|
label={'Plugins'}
|
||||||
|
description={`Extend your SaaS with plugins that you can install using the CLI.`}
|
||||||
|
/>
|
||||||
|
</FeatureGrid>
|
||||||
|
</FeatureShowcase>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'container mx-auto'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex flex-col items-center justify-center space-y-16 py-16'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SecondaryHero
|
||||||
|
pill={<Pill label="Start for free">No credit card required.</Pill>}
|
||||||
|
heading="Fair pricing for all types of businesses"
|
||||||
|
subheading="Get started on our free plan and upgrade when you are ready."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'w-full'}>
|
||||||
|
<PricingTable
|
||||||
|
config={billingConfig}
|
||||||
|
paths={{
|
||||||
|
signUp: pathsConfig.auth.signUp,
|
||||||
|
return: pathsConfig.app.home,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(Home);
|
||||||
|
|
||||||
|
function MainCallToActionButton() {
|
||||||
|
return (
|
||||||
|
<div className={'flex space-x-4'}>
|
||||||
|
<CtaButton>
|
||||||
|
<Link href={'/auth/sign-up'}>
|
||||||
|
<span className={'flex items-center space-x-0.5'}>
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey={'common:getStarted'} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ArrowRightIcon
|
||||||
|
className={
|
||||||
|
'animate-in fade-in slide-in-from-left-8 h-4' +
|
||||||
|
' zoom-in fill-mode-both delay-1000 duration-1000'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</CtaButton>
|
||||||
|
|
||||||
|
<CtaButton variant={'link'}>
|
||||||
|
<Link href={'/contact'}>
|
||||||
|
<Trans i18nKey={'common:contactUs'} />
|
||||||
|
</Link>
|
||||||
|
</CtaButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/(marketing)/pricing/page.tsx
Normal file
39
app/(marketing)/pricing/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { PricingTable } from '@kit/billing-gateway/marketing';
|
||||||
|
|
||||||
|
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||||
|
import billingConfig from '~/config/billing.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('marketing:pricing'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
signUp: pathsConfig.auth.signUp,
|
||||||
|
return: pathsConfig.app.home,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function PricingPage() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-col space-y-12'}>
|
||||||
|
<SitePageHeader
|
||||||
|
title={t('marketing:pricing')}
|
||||||
|
subtitle={t('marketing:pricingSubtitle')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'container mx-auto pb-8 xl:pb-16'}>
|
||||||
|
<PricingTable paths={paths} config={billingConfig} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(PricingPage);
|
||||||
134
app/actions.ts
134
app/actions.ts
@@ -1,134 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { encodedRedirect } from "@/utils/utils";
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const signUpAction = async (formData: FormData) => {
|
|
||||||
const email = formData.get("email")?.toString();
|
|
||||||
const password = formData.get("password")?.toString();
|
|
||||||
const supabase = await createClient();
|
|
||||||
const origin = (await headers()).get("origin");
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/sign-up",
|
|
||||||
"Email and password are required",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.signUp({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
options: {
|
|
||||||
emailRedirectTo: `${origin}/auth/callback`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error(error.code + " " + error.message);
|
|
||||||
return encodedRedirect("error", "/sign-up", error.message);
|
|
||||||
} else {
|
|
||||||
return encodedRedirect(
|
|
||||||
"success",
|
|
||||||
"/sign-up",
|
|
||||||
"Thanks for signing up! Please check your email for a verification link.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signInAction = async (formData: FormData) => {
|
|
||||||
const email = formData.get("email") as string;
|
|
||||||
const password = formData.get("password") as string;
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return encodedRedirect("error", "/sign-in", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect("/protected");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const forgotPasswordAction = async (formData: FormData) => {
|
|
||||||
const email = formData.get("email")?.toString();
|
|
||||||
const supabase = await createClient();
|
|
||||||
const origin = (await headers()).get("origin");
|
|
||||||
const callbackUrl = formData.get("callbackUrl")?.toString();
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
return encodedRedirect("error", "/forgot-password", "Email is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error(error.message);
|
|
||||||
return encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/forgot-password",
|
|
||||||
"Could not reset password",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callbackUrl) {
|
|
||||||
return redirect(callbackUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return encodedRedirect(
|
|
||||||
"success",
|
|
||||||
"/forgot-password",
|
|
||||||
"Check your email for a link to reset your password.",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resetPasswordAction = async (formData: FormData) => {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const password = formData.get("password") as string;
|
|
||||||
const confirmPassword = formData.get("confirmPassword") as string;
|
|
||||||
|
|
||||||
if (!password || !confirmPassword) {
|
|
||||||
encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/protected/reset-password",
|
|
||||||
"Password and confirm password are required",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/protected/reset-password",
|
|
||||||
"Passwords do not match",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.updateUser({
|
|
||||||
password: password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/protected/reset-password",
|
|
||||||
"Password update failed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
encodedRedirect("success", "/protected/reset-password", "Password updated");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signOutAction = async () => {
|
|
||||||
const supabase = await createClient();
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
return redirect("/sign-in");
|
|
||||||
};
|
|
||||||
67
app/admin/_components/admin-sidebar.tsx
Normal file
67
app/admin/_components/admin-sidebar.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { LayoutDashboard, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
} from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||||
|
|
||||||
|
export function AdminSidebar() {
|
||||||
|
const path = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className={'m-2'}>
|
||||||
|
<AppLogo href={'/admin'} className="max-w-full" />
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Super Admin</SidebarGroupLabel>
|
||||||
|
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuButton isActive={path === '/admin'} asChild>
|
||||||
|
<Link className={'flex gap-2.5'} href={'/admin'}>
|
||||||
|
<LayoutDashboard className={'h-4'} />
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={path.includes('/admin/accounts')}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className={'flex size-full gap-2.5'}
|
||||||
|
href={'/admin/accounts'}
|
||||||
|
>
|
||||||
|
<Users className={'h-4'} />
|
||||||
|
<span>Accounts</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<ProfileAccountDropdownContainer />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
app/admin/_components/mobile-navigation.tsx
Normal file
30
app/admin/_components/mobile-navigation.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@kit/ui/dropdown-menu';
|
||||||
|
|
||||||
|
export function AdminMobileNavigation() {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Menu className={'h-8 w-8'} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={'/admin'}>Home</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={'/admin/accounts'}>Accounts</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
app/admin/accounts/[id]/page.tsx
Normal file
47
app/admin/accounts/[id]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
|
||||||
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = async (props: Params) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const account = await loadAccount(params.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Admin | ${account.name}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function AccountPage(props: Params) {
|
||||||
|
const params = await props.params;
|
||||||
|
const account = await loadAccount(params.id);
|
||||||
|
|
||||||
|
return <AdminAccountPage account={account} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminGuard(AccountPage);
|
||||||
|
|
||||||
|
const loadAccount = cache(accountLoader);
|
||||||
|
|
||||||
|
async function accountLoader(id: string) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('*, memberships: accounts_memberships (*)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
79
app/admin/accounts/page.tsx
Normal file
79
app/admin/accounts/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
|
||||||
|
|
||||||
|
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
|
||||||
|
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
|
||||||
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
interface SearchParams {
|
||||||
|
page?: string;
|
||||||
|
account_type?: 'all' | 'team' | 'personal';
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminAccountsPageProps {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: `Accounts`,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function AccountsPage(props: AdminAccountsPageProps) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const page = searchParams.page ? parseInt(searchParams.page) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader description={<AppBreadcrumbs />}>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<AdminCreateUserDialog>
|
||||||
|
<Button data-test="admin-create-user-button">Create User</Button>
|
||||||
|
</AdminCreateUserDialog>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<PageBody>
|
||||||
|
<ServerDataLoader
|
||||||
|
table={'accounts'}
|
||||||
|
client={client}
|
||||||
|
page={page}
|
||||||
|
where={(queryBuilder) => {
|
||||||
|
const { account_type: type, query } = searchParams;
|
||||||
|
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
queryBuilder.eq('is_personal_account', type === 'personal');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryBuilder;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ data, page, pageSize, pageCount }) => {
|
||||||
|
return (
|
||||||
|
<AdminAccountsTable
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
pageCount={pageCount}
|
||||||
|
data={data}
|
||||||
|
filters={{
|
||||||
|
type: searchParams.account_type ?? 'all',
|
||||||
|
query: searchParams.query ?? '',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ServerDataLoader>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminGuard(AccountsPage);
|
||||||
44
app/admin/layout.tsx
Normal file
44
app/admin/layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||||
|
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
|
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
|
||||||
|
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: `Super Admin`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function AdminLayout(props: React.PropsWithChildren) {
|
||||||
|
const state = use(getLayoutState());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider defaultOpen={state.open}>
|
||||||
|
<Page style={'sidebar'}>
|
||||||
|
<PageNavigation>
|
||||||
|
<AdminSidebar />
|
||||||
|
</PageNavigation>
|
||||||
|
|
||||||
|
<PageMobileNavigation>
|
||||||
|
<AdminMobileNavigation />
|
||||||
|
</PageMobileNavigation>
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</Page>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLayoutState() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: sidebarOpenCookie?.value !== 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
3
app/admin/loading.tsx
Normal file
3
app/admin/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||||
|
|
||||||
|
export default GlobalLoader;
|
||||||
17
app/admin/page.tsx
Normal file
17
app/admin/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { AdminDashboard } from '@kit/admin/components/admin-dashboard';
|
||||||
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
function AdminPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader description={`Super Admin`} />
|
||||||
|
|
||||||
|
<PageBody>
|
||||||
|
<AdminDashboard />
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminGuard(AdminPage);
|
||||||
49
app/api/billing/webhook/route.ts
Normal file
49
app/api/billing/webhook/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { getPlanTypesMap } from '@kit/billing';
|
||||||
|
import { getBillingEventHandlerService } from '@kit/billing-gateway';
|
||||||
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
|
import billingConfig from '~/config/billing.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Handle the webhooks from Stripe related to checkouts
|
||||||
|
*/
|
||||||
|
export const POST = enhanceRouteHandler(
|
||||||
|
async ({ request }) => {
|
||||||
|
const provider = billingConfig.provider;
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
name: 'billing.webhook',
|
||||||
|
provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(ctx, `Received billing webhook. Processing...`);
|
||||||
|
|
||||||
|
const supabaseClientProvider = () => getSupabaseServerAdminClient();
|
||||||
|
|
||||||
|
const service = await getBillingEventHandlerService(
|
||||||
|
supabaseClientProvider,
|
||||||
|
provider,
|
||||||
|
getPlanTypesMap(billingConfig),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.handleWebhookEvent(request);
|
||||||
|
|
||||||
|
logger.info(ctx, `Successfully processed billing webhook`);
|
||||||
|
|
||||||
|
return new Response('OK', { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ ...ctx, error }, `Failed to process billing webhook`);
|
||||||
|
|
||||||
|
return new Response('Failed to process billing webhook', {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
43
app/api/db/webhook/route.ts
Normal file
43
app/api/db/webhook/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { getDatabaseWebhookHandlerService } from '@kit/database-webhooks';
|
||||||
|
import { getServerMonitoringService } from '@kit/monitoring/server';
|
||||||
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name POST
|
||||||
|
* @description POST handler for the webhook route that handles the webhook event
|
||||||
|
*/
|
||||||
|
export const POST = enhanceRouteHandler(
|
||||||
|
async ({ request }) => {
|
||||||
|
const service = getDatabaseWebhookHandlerService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signature = request.headers.get('X-Supabase-Event-Signature');
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
return new Response('Missing signature', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.clone().json();
|
||||||
|
|
||||||
|
// handle the webhook event
|
||||||
|
await service.handleWebhook({
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
// return a successful response
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
const service = await getServerMonitoringService();
|
||||||
|
|
||||||
|
await service.ready();
|
||||||
|
await service.captureException(error as Error);
|
||||||
|
|
||||||
|
// return an error response
|
||||||
|
return new Response(null, { status: 500 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
71
app/auth/callback/error/page.tsx
Normal file
71
app/auth/callback/error/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import type { AuthError } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { ResendAuthLinkForm } from '@kit/auth/resend-email-link';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
interface AuthCallbackErrorPageProps {
|
||||||
|
searchParams: Promise<{
|
||||||
|
error: string;
|
||||||
|
callback?: string;
|
||||||
|
email?: string;
|
||||||
|
code?: AuthError['code'];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) {
|
||||||
|
const { error, callback, code } = await props.searchParams;
|
||||||
|
const signInPath = pathsConfig.auth.signIn;
|
||||||
|
const redirectPath = callback ?? pathsConfig.auth.callback;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-col space-y-4 py-4'}>
|
||||||
|
<Alert variant={'warning'}>
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans i18nKey={'auth:authenticationErrorAlertHeading'} />
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans i18nKey={error ?? 'auth:authenticationErrorAlertBody'} />
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<AuthCallbackForm
|
||||||
|
code={code}
|
||||||
|
signInPath={signInPath}
|
||||||
|
redirectPath={redirectPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthCallbackForm(props: {
|
||||||
|
signInPath: string;
|
||||||
|
redirectPath?: string;
|
||||||
|
code?: AuthError['code'];
|
||||||
|
}) {
|
||||||
|
switch (props.code) {
|
||||||
|
case 'otp_expired':
|
||||||
|
return <ResendAuthLinkForm redirectPath={props.redirectPath} />;
|
||||||
|
default:
|
||||||
|
return <SignInButton signInPath={props.signInPath} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignInButton(props: { signInPath: string }) {
|
||||||
|
return (
|
||||||
|
<Button className={'w-full'} asChild>
|
||||||
|
<Link href={props.signInPath}>
|
||||||
|
<Trans i18nKey={'auth:signIn'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(AuthCallbackErrorPage);
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
import { createClient } from "@/utils/supabase/server";
|
import { redirect } from 'next/navigation';
|
||||||
import { NextResponse } from "next/server";
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||||
// The `/auth/callback` route is required for the server-side auth flow implemented
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
// by the SSR package. It exchanges an auth code for the user's session.
|
|
||||||
// https://supabase.com/docs/guides/auth/server-side/nextjs
|
|
||||||
const requestUrl = new URL(request.url);
|
|
||||||
const code = requestUrl.searchParams.get("code");
|
|
||||||
const origin = requestUrl.origin;
|
|
||||||
const redirectTo = requestUrl.searchParams.get("redirect_to")?.toString();
|
|
||||||
|
|
||||||
if (code) {
|
import pathsConfig from '~/config/paths.config';
|
||||||
const supabase = await createClient();
|
|
||||||
await supabase.auth.exchangeCodeForSession(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (redirectTo) {
|
export async function GET(request: NextRequest) {
|
||||||
return NextResponse.redirect(`${origin}${redirectTo}`);
|
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
}
|
|
||||||
|
|
||||||
// URL to redirect to after sign up process completes
|
const { nextPath } = await service.exchangeCodeForSession(request, {
|
||||||
return NextResponse.redirect(`${origin}/protected`);
|
joinTeamPath: pathsConfig.app.joinTeam,
|
||||||
|
redirectPath: pathsConfig.app.home,
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(nextPath);
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/auth/confirm/route.ts
Normal file
17
app/auth/confirm/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
|
|
||||||
|
const url = await service.verifyTokenHash(request, {
|
||||||
|
joinTeamPath: pathsConfig.app.joinTeam,
|
||||||
|
redirectPath: pathsConfig.app.home,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
9
app/auth/layout.tsx
Normal file
9
app/auth/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthLayout;
|
||||||
3
app/auth/loading.tsx
Normal file
3
app/auth/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||||
|
|
||||||
|
export default GlobalLoader;
|
||||||
51
app/auth/password-reset/page.tsx
Normal file
51
app/auth/password-reset/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('auth:passwordResetLabel'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { callback, passwordUpdate, signIn } = pathsConfig.auth;
|
||||||
|
const redirectPath = `${callback}?next=${passwordUpdate}`;
|
||||||
|
|
||||||
|
function PasswordResetPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
<Heading level={4} className={'tracking-tight'}>
|
||||||
|
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-sm'}>
|
||||||
|
<Trans i18nKey={'auth:passwordResetSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex flex-col space-y-4'}>
|
||||||
|
<PasswordResetRequestContainer redirectPath={redirectPath} />
|
||||||
|
|
||||||
|
<div className={'flex justify-center text-xs'}>
|
||||||
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
|
<Link href={signIn}>
|
||||||
|
<Trans i18nKey={'auth:passwordRecoveredQuestion'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(PasswordResetPage);
|
||||||
70
app/auth/sign-in/page.tsx
Normal file
70
app/auth/sign-in/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import authConfig from '~/config/auth.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
interface SignInPageProps {
|
||||||
|
searchParams: Promise<{
|
||||||
|
invite_token?: string;
|
||||||
|
next?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: i18n.t('auth:signIn'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
|
const { invite_token: inviteToken, next = '' } = await searchParams;
|
||||||
|
|
||||||
|
const signUpPath =
|
||||||
|
pathsConfig.auth.signUp +
|
||||||
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
callback: pathsConfig.auth.callback,
|
||||||
|
returnPath: next ?? pathsConfig.app.home,
|
||||||
|
joinTeam: pathsConfig.app.joinTeam,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
<Heading level={4} className={'tracking-tight'}>
|
||||||
|
<Trans i18nKey={'auth:signInHeading'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-sm'}>
|
||||||
|
<Trans i18nKey={'auth:signInSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignInMethodsContainer
|
||||||
|
inviteToken={inviteToken}
|
||||||
|
paths={paths}
|
||||||
|
providers={authConfig.providers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'flex justify-center'}>
|
||||||
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
|
<Link href={signUpPath} prefetch={true}>
|
||||||
|
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(SignInPage);
|
||||||
69
app/auth/sign-up/page.tsx
Normal file
69
app/auth/sign-up/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import authConfig from '~/config/auth.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: i18n.t('auth:signUp'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
searchParams: Promise<{
|
||||||
|
invite_token?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
callback: pathsConfig.auth.callback,
|
||||||
|
appHome: pathsConfig.app.home,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function SignUpPage({ searchParams }: Props) {
|
||||||
|
const inviteToken = (await searchParams).invite_token;
|
||||||
|
|
||||||
|
const signInPath =
|
||||||
|
pathsConfig.auth.signIn +
|
||||||
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
<Heading level={4} className={'tracking-tight'}>
|
||||||
|
<Trans i18nKey={'auth:signUpHeading'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-sm'}>
|
||||||
|
<Trans i18nKey={'auth:signUpSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignUpMethodsContainer
|
||||||
|
providers={authConfig.providers}
|
||||||
|
displayTermsCheckbox={authConfig.displayTermsCheckbox}
|
||||||
|
inviteToken={inviteToken}
|
||||||
|
paths={paths}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'flex justify-center'}>
|
||||||
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
|
<Link href={signInPath} prefetch={true}>
|
||||||
|
<Trans i18nKey={'auth:alreadyHaveAnAccount'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(SignUpPage);
|
||||||
55
app/auth/verify/page.tsx
Normal file
55
app/auth/verify/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
|
||||||
|
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
searchParams: Promise<{
|
||||||
|
next?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: i18n.t('auth:signIn'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function VerifyPage(props: Props) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await client.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect(pathsConfig.auth.signIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsMfa = await checkRequiresMultiFactorAuthentication(client);
|
||||||
|
|
||||||
|
if (!needsMfa) {
|
||||||
|
redirect(pathsConfig.auth.signIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPath = (await props.searchParams).next;
|
||||||
|
const redirectPath = nextPath ?? pathsConfig.app.home;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiFactorChallengeContainer
|
||||||
|
userId={user.id}
|
||||||
|
paths={{
|
||||||
|
redirectPath,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(VerifyPage);
|
||||||
78
app/error.tsx
Normal file
78
app/error.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { ArrowLeft, MessageCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useCaptureException } from '@kit/monitoring/hooks';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||||
|
|
||||||
|
const ErrorPage = ({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) => {
|
||||||
|
useCaptureException(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex h-screen flex-1 flex-col'}>
|
||||||
|
<SiteHeader />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'container m-auto flex w-full flex-1 flex-col items-center justify-center'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={'flex flex-col items-center space-y-8'}>
|
||||||
|
<div>
|
||||||
|
<h1 className={'font-heading text-9xl font-semibold'}>
|
||||||
|
<Trans i18nKey={'common:errorPageHeading'} />
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex flex-col items-center space-y-8'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex max-w-xl flex-col items-center gap-y-2 text-center'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Heading level={2}>
|
||||||
|
<Trans i18nKey={'common:genericError'} />
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-lg'}>
|
||||||
|
<Trans i18nKey={'common:genericErrorSubHeading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex space-x-4'}>
|
||||||
|
<Button className={'w-full'} variant={'default'} onClick={reset}>
|
||||||
|
<ArrowLeft className={'mr-2 h-4'} />
|
||||||
|
|
||||||
|
<Trans i18nKey={'common:goBack'} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button className={'w-full'} variant={'outline'} asChild>
|
||||||
|
<Link href={'/contact'}>
|
||||||
|
<MessageCircle className={'mr-2 h-4'} />
|
||||||
|
|
||||||
|
<Trans i18nKey={'common:contactUs'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorPage;
|
||||||
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
89
app/global-error.tsx
Normal file
89
app/global-error.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { ArrowLeft, MessageCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useCaptureException } from '@kit/monitoring/hooks';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||||
|
import { RootProviders } from '~/components/root-providers';
|
||||||
|
|
||||||
|
const GlobalErrorPage = ({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) => {
|
||||||
|
useCaptureException(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<RootProviders>
|
||||||
|
<div className={'flex h-screen flex-1 flex-col'}>
|
||||||
|
<SiteHeader />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'container m-auto flex w-full flex-1 flex-col items-center justify-center'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={'flex flex-col items-center space-y-8'}>
|
||||||
|
<div>
|
||||||
|
<h1 className={'font-heading text-9xl font-semibold'}>
|
||||||
|
<Trans i18nKey={'common:errorPageHeading'} />
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex flex-col items-center space-y-8'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex max-w-xl flex-col items-center gap-y-2 text-center'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Heading level={2}>
|
||||||
|
<Trans i18nKey={'common:genericError'} />
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-lg'}>
|
||||||
|
<Trans i18nKey={'common:genericErrorSubHeading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex space-x-4'}>
|
||||||
|
<Button
|
||||||
|
className={'w-full'}
|
||||||
|
variant={'default'}
|
||||||
|
onClick={reset}
|
||||||
|
>
|
||||||
|
<ArrowLeft className={'mr-2 h-4'} />
|
||||||
|
|
||||||
|
<Trans i18nKey={'common:goBack'} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button className={'w-full'} variant={'outline'} asChild>
|
||||||
|
<Link href={'/contact'}>
|
||||||
|
<MessageCircle className={'mr-2 h-4'} />
|
||||||
|
|
||||||
|
<Trans i18nKey={'common:contactUs'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RootProviders>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalErrorPage;
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 0 0% 3.9%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 0 0% 3.9%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 0 0% 3.9%;
|
|
||||||
--primary: 0 0% 9%;
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
--secondary: 0 0% 96.1%;
|
|
||||||
--secondary-foreground: 0 0% 9%;
|
|
||||||
--muted: 0 0% 96.1%;
|
|
||||||
--muted-foreground: 0 0% 45.1%;
|
|
||||||
--accent: 0 0% 96.1%;
|
|
||||||
--accent-foreground: 0 0% 9%;
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 0 0% 89.8%;
|
|
||||||
--input: 0 0% 89.8%;
|
|
||||||
--ring: 0 0% 3.9%;
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--chart-1: 12 76% 61%;
|
|
||||||
--chart-2: 173 58% 39%;
|
|
||||||
--chart-3: 197 37% 24%;
|
|
||||||
--chart-4: 43 74% 66%;
|
|
||||||
--chart-5: 27 87% 67%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 0 0% 3.9%;
|
|
||||||
--foreground: 0 0% 98%;
|
|
||||||
--card: 0 0% 3.9%;
|
|
||||||
--card-foreground: 0 0% 98%;
|
|
||||||
--popover: 0 0% 3.9%;
|
|
||||||
--popover-foreground: 0 0% 98%;
|
|
||||||
--primary: 0 0% 98%;
|
|
||||||
--primary-foreground: 0 0% 9%;
|
|
||||||
--secondary: 0 0% 14.9%;
|
|
||||||
--secondary-foreground: 0 0% 98%;
|
|
||||||
--muted: 0 0% 14.9%;
|
|
||||||
--muted-foreground: 0 0% 63.9%;
|
|
||||||
--accent: 0 0% 14.9%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 0 0% 14.9%;
|
|
||||||
--input: 0 0% 14.9%;
|
|
||||||
--ring: 0 0% 83.1%;
|
|
||||||
--chart-1: 220 70% 50%;
|
|
||||||
--chart-2: 160 60% 45%;
|
|
||||||
--chart-3: 30 80% 55%;
|
|
||||||
--chart-4: 280 65% 60%;
|
|
||||||
--chart-5: 340 75% 55%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
37
app/healthcheck/route.ts
Normal file
37
app/healthcheck/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Healthcheck endpoint for the web app. If this endpoint returns a 200, the web app will be considered healthy.
|
||||||
|
* If this endpoint returns a 500, the web app will be considered unhealthy.
|
||||||
|
* This endpoint can be used by Docker to determine if the web app is healthy and should be restarted.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const isDbHealthy = await getSupabaseHealthCheck();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
services: {
|
||||||
|
database: isDbHealthy,
|
||||||
|
// add other services here
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick check to see if the database is healthy by querying the config table
|
||||||
|
* @returns true if the database is healthy, false otherwise
|
||||||
|
*/
|
||||||
|
async function getSupabaseHealthCheck() {
|
||||||
|
try {
|
||||||
|
const client = getSupabaseServerAdminClient();
|
||||||
|
|
||||||
|
const { error } = await client.rpc('is_set', {
|
||||||
|
field_name: 'billing_provider',
|
||||||
|
});
|
||||||
|
|
||||||
|
return !error;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/home/(user)/_components/home-account-selector.tsx
Normal file
45
app/home/(user)/_components/home-account-selector.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||||
|
import { SidebarContext } from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
|
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
|
const features = {
|
||||||
|
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HomeAccountSelector(props: {
|
||||||
|
accounts: Array<{
|
||||||
|
label: string | null;
|
||||||
|
value: string | null;
|
||||||
|
image: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
userId: string;
|
||||||
|
collisionPadding?: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const context = useContext(SidebarContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountSelector
|
||||||
|
collapsed={!context?.open}
|
||||||
|
collisionPadding={props.collisionPadding ?? 20}
|
||||||
|
accounts={props.accounts}
|
||||||
|
features={features}
|
||||||
|
userId={props.userId}
|
||||||
|
onAccountChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
const path = pathsConfig.app.accountHome.replace('[account]', value);
|
||||||
|
router.replace(path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
app/home/(user)/_components/home-accounts-list.tsx
Normal file
61
app/home/(user)/_components/home-accounts-list.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CardButton,
|
||||||
|
CardButtonHeader,
|
||||||
|
CardButtonTitle,
|
||||||
|
} from '@kit/ui/card-button';
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
EmptyStateButton,
|
||||||
|
EmptyStateHeading,
|
||||||
|
EmptyStateText,
|
||||||
|
} from '@kit/ui/empty-state';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
|
||||||
|
import { HomeAddAccountButton } from './home-add-account-button';
|
||||||
|
|
||||||
|
export function HomeAccountsList() {
|
||||||
|
const { accounts } = use(loadUserWorkspace());
|
||||||
|
|
||||||
|
if (!accounts.length) {
|
||||||
|
return <HomeAccountsListEmptyState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<CardButton key={account.value} asChild>
|
||||||
|
<Link href={`/home/${account.value}`}>
|
||||||
|
<CardButtonHeader>
|
||||||
|
<CardButtonTitle>{account.label}</CardButtonTitle>
|
||||||
|
</CardButtonHeader>
|
||||||
|
</Link>
|
||||||
|
</CardButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeAccountsListEmptyState() {
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-1'}>
|
||||||
|
<EmptyState>
|
||||||
|
<EmptyStateButton asChild>
|
||||||
|
<HomeAddAccountButton className={'mt-4'} />
|
||||||
|
</EmptyStateButton>
|
||||||
|
<EmptyStateHeading>
|
||||||
|
<Trans i18nKey={'account:noTeamsYet'} />
|
||||||
|
</EmptyStateHeading>
|
||||||
|
<EmptyStateText>
|
||||||
|
<Trans i18nKey={'account:createTeam'} />
|
||||||
|
</EmptyStateText>
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/home/(user)/_components/home-add-account-button.tsx
Normal file
27
app/home/(user)/_components/home-add-account-button.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export function HomeAddAccountButton(props: { className?: string }) {
|
||||||
|
const [isAddingAccount, setIsAddingAccount] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className={props.className}
|
||||||
|
onClick={() => setIsAddingAccount(true)}
|
||||||
|
>
|
||||||
|
<Trans i18nKey={'account:createTeamButtonLabel'} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CreateTeamAccountDialog
|
||||||
|
isOpen={isAddingAccount}
|
||||||
|
setIsOpen={setIsAddingAccount}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
app/home/(user)/_components/home-menu-navigation.tsx
Normal file
68
app/home/(user)/_components/home-menu-navigation.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
BorderedNavigationMenu,
|
||||||
|
BorderedNavigationMenuItem,
|
||||||
|
} from '@kit/ui/bordered-navigation-menu';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||||
|
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||||
|
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||||
|
|
||||||
|
// home imports
|
||||||
|
import { HomeAccountSelector } from '../_components/home-account-selector';
|
||||||
|
import { UserNotifications } from '../_components/user-notifications';
|
||||||
|
import { type UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||||
|
|
||||||
|
export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
|
||||||
|
const { workspace, user, accounts } = props.workspace;
|
||||||
|
|
||||||
|
const routes = personalAccountNavigationConfig.routes.reduce<
|
||||||
|
Array<{
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
Icon?: React.ReactNode;
|
||||||
|
end?: boolean | ((path: string) => boolean);
|
||||||
|
}>
|
||||||
|
>((acc, item) => {
|
||||||
|
if ('children' in item) {
|
||||||
|
return [...acc, ...item.children];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('divider' in item) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...acc, item];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex w-full flex-1 justify-between'}>
|
||||||
|
<div className={'flex items-center space-x-8'}>
|
||||||
|
<AppLogo />
|
||||||
|
|
||||||
|
<BorderedNavigationMenu>
|
||||||
|
{routes.map((route) => (
|
||||||
|
<BorderedNavigationMenuItem {...route} key={route.path} />
|
||||||
|
))}
|
||||||
|
</BorderedNavigationMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex justify-end space-x-2.5'}>
|
||||||
|
<UserNotifications userId={user.id} />
|
||||||
|
|
||||||
|
<If condition={featuresFlagConfig.enableTeamAccounts}>
|
||||||
|
<HomeAccountSelector userId={user.id} accounts={accounts} />
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ProfileAccountDropdownContainer
|
||||||
|
user={user}
|
||||||
|
account={workspace}
|
||||||
|
showProfileName={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
app/home/(user)/_components/home-mobile-navigation.tsx
Normal file
122
app/home/(user)/_components/home-mobile-navigation.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { LogOut, Menu } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@kit/ui/dropdown-menu';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||||
|
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||||
|
|
||||||
|
// home imports
|
||||||
|
import { HomeAccountSelector } from '../_components/home-account-selector';
|
||||||
|
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||||
|
|
||||||
|
export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
|
||||||
|
const signOut = useSignOut();
|
||||||
|
|
||||||
|
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
|
||||||
|
if ('children' in item) {
|
||||||
|
return item.children.map((child) => {
|
||||||
|
return (
|
||||||
|
<DropdownLink
|
||||||
|
key={child.path}
|
||||||
|
Icon={child.Icon}
|
||||||
|
path={child.path}
|
||||||
|
label={child.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('divider' in item) {
|
||||||
|
return <DropdownMenuSeparator key={index} />;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Menu className={'h-9'} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
|
||||||
|
<If condition={featuresFlagConfig.enableTeamAccounts}>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<Trans i18nKey={'common:yourAccounts'} />
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<HomeAccountSelector
|
||||||
|
userId={props.workspace.user.id}
|
||||||
|
accounts={props.workspace.accounts}
|
||||||
|
collisionPadding={0}
|
||||||
|
/>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownLink(
|
||||||
|
props: React.PropsWithChildren<{
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
Icon: React.ReactNode;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem asChild key={props.path}>
|
||||||
|
<Link
|
||||||
|
href={props.path}
|
||||||
|
className={'flex h-12 w-full items-center space-x-4'}
|
||||||
|
>
|
||||||
|
{props.Icon}
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey={props.label} defaults={props.label} />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignOutDropdownItem(
|
||||||
|
props: React.PropsWithChildren<{
|
||||||
|
onSignOut: () => unknown;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={'flex h-12 w-full items-center space-x-4'}
|
||||||
|
onClick={props.onSignOut}
|
||||||
|
>
|
||||||
|
<LogOut className={'h-6'} />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/home/(user)/_components/home-page-header.tsx
Normal file
12
app/home/(user)/_components/home-page-header.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
export function HomeLayoutPageHeader(
|
||||||
|
props: React.PropsWithChildren<{
|
||||||
|
title: string | React.ReactNode;
|
||||||
|
description: string | React.ReactNode;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<PageHeader description={props.description}>{props.children}</PageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
app/home/(user)/_components/home-sidebar.tsx
Normal file
61
app/home/(user)/_components/home-sidebar.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarNavigation,
|
||||||
|
} from '@kit/ui/shadcn-sidebar';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||||
|
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||||
|
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||||
|
import { UserNotifications } from '~/home/(user)/_components/user-notifications';
|
||||||
|
|
||||||
|
// home imports
|
||||||
|
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||||
|
import { HomeAccountSelector } from './home-account-selector';
|
||||||
|
|
||||||
|
interface HomeSidebarProps {
|
||||||
|
workspace: UserWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeSidebar(props: HomeSidebarProps) {
|
||||||
|
const { workspace, user, accounts } = props.workspace;
|
||||||
|
const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible={collapsible}>
|
||||||
|
<SidebarHeader className={'h-16 justify-center'}>
|
||||||
|
<div className={'flex items-center justify-between gap-x-3'}>
|
||||||
|
<If
|
||||||
|
condition={featuresFlagConfig.enableTeamAccounts}
|
||||||
|
fallback={
|
||||||
|
<AppLogo
|
||||||
|
className={cn(
|
||||||
|
'p-2 group-data-[minimized=true]:max-w-full group-data-[minimized=true]:py-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<HomeAccountSelector userId={user.id} accounts={accounts} />
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<div className={'group-data-[minimized=true]:hidden'}>
|
||||||
|
<UserNotifications userId={user.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarNavigation config={personalAccountNavigationConfig} />
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<ProfileAccountDropdownContainer user={user} account={workspace} />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/home/(user)/_components/user-notifications.tsx
Normal file
16
app/home/(user)/_components/user-notifications.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NotificationsPopover } from '@kit/notifications/components';
|
||||||
|
|
||||||
|
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||||
|
|
||||||
|
export function UserNotifications(props: { userId: string }) {
|
||||||
|
if (!featuresFlagConfig.enableNotifications) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationsPopover
|
||||||
|
accountIds={[props.userId]}
|
||||||
|
realtime={featuresFlagConfig.realtimeNotifications}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
app/home/(user)/_lib/server/load-user-workspace.ts
Normal file
42
app/home/(user)/_lib/server/load-user-workspace.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { createAccountsApi } from '@kit/accounts/api';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||||
|
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
|
||||||
|
|
||||||
|
const shouldLoadAccounts = featureFlagsConfig.enableTeamAccounts;
|
||||||
|
|
||||||
|
export type UserWorkspace = Awaited<ReturnType<typeof loadUserWorkspace>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name loadUserWorkspace
|
||||||
|
* @description
|
||||||
|
* Load the user workspace data. It's a cached per-request function that fetches the user workspace data.
|
||||||
|
* It can be used across the server components to load the user workspace data.
|
||||||
|
*/
|
||||||
|
export const loadUserWorkspace = cache(workspaceLoader);
|
||||||
|
|
||||||
|
async function workspaceLoader() {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createAccountsApi(client);
|
||||||
|
|
||||||
|
const accountsPromise = shouldLoadAccounts
|
||||||
|
? () => api.loadUserAccounts()
|
||||||
|
: () => Promise.resolve([]);
|
||||||
|
|
||||||
|
const workspacePromise = api.getAccountWorkspace();
|
||||||
|
|
||||||
|
const [accounts, workspace, user] = await Promise.all([
|
||||||
|
accountsPromise(),
|
||||||
|
workspacePromise,
|
||||||
|
requireUserInServerComponent(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
workspace,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||||
|
|
||||||
|
import { PlanPicker } from '@kit/billing-gateway/components';
|
||||||
|
import { useAppEvents } from '@kit/shared/events';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@kit/ui/card';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import billingConfig from '~/config/billing.config';
|
||||||
|
|
||||||
|
import { createPersonalAccountCheckoutSession } from '../_lib/server/server-actions';
|
||||||
|
|
||||||
|
const EmbeddedCheckout = dynamic(
|
||||||
|
async () => {
|
||||||
|
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: EmbeddedCheckout,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function PersonalAccountCheckoutForm(props: {
|
||||||
|
customerId: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const appEvents = useAppEvents();
|
||||||
|
|
||||||
|
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// only allow trial if the user is not already a customer
|
||||||
|
const canStartTrial = !props.customerId;
|
||||||
|
|
||||||
|
// If the checkout token is set, render the embedded checkout component
|
||||||
|
if (checkoutToken) {
|
||||||
|
return (
|
||||||
|
<EmbeddedCheckout
|
||||||
|
checkoutToken={checkoutToken}
|
||||||
|
provider={billingConfig.provider}
|
||||||
|
onClose={() => setCheckoutToken(undefined)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, render the plan picker component
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<Trans i18nKey={'common:planCardLabel'} />
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<CardDescription>
|
||||||
|
<Trans i18nKey={'common:planCardDescription'} />
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className={'space-y-4'}>
|
||||||
|
<If condition={error}>
|
||||||
|
<ErrorAlert />
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<PlanPicker
|
||||||
|
pending={pending}
|
||||||
|
config={billingConfig}
|
||||||
|
canStartTrial={canStartTrial}
|
||||||
|
onSubmit={({ planId, productId }) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
appEvents.emit({
|
||||||
|
type: 'checkout.started',
|
||||||
|
payload: { planId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { checkoutToken } =
|
||||||
|
await createPersonalAccountCheckoutSession({
|
||||||
|
planId,
|
||||||
|
productId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCheckoutToken(checkoutToken);
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorAlert() {
|
||||||
|
return (
|
||||||
|
<Alert variant={'destructive'}>
|
||||||
|
<ExclamationTriangleIcon className={'h-4'} />
|
||||||
|
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans i18nKey={'common:planPickerAlertErrorTitle'} />
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans i18nKey={'common:planPickerAlertErrorDescription'} />
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const PersonalAccountCheckoutSchema = z.object({
|
||||||
|
planId: z.string().min(1),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { createAccountsApi } from '@kit/accounts/api';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The variable BILLING_MODE represents the billing mode for a service. It can
|
||||||
|
* have either the value 'subscription' or 'one-time'. If not provided, the default
|
||||||
|
* value is 'subscription'. The value can be overridden by the environment variable
|
||||||
|
* BILLING_MODE.
|
||||||
|
*
|
||||||
|
* If the value is 'subscription', we fetch the subscription data for the user.
|
||||||
|
* If the value is 'one-time', we fetch the orders data for the user.
|
||||||
|
* if none of these suits your needs, please override the below function.
|
||||||
|
*/
|
||||||
|
const BILLING_MODE = z
|
||||||
|
.enum(['subscription', 'one-time'])
|
||||||
|
.default('subscription')
|
||||||
|
.parse(process.env.BILLING_MODE);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the personal account billing page data for the given user.
|
||||||
|
* @param userId
|
||||||
|
* @returns The subscription data or the orders data and the billing customer ID.
|
||||||
|
* This function is cached per-request.
|
||||||
|
*/
|
||||||
|
export const loadPersonalAccountBillingPageData = cache(
|
||||||
|
personalAccountBillingPageDataLoader,
|
||||||
|
);
|
||||||
|
|
||||||
|
function personalAccountBillingPageDataLoader(userId: string) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createAccountsApi(client);
|
||||||
|
|
||||||
|
const data =
|
||||||
|
BILLING_MODE === 'subscription'
|
||||||
|
? api.getSubscription(userId)
|
||||||
|
: api.getOrder(userId);
|
||||||
|
|
||||||
|
const customerId = api.getCustomerId(userId);
|
||||||
|
|
||||||
|
return Promise.all([data, customerId]);
|
||||||
|
}
|
||||||
58
app/home/(user)/billing/_lib/server/server-actions.ts
Normal file
58
app/home/(user)/billing/_lib/server/server-actions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||||
|
|
||||||
|
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
|
||||||
|
import { createUserBillingService } from './user-billing.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name enabled
|
||||||
|
* @description This feature flag is used to enable or disable personal account billing.
|
||||||
|
*/
|
||||||
|
const enabled = featureFlagsConfig.enablePersonalAccountBilling;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name createPersonalAccountCheckoutSession
|
||||||
|
* @description Creates a checkout session for a personal account.
|
||||||
|
*/
|
||||||
|
export const createPersonalAccountCheckoutSession = enhanceAction(
|
||||||
|
async function (data) {
|
||||||
|
if (!enabled) {
|
||||||
|
throw new Error('Personal account billing is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const service = createUserBillingService(client);
|
||||||
|
|
||||||
|
return await service.createCheckoutSession(data);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schema: PersonalAccountCheckoutSchema,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name createPersonalAccountBillingPortalSession
|
||||||
|
* @description Creates a billing Portal session for a personal account
|
||||||
|
*/
|
||||||
|
export const createPersonalAccountBillingPortalSession = enhanceAction(
|
||||||
|
async () => {
|
||||||
|
if (!enabled) {
|
||||||
|
throw new Error('Personal account billing is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const service = createUserBillingService(client);
|
||||||
|
|
||||||
|
// get url to billing portal
|
||||||
|
const url = await service.createBillingPortalSession();
|
||||||
|
|
||||||
|
return redirect(url);
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
202
app/home/(user)/billing/_lib/server/user-billing.service.ts
Normal file
202
app/home/(user)/billing/_lib/server/user-billing.service.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { createAccountsApi } from '@kit/accounts/api';
|
||||||
|
import { getProductPlanPair } from '@kit/billing';
|
||||||
|
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { requireUser } from '@kit/supabase/require-user';
|
||||||
|
|
||||||
|
import appConfig from '~/config/app.config';
|
||||||
|
import billingConfig from '~/config/billing.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
import { Database } from '~/lib/database.types';
|
||||||
|
|
||||||
|
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
|
||||||
|
|
||||||
|
export function createUserBillingService(client: SupabaseClient<Database>) {
|
||||||
|
return new UserBillingService(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name UserBillingService
|
||||||
|
* @description Service for managing billing for personal accounts.
|
||||||
|
*/
|
||||||
|
class UserBillingService {
|
||||||
|
private readonly namespace = 'billing.personal-account';
|
||||||
|
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name createCheckoutSession
|
||||||
|
* @description Create a checkout session for the user
|
||||||
|
* @param planId
|
||||||
|
* @param productId
|
||||||
|
*/
|
||||||
|
async createCheckoutSession({
|
||||||
|
planId,
|
||||||
|
productId,
|
||||||
|
}: z.infer<typeof PersonalAccountCheckoutSchema>) {
|
||||||
|
// get the authenticated user
|
||||||
|
const { data: user, error } = await requireUser(this.client);
|
||||||
|
|
||||||
|
if (error ?? !user) {
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = await getBillingGatewayProvider(this.client);
|
||||||
|
|
||||||
|
// in the case of personal accounts
|
||||||
|
// the account ID is the same as the user ID
|
||||||
|
const accountId = user.id;
|
||||||
|
|
||||||
|
// the return URL for the checkout session
|
||||||
|
const returnUrl = getCheckoutSessionReturnUrl();
|
||||||
|
|
||||||
|
// find the customer ID for the account if it exists
|
||||||
|
// (eg. if the account has been billed before)
|
||||||
|
const api = createAccountsApi(this.client);
|
||||||
|
const customerId = await api.getCustomerId(accountId);
|
||||||
|
|
||||||
|
const product = billingConfig.products.find(
|
||||||
|
(item) => item.id === productId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new Error('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { plan } = getProductPlanPair(billingConfig, planId);
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
name: `billing.personal-account`,
|
||||||
|
planId,
|
||||||
|
customerId,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
`User requested a personal account checkout session. Contacting provider...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// call the payment gateway to create the checkout session
|
||||||
|
const { checkoutToken } = await service.createCheckoutSession({
|
||||||
|
returnUrl,
|
||||||
|
accountId,
|
||||||
|
customerEmail: user.email,
|
||||||
|
customerId,
|
||||||
|
plan,
|
||||||
|
variantQuantities: [],
|
||||||
|
enableDiscountField: product.enableDiscountField,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
`Checkout session created. Returning checkout token to client...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// return the checkout token to the client
|
||||||
|
// so we can call the payment gateway to complete the checkout
|
||||||
|
return {
|
||||||
|
checkoutToken,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
name: `billing.personal-account`,
|
||||||
|
planId,
|
||||||
|
customerId,
|
||||||
|
accountId,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
`Checkout session not created due to an error`,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(`Failed to create a checkout session`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name createBillingPortalSession
|
||||||
|
* @description Create a billing portal session for the user
|
||||||
|
* @returns The URL to redirect the user to the billing portal
|
||||||
|
*/
|
||||||
|
async createBillingPortalSession() {
|
||||||
|
const { data, error } = await requireUser(this.client);
|
||||||
|
|
||||||
|
if (error ?? !data) {
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = await getBillingGatewayProvider(this.client);
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
const accountId = data.id;
|
||||||
|
const api = createAccountsApi(this.client);
|
||||||
|
const customerId = await api.getCustomerId(accountId);
|
||||||
|
const returnUrl = getBillingPortalReturnUrl();
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
throw new Error('Customer not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
name: this.namespace,
|
||||||
|
customerId,
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
ctx,
|
||||||
|
`User requested a Billing Portal session. Contacting provider...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await service.createBillingPortalSession({
|
||||||
|
customerId,
|
||||||
|
returnUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
url = session.url;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
...ctx,
|
||||||
|
},
|
||||||
|
`Failed to create a Billing Portal session`,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Encountered an error creating the Billing Portal session`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(ctx, `Session successfully created.`);
|
||||||
|
|
||||||
|
// redirect user to billing portal
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckoutSessionReturnUrl() {
|
||||||
|
return new URL(
|
||||||
|
pathsConfig.app.personalAccountBillingReturn,
|
||||||
|
appConfig.url,
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBillingPortalReturnUrl() {
|
||||||
|
return new URL(
|
||||||
|
pathsConfig.app.personalAccountBilling,
|
||||||
|
appConfig.url,
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
7
app/home/(user)/billing/error.tsx
Normal file
7
app/home/(user)/billing/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// We reuse the page from the billing module
|
||||||
|
// as there is no need to create a new one.
|
||||||
|
import BillingErrorPage from '~/home/[account]/billing/error';
|
||||||
|
|
||||||
|
export default BillingErrorPage;
|
||||||
15
app/home/(user)/billing/layout.tsx
Normal file
15
app/home/(user)/billing/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||||
|
|
||||||
|
function UserBillingLayout(props: React.PropsWithChildren) {
|
||||||
|
const isEnabled = featureFlagsConfig.enablePersonalAccountBilling;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{props.children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserBillingLayout;
|
||||||
92
app/home/(user)/billing/page.tsx
Normal file
92
app/home/(user)/billing/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
BillingPortalCard,
|
||||||
|
CurrentLifetimeOrderCard,
|
||||||
|
CurrentSubscriptionCard,
|
||||||
|
} from '@kit/billing-gateway/components';
|
||||||
|
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import billingConfig from '~/config/billing.config';
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
|
||||||
|
|
||||||
|
// local imports
|
||||||
|
import { HomeLayoutPageHeader } from '../_components/home-page-header';
|
||||||
|
import { createPersonalAccountBillingPortalSession } from '../billing/_lib/server/server-actions';
|
||||||
|
import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form';
|
||||||
|
import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
const title = i18n.t('account:billingTab');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function PersonalAccountBillingPage() {
|
||||||
|
const user = await requireUserInServerComponent();
|
||||||
|
|
||||||
|
const [data, customerId] = await loadPersonalAccountBillingPageData(user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'common:routes.billing'} />}
|
||||||
|
description={<AppBreadcrumbs />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageBody>
|
||||||
|
<div className={'flex flex-col space-y-4'}>
|
||||||
|
<If condition={!data}>
|
||||||
|
<PersonalAccountCheckoutForm customerId={customerId} />
|
||||||
|
|
||||||
|
<If condition={customerId}>
|
||||||
|
<CustomerBillingPortalForm />
|
||||||
|
</If>
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<If condition={data}>
|
||||||
|
{(data) => (
|
||||||
|
<div className={'flex w-full max-w-2xl flex-col space-y-6'}>
|
||||||
|
{'active' in data ? (
|
||||||
|
<CurrentSubscriptionCard
|
||||||
|
subscription={data}
|
||||||
|
config={billingConfig}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CurrentLifetimeOrderCard
|
||||||
|
order={data}
|
||||||
|
config={billingConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<If condition={!data}>
|
||||||
|
<PersonalAccountCheckoutForm customerId={customerId} />
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<If condition={customerId}>
|
||||||
|
<CustomerBillingPortalForm />
|
||||||
|
</If>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</If>
|
||||||
|
</div>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(PersonalAccountBillingPage);
|
||||||
|
|
||||||
|
function CustomerBillingPortalForm() {
|
||||||
|
return (
|
||||||
|
<form action={createPersonalAccountBillingPortalSession}>
|
||||||
|
<BillingPortalCard />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/home/(user)/billing/return/page.tsx
Normal file
5
app/home/(user)/billing/return/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// We reuse the page from the billing module
|
||||||
|
// as there is no need to create a new one.
|
||||||
|
import ReturnCheckoutSessionPage from '~/home/[account]/billing/return/page';
|
||||||
|
|
||||||
|
export default ReturnCheckoutSessionPage;
|
||||||
112
app/home/(user)/layout.tsx
Normal file
112
app/home/(user)/layout.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
|
||||||
|
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||||
|
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
// home imports
|
||||||
|
import { HomeMenuNavigation } from './_components/home-menu-navigation';
|
||||||
|
import { HomeMobileNavigation } from './_components/home-mobile-navigation';
|
||||||
|
import { HomeSidebar } from './_components/home-sidebar';
|
||||||
|
import { loadUserWorkspace } from './_lib/server/load-user-workspace';
|
||||||
|
|
||||||
|
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
||||||
|
const state = use(getLayoutState());
|
||||||
|
|
||||||
|
if (state.style === 'sidebar') {
|
||||||
|
return <SidebarLayout>{children}</SidebarLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <HeaderLayout>{children}</HeaderLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(UserHomeLayout);
|
||||||
|
|
||||||
|
function SidebarLayout({ children }: React.PropsWithChildren) {
|
||||||
|
const workspace = use(loadUserWorkspace());
|
||||||
|
const state = use(getLayoutState());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserWorkspaceContextProvider value={workspace}>
|
||||||
|
<SidebarProvider defaultOpen={state.open}>
|
||||||
|
<Page style={'sidebar'}>
|
||||||
|
<PageNavigation>
|
||||||
|
<HomeSidebar workspace={workspace} />
|
||||||
|
</PageNavigation>
|
||||||
|
|
||||||
|
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||||
|
<MobileNavigation workspace={workspace} />
|
||||||
|
</PageMobileNavigation>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Page>
|
||||||
|
</SidebarProvider>
|
||||||
|
</UserWorkspaceContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderLayout({ children }: React.PropsWithChildren) {
|
||||||
|
const workspace = use(loadUserWorkspace());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserWorkspaceContextProvider value={workspace}>
|
||||||
|
<Page style={'header'}>
|
||||||
|
<PageNavigation>
|
||||||
|
<HomeMenuNavigation workspace={workspace} />
|
||||||
|
</PageNavigation>
|
||||||
|
|
||||||
|
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||||
|
<MobileNavigation workspace={workspace} />
|
||||||
|
</PageMobileNavigation>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Page>
|
||||||
|
</UserWorkspaceContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileNavigation({
|
||||||
|
workspace,
|
||||||
|
}: {
|
||||||
|
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppLogo />
|
||||||
|
|
||||||
|
<HomeMobileNavigation workspace={workspace} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLayoutState() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
|
||||||
|
|
||||||
|
const layoutStyleCookie = cookieStore.get('layout-style');
|
||||||
|
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
||||||
|
|
||||||
|
const sidebarOpen = sidebarOpenCookie
|
||||||
|
? sidebarOpenCookie.value === 'false'
|
||||||
|
: !personalAccountNavigationConfig.sidebarCollapsed;
|
||||||
|
|
||||||
|
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
|
||||||
|
|
||||||
|
const style = parsedStyle.success
|
||||||
|
? parsedStyle.data
|
||||||
|
: personalAccountNavigationConfig.style;
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: sidebarOpen,
|
||||||
|
style,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
app/home/(user)/loading.tsx
Normal file
3
app/home/(user)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||||
|
|
||||||
|
export default GlobalLoader;
|
||||||
32
app/home/(user)/page.tsx
Normal file
32
app/home/(user)/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
// local imports
|
||||||
|
import { HomeLayoutPageHeader } from './_components/home-page-header';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
const title = i18n.t('account:homePage');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function UserHomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'common:routes.home'} />}
|
||||||
|
description={<Trans i18nKey={'common:homeTabDescription'} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageBody></PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(UserHomePage);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user