diff --git a/.claude/commands/feature-builder.md b/.claude/commands/feature-builder.md index 47f1ee7fe..50db55ebf 100644 --- a/.claude/commands/feature-builder.md +++ b/.claude/commands/feature-builder.md @@ -55,7 +55,10 @@ create policy "projects_write" on public.projects for all Use `server-action-builder` skill for detailed patterns. -**Rule: Services are decoupled from interfaces.** The service is pure logic that receives dependencies (database client, etc.) as arguments — it never imports framework-specific modules. The server action is a thin adapter that resolves dependencies and calls the service. This means the same service can be called from a server action, an MCP tool, a CLI command, or a unit test with zero changes. +**Rule: Services are decoupled from interfaces.** The service is pure logic that receives dependencies (database client, +etc.) as arguments — it never imports framework-specific modules. The server action is a thin adapter that resolves +dependencies and calls the service. This means the same service can be called from a server action, an MCP tool, a CLI +command, or a unit test with zero changes. Create in route's `_lib/server/` directory: diff --git a/.claude/skills/playwright-e2e/makerkit.md b/.claude/skills/playwright-e2e/makerkit.md index babc49764..d68f26a86 100644 --- a/.claude/skills/playwright-e2e/makerkit.md +++ b/.claude/skills/playwright-e2e/makerkit.md @@ -19,8 +19,8 @@ export class AuthPageObject { } async signOut() { - await this.page.click('[data-test="account-dropdown-trigger"]'); - await this.page.click('[data-test="account-dropdown-sign-out"]'); + await this.page.click('[data-test="workspace-dropdown-trigger"]'); + await this.page.click('[data-test="workspace-sign-out"]'); } async bootstrapUser(params: { email: string; password: string; name: string }) { @@ -47,9 +47,19 @@ export class AuthPageObject { ## Common Selectors ```typescript -// Account dropdown -'[data-test="account-dropdown-trigger"]' -'[data-test="account-dropdown-sign-out"]' +// Workspace dropdown (sidebar header - combined account switcher + user menu) +'[data-test="workspace-dropdown-trigger"]' // Opens the dropdown +'[data-test="workspace-switch-submenu"]' // Sub-trigger for workspace switching +'[data-test="workspace-switch-content"]' // Sub-menu content with workspace list +'[data-test="workspace-team-item"]' // Individual team items in switcher +'[data-test="create-team-trigger"]' // Create team button in switcher +'[data-test="workspace-sign-out"]' // Sign out button +'[data-test="workspace-settings-link"]' // Settings link +'[data-test="account-dropdown-display-name"]' // User display name (inside dropdown panel) + +// Opening the workspace switcher (two-step: open dropdown, then submenu) +await page.click('[data-test="workspace-dropdown-trigger"]'); +await page.click('[data-test="workspace-switch-submenu"]'); // Navigation '[data-test="sidebar-menu"]' diff --git a/.claude/skills/react-form-builder/SKILL.md b/.claude/skills/react-form-builder/SKILL.md index 39ec03cfa..e8fa54abb 100644 --- a/.claude/skills/react-form-builder/SKILL.md +++ b/.claude/skills/react-form-builder/SKILL.md @@ -5,193 +5,93 @@ description: Create or modify client-side forms in React applications following # React Form Builder Expert -You are an expert React form architect specializing in building robust, accessible, and type-safe forms using react-hook-form, @kit/ui/form components, and Next.js server actions. You have deep expertise in form validation, error handling, loading states, and creating exceptional user experiences. +You are an expert React form architect specializing in building robust, accessible, and type-safe forms using react-hook-form, @kit/ui/form components, and Next.js server actions via next-safe-action. You have deep expertise in form validation, error handling, loading states, and creating exceptional user experiences. ## Core Responsibilities You will create and modify client-side forms that strictly adhere to these architectural patterns: ### 1. Form Structure Requirements + - Always use `useForm` from react-hook-form WITHOUT redundant generic types when using zodResolver - Implement Zod schemas for validation, stored in `_lib/schemas/` directory - Use `@kit/ui/form` components (Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage) -- Handle loading states with `useTransition` hook -- Implement proper error handling with try/catch blocks +- ALWAYS use `useAction` from `next-safe-action/hooks` for server action integration — NEVER use raw `startTransition` + direct action calls +- Use `isPending` from `useAction` for loading states -### 2. Server Action Integration -- Call server actions within `startTransition` for proper loading states -- Handle redirect errors using `isRedirectError` from 'next/dist/client/components/redirect-error' -- Display error states using Alert components from '@kit/ui/alert' +### 2. Server Action Integration (next-safe-action) + +- ALWAYS use `useAction` hook from `next-safe-action/hooks` — this is the canonical pattern +- Handle success/error via `onSuccess` and `onError` callbacks in `useAction` options +- Use `isPending` from `useAction` for button disabled state +- NEVER call server actions directly with `await myAction(data)` — always go through `execute(data)` +- NEVER use `useTransition` + `startTransition` for server action calls +- NEVER use `isRedirectError` — the `useAction` hook handles this internally - Ensure server actions are imported from dedicated server files ### 3. Code Organization Pattern + ``` _lib/ ├── schemas/ │ └── feature.schema.ts # Shared Zod schemas ├── server/ -│ └── server-actions.ts # Server actions +│ └── server-actions.ts # Server actions (next-safe-action) └── client/ └── forms.tsx # Form components ``` ### 4. Import Guidelines + - Toast notifications: `import { toast } from '@kit/ui/sonner'` - Form components: `import { Form, FormField, ... } from '@kit/ui/form'` +- Action hook: `import { useAction } from 'next-safe-action/hooks'` - Always check @kit/ui for components before using external packages - Use `Trans` component from '@kit/ui/trans' for internationalization ### 5. Best Practices You Must Follow + - Add `data-test` attributes for E2E testing on form elements and submit buttons -- Use `reValidateMode: 'onChange'` and `mode: 'onChange'` for responsive validation - Implement proper TypeScript typing without using `any` - Handle both success and error states gracefully - Use `If` component from '@kit/ui/if' for conditional rendering - Disable submit buttons during pending states - Include FormDescription for user guidance -- Use Dialog components from '@kit/ui/dialog' when forms are in modals +- When forms are inside dialogs, ALWAYS use `useAsyncDialog` from `@kit/ui/hooks/use-async-dialog` — it prevents the dialog from closing while an async operation is in progress (blocks Escape and backdrop clicks). Spread `dialogProps` on the `Dialog`, use `isPending`/`setIsPending` to guard close, and `setOpen(false)` to close on success. ### 6. State Management -- Use `useState` for error states -- Use `useTransition` for pending states -- Avoid multiple separate useState calls - prefer single state objects when appropriate + +- Use `useState` for UI state (success/error display) +- Use `isPending` from `useAction` for loading states — NEVER `useTransition` +- Avoid multiple separate useState calls — prefer single state objects when appropriate - Never use useEffect unless absolutely necessary and justified ### 7. Validation Patterns + - Create reusable Zod schemas that can be shared between client and server - Use schema.refine() for custom validation logic - Provide clear, user-friendly error messages - Implement field-level validation with proper error display -### 8. Error Handling Template +### 8. Type Safety -```typescript -const onSubmit = (data: FormData) => { - startTransition(async () => { - try { - await serverAction(data); - } catch (error) { - if (!isRedirectError(error)) { - setError(true); - } - } - }); -}; -``` - -### 9. Type Safety -- Let zodResolver infer types - don't add redundant generics to useForm +- Let zodResolver infer types — don't add redundant generics to useForm - Export schema types when needed for reuse - Ensure all form fields have proper typing -### 10. Accessibility and UX +### 9. Accessibility and UX + - Always include FormLabel for screen readers - Provide helpful FormDescription text - Show clear error messages with FormMessage - Implement loading indicators during form submission - Use semantic HTML and ARIA attributes where appropriate -## Complete Form Example +## Exemplars -```tsx -'use client'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; -import { useTransition, useState } from 'react'; -import { isRedirectError } from 'next/dist/client/components/redirect-error'; - -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form'; -import { Input } from '@kit/ui/input'; -import { Button } from '@kit/ui/button'; -import { Alert, AlertDescription } from '@kit/ui/alert'; -import { If } from '@kit/ui/if'; -import { toast } from '@kit/ui/sonner'; -import { Trans } from '@kit/ui/trans'; - -import { CreateEntitySchema } from '../_lib/schemas/entity.schema'; -import { createEntityAction } from '../_lib/server/server-actions'; - -export function CreateEntityForm() { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(false); - - const form = useForm({ - resolver: zodResolver(CreateEntitySchema), - defaultValues: { - name: '', - description: '', - }, - mode: 'onChange', - reValidateMode: 'onChange', - }); - - const onSubmit = (data: z.infer) => { - setError(false); - - startTransition(async () => { - try { - await createEntityAction(data); - toast.success('Entity created successfully'); - } catch (e) { - if (!isRedirectError(e)) { - setError(true); - } - } - }); - }; - - return ( -
- - - - - - - - - - ( - - - - - - - - - - )} - /> - - - - - ); -} -``` - -When creating forms, you will analyze requirements and produce complete, production-ready implementations that handle all edge cases, provide excellent user feedback, and maintain consistency with the codebase's established patterns. You prioritize type safety, reusability, and maintainability in every form you create. - -Always verify that UI components exist in @kit/ui before importing from external packages, and ensure your forms integrate seamlessly with the project's internationalization system using Trans components. +- Standalone form: `apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx` +- Dialog with form: `packages/features/team-accounts/src/components/create-team-account-dialog.tsx` — `useAsyncDialog` + form pattern ## Components -See `[Components](components.md)` for examples of form components. \ No newline at end of file +See `[Components](components.md)` for examples of form components. diff --git a/.claude/skills/react-form-builder/components.md b/.claude/skills/react-form-builder/components.md index e0dcb8bd2..5246c7c42 100644 --- a/.claude/skills/react-form-builder/components.md +++ b/.claude/skills/react-form-builder/components.md @@ -3,6 +3,7 @@ ## Import Pattern ```typescript +import { useAction } from 'next-safe-action/hooks'; import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from '@kit/ui/form'; import { Input } from '@kit/ui/input'; import { Button } from '@kit/ui/button'; @@ -10,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Textarea } from '@kit/ui/textarea'; import { Checkbox } from '@kit/ui/checkbox'; import { Switch } from '@kit/ui/switch'; -import { Alert, AlertDescription } from '@kit/ui/alert'; +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; import { toast } from '@kit/ui/sonner'; @@ -21,7 +22,6 @@ import { toast } from '@kit/ui/sonner'; ```tsx ( @@ -48,7 +48,6 @@ import { toast } from '@kit/ui/sonner'; ```tsx ( Category @@ -74,7 +73,6 @@ import { toast } from '@kit/ui/sonner'; ```tsx ( @@ -97,7 +95,6 @@ import { toast } from '@kit/ui/sonner'; ```tsx (
@@ -121,7 +118,6 @@ import { toast } from '@kit/ui/sonner'; ```tsx ( Description @@ -142,13 +138,14 @@ import { toast } from '@kit/ui/sonner'; ## Error Alert ```tsx - - - - - - - + + + + + + + + ``` ## Submit Button @@ -156,14 +153,10 @@ import { toast } from '@kit/ui/sonner'; ```tsx ``` @@ -172,65 +165,79 @@ import { toast } from '@kit/ui/sonner'; ```tsx 'use client'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; -import { useTransition, useState } from 'react'; -import { isRedirectError } from 'next/dist/client/components/redirect-error'; -import type { z } from 'zod'; +import { useState } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; +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 { Button } from '@kit/ui/button'; -import { Alert, AlertDescription } from '@kit/ui/alert'; -import { If } from '@kit/ui/if'; -import { toast } from '@kit/ui/sonner'; import { Trans } from '@kit/ui/trans'; import { MySchema } from '../_lib/schemas/my.schema'; import { myAction } from '../_lib/server/server-actions'; export function MyForm() { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(false); + const [state, setState] = useState({ + success: false, + error: false, + }); + + const { execute, isPending } = useAction(myAction, { + onSuccess: () => { + setState({ success: true, error: false }); + }, + onError: () => { + setState({ error: true, success: false }); + }, + }); const form = useForm({ resolver: zodResolver(MySchema), defaultValues: { name: '' }, - mode: 'onChange', }); - const onSubmit = (data: z.infer) => { - setError(false); + if (state.success) { + return ( + + + + + + ); + } - startTransition(async () => { - try { - await myAction(data); - toast.success('Success!'); - } catch (e) { - if (!isRedirectError(e)) { - setError(true); - } - } - }); - }; + if (state.error) { + return ( + + + + + + + + + ); + } return ( -
- - - - - - - - - + + { + execute(data); + })} + > ( - Name + + + @@ -239,11 +246,11 @@ export function MyForm() { )} /> - - - + + ); } ``` diff --git a/.claude/skills/server-action-builder/SKILL.md b/.claude/skills/server-action-builder/SKILL.md index a6732816d..796a7fae8 100644 --- a/.claude/skills/server-action-builder/SKILL.md +++ b/.claude/skills/server-action-builder/SKILL.md @@ -17,19 +17,21 @@ Create validation schema in `_lib/schemas/`: ```typescript // _lib/schemas/feature.schema.ts -import { z } from 'zod'; +import * as z from 'zod'; export const CreateFeatureSchema = z.object({ name: z.string().min(1, 'Name is required'), accountId: z.string().uuid('Invalid account ID'), }); -export type CreateFeatureInput = z.infer; +export type CreateFeatureInput = z.output; ``` ### Step 2: Create Service Layer -**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool, a CLI command, or a plain unit test. +**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client +as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool, +a CLI command, or a plain unit test. Create service in `_lib/server/`: @@ -62,11 +64,13 @@ class FeatureService { } ``` -The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable (pass a mock client) and reusable (any interface can supply its own client). +The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable ( +pass a mock client) and reusable (any interface can supply its own client). ### Step 3: Create Server Action (Thin Adapter) -The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business logic lives here. +The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business +logic lives here. Create action in `_lib/server/server-actions.ts`: @@ -107,13 +111,18 @@ export const createFeatureAction = enhanceAction( ## Key Patterns -1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server action do the same thing, they call the same service function. -2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with stubs and reusable across interfaces. +1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or + MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server + action do the same thing, they call the same service function. +2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O + capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with + stubs and reusable across interfaces. 3. **Schema in separate file** - Reusable between client and server 4. **Logging** - Always log before and after operations 5. **Revalidation** - Use `revalidatePath` after mutations 6. **Trust RLS** - Don't add manual auth checks (RLS handles it) -7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no running infrastructure +7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no + running infrastructure ## File Structure diff --git a/.claude/skills/server-action-builder/reference.md b/.claude/skills/server-action-builder/reference.md index b5fa35b15..ec003106a 100644 --- a/.claude/skills/server-action-builder/reference.md +++ b/.claude/skills/server-action-builder/reference.md @@ -28,10 +28,10 @@ export const myAction = enhanceAction( ### Handler Parameters -| Parameter | Type | Description | -|-----------|------|-------------| -| `data` | `z.infer` | Validated input data | -| `user` | `User` | Authenticated user (if auth: true) | +| Parameter | Type | Description | +|-----------|--------------------|------------------------------------| +| `data` | `z.output` | Validated input data | +| `user` | `User` | Authenticated user (if auth: true) | ## enhanceRouteHandler API @@ -69,7 +69,7 @@ export const GET = enhanceRouteHandler( ## Common Zod Patterns ```typescript -import { z } from 'zod'; +import * as z from 'zod'; // Basic schema export const CreateItemSchema = z.object({ diff --git a/.claude/skills/service-builder/SKILL.md b/.claude/skills/service-builder/SKILL.md index f27daf8be..79a8d8d73 100644 --- a/.claude/skills/service-builder/SKILL.md +++ b/.claude/skills/service-builder/SKILL.md @@ -9,7 +9,9 @@ You are an expert at building pure, testable services that are decoupled from th ## North Star -**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route handler, or a test. The caller is a thin adapter that resolves dependencies and delegates. +**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain +data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route +handler, or a test. The caller is a thin adapter that resolves dependencies and delegates. ## Workflow @@ -21,7 +23,7 @@ Start with the input/output types. These are plain TypeScript — no framework t ```typescript // _lib/schemas/project.schema.ts -import { z } from 'zod'; +import * as z from 'zod'; export const CreateProjectSchema = z.object({ name: z.string().min(1), @@ -40,7 +42,8 @@ export interface Project { ### Step 2: Build the Service -The service receives all dependencies through its constructor. It never imports framework-specific modules (`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.). +The service receives all dependencies through its constructor. It never imports framework-specific modules ( +`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.). ```typescript // _lib/server/project.service.ts @@ -95,7 +98,8 @@ class ProjectService { ### Step 3: Write Thin Adapters -Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific concerns (revalidation, redirects, MCP formatting, CLI output). +Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific +concerns (revalidation, redirects, MCP formatting, CLI output). **Server Action adapter:** @@ -234,27 +238,32 @@ describe('ProjectService', () => { ## Rules -1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/`Response`, no MCP context, no `FormData`. +1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/ + `Response`, no MCP context, no `FormData`. -2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service. +2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O + capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service. -3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`. An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters. +3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`. + An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters. -4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating logic is a violation. +4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating + logic is a violation. -5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service, refactor until you don't. +5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service, + refactor until you don't. ## What Goes Where -| Concern | Location | Example | -|---------|----------|---------| -| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` | -| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` | -| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper | -| Logging | Adapter | `logger.info()` before/after service call | -| Cache revalidation | Adapter | `revalidatePath()` after mutation | -| Redirect | Adapter | `redirect()` after creation | -| MCP response format | Adapter | Return service result as MCP content | +| Concern | Location | Example | +|------------------------|-------------------------------------------|-------------------------------------------| +| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` | +| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` | +| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper | +| Logging | Adapter | `logger.info()` before/after service call | +| Cache revalidation | Adapter | `revalidatePath()` after mutation | +| Redirect | Adapter | `redirect()` after creation | +| MCP response format | Adapter | Return service result as MCP content | ## File Structure @@ -305,4 +314,5 @@ const result = await client.from('projects').insert(...).select().single(); ## Reference -See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose other services, and testing strategies. +See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose +other services, and testing strategies. diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml index 9e9369beb..71ce4e440 100644 --- a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -1,14 +1,12 @@ -name: "🐛 Bug Report" +name: '🐛 Bug Report' description: Create a new ticket for a bug. -title: "🐛 [BUG] - " -labels: [ - "bug" -] +title: '🐛 [BUG] - <title>' +labels: ['bug'] body: - type: textarea id: description attributes: - label: "Description" + label: 'Description' description: Please enter an explicit description of your issue placeholder: Short and explicit description of your incident... validations: @@ -16,7 +14,7 @@ body: - type: textarea id: reprod attributes: - label: "Reproduction steps" + label: 'Reproduction steps' description: Please enter an explicit description of your issue value: | 1. Go to '...' @@ -29,13 +27,13 @@ body: - type: textarea id: logs attributes: - label: "Logs" + label: 'Logs' description: Please copy and paste any relevant log output. Please verify both the server logs and the browser logs (if applicable). This will be automatically formatted into code, so no need for backticks. If you cannot provide logs, simply write N/A. render: bash - type: textarea id: screenshot attributes: - label: "Screenshots" + label: 'Screenshots' description: If applicable, add screenshots to help explain your problem. value: | ![DESCRIPTION](LINK.png) @@ -45,7 +43,7 @@ body: - type: dropdown id: browsers attributes: - label: "Browsers" + label: 'Browsers' description: What browsers are you seeing the problem on ? multiple: true options: @@ -59,7 +57,7 @@ body: - type: dropdown id: os attributes: - label: "OS" + label: 'OS' description: What is the impacted environment ? multiple: true options: @@ -67,4 +65,4 @@ body: - Linux - Mac validations: - required: false \ No newline at end of file + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 18bda7da2..dc9c4c9e7 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -8,4 +8,4 @@ contact_links: about: For chatting with the community and getting help. - name: Administrative Requests (licenses, etc.) url: https://makerkit.dev/contact - about: Please report administrative requests here. \ No newline at end of file + about: Please report administrative requests here. diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 5de3f0572..f679b690c 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -1,14 +1,14 @@ name: Workflow on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: typescript: name: ʦ TypeScript timeout-minutes: 10 - runs-on: ubuntu-latest + runs-on: ${{ vars.RUNNER || 'ubuntu-latest' }} env: SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} SUPABASE_DB_WEBHOOK_SECRET: ${{ secrets.SUPABASE_DB_WEBHOOK_SECRET }} @@ -48,7 +48,7 @@ jobs: test: name: ⚫️ Test timeout-minutes: 20 - runs-on: ubuntu-latest + runs-on: ${{ vars.RUNNER || 'ubuntu-latest' }} if: ${{ vars.ENABLE_E2E_JOB == 'true' }} env: SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} @@ -120,4 +120,4 @@ jobs: with: name: playwright-report path: apps/e2e/playwright-report/ - retention-days: 7 \ No newline at end of file + retention-days: 7 diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 16a476820..5f1e69357 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -556,8 +556,8 @@ function MyFeaturePage() { return ( <> <MyFeatureHeader - title={<Trans i18nKey={'common:routes.myFeature'} />} - description={<Trans i18nKey={'common:myFeatureDescription'} />} + title={<Trans i18nKey={'common.routes.myFeature'} />} + description={<Trans i18nKey={'common.myFeatureDescription'} />} /> <PageBody> @@ -829,40 +829,40 @@ import { ProfileAvatar } from '@kit/ui/profile-avatar'; ## Core Shadcn UI Components -| Component | Description | Import Path | -|-----------|-------------|-------------| -| `Accordion` | Expandable/collapsible content sections | `@kit/ui/accordion` [accordion.tsx](mdc:packages/ui/src/shadcn/accordion.tsx) | -| `AlertDialog` | Modal dialog for important actions | `@kit/ui/alert-dialog` [alert-dialog.tsx](mdc:packages/ui/src/shadcn/alert-dialog.tsx) | -| `Alert` | Status/notification messages | `@kit/ui/alert` [alert.tsx](mdc:packages/ui/src/shadcn/alert.tsx) | -| `Avatar` | User profile images with fallback | `@kit/ui/avatar` [avatar.tsx](mdc:packages/ui/src/shadcn/avatar.tsx) | -| `Badge` | Small status indicators | `@kit/ui/badge` [badge.tsx](mdc:packages/ui/src/shadcn/badge.tsx) | -| `Breadcrumb` | Navigation path indicators | `@kit/ui/breadcrumb` [breadcrumb.tsx](mdc:packages/ui/src/shadcn/breadcrumb.tsx) | -| `Button` | Clickable action elements | `@kit/ui/button` [button.tsx](mdc:packages/ui/src/shadcn/button.tsx) | -| `Calendar` | Date picker and date display | `@kit/ui/calendar` [calendar.tsx](mdc:packages/ui/src/shadcn/calendar.tsx) | -| `Card` | Container for grouped content | `@kit/ui/card` [card.tsx](mdc:packages/ui/src/shadcn/card.tsx) | -| `Checkbox` | Selection input | `@kit/ui/checkbox` [checkbox.tsx](mdc:packages/ui/src/shadcn/checkbox.tsx) | -| `Command` | Command palette interface | `@kit/ui/command` [command.tsx](mdc:packages/ui/src/shadcn/command.tsx) | -| `DataTable` | Table | `@kit/ui/data-table` [data-table.tsx](mdc:packages/ui/src/shadcn/data-table.tsx) | -| `Dialog` | Modal window for focused interactions | `@kit/ui/dialog` [dialog.tsx](mdc:packages/ui/src/shadcn/dialog.tsx) | -| `DropdownMenu` | Menu triggered by a button | `@kit/ui/dropdown-menu` [dropdown-menu.tsx](mdc:packages/ui/src/shadcn/dropdown-menu.tsx) | -| `Form` | Form components with validation | `@kit/ui/form` [form.tsx](mdc:packages/ui/src/shadcn/form.tsx) | -| `Input` | Text input field | `@kit/ui/input` [input.tsx](mdc:packages/ui/src/shadcn/input.tsx) | -| `Input OTP` | OTP Text input field | `@kit/ui/input-otp` [input-otp.tsx](mdc:packages/ui/src/shadcn/input-otp.tsx) | -| `Label` | Text label for form elements | `@kit/ui/label` [label.tsx](mdc:packages/ui/src/shadcn/label.tsx) | -| `NavigationMenu` | Hierarchical navigation component | `@kit/ui/navigation-menu` [navigation-menu.tsx](mdc:packages/ui/src/shadcn/navigation-menu.tsx) | -| `Popover` | Floating content triggered by interaction | `@kit/ui/popover` [popover.tsx](mdc:packages/ui/src/shadcn/popover.tsx) | -| `RadioGroup` | Radio button selection group | `@kit/ui/radio-group` [radio-group.tsx](mdc:packages/ui/src/shadcn/radio-group.tsx) | -| `ScrollArea` | Customizable scrollable area | `@kit/ui/scroll-area` [scroll-area.tsx](mdc:packages/ui/src/shadcn/scroll-area.tsx) | -| `Select` | Dropdown selection menu | `@kit/ui/select` [select.tsx](mdc:packages/ui/src/shadcn/select.tsx) | -| `Separator` | Visual divider between content | `@kit/ui/separator` [separator.tsx](mdc:packages/ui/src/shadcn/separator.tsx) | -| `Sheet` | Sliding panel from screen edge | `@kit/ui/sheet` [sheet.tsx](mdc:packages/ui/src/shadcn/sheet.tsx) | -| `Sidebar` | Advanced sidebar navigation | `@kit/ui/shadcn-sidebar` [sidebar.tsx](mdc:packages/ui/src/shadcn/sidebar.tsx) | -| `Skeleton` | Loading placeholder | `@kit/ui/skeleton` [skeleton.tsx](mdc:packages/ui/src/shadcn/skeleton.tsx) | -| `Switch` | Toggle control | `@kit/ui/switch` [switch.tsx](mdc:packages/ui/src/shadcn/switch.tsx) | -| `Toast` | Toaster | `@kit/ui/sonner` [sonner.tsx](mdc:packages/ui/src/shadcn/sonner.tsx) | -| `Tabs` | Tab-based navigation | `@kit/ui/tabs` [tabs.tsx](mdc:packages/ui/src/shadcn/tabs.tsx) | -| `Textarea` | Multi-line text input | `@kit/ui/textarea` [textarea.tsx](mdc:packages/ui/src/shadcn/textarea.tsx) | -| `Tooltip` | Contextual information on hover | `@kit/ui/tooltip` [tooltip.tsx](mdc:packages/ui/src/shadcn/tooltip.tsx) | +| Component | Description | Import Path | +|------------------|-------------------------------------------|-------------------------------------------------------------------------------------------------| +| `Accordion` | Expandable/collapsible content sections | `@kit/ui/accordion` [accordion.tsx](mdc:packages/ui/src/shadcn/accordion.tsx) | +| `AlertDialog` | Modal dialog for important actions | `@kit/ui/alert-dialog` [alert-dialog.tsx](mdc:packages/ui/src/shadcn/alert-dialog.tsx) | +| `Alert` | Status/notification messages | `@kit/ui/alert` [alert.tsx](mdc:packages/ui/src/shadcn/alert.tsx) | +| `Avatar` | User profile images with fallback | `@kit/ui/avatar` [avatar.tsx](mdc:packages/ui/src/shadcn/avatar.tsx) | +| `Badge` | Small status indicators | `@kit/ui/badge` [badge.tsx](mdc:packages/ui/src/shadcn/badge.tsx) | +| `Breadcrumb` | Navigation path indicators | `@kit/ui/breadcrumb` [breadcrumb.tsx](mdc:packages/ui/src/shadcn/breadcrumb.tsx) | +| `Button` | Clickable action elements | `@kit/ui/button` [button.tsx](mdc:packages/ui/src/shadcn/button.tsx) | +| `Calendar` | Date picker and date display | `@kit/ui/calendar` [calendar.tsx](mdc:packages/ui/src/shadcn/calendar.tsx) | +| `Card` | Container for grouped content | `@kit/ui/card` [card.tsx](mdc:packages/ui/src/shadcn/card.tsx) | +| `Checkbox` | Selection input | `@kit/ui/checkbox` [checkbox.tsx](mdc:packages/ui/src/shadcn/checkbox.tsx) | +| `Command` | Command palette interface | `@kit/ui/command` [command.tsx](mdc:packages/ui/src/shadcn/command.tsx) | +| `DataTable` | Table | `@kit/ui/data-table` [data-table.tsx](mdc:packages/ui/src/shadcn/data-table.tsx) | +| `Dialog` | Modal window for focused interactions | `@kit/ui/dialog` [dialog.tsx](mdc:packages/ui/src/shadcn/dialog.tsx) | +| `DropdownMenu` | Menu triggered by a button | `@kit/ui/dropdown-menu` [dropdown-menu.tsx](mdc:packages/ui/src/shadcn/dropdown-menu.tsx) | +| `Form` | Form components with validation | `@kit/ui/form` [form.tsx](mdc:packages/ui/src/shadcn/form.tsx) | +| `Input` | Text input field | `@kit/ui/input` [input.tsx](mdc:packages/ui/src/shadcn/input.tsx) | +| `Input OTP` | OTP Text input field | `@kit/ui/input-otp` [input-otp.tsx](mdc:packages/ui/src/shadcn/input-otp.tsx) | +| `Label` | Text label for form elements | `@kit/ui/label` [label.tsx](mdc:packages/ui/src/shadcn/label.tsx) | +| `NavigationMenu` | Hierarchical navigation component | `@kit/ui/navigation-menu` [navigation-menu.tsx](mdc:packages/ui/src/shadcn/navigation-menu.tsx) | +| `Popover` | Floating content triggered by interaction | `@kit/ui/popover` [popover.tsx](mdc:packages/ui/src/shadcn/popover.tsx) | +| `RadioGroup` | Radio button selection group | `@kit/ui/radio-group` [radio-group.tsx](mdc:packages/ui/src/shadcn/radio-group.tsx) | +| `ScrollArea` | Customizable scrollable area | `@kit/ui/scroll-area` [scroll-area.tsx](mdc:packages/ui/src/shadcn/scroll-area.tsx) | +| `Select` | Dropdown selection menu | `@kit/ui/select` [select.tsx](mdc:packages/ui/src/shadcn/select.tsx) | +| `Separator` | Visual divider between content | `@kit/ui/separator` [separator.tsx](mdc:packages/ui/src/shadcn/separator.tsx) | +| `Sheet` | Sliding panel from screen edge | `@kit/ui/sheet` [sheet.tsx](mdc:packages/ui/src/shadcn/sheet.tsx) | +| `Sidebar` | Advanced sidebar navigation | `@kit/ui/sidebar` [sidebar.tsx](mdc:packages/ui/src/shadcn/sidebar.tsx) | +| `Skeleton` | Loading placeholder | `@kit/ui/skeleton` [skeleton.tsx](mdc:packages/ui/src/shadcn/skeleton.tsx) | +| `Switch` | Toggle control | `@kit/ui/switch` [switch.tsx](mdc:packages/ui/src/shadcn/switch.tsx) | +| `Toast` | Toaster | `@kit/ui/sonner` [sonner.tsx](mdc:packages/ui/src/shadcn/sonner.tsx) | +| `Tabs` | Tab-based navigation | `@kit/ui/tabs` [tabs.tsx](mdc:packages/ui/src/shadcn/tabs.tsx) | +| `Textarea` | Multi-line text input | `@kit/ui/textarea` [textarea.tsx](mdc:packages/ui/src/shadcn/textarea.tsx) | +| `Tooltip` | Contextual information on hover | `@kit/ui/tooltip` [tooltip.tsx](mdc:packages/ui/src/shadcn/tooltip.tsx) | ## Makerkit-specific Components @@ -920,7 +920,7 @@ Zod schemas should be defined in the `schema` folder and exported, so we can reu ```tsx // _lib/schema/create-note.schema.ts -import { z } from 'zod'; +import * as z from 'zod'; export const CreateNoteSchema = z.object({ title: z.string().min(1), @@ -935,7 +935,7 @@ Server Actions [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) can he ```tsx 'use server'; -import { z } from 'zod'; +import * as z from 'zod'; import { enhanceAction } from '@kit/next/actions'; import { CreateNoteSchema } from '../schema/create-note.schema'; @@ -965,7 +965,7 @@ Then create a client component to handle the form submission: import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Textarea } from '@kit/ui/textarea'; import { Input } from '@kit/ui/input'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form'; @@ -1436,7 +1436,7 @@ You always must use `(security_invoker = true)` for views. ```tsx 'use server'; -import { z } from 'zod'; +import * as z from 'zod'; import { enhanceAction } from '@kit/next/actions'; import { EntitySchema } from '../entity.schema.ts`; @@ -1463,7 +1463,7 @@ export const myServerAction = enhanceAction( - To create API routes (route.ts), always use the `enhanceRouteHandler` function from the "@kit/supabase/routes" package. [index.ts](mdc:packages/next/src/routes/index.ts) ```tsx -import { z } from 'zod'; +import * as z from 'zod'; import { enhanceRouteHandler } from '@kit/next/routes'; import { NextResponse } from 'next/server'; diff --git a/.mcp.json b/.mcp.json index 43e5b5ca2..c98ddd843 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,4 +6,4 @@ "args": ["packages/mcp-server/build/index.cjs"] } } -} \ No newline at end of file +} diff --git a/.npmrc b/.npmrc index 3c9ebabef..c6fbc1f26 100644 --- a/.npmrc +++ b/.npmrc @@ -3,9 +3,6 @@ dedupe-peer-dependents=true use-lockfile-v6=true resolution-mode=highest package-manager-strict=false -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* public-hoist-pattern[]=*pino* \ No newline at end of file diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc new file mode 100644 index 000000000..4ce59e70f --- /dev/null +++ b/.oxfmtrc.jsonc @@ -0,0 +1,64 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "printWidth": 80, + "semi": true, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "sortImports": { + "customGroups": [ + { + "groupName": "server-only", + "elementNamePattern": ["server-only"], + }, + { + "groupName": "react", + "elementNamePattern": ["react", "react-dom"], + }, + { + "groupName": "next", + "elementNamePattern": ["next", "next/**"], + }, + { + "groupName": "supabase", + "elementNamePattern": ["@supabase/**"], + }, + { + "groupName": "kit", + "elementNamePattern": ["@kit/**"], + }, + { + "groupName": "app", + "elementNamePattern": ["~/**"], + }, + ], + "groups": [ + "server-only", + "react", + "next", + "supabase", + "external", + "kit", + "app", + ["parent", "sibling", "index"], + "style", + ], + "newlinesBetween": true, + "order": "asc", + }, + "sortTailwindcss": { + "functions": ["tw", "clsx", "cn", "cva"], + }, + "ignorePatterns": [ + "node_modules", + "database.types.ts", + "playwright-report", + "*.hbs", + "*.md", + "dist", + "build", + ".next", + "next-env.d.ts", + ], +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..e839ded47 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,59 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "nextjs", "import"], + "rules": { + "no-undef": "off", + "typescript/triple-slash-reference": "off", + "react/react-in-jsx-scope": "off", + "import/no-anonymous-default-export": "off", + "import/named": "off", + "import/namespace": "off", + "import/default": "off", + "import/no-unresolved": "off", + "import/no-named-as-default-member": "off", + "import/no-named-as-default": "off", + "import/no-cycle": "off", + "import/no-unused-modules": "off", + "import/no-deprecated": "off", + "typescript/array-type": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-argument": "off", + "typescript/consistent-type-definitions": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/non-nullable-type-assertion-style": "off", + "typescript/only-throw-error": "off", + "typescript/prefer-nullish-coalescing": "off", + "typescript/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "nextjs/no-html-link-for-pages": "off", + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react-i18next", + "importNames": ["Trans"], + "message": "Please use `@kit/ui/trans` instead" + } + ] + } + ] + }, + "ignorePatterns": [ + "**/node_modules", + "**/database.types.ts", + "**/.next", + "**/public", + "**/__tests__/", + "dist", + "pnpm-lock.yaml", + "apps/dev-tool", + "tooling/scripts" + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 41647210d..000000000 --- a/.prettierignore +++ /dev/null @@ -1,8 +0,0 @@ -database.types.ts -playwright-report -*.hbs -*.md -dist -build -.next -next-env.d.ts \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0f4dacdb5..d1dc564cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,15 +9,23 @@ ## Monorepo Structure -| Directory | Purpose | Details | -|-----------|---------|---------| -| `apps/web` | Main Next.js app | See `apps/web/AGENTS.md` | +| Directory | Purpose | Details | +| ------------------- | ----------------------------- | --------------------------------- | +| `apps/web` | Main Next.js app | See `apps/web/AGENTS.md` | | `apps/web/supabase` | Database schemas & migrations | See `apps/web/supabase/AGENTS.md` | -| `apps/e2e` | Playwright E2E tests | See `apps/e2e/AGENTS.md` | -| `packages/ui` | UI components (@kit/ui) | See `packages/ui/AGENTS.md` | -| `packages/supabase` | Supabase clients | See `packages/supabase/AGENTS.md` | -| `packages/next` | Next.js utilities | See `packages/next/AGENTS.md` | -| `packages/features` | Feature packages | See `packages/features/AGENTS.md` | +| `apps/e2e` | Playwright E2E tests | See `apps/e2e/AGENTS.md` | +| `packages/ui` | UI components (@kit/ui) | See `packages/ui/AGENTS.md` | +| `packages/supabase` | Supabase clients | See `packages/supabase/AGENTS.md` | +| `packages/next` | Next.js utilities | See `packages/next/AGENTS.md` | +| `packages/features` | Feature packages | See `packages/features/AGENTS.md` | + +<!-- BEGIN:nextjs-agent-rules --> + +# Next.js: ALWAYS read docs before coding + +Before any Next.js work, find and read the relevant doc in `apps/web/node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth. + +<!-- END:nextjs-agent-rules --> ## Multi-Tenant Architecture @@ -39,13 +47,13 @@ pnpm format:fix # Format code ## Key Patterns (Quick Reference) -| Pattern | Import | Details | -|---------|--------|---------| -| Server Actions | `enhanceAction` from `@kit/next/actions` | `packages/next/AGENTS.md` | -| Route Handlers | `enhanceRouteHandler` from `@kit/next/routes` | `packages/next/AGENTS.md` | -| Server Client | `getSupabaseServerClient` from `@kit/supabase/server-client` | `packages/supabase/AGENTS.md` | -| UI Components | `@kit/ui/{component}` | `packages/ui/AGENTS.md` | -| Translations | `Trans` from `@kit/ui/trans` | `packages/ui/AGENTS.md` | +| Pattern | Import | Details | +| -------------- | ------------------------------------------------------------ | ----------------------------- | +| Server Actions | `authActionClient` from `@kit/next/safe-action` | `packages/next/AGENTS.md` | +| Route Handlers | `enhanceRouteHandler` from `@kit/next/routes` | `packages/next/AGENTS.md` | +| Server Client | `getSupabaseServerClient` from `@kit/supabase/server-client` | `packages/supabase/AGENTS.md` | +| UI Components | `@kit/ui/{component}` | `packages/ui/AGENTS.md` | +| Translations | `Trans` from `@kit/ui/trans` | `packages/ui/AGENTS.md` | ## Authorization @@ -55,7 +63,8 @@ pnpm format:fix # Format code ## Verification After implementation, always run: + 1. `pnpm typecheck` 2. `pnpm lint:fix` 3. `pnpm format:fix` -4. Run code quality reviewer agent \ No newline at end of file +4. Run code quality reviewer agent diff --git a/apps/dev-tool/app/components/components/alert-dialog-story.tsx b/apps/dev-tool/app/components/components/alert-dialog-story.tsx index cd9c378d5..20ed44323 100644 --- a/apps/dev-tool/app/components/components/alert-dialog-story.tsx +++ b/apps/dev-tool/app/components/components/alert-dialog-story.tsx @@ -120,9 +120,7 @@ export function AlertDialogStory() { const generateCode = () => { let code = `<AlertDialog>\n`; - code += ` <AlertDialogTrigger asChild>\n`; - code += ` <Button variant="${controls.triggerVariant}">${controls.triggerText}</Button>\n`; - code += ` </AlertDialogTrigger>\n`; + code += ` <AlertDialogTrigger render={<Button variant="${controls.triggerVariant}">${controls.triggerText}</Button>} />\n`; code += ` <AlertDialogContent>\n`; code += ` <AlertDialogHeader>\n`; @@ -179,11 +177,14 @@ export function AlertDialogStory() { const renderPreview = () => { return ( <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant={controls.triggerVariant}> - {controls.triggerText} - </Button> - </AlertDialogTrigger> + <AlertDialogTrigger + render={ + <Button variant={controls.triggerVariant}> + {controls.triggerText} + </Button> + } + /> + <AlertDialogContent> <AlertDialogHeader> {controls.withIcon ? ( @@ -341,11 +342,11 @@ export function AlertDialogStory() { <CardContent className="space-y-4"> <div className="flex flex-wrap gap-3"> <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash2 className="mr-2 h-4 w-4" /> - Delete Item - </Button> + <AlertDialogTrigger + render={<Button variant="destructive" size="sm" />} + > + <Trash2 className="mr-2 h-4 w-4" /> + Delete Item </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -370,11 +371,9 @@ export function AlertDialogStory() { </AlertDialog> <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="outline"> - <LogOut className="mr-2 h-4 w-4" /> - Sign Out - </Button> + <AlertDialogTrigger render={<Button variant="outline" />}> + <LogOut className="mr-2 h-4 w-4" /> + Sign Out </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -397,11 +396,9 @@ export function AlertDialogStory() { </AlertDialog> <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="outline"> - <UserX className="mr-2 h-4 w-4" /> - Remove User - </Button> + <AlertDialogTrigger render={<Button variant="outline" />}> + <UserX className="mr-2 h-4 w-4" /> + Remove User </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -438,11 +435,9 @@ export function AlertDialogStory() { <CardContent className="space-y-4"> <div className="flex flex-wrap gap-3"> <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="outline"> - <Archive className="mr-2 h-4 w-4" /> - Archive Project - </Button> + <AlertDialogTrigger render={<Button variant="outline" />}> + <Archive className="mr-2 h-4 w-4" /> + Archive Project </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -465,11 +460,9 @@ export function AlertDialogStory() { </AlertDialog> <AlertDialog> - <AlertDialogTrigger asChild> - <Button> - <Download className="mr-2 h-4 w-4" /> - Export Data - </Button> + <AlertDialogTrigger render={<Button />}> + <Download className="mr-2 h-4 w-4" /> + Export Data </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -493,11 +486,9 @@ export function AlertDialogStory() { </AlertDialog> <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="outline"> - <RefreshCw className="mr-2 h-4 w-4" /> - Reset Settings - </Button> + <AlertDialogTrigger render={<Button variant="outline" />}> + <RefreshCw className="mr-2 h-4 w-4" /> + Reset Settings </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -535,11 +526,11 @@ export function AlertDialogStory() { <div className="space-y-3"> <h4 className="text-sm font-semibold">Error/Destructive</h4> <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash2 className="mr-2 h-4 w-4" /> - Delete Forever - </Button> + <AlertDialogTrigger + render={<Button variant="destructive" size="sm" />} + > + <Trash2 className="mr-2 h-4 w-4" /> + Delete Forever </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -567,11 +558,11 @@ export function AlertDialogStory() { <div className="space-y-3"> <h4 className="text-sm font-semibold">Warning</h4> <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="outline" size="sm"> - <AlertTriangle className="mr-2 h-4 w-4" /> - Unsaved Changes - </Button> + <AlertDialogTrigger + render={<Button variant="outline" size="sm" />} + > + <AlertTriangle className="mr-2 h-4 w-4" /> + Unsaved Changes </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -597,11 +588,11 @@ export function AlertDialogStory() { <div className="space-y-3"> <h4 className="text-sm font-semibold">Info</h4> <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="outline" size="sm"> - <Share className="mr-2 h-4 w-4" /> - Share Publicly - </Button> + <AlertDialogTrigger + render={<Button variant="outline" size="sm" />} + > + <Share className="mr-2 h-4 w-4" /> + Share Publicly </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -627,11 +618,9 @@ export function AlertDialogStory() { <div className="space-y-3"> <h4 className="text-sm font-semibold">Success</h4> <AlertDialog> - <AlertDialogTrigger asChild> - <Button size="sm"> - <Download className="mr-2 h-4 w-4" /> - Complete Setup - </Button> + <AlertDialogTrigger render={<Button size="sm" />}> + <Download className="mr-2 h-4 w-4" /> + Complete Setup </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> @@ -850,10 +839,8 @@ export function AlertDialogStory() { <h4 className="text-sm font-semibold">Focus Management</h4> <p className="text-muted-foreground text-sm"> • Focus moves to Cancel button by default - <br /> - • Tab navigation between Cancel and Action - <br /> - • Escape key activates Cancel action + <br />• Tab navigation between Cancel and Action + <br />• Escape key activates Cancel action <br />• Enter key activates Action button when focused </p> </div> @@ -861,10 +848,8 @@ export function AlertDialogStory() { <h4 className="text-sm font-semibold">Content Guidelines</h4> <p className="text-muted-foreground text-sm"> • Use clear, specific titles and descriptions - <br /> - • Explain consequences of the action - <br /> - • Use action-specific button labels + <br />• Explain consequences of the action + <br />• Use action-specific button labels <br />• Always provide a way to cancel </p> </div> @@ -872,8 +857,7 @@ export function AlertDialogStory() { <h4 className="text-sm font-semibold">Visual Design</h4> <p className="text-muted-foreground text-sm"> • Use appropriate icons and colors for severity - <br /> - • Make destructive actions visually distinct + <br />• Make destructive actions visually distinct <br />• Ensure sufficient contrast for all text </p> </div> @@ -892,8 +876,7 @@ export function AlertDialogStory() { <h4 className="text-sm font-semibold">Title Guidelines</h4> <p className="text-muted-foreground text-sm"> • Be specific about the action (not just "Are you sure?") - <br /> - • Use active voice ("Delete account" not "Account deletion") + <br />• Use active voice ("Delete account" not "Account deletion") <br />• Keep it concise but descriptive </p> </div> @@ -901,10 +884,8 @@ export function AlertDialogStory() { <h4 className="text-sm font-semibold">Description Guidelines</h4> <p className="text-muted-foreground text-sm"> • Explain what will happen - <br /> - • Mention if the action is irreversible - <br /> - • Provide context about consequences + <br />• Mention if the action is irreversible + <br />• Provide context about consequences <br />• Use plain, non-technical language </p> </div> @@ -912,10 +893,8 @@ export function AlertDialogStory() { <h4 className="text-sm font-semibold">Button Labels</h4> <p className="text-muted-foreground text-sm"> • Use specific verbs ("Delete", "Save", "Continue") - <br /> - • Match the action being performed - <br /> - • Avoid generic labels when possible + <br />• Match the action being performed + <br />• Avoid generic labels when possible <br />• Make the primary action clear </p> </div> diff --git a/apps/dev-tool/app/components/components/button-story.tsx b/apps/dev-tool/app/components/components/button-story.tsx index 0cfec682a..8d7dae014 100644 --- a/apps/dev-tool/app/components/components/button-story.tsx +++ b/apps/dev-tool/app/components/components/button-story.tsx @@ -33,7 +33,6 @@ interface ButtonControls { loading: boolean; withIcon: boolean; fullWidth: boolean; - asChild: boolean; } const variantOptions = [ @@ -68,7 +67,6 @@ export function ButtonStory() { loading: false, withIcon: false, fullWidth: false, - asChild: false, }); const generateCode = () => { @@ -77,14 +75,12 @@ export function ButtonStory() { variant: controls.variant, size: controls.size, disabled: controls.disabled, - asChild: controls.asChild, className: controls.fullWidth ? 'w-full' : '', }, { variant: 'default', size: 'default', disabled: false, - asChild: false, className: '', }, ); @@ -194,15 +190,6 @@ export function ButtonStory() { onCheckedChange={(checked) => updateControl('fullWidth', checked)} /> </div> - - <div className="flex items-center justify-between"> - <Label htmlFor="asChild">As Child</Label> - <Switch - id="asChild" - checked={controls.asChild} - onCheckedChange={(checked) => updateControl('asChild', checked)} - /> - </div> </> ); diff --git a/apps/dev-tool/app/components/components/calendar-story.tsx b/apps/dev-tool/app/components/components/calendar-story.tsx index 52cb9f036..66d2cb021 100644 --- a/apps/dev-tool/app/components/components/calendar-story.tsx +++ b/apps/dev-tool/app/components/components/calendar-story.tsx @@ -276,11 +276,11 @@ export default function CalendarStory() { <Card> <CardContent className="flex justify-center pt-6"> <Popover> - <PopoverTrigger asChild> - <Button variant="outline" className="justify-start"> - <CalendarIcon className="mr-2 h-4 w-4" /> - Pick a date - </Button> + <PopoverTrigger + render={<Button variant="outline" className="justify-start" />} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + Pick a date </PopoverTrigger> <PopoverContent className="w-auto p-0" align="start"> <Calendar diff --git a/apps/dev-tool/app/components/components/card-button-story.tsx b/apps/dev-tool/app/components/components/card-button-story.tsx index 66447d69d..3337a717b 100644 --- a/apps/dev-tool/app/components/components/card-button-story.tsx +++ b/apps/dev-tool/app/components/components/card-button-story.tsx @@ -320,10 +320,12 @@ export function CardButtonStory() { </thead> <tbody> <tr className="border-b"> - <td className="p-3 font-mono text-sm">asChild</td> - <td className="p-3 font-mono text-sm">boolean</td> - <td className="p-3 font-mono text-sm">false</td> - <td className="p-3">Render as child element</td> + <td className="p-3 font-mono text-sm">render</td> + <td className="p-3 font-mono text-sm"> + React.ReactElement + </td> + <td className="p-3 font-mono text-sm">-</td> + <td className="p-3">Compose with a custom element</td> </tr> <tr className="border-b"> <td className="p-3 font-mono text-sm">className</td> diff --git a/apps/dev-tool/app/components/components/dialog-story.tsx b/apps/dev-tool/app/components/components/dialog-story.tsx index 75a4405d4..942964a5f 100644 --- a/apps/dev-tool/app/components/components/dialog-story.tsx +++ b/apps/dev-tool/app/components/components/dialog-story.tsx @@ -139,8 +139,8 @@ export function DialogStory() { }); let code = `<Dialog>\n`; - code += ` <DialogTrigger asChild>\n`; - code += ` <Button variant="${controls.triggerVariant}">${controls.triggerText}</Button>\n`; + code += ` <DialogTrigger render={<Button variant="${controls.triggerVariant}" />}>\n`; + code += ` ${controls.triggerText}\n`; code += ` </DialogTrigger>\n`; code += ` <DialogContent${contentPropsString}>\n`; code += ` <DialogHeader>\n`; @@ -182,8 +182,8 @@ export function DialogStory() { if (controls.withFooter) { code += ` <DialogFooter>\n`; - code += ` <DialogClose asChild>\n`; - code += ` <Button variant="outline">Cancel</Button>\n`; + code += ` <DialogClose render={<Button variant="outline" />}>\n`; + code += ` Cancel\n`; code += ` </DialogClose>\n`; code += ` <Button>Save Changes</Button>\n`; code += ` </DialogFooter>\n`; @@ -198,10 +198,8 @@ export function DialogStory() { const renderPreview = () => { return ( <Dialog> - <DialogTrigger asChild> - <Button variant={controls.triggerVariant}> - {controls.triggerText} - </Button> + <DialogTrigger render={<Button variant={controls.triggerVariant} />}> + {controls.triggerText} </DialogTrigger> <DialogContent className={cn( @@ -271,8 +269,8 @@ export function DialogStory() { {controls.withFooter && ( <DialogFooter> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <DialogClose render={<Button variant="outline" />}> + Cancel </DialogClose> <Button>Save Changes</Button> </DialogFooter> @@ -391,11 +389,9 @@ export function DialogStory() { <CardContent className="space-y-4"> <div className="flex flex-wrap gap-3"> <Dialog> - <DialogTrigger asChild> - <Button variant="outline"> - <Info className="mr-2 h-4 w-4" /> - Info Dialog - </Button> + <DialogTrigger render={<Button variant="outline" />}> + <Info className="mr-2 h-4 w-4" /> + Info Dialog </DialogTrigger> <DialogContent> <DialogHeader> @@ -412,19 +408,15 @@ export function DialogStory() { </p> </div> <DialogFooter> - <DialogClose asChild> - <Button>Got it</Button> - </DialogClose> + <DialogClose render={<Button />}>Got it</DialogClose> </DialogFooter> </DialogContent> </Dialog> <Dialog> - <DialogTrigger asChild> - <Button> - <Edit className="mr-2 h-4 w-4" /> - Edit Profile - </Button> + <DialogTrigger render={<Button />}> + <Edit className="mr-2 h-4 w-4" /> + Edit Profile </DialogTrigger> <DialogContent> <DialogHeader> @@ -456,8 +448,8 @@ export function DialogStory() { </div> </div> <DialogFooter> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <DialogClose render={<Button variant="outline" />}> + Cancel </DialogClose> <Button>Save Changes</Button> </DialogFooter> @@ -465,11 +457,9 @@ export function DialogStory() { </Dialog> <Dialog> - <DialogTrigger asChild> - <Button variant="secondary"> - <Settings className="mr-2 h-4 w-4" /> - Settings - </Button> + <DialogTrigger render={<Button variant="secondary" />}> + <Settings className="mr-2 h-4 w-4" /> + Settings </DialogTrigger> <DialogContent> <DialogHeader> @@ -499,8 +489,8 @@ export function DialogStory() { </div> </div> <DialogFooter> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <DialogClose render={<Button variant="outline" />}> + Cancel </DialogClose> <Button>Save</Button> </DialogFooter> @@ -518,10 +508,8 @@ export function DialogStory() { <CardContent className="space-y-4"> <div className="flex flex-wrap gap-3"> <Dialog> - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - Small Dialog - </Button> + <DialogTrigger render={<Button variant="outline" size="sm" />}> + Small Dialog </DialogTrigger> <DialogContent className="max-w-md"> <DialogHeader> @@ -536,16 +524,14 @@ export function DialogStory() { </p> </div> <DialogFooter> - <DialogClose asChild> - <Button>Close</Button> - </DialogClose> + <DialogClose render={<Button />}>Close</DialogClose> </DialogFooter> </DialogContent> </Dialog> <Dialog> - <DialogTrigger asChild> - <Button variant="outline">Large Dialog</Button> + <DialogTrigger render={<Button variant="outline" />}> + Large Dialog </DialogTrigger> <DialogContent className="max-w-2xl"> <DialogHeader> @@ -571,8 +557,8 @@ export function DialogStory() { </div> </div> <DialogFooter> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <DialogClose render={<Button variant="outline" />}> + Cancel </DialogClose> <Button>Save</Button> </DialogFooter> @@ -590,11 +576,9 @@ export function DialogStory() { <CardContent className="space-y-4"> <div className="flex flex-wrap gap-3"> <Dialog> - <DialogTrigger asChild> - <Button variant="outline"> - <Image className="mr-2 h-4 w-4" /> - Image Gallery - </Button> + <DialogTrigger render={<Button variant="outline" />}> + <Image className="mr-2 h-4 w-4" /> + Image Gallery </DialogTrigger> <DialogContent className="max-w-2xl"> <DialogHeader> @@ -627,11 +611,9 @@ export function DialogStory() { </Dialog> <Dialog> - <DialogTrigger asChild> - <Button variant="outline"> - <MessageSquare className="mr-2 h-4 w-4" /> - Feedback - </Button> + <DialogTrigger render={<Button variant="outline" />}> + <MessageSquare className="mr-2 h-4 w-4" /> + Feedback </DialogTrigger> <DialogContent> <DialogHeader> @@ -668,8 +650,8 @@ export function DialogStory() { </div> </div> <DialogFooter> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <DialogClose render={<Button variant="outline" />}> + Cancel </DialogClose> <Button> <MessageSquare className="mr-2 h-4 w-4" /> @@ -736,8 +718,8 @@ export function DialogStory() { <div> <h4 className="mb-3 text-lg font-semibold">DialogTrigger</h4> <p className="text-muted-foreground mb-3 text-sm"> - The element that opens the dialog. Use asChild prop to render as - child element. + The element that opens the dialog. Use the render prop to compose + with a custom element. </p> </div> @@ -840,10 +822,8 @@ export function DialogStory() { <h4 className="text-sm font-semibold">Focus Management</h4> <p className="text-muted-foreground text-sm"> • Focus moves to dialog when opened - <br /> - • Focus returns to trigger when closed - <br /> - • Tab navigation stays within dialog + <br />• Focus returns to trigger when closed + <br />• Tab navigation stays within dialog <br />• Escape key closes the dialog </p> </div> diff --git a/apps/dev-tool/app/components/components/docs-sidebar.tsx b/apps/dev-tool/app/components/components/docs-sidebar.tsx index 98e3717d5..ca883168a 100644 --- a/apps/dev-tool/app/components/components/docs-sidebar.tsx +++ b/apps/dev-tool/app/components/components/docs-sidebar.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Code2, FileText, Search } from 'lucide-react'; @@ -35,6 +35,7 @@ export function DocsSidebar({ selectedCategory, }: DocsSidebarProps) { const [searchQuery, setSearchQuery] = useState(''); + const searchParams = useSearchParams(); const router = useRouter(); const filteredComponents = COMPONENTS_REGISTRY.filter((c) => @@ -50,21 +51,21 @@ export function DocsSidebar({ .sort((a, b) => a.name.localeCompare(b.name)); const onCategorySelect = (category: string | null) => { - const searchParams = new URLSearchParams(window.location.search); - searchParams.set('category', category || ''); - router.push(`/components?${searchParams.toString()}`); + const sp = new URLSearchParams(searchParams); + sp.set('category', category || ''); + router.push(`/components?${sp.toString()}`); }; const onComponentSelect = (component: ComponentInfo) => { - const searchParams = new URLSearchParams(window.location.search); - searchParams.set('component', component.name); - router.push(`/components?${searchParams.toString()}`); + const sp = new URLSearchParams(searchParams); + sp.set('component', component.name); + router.push(`/components?${sp.toString()}`); }; return ( <div className="bg-muted/30 flex h-screen w-80 flex-col overflow-hidden border-r"> {/* Header */} - <div className="flex-shrink-0 border-b p-4"> + <div className="shrink-0 border-b p-4"> <div className="mb-2 flex items-center gap-2"> <Code2 className="text-primary h-6 w-6" /> @@ -77,13 +78,14 @@ export function DocsSidebar({ </div> {/* Controls */} - <div className="flex-shrink-0 space-y-2 border-b p-4"> + <div className="shrink-0 space-y-2 border-b p-4"> {/* Category Select */} <div className="space-y-2"> <Select - value={selectedCategory || 'all'} + defaultValue={selectedCategory || 'all'} onValueChange={(value) => { const category = value === 'all' ? null : value; + onCategorySelect(category); // Select first component in the filtered results @@ -96,8 +98,12 @@ export function DocsSidebar({ } }} > - <SelectTrigger> - <SelectValue placeholder={'Select a category'} /> + <SelectTrigger className="w-full"> + <SelectValue> + {(category) => { + return category === 'all' ? 'All Categories' : category; + }} + </SelectValue> </SelectTrigger> <SelectContent> @@ -154,7 +160,7 @@ export function DocsSidebar({ {/* Components List - Scrollable */} <div className="flex flex-1 flex-col overflow-y-auto"> - <div className="flex-shrink-0 p-4 pb-2"> + <div className="shrink-0 p-4 pb-2"> <h3 className="flex items-center gap-2 text-sm font-semibold"> <FileText className="h-4 w-4" /> Components diff --git a/apps/dev-tool/app/components/components/dropdown-menu-story.tsx b/apps/dev-tool/app/components/components/dropdown-menu-story.tsx index 94959ee77..01c2b7654 100644 --- a/apps/dev-tool/app/components/components/dropdown-menu-story.tsx +++ b/apps/dev-tool/app/components/components/dropdown-menu-story.tsx @@ -101,13 +101,18 @@ const examples = [ return ( <div className="flex min-h-32 items-center justify-center"> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="relative h-8 w-8 rounded-full"> - <Avatar className="h-8 w-8"> - <AvatarImage src="/avatars/01.png" alt="@username" /> - <AvatarFallback>JD</AvatarFallback> - </Avatar> - </Button> + <DropdownMenuTrigger + render={ + <Button + variant="ghost" + className="relative h-8 w-8 rounded-full" + /> + } + > + <Avatar className="h-8 w-8"> + <AvatarImage src="/avatars/01.png" alt="@username" /> + <AvatarFallback>JD</AvatarFallback> + </Avatar> </DropdownMenuTrigger> <DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuLabel className="font-normal"> @@ -185,11 +190,11 @@ const examples = [ </div> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="h-8 w-8 p-0"> - <span className="sr-only">Open menu</span> - <MoreHorizontal className="h-4 w-4" /> - </Button> + <DropdownMenuTrigger + render={<Button variant="ghost" className="h-8 w-8 p-0" />} + > + <span className="sr-only">Open menu</span> + <MoreHorizontal className="h-4 w-4" /> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuItem onClick={() => setSelectedAction('open')}> @@ -275,11 +280,9 @@ const examples = [ return ( <div className="flex min-h-48 items-center justify-center"> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline"> - <Plus className="mr-2 h-4 w-4" /> - Create New - </Button> + <DropdownMenuTrigger render={<Button variant="outline" />}> + <Plus className="mr-2 h-4 w-4" /> + Create New </DropdownMenuTrigger> <DropdownMenuContent className="w-56"> <DropdownMenuLabel>Create Content</DropdownMenuLabel> @@ -393,11 +396,11 @@ const examples = [ <span className="text-sm">Appearance & Layout</span> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline" size="sm"> - <Settings className="mr-2 h-4 w-4" /> - Configure - </Button> + <DropdownMenuTrigger + render={<Button variant="outline" size="sm" />} + > + <Settings className="mr-2 h-4 w-4" /> + Configure </DropdownMenuTrigger> <DropdownMenuContent className="w-64" align="end"> <DropdownMenuLabel>View Options</DropdownMenuLabel> @@ -547,10 +550,10 @@ const examples = [ </div> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="h-8 w-8 p-0"> - <MoreHorizontal className="h-4 w-4" /> - </Button> + <DropdownMenuTrigger + render={<Button variant="ghost" className="h-8 w-8 p-0" />} + > + <MoreHorizontal className="h-4 w-4" /> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuItem @@ -863,7 +866,7 @@ export default function DropdownMenuStory() { modal: controls.modal ? true : undefined, }); - const dropdownStructure = `<DropdownMenu${rootProps}>\n <DropdownMenuTrigger asChild>\n <Button variant="outline">Open Menu</Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent${contentProps}>\n <DropdownMenuItem>\n <User className="mr-2 h-4 w-4" />\n <span>Profile</span>\n </DropdownMenuItem>\n <DropdownMenuItem>\n <Settings className="mr-2 h-4 w-4" />\n <span>Settings</span>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem>\n <LogOut className="mr-2 h-4 w-4" />\n <span>Log out</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n</DropdownMenu>`; + const dropdownStructure = `<DropdownMenu${rootProps}>\n <DropdownMenuTrigger render={<Button variant="outline" />}>\n Open Menu\n </DropdownMenuTrigger>\n <DropdownMenuContent${contentProps}>\n <DropdownMenuItem>\n <User className="mr-2 h-4 w-4" />\n <span>Profile</span>\n </DropdownMenuItem>\n <DropdownMenuItem>\n <Settings className="mr-2 h-4 w-4" />\n <span>Settings</span>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem>\n <LogOut className="mr-2 h-4 w-4" />\n <span>Log out</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n</DropdownMenu>`; return `${importStatement}\n${buttonImport}\n${iconImport}\n\n${dropdownStructure}`; }; @@ -971,8 +974,8 @@ export default function DropdownMenuStory() { const previewContent = ( <div className="flex justify-center p-6"> <DropdownMenu modal={controls.modal}> - <DropdownMenuTrigger asChild> - <Button variant="outline">Open Menu</Button> + <DropdownMenuTrigger render={<Button variant="outline" />}> + Open Menu </DropdownMenuTrigger> <DropdownMenuContent side={controls.side} diff --git a/apps/dev-tool/app/components/components/form-story.tsx b/apps/dev-tool/app/components/components/form-story.tsx index 04b10ab94..797cd9757 100644 --- a/apps/dev-tool/app/components/components/form-story.tsx +++ b/apps/dev-tool/app/components/components/form-story.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; @@ -119,7 +119,7 @@ export default function FormStory() { const formImport = generateImportStatement(formComponents, '@kit/ui/form'); const inputImport = generateImportStatement(['Input'], '@kit/ui/input'); const buttonImport = generateImportStatement(['Button'], '@kit/ui/button'); - const hookFormImports = `import { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { z } from 'zod';`; + const hookFormImports = `import { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';`; let schemaCode = ''; let formFieldsCode = ''; @@ -130,19 +130,19 @@ export default function FormStory() { formFieldsCode = ` <FormField\n control={form.control}\n name="username"\n render={({ field }) => (\n <FormItem>\n <FormLabel>Username</FormLabel>\n <FormControl>\n <Input ${controls.disabled ? 'disabled ' : ''}placeholder="Enter username" {...field} />\n </FormControl>${controls.showDescriptions ? '\n <FormDescription>\n Your public display name.\n </FormDescription>' : ''}${controls.showValidation ? '\n <FormMessage />' : ''}\n </FormItem>\n )}\n />\n <FormField\n control={form.control}\n name="email"\n render={({ field }) => (\n <FormItem>\n <FormLabel>Email</FormLabel>\n <FormControl>\n <Input ${controls.disabled ? 'disabled ' : ''}type="email" placeholder="Enter email" {...field} />\n </FormControl>${controls.showDescriptions ? "\n <FormDescription>\n We'll never share your email.\n </FormDescription>" : ''}${controls.showValidation ? '\n <FormMessage />' : ''}\n </FormItem>\n )}\n />`; - onSubmitCode = ` function onSubmit(values: z.infer<typeof formSchema>) {\n console.log('Form submitted:', values);\n }`; + onSubmitCode = ` function onSubmit(values: z.output<typeof formSchema>) {\n console.log('Form submitted:', values);\n }`; } else if (controls.formType === 'advanced') { schemaCode = `const formSchema = z.object({\n firstName: z.string().min(1, 'First name is required.'),\n lastName: z.string().min(1, 'Last name is required.'),\n email: z.string().email('Please enter a valid email address.'),\n});`; formFieldsCode = ` <FormField\n control={form.control}\n name="firstName"\n render={({ field }) => (\n <FormItem>\n <FormLabel>First Name</FormLabel>\n <FormControl>\n <Input ${controls.disabled ? 'disabled ' : ''}placeholder="John" {...field} />\n </FormControl>${controls.showValidation ? '\n <FormMessage />' : ''}\n </FormItem>\n )}\n />\n <FormField\n control={form.control}\n name="lastName"\n render={({ field }) => (\n <FormItem>\n <FormLabel>Last Name</FormLabel>\n <FormControl>\n <Input ${controls.disabled ? 'disabled ' : ''}placeholder="Doe" {...field} />\n </FormControl>${controls.showValidation ? '\n <FormMessage />' : ''}\n </FormItem>\n )}\n />`; - onSubmitCode = ` function onSubmit(values: z.infer<typeof formSchema>) {\n console.log('Advanced form submitted:', values);\n }`; + onSubmitCode = ` function onSubmit(values: z.output<typeof formSchema>) {\n console.log('Advanced form submitted:', values);\n }`; } else { schemaCode = `const formSchema = z.object({\n password: z.string().min(8, 'Password must be at least 8 characters.'),\n confirmPassword: z.string(),\n}).refine((data) => data.password === data.confirmPassword, {\n message: 'Passwords do not match.',\n path: ['confirmPassword'],\n});`; formFieldsCode = ` <FormField\n control={form.control}\n name="password"\n render={({ field }) => (\n <FormItem>\n <FormLabel>Password</FormLabel>\n <FormControl>\n <Input ${controls.disabled ? 'disabled ' : ''}type="password" {...field} />\n </FormControl>${controls.showValidation ? '\n <FormMessage />' : ''}\n </FormItem>\n )}\n />\n <FormField\n control={form.control}\n name="confirmPassword"\n render={({ field }) => (\n <FormItem>\n <FormLabel>Confirm Password</FormLabel>\n <FormControl>\n <Input ${controls.disabled ? 'disabled ' : ''}type="password" {...field} />\n </FormControl>${controls.showValidation ? '\n <FormMessage />' : ''}\n </FormItem>\n )}\n />`; - onSubmitCode = ` function onSubmit(values: z.infer<typeof formSchema>) {\n console.log('Validation form submitted:', values);\n }`; + onSubmitCode = ` function onSubmit(values: z.output<typeof formSchema>) {\n console.log('Validation form submitted:', values);\n }`; } const defaultValuesCode = @@ -152,13 +152,13 @@ export default function FormStory() { ? ` defaultValues: {\n firstName: '',\n lastName: '',\n email: '',\n },` : ` defaultValues: {\n password: '',\n confirmPassword: '',\n },`; - const fullFormCode = `${hookFormImports}\n${formImport}\n${inputImport}\n${buttonImport}\n\n${schemaCode}\n\nfunction MyForm() {\n const form = useForm<z.infer<typeof formSchema>>({\n resolver: zodResolver(formSchema),\n${defaultValuesCode}\n });\n\n${onSubmitCode}\n\n return (\n <Form {...form}>\n <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">\n${formFieldsCode}\n <Button type="submit"${controls.disabled ? ' disabled' : ''}>Submit</Button>\n </form>\n </Form>\n );\n}`; + const fullFormCode = `${hookFormImports}\n${formImport}\n${inputImport}\n${buttonImport}\n\n${schemaCode}\n\nfunction MyForm() {\n const form = useForm<z.output<typeof formSchema>>({\n resolver: zodResolver(formSchema),\n${defaultValuesCode}\n });\n\n${onSubmitCode}\n\n return (\n <Form {...form}>\n <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">\n${formFieldsCode}\n <Button type="submit"${controls.disabled ? ' disabled' : ''}>Submit</Button>\n </form>\n </Form>\n );\n}`; return fullFormCode; }; // Basic form - const basicForm = useForm<z.infer<typeof basicFormSchema>>({ + const basicForm = useForm<z.output<typeof basicFormSchema>>({ resolver: zodResolver(basicFormSchema), defaultValues: { username: '', @@ -169,7 +169,7 @@ export default function FormStory() { }); // Advanced form - const advancedForm = useForm<z.infer<typeof advancedFormSchema>>({ + const advancedForm = useForm<z.output<typeof advancedFormSchema>>({ resolver: zodResolver(advancedFormSchema), defaultValues: { firstName: '', @@ -183,7 +183,7 @@ export default function FormStory() { }); // Validation form - const validationForm = useForm<z.infer<typeof validationFormSchema>>({ + const validationForm = useForm<z.output<typeof validationFormSchema>>({ resolver: zodResolver(validationFormSchema), defaultValues: { password: '', @@ -1056,7 +1056,7 @@ export default function FormStory() { <pre className="overflow-x-auto text-sm"> {`import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; +import * as z from 'zod'; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form'; const formSchema = z.object({ @@ -1065,7 +1065,7 @@ const formSchema = z.object({ }); function MyForm() { - const form = useForm<z.infer<typeof formSchema>>({ + const form = useForm<z.output<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { username: '', @@ -1073,7 +1073,7 @@ function MyForm() { }, }); - function onSubmit(values: z.infer<typeof formSchema>) { + function onSubmit(values: z.output<typeof formSchema>) { console.log(values); } diff --git a/apps/dev-tool/app/components/components/kbd-story.tsx b/apps/dev-tool/app/components/components/kbd-story.tsx index fdca7aa0d..6f235dc56 100644 --- a/apps/dev-tool/app/components/components/kbd-story.tsx +++ b/apps/dev-tool/app/components/components/kbd-story.tsx @@ -99,7 +99,7 @@ export function KbdStory() { let snippet = groupLines.join('\n'); if (controls.showTooltip) { - snippet = `<TooltipProvider>\n <Tooltip>\n <TooltipTrigger asChild>\n <Button variant="outline">Command palette</Button>\n </TooltipTrigger>\n <TooltipContent className="flex items-center gap-2">\n <span>Press</span>\n ${groupLines.join('\n ')}\n </TooltipContent>\n </Tooltip>\n</TooltipProvider>`; + snippet = `<TooltipProvider>\n <Tooltip>\n <TooltipTrigger render={<Button variant="outline" />}>\n Command palette\n </TooltipTrigger>\n <TooltipContent className="flex items-center gap-2">\n <span>Press</span>\n ${groupLines.join('\n ')}\n </TooltipContent>\n </Tooltip>\n</TooltipProvider>`; } return formatCodeBlock(snippet, [ @@ -115,11 +115,11 @@ export function KbdStory() { {controls.showTooltip ? ( <TooltipProvider delayDuration={200}> <Tooltip> - <TooltipTrigger asChild> - <Button variant="outline" className="gap-2"> - <Command className="h-4 w-4" /> - Command palette - </Button> + <TooltipTrigger + render={<Button variant="outline" className="gap-2" />} + > + <Command className="h-4 w-4" /> + Command palette </TooltipTrigger> <TooltipContent className="flex items-center gap-2"> <span>Press</span> diff --git a/apps/dev-tool/app/components/components/select-story.tsx b/apps/dev-tool/app/components/components/select-story.tsx index c61d1b30d..c4e481cf3 100644 --- a/apps/dev-tool/app/components/components/select-story.tsx +++ b/apps/dev-tool/app/components/components/select-story.tsx @@ -763,10 +763,8 @@ export function SelectStory() { <h4 className="text-sm font-semibold">Keyboard Navigation</h4> <p className="text-muted-foreground text-sm"> • Space/Enter opens the select - <br /> - • Arrow keys navigate options - <br /> - • Escape closes the dropdown + <br />• Arrow keys navigate options + <br />• Escape closes the dropdown <br />• Type to search/filter options </p> </div> diff --git a/apps/dev-tool/app/components/components/simple-data-table-story.tsx b/apps/dev-tool/app/components/components/simple-data-table-story.tsx index 826339470..2bc4c2b28 100644 --- a/apps/dev-tool/app/components/components/simple-data-table-story.tsx +++ b/apps/dev-tool/app/components/components/simple-data-table-story.tsx @@ -136,11 +136,13 @@ export function SimpleDataTableStory() { {controls.showActions && ( <TableCell> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="h-8 w-8 p-0"> - <span className="sr-only">Open menu</span> - <MoreHorizontal className="h-4 w-4" /> - </Button> + <DropdownMenuTrigger + render={ + <Button variant="ghost" className="h-8 w-8 p-0" /> + } + > + <span className="sr-only">Open menu</span> + <MoreHorizontal className="h-4 w-4" /> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem diff --git a/apps/dev-tool/app/components/components/switch-story.tsx b/apps/dev-tool/app/components/components/switch-story.tsx index 41536ac53..03445ee67 100644 --- a/apps/dev-tool/app/components/components/switch-story.tsx +++ b/apps/dev-tool/app/components/components/switch-story.tsx @@ -100,7 +100,7 @@ export function SwitchStory() { className: cn( controls.size === 'sm' && 'h-4 w-7', controls.size === 'lg' && 'h-6 w-11', - controls.error && 'data-[state=checked]:bg-destructive', + controls.error && 'data-checked:bg-destructive', ), }; @@ -200,7 +200,7 @@ export function SwitchStory() { className={cn( controls.size === 'sm' && 'h-4 w-7', controls.size === 'lg' && 'h-6 w-11', - controls.error && 'data-[state=checked]:bg-destructive', + controls.error && 'data-checked:bg-destructive', )} /> ); @@ -616,7 +616,7 @@ export function SwitchStory() { </Label> <Switch id="error-switch" - className="data-[state=checked]:bg-destructive" + className="data-checked:bg-destructive" /> </div> <p className="text-destructive text-sm"> @@ -642,7 +642,7 @@ export function SwitchStory() { <div> <h4 className="mb-3 text-lg font-semibold">Switch</h4> <p className="text-muted-foreground mb-3 text-sm"> - A toggle switch component for boolean states. Built on Radix UI + A toggle switch component for boolean states. Built on Base UI Switch primitive. </p> <div className="overflow-x-auto"> @@ -792,8 +792,7 @@ export function SwitchStory() { <h4 className="text-sm font-semibold">Keyboard Navigation</h4> <p className="text-muted-foreground text-sm"> • Tab to focus the switch - <br /> - • Space or Enter to toggle state + <br />• Space or Enter to toggle state <br />• Arrow keys when part of a radio group </p> </div> diff --git a/apps/dev-tool/app/components/components/tabs-story.tsx b/apps/dev-tool/app/components/components/tabs-story.tsx index 2ee904ceb..8ccb73ff8 100644 --- a/apps/dev-tool/app/components/components/tabs-story.tsx +++ b/apps/dev-tool/app/components/components/tabs-story.tsx @@ -62,9 +62,9 @@ interface TabsControlsProps { const variantClasses = { default: '', pills: - '[&>div]:bg-background [&>div]:border [&>div]:rounded-lg [&>div]:p-1 [&_button]:rounded-md [&_button[data-state=active]]:bg-primary [&_button[data-state=active]]:text-primary-foreground', + '[&>div]:bg-background [&>div]:border [&>div]:rounded-lg [&>div]:p-1 [&_button]:rounded-md [&_button[data-active]]:bg-primary [&_button[data-active]]:text-primary-foreground', underline: - '[&>div]:bg-transparent [&>div]:border-b [&>div]:rounded-none [&_button]:rounded-none [&_button]:border-b-2 [&_button]:border-transparent [&_button[data-state=active]]:border-primary [&_button[data-state=active]]:bg-transparent', + '[&>div]:bg-transparent [&>div]:border-b [&>div]:rounded-none [&_button]:rounded-none [&_button]:border-b-2 [&_button]:border-transparent [&_button[data-active]]:border-primary [&_button[data-active]]:bg-transparent', }; const sizeClasses = { @@ -683,28 +683,28 @@ function App() { <TabsList className="h-auto rounded-none border-b bg-transparent p-0"> <TabsTrigger value="overview" - className="data-[state=active]:border-primary rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent" + className="data-active:border-primary rounded-none border-b-2 border-transparent data-active:bg-transparent" > <BarChart3 className="mr-2 h-4 w-4" /> Overview </TabsTrigger> <TabsTrigger value="users" - className="data-[state=active]:border-primary rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent" + className="data-active:border-primary rounded-none border-b-2 border-transparent data-active:bg-transparent" > <User className="mr-2 h-4 w-4" /> Users </TabsTrigger> <TabsTrigger value="revenue" - className="data-[state=active]:border-primary rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent" + className="data-active:border-primary rounded-none border-b-2 border-transparent data-active:bg-transparent" > <CreditCard className="mr-2 h-4 w-4" /> Revenue </TabsTrigger> <TabsTrigger value="reports" - className="data-[state=active]:border-primary rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent" + className="data-active:border-primary rounded-none border-b-2 border-transparent data-active:bg-transparent" > <FileText className="mr-2 h-4 w-4" /> Reports @@ -905,8 +905,7 @@ const apiReference = { { name: '...props', type: 'React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>', - description: - 'All props from Radix UI Tabs.Root component including asChild, id, etc.', + description: 'All additional props from Base UI Tabs.Root component.', }, ], examples: [ diff --git a/apps/dev-tool/app/components/components/tooltip-story.tsx b/apps/dev-tool/app/components/components/tooltip-story.tsx index d063bb217..ece18bcdc 100644 --- a/apps/dev-tool/app/components/components/tooltip-story.tsx +++ b/apps/dev-tool/app/components/components/tooltip-story.tsx @@ -144,22 +144,23 @@ function TooltipStory() { let code = `<TooltipProvider${providerPropsString}>\n`; code += ` <Tooltip>\n`; - code += ` <TooltipTrigger asChild>\n`; - if (controls.triggerType === 'button') { - code += ` <Button variant="${controls.triggerVariant}">Hover me</Button>\n`; + code += ` <TooltipTrigger render={<Button variant="${controls.triggerVariant}" />}>\n`; + code += ` Hover me\n`; } else if (controls.triggerType === 'icon') { - code += ` <Button variant="${controls.triggerVariant}" size="icon">\n`; const iconName = selectedIconData?.icon.name || 'Info'; - code += ` <${iconName} className="h-4 w-4" />\n`; - code += ` </Button>\n`; + code += ` <TooltipTrigger render={<Button variant="${controls.triggerVariant}" size="icon" />}>\n`; + code += ` <${iconName} className="h-4 w-4" />\n`; } else if (controls.triggerType === 'text') { - code += ` <span className="cursor-help underline decoration-dotted">Hover me</span>\n`; + code += ` <TooltipTrigger render={<span className="cursor-help underline decoration-dotted" />}>\n`; + code += ` Hover me\n`; } else if (controls.triggerType === 'input') { - code += ` <Input placeholder="Hover over this input" />\n`; + code += ` <TooltipTrigger render={<Input placeholder="Hover over this input" />} />\n`; } - code += ` </TooltipTrigger>\n`; + if (controls.triggerType !== 'input') { + code += ` </TooltipTrigger>\n`; + } code += ` <TooltipContent${contentPropsString}>\n`; code += ` <p>${controls.content}</p>\n`; code += ` </TooltipContent>\n`; @@ -170,28 +171,50 @@ function TooltipStory() { }; const renderPreview = () => { - const trigger = (() => { + const renderTrigger = () => { switch (controls.triggerType) { case 'button': - return <Button variant={controls.triggerVariant}>Hover me</Button>; + return ( + <TooltipTrigger + render={<Button variant={controls.triggerVariant} />} + > + Hover me + </TooltipTrigger> + ); case 'icon': return ( - <Button variant={controls.triggerVariant} size="icon"> + <TooltipTrigger + render={<Button variant={controls.triggerVariant} size="icon" />} + > <IconComponent className="h-4 w-4" /> - </Button> + </TooltipTrigger> ); case 'text': return ( - <span className="cursor-help underline decoration-dotted"> + <TooltipTrigger + render={ + <span className="cursor-help underline decoration-dotted" /> + } + > Hover me - </span> + </TooltipTrigger> ); case 'input': - return <Input placeholder="Hover over this input" />; + return ( + <TooltipTrigger + render={<Input placeholder="Hover over this input" />} + /> + ); default: - return <Button variant={controls.triggerVariant}>Hover me</Button>; + return ( + <TooltipTrigger + render={<Button variant={controls.triggerVariant} />} + > + Hover me + </TooltipTrigger> + ); } - })(); + }; return ( <div className="flex min-h-[200px] items-center justify-center"> @@ -201,7 +224,7 @@ function TooltipStory() { disableHoverableContent={controls.disableHoverableContent} > <Tooltip> - <TooltipTrigger asChild>{trigger}</TooltipTrigger> + {renderTrigger()} <TooltipContent side={controls.side} align={controls.align} @@ -376,11 +399,9 @@ function TooltipStory() { <TooltipProvider> <div className="flex flex-wrap gap-4"> <Tooltip> - <TooltipTrigger asChild> - <Button variant="outline"> - <Info className="mr-2 h-4 w-4" /> - Info Button - </Button> + <TooltipTrigger render={<Button variant="outline" />}> + <Info className="mr-2 h-4 w-4" /> + Info Button </TooltipTrigger> <TooltipContent> <p>This provides additional information</p> @@ -388,10 +409,8 @@ function TooltipStory() { </Tooltip> <Tooltip> - <TooltipTrigger asChild> - <Button variant="ghost" size="icon"> - <HelpCircle className="h-4 w-4" /> - </Button> + <TooltipTrigger render={<Button variant="ghost" size="icon" />}> + <HelpCircle className="h-4 w-4" /> </TooltipTrigger> <TooltipContent> <p>Click for help documentation</p> @@ -399,10 +418,12 @@ function TooltipStory() { </Tooltip> <Tooltip> - <TooltipTrigger asChild> - <span className="cursor-help underline decoration-dotted"> - Hover for explanation - </span> + <TooltipTrigger + render={ + <span className="cursor-help underline decoration-dotted" /> + } + > + Hover for explanation </TooltipTrigger> <TooltipContent> <p>This term needs clarification for better understanding</p> @@ -410,9 +431,9 @@ function TooltipStory() { </Tooltip> <Tooltip> - <TooltipTrigger asChild> - <Input placeholder="Hover me" className="w-48" /> - </TooltipTrigger> + <TooltipTrigger + render={<Input placeholder="Hover me" className="w-48" />} + /> <TooltipContent> <p>Enter your email address here</p> </TooltipContent> @@ -434,10 +455,10 @@ function TooltipStory() { {/* Top Row */} <div></div> <Tooltip> - <TooltipTrigger asChild> - <Button variant="outline" size="sm"> - Top - </Button> + <TooltipTrigger + render={<Button variant="outline" size="sm" />} + > + Top </TooltipTrigger> <TooltipContent side="top"> <p>Tooltip on top</p> @@ -447,10 +468,10 @@ function TooltipStory() { {/* Middle Row */} <Tooltip> - <TooltipTrigger asChild> - <Button variant="outline" size="sm"> - Left - </Button> + <TooltipTrigger + render={<Button variant="outline" size="sm" />} + > + Left </TooltipTrigger> <TooltipContent side="left"> <p>Tooltip on left</p> @@ -460,10 +481,10 @@ function TooltipStory() { <span className="text-muted-foreground text-sm">Center</span> </div> <Tooltip> - <TooltipTrigger asChild> - <Button variant="outline" size="sm"> - Right - </Button> + <TooltipTrigger + render={<Button variant="outline" size="sm" />} + > + Right </TooltipTrigger> <TooltipContent side="right"> <p>Tooltip on right</p> @@ -473,10 +494,10 @@ function TooltipStory() { {/* Bottom Row */} <div></div> <Tooltip> - <TooltipTrigger asChild> - <Button variant="outline" size="sm"> - Bottom - </Button> + <TooltipTrigger + render={<Button variant="outline" size="sm" />} + > + Bottom </TooltipTrigger> <TooltipContent side="bottom"> <p>Tooltip on bottom</p> @@ -498,11 +519,9 @@ function TooltipStory() { <TooltipProvider> <div className="flex flex-wrap gap-4"> <Tooltip> - <TooltipTrigger asChild> - <Button variant="outline"> - <Star className="mr-2 h-4 w-4" /> - Premium Feature - </Button> + <TooltipTrigger render={<Button variant="outline" />}> + <Star className="mr-2 h-4 w-4" /> + Premium Feature </TooltipTrigger> <TooltipContent className="max-w-xs"> <div className="space-y-1"> @@ -516,11 +535,9 @@ function TooltipStory() { </Tooltip> <Tooltip> - <TooltipTrigger asChild> - <Button variant="outline"> - <Settings className="mr-2 h-4 w-4" /> - Advanced Settings - </Button> + <TooltipTrigger render={<Button variant="outline" />}> + <Settings className="mr-2 h-4 w-4" /> + Advanced Settings </TooltipTrigger> <TooltipContent> <div className="space-y-1"> @@ -537,11 +554,9 @@ function TooltipStory() { </Tooltip> <Tooltip> - <TooltipTrigger asChild> - <Button variant="destructive"> - <AlertCircle className="mr-2 h-4 w-4" /> - Delete Account - </Button> + <TooltipTrigger render={<Button variant="destructive" />}> + <AlertCircle className="mr-2 h-4 w-4" /> + Delete Account </TooltipTrigger> <TooltipContent className="border-destructive bg-destructive text-destructive-foreground max-w-xs"> <div className="space-y-1"> @@ -568,10 +583,10 @@ function TooltipStory() { <div className="space-y-4"> <div className="flex items-center gap-4"> <Tooltip> - <TooltipTrigger asChild> - <Button size="icon" variant="ghost"> - <Copy className="h-4 w-4" /> - </Button> + <TooltipTrigger + render={<Button size="icon" variant="ghost" />} + > + <Copy className="h-4 w-4" /> </TooltipTrigger> <TooltipContent> <p>Copy to clipboard</p> @@ -579,10 +594,10 @@ function TooltipStory() { </Tooltip> <Tooltip> - <TooltipTrigger asChild> - <Button size="icon" variant="ghost"> - <Download className="h-4 w-4" /> - </Button> + <TooltipTrigger + render={<Button size="icon" variant="ghost" />} + > + <Download className="h-4 w-4" /> </TooltipTrigger> <TooltipContent> <p>Download file</p> @@ -590,10 +605,10 @@ function TooltipStory() { </Tooltip> <Tooltip> - <TooltipTrigger asChild> - <Button size="icon" variant="ghost"> - <Share className="h-4 w-4" /> - </Button> + <TooltipTrigger + render={<Button size="icon" variant="ghost" />} + > + <Share className="h-4 w-4" /> </TooltipTrigger> <TooltipContent> <p>Share with others</p> @@ -605,9 +620,11 @@ function TooltipStory() { <div className="space-y-2"> <Label htmlFor="username">Username</Label> <Tooltip> - <TooltipTrigger asChild> - <Input id="username" placeholder="Enter username" /> - </TooltipTrigger> + <TooltipTrigger + render={ + <Input id="username" placeholder="Enter username" /> + } + /> <TooltipContent> <p>Must be 3-20 characters, letters and numbers only</p> </TooltipContent> @@ -616,9 +633,7 @@ function TooltipStory() { <div className="flex items-center space-x-2"> <Tooltip> - <TooltipTrigger asChild> - <Checkbox id="terms" /> - </TooltipTrigger> + <TooltipTrigger render={<Checkbox id="terms" />} /> <TooltipContent className="max-w-xs"> <p> By checking this, you agree to our Terms of Service and @@ -751,7 +766,7 @@ function TooltipStory() { </li> <li> <strong>TooltipTrigger:</strong> Element that triggers the - tooltip (use asChild prop) + tooltip (use render prop) </li> </ul> </div> @@ -856,8 +871,7 @@ function TooltipStory() { <h4 className="text-sm font-semibold">Keyboard Support</h4> <p className="text-muted-foreground text-sm"> • Tooltips appear on focus and disappear on blur - <br /> - • Escape key dismisses tooltips + <br />• Escape key dismisses tooltips <br />• Tooltips don't trap focus or interfere with navigation </p> </div> diff --git a/apps/dev-tool/app/components/lib/components-data.tsx b/apps/dev-tool/app/components/lib/components-data.tsx index 40c8d7f6a..e891d5ebf 100644 --- a/apps/dev-tool/app/components/lib/components-data.tsx +++ b/apps/dev-tool/app/components/lib/components-data.tsx @@ -492,7 +492,7 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [ status: 'stable', component: CardButtonStory, sourceFile: '@kit/ui/card-button', - props: ['asChild', 'className', 'children', 'onClick', 'disabled'], + props: ['className', 'children', 'onClick', 'disabled'], icon: MousePointer, }, @@ -950,7 +950,7 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [ status: 'stable', component: ItemStory, sourceFile: '@kit/ui/item', - props: ['variant', 'size', 'asChild', 'className'], + props: ['variant', 'size', 'className'], icon: Layers, }, @@ -1004,7 +1004,7 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [ status: 'stable', component: BreadcrumbStory, sourceFile: '@kit/ui/breadcrumb', - props: ['separator', 'asChild', 'href', 'className'], + props: ['separator', 'href', 'className'], icon: ChevronRight, }, diff --git a/apps/dev-tool/app/components/page.tsx b/apps/dev-tool/app/components/page.tsx index 0d27d30b4..84d0533d0 100644 --- a/apps/dev-tool/app/components/page.tsx +++ b/apps/dev-tool/app/components/page.tsx @@ -1,4 +1,3 @@ -import { withI18n } from '../../lib/i18n/with-i18n'; import { DocsContent } from './components/docs-content'; import { DocsHeader } from './components/docs-header'; import { DocsSidebar } from './components/docs-sidebar'; @@ -29,4 +28,4 @@ async function ComponentDocsPage(props: ComponentDocsPageProps) { ); } -export default withI18n(ComponentDocsPage); +export default ComponentDocsPage; diff --git a/apps/dev-tool/app/database/_lib/server/database-tools.loader.ts b/apps/dev-tool/app/database/_lib/server/database-tools.loader.ts index 4649432b2..e43e52547 100644 --- a/apps/dev-tool/app/database/_lib/server/database-tools.loader.ts +++ b/apps/dev-tool/app/database/_lib/server/database-tools.loader.ts @@ -1,9 +1,8 @@ import 'server-only'; +import { DatabaseTool } from '@kit/mcp-server/database'; import { relative } from 'path'; -import { DatabaseTool } from '@kit/mcp-server/database'; - export interface DatabaseTable { name: string; schema: string; diff --git a/apps/dev-tool/app/database/_lib/server/table-server-actions.ts b/apps/dev-tool/app/database/_lib/server/table-server-actions.ts index 86f665c54..6516c4db4 100644 --- a/apps/dev-tool/app/database/_lib/server/table-server-actions.ts +++ b/apps/dev-tool/app/database/_lib/server/table-server-actions.ts @@ -1,9 +1,9 @@ 'use server'; -import { relative } from 'path'; - import { DatabaseTool } from '@kit/mcp-server/database'; +import { relative } from 'path'; + export async function getTableDetailsAction( tableName: string, schema = 'public', diff --git a/apps/dev-tool/app/emails/[id]/components/email-tester-form.tsx b/apps/dev-tool/app/emails/[id]/components/email-tester-form.tsx index 40c3973e4..55cb4faa1 100644 --- a/apps/dev-tool/app/emails/[id]/components/email-tester-form.tsx +++ b/apps/dev-tool/app/emails/[id]/components/email-tester-form.tsx @@ -2,8 +2,6 @@ import Link from 'next/link'; -import { EmailTesterFormSchema } from '@/app/emails/lib/email-tester-form-schema'; -import { sendEmailAction } from '@/app/emails/lib/server-actions'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -19,6 +17,9 @@ import { Input } from '@kit/ui/input'; import { toast } from '@kit/ui/sonner'; import { Switch } from '@kit/ui/switch'; +import { EmailTesterFormSchema } from '@/app/emails/lib/email-tester-form-schema'; +import { sendEmailAction } from '@/app/emails/lib/server-actions'; + export function EmailTesterForm(props: { template: string; settings: { diff --git a/apps/dev-tool/app/emails/[id]/page.tsx b/apps/dev-tool/app/emails/[id]/page.tsx index 377fbe680..d4188760f 100644 --- a/apps/dev-tool/app/emails/[id]/page.tsx +++ b/apps/dev-tool/app/emails/[id]/page.tsx @@ -1,7 +1,3 @@ -import { EmailTesterForm } from '@/app/emails/[id]/components/email-tester-form'; -import { EnvModeSelector } from '@/components/env-mode-selector'; -import { IFrame } from '@/components/iframe'; - import { createKitEmailsDeps, createKitEmailsService, @@ -17,6 +13,10 @@ import { } from '@kit/ui/dialog'; import { Page, PageBody, PageHeader } from '@kit/ui/page'; +import { EmailTesterForm } from '@/app/emails/[id]/components/email-tester-form'; +import { EnvModeSelector } from '@/components/env-mode-selector'; +import { IFrame } from '@/components/iframe'; + type EnvMode = 'development' | 'production'; type EmailPageProps = React.PropsWithChildren<{ @@ -67,10 +67,10 @@ export default async function EmailPage(props: EmailPageProps) { Remember that the below is an approximation of the email. Always test it in your inbox.{' '} <Dialog> - <DialogTrigger asChild> - <Button variant={'link'} className="p-0 underline"> - Test Email - </Button> + <DialogTrigger + render={<Button variant={'link'} className="p-0 underline" />} + > + Test Email </DialogTrigger> <DialogContent> diff --git a/apps/dev-tool/app/emails/lib/email-tester-form-schema.ts b/apps/dev-tool/app/emails/lib/email-tester-form-schema.ts index dad963164..9f755f805 100644 --- a/apps/dev-tool/app/emails/lib/email-tester-form-schema.ts +++ b/apps/dev-tool/app/emails/lib/email-tester-form-schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const EmailTesterFormSchema = z.object({ username: z.string().min(1), diff --git a/apps/dev-tool/app/emails/page.tsx b/apps/dev-tool/app/emails/page.tsx index 5b3a0f26e..231267d2a 100644 --- a/apps/dev-tool/app/emails/page.tsx +++ b/apps/dev-tool/app/emails/page.tsx @@ -49,13 +49,16 @@ export default async function EmailsPage() { <div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}> {categoryTemplates.map((template) => ( - <CardButton key={template.id} asChild> - <Link href={`/emails/${template.id}`}> - <CardButtonHeader> - <CardButtonTitle>{template.name}</CardButtonTitle> - </CardButtonHeader> - </Link> - </CardButton> + <CardButton + key={template.id} + render={ + <Link href={`/emails/${template.id}`}> + <CardButtonHeader> + <CardButtonTitle>{template.name}</CardButtonTitle> + </CardButtonHeader> + </Link> + } + /> ))} </div> </div> diff --git a/apps/dev-tool/app/layout.tsx b/apps/dev-tool/app/layout.tsx index 2a749fc1e..f72ce2de9 100644 --- a/apps/dev-tool/app/layout.tsx +++ b/apps/dev-tool/app/layout.tsx @@ -1,8 +1,9 @@ import type { Metadata } from 'next'; +import { getMessages } from 'next-intl/server'; + import { DevToolLayout } from '@/components/app-layout'; import { RootProviders } from '@/components/root-providers'; - import '../styles/globals.css'; export const metadata: Metadata = { @@ -10,15 +11,17 @@ export const metadata: Metadata = { description: 'The dev tool for Makerkit', }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const messages = await getMessages(); + return ( <html lang="en"> <body> - <RootProviders> + <RootProviders messages={messages}> <DevToolLayout>{children}</DevToolLayout> </RootProviders> </body> diff --git a/apps/dev-tool/app/lib/prerequisites-dashboard.loader.ts b/apps/dev-tool/app/lib/prerequisites-dashboard.loader.ts index 7992ef2eb..6f2dd1e5d 100644 --- a/apps/dev-tool/app/lib/prerequisites-dashboard.loader.ts +++ b/apps/dev-tool/app/lib/prerequisites-dashboard.loader.ts @@ -1,13 +1,13 @@ -import { execFile } from 'node:child_process'; -import { access, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; - import { type KitPrerequisitesDeps, createKitPrerequisitesService, } from '@kit/mcp-server/prerequisites'; +import { execFile } from 'node:child_process'; +import { access, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + const execFileAsync = promisify(execFile); export async function loadDashboardKitPrerequisites() { diff --git a/apps/dev-tool/app/lib/status-dashboard.loader.ts b/apps/dev-tool/app/lib/status-dashboard.loader.ts index 5ed23db5f..8c6c592f1 100644 --- a/apps/dev-tool/app/lib/status-dashboard.loader.ts +++ b/apps/dev-tool/app/lib/status-dashboard.loader.ts @@ -1,14 +1,14 @@ +import { + type KitStatusDeps, + createKitStatusService, +} from '@kit/mcp-server/status'; + import { execFile } from 'node:child_process'; import { access, readFile, stat } from 'node:fs/promises'; import { Socket } from 'node:net'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { - type KitStatusDeps, - createKitStatusService, -} from '@kit/mcp-server/status'; - const execFileAsync = promisify(execFile); export async function loadDashboardKitStatus() { diff --git a/apps/dev-tool/app/page.tsx b/apps/dev-tool/app/page.tsx index c87944362..1bf17b0e0 100644 --- a/apps/dev-tool/app/page.tsx +++ b/apps/dev-tool/app/page.tsx @@ -1,11 +1,11 @@ -import { ServiceCard } from '@/components/status-tile'; - import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Page, PageBody, PageHeader } from '@kit/ui/page'; import { loadDashboardKitPrerequisites } from './lib/prerequisites-dashboard.loader'; import { loadDashboardKitStatus } from './lib/status-dashboard.loader'; +import { ServiceCard } from '@/components/status-tile'; + export default async function DashboardPage() { const [status, prerequisites] = await Promise.all([ loadDashboardKitStatus(), @@ -37,7 +37,6 @@ export default async function DashboardPage() { return ( <Page style={'custom'}> <PageHeader - displaySidebarTrigger={false} title={'Dev Tool'} description={'Kit MCP status for this workspace'} /> diff --git a/apps/dev-tool/app/prds/[filename]/page.tsx b/apps/dev-tool/app/prds/[filename]/page.tsx index f03946d63..f51c6b3f3 100644 --- a/apps/dev-tool/app/prds/[filename]/page.tsx +++ b/apps/dev-tool/app/prds/[filename]/page.tsx @@ -1,5 +1,4 @@ import { Metadata } from 'next'; - import { notFound } from 'next/navigation'; import { loadPRDPageData } from '../_lib/server/prd-page.loader'; diff --git a/apps/dev-tool/app/prds/_lib/schemas/create-prd.schema.ts b/apps/dev-tool/app/prds/_lib/schemas/create-prd.schema.ts index bc9794914..d68ef897c 100644 --- a/apps/dev-tool/app/prds/_lib/schemas/create-prd.schema.ts +++ b/apps/dev-tool/app/prds/_lib/schemas/create-prd.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const CreatePRDSchema = z.object({ title: z @@ -32,4 +32,4 @@ export const CreatePRDSchema = z.object({ .min(1, 'At least one success metric is required'), }); -export type CreatePRDData = z.infer<typeof CreatePRDSchema>; +export type CreatePRDData = z.output<typeof CreatePRDSchema>; diff --git a/apps/dev-tool/app/prds/_lib/server/prd-loader.ts b/apps/dev-tool/app/prds/_lib/server/prd-loader.ts index 5ebecda4f..87fafefee 100644 --- a/apps/dev-tool/app/prds/_lib/server/prd-loader.ts +++ b/apps/dev-tool/app/prds/_lib/server/prd-loader.ts @@ -1,7 +1,7 @@ -import { relative } from 'node:path'; - import { PRDManager } from '@kit/mcp-server/prd-manager'; +import { relative } from 'node:path'; + interface PRDSummary { filename: string; title: string; diff --git a/apps/dev-tool/app/prds/_lib/server/prd-page.loader.ts b/apps/dev-tool/app/prds/_lib/server/prd-page.loader.ts index ae906f0ee..5f9a3be4f 100644 --- a/apps/dev-tool/app/prds/_lib/server/prd-page.loader.ts +++ b/apps/dev-tool/app/prds/_lib/server/prd-page.loader.ts @@ -1,9 +1,8 @@ import 'server-only'; +import { PRDManager } from '@kit/mcp-server/prd-manager'; import { relative } from 'node:path'; -import { PRDManager } from '@kit/mcp-server/prd-manager'; - export interface CustomPhase { id: string; name: string; diff --git a/apps/dev-tool/app/translations/components/translations-comparison.tsx b/apps/dev-tool/app/translations/components/translations-comparison.tsx index 4c5f12ef7..09b8d3fdd 100644 --- a/apps/dev-tool/app/translations/components/translations-comparison.tsx +++ b/apps/dev-tool/app/translations/components/translations-comparison.tsx @@ -131,12 +131,14 @@ export function TranslationsComparison({ <If condition={locales.length > 1}> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline" className="ml-auto"> - Select Languages - <ChevronDownIcon className="ml-2 h-4 w-4" /> - </Button> - </DropdownMenuTrigger> + <DropdownMenuTrigger + render={ + <Button variant="outline" className="ml-auto"> + Select Languages + <ChevronDownIcon className="ml-2 h-4 w-4" /> + </Button> + } + /> <DropdownMenuContent align="end" className="w-[200px]"> {locales.map((locale) => ( diff --git a/apps/dev-tool/app/translations/lib/server-actions.ts b/apps/dev-tool/app/translations/lib/server-actions.ts index 4b4a0ac60..0ec8837bb 100644 --- a/apps/dev-tool/app/translations/lib/server-actions.ts +++ b/apps/dev-tool/app/translations/lib/server-actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache'; -import { z } from 'zod'; +import * as z from 'zod'; import { findWorkspaceRoot } from '@kit/mcp-server/env'; import { diff --git a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx index 1c6df90e0..b540d10b5 100644 --- a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx +++ b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx @@ -5,9 +5,6 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { envVariables } from '@/app/variables/lib/env-variables-model'; -import { updateEnvironmentVariableAction } from '@/app/variables/lib/server-actions'; -import { EnvModeSelector } from '@/components/env-mode-selector'; import { ChevronsUpDownIcon, Copy, @@ -44,6 +41,10 @@ import { cn } from '@kit/ui/utils'; import { AppEnvState, EnvVariableState } from '../lib/types'; import { DynamicFormInput } from './dynamic-form-input'; +import { envVariables } from '@/app/variables/lib/env-variables-model'; +import { updateEnvironmentVariableAction } from '@/app/variables/lib/server-actions'; +import { EnvModeSelector } from '@/components/env-mode-selector'; + export function AppEnvironmentVariablesManager({ state, }: React.PropsWithChildren<{ @@ -731,13 +732,15 @@ function FilterSwitcher(props: { return ( <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline" className="font-normal"> - {buttonLabel()} + <DropdownMenuTrigger + render={ + <Button variant="outline" className="font-normal"> + {buttonLabel()} - <ChevronsUpDownIcon className="text-muted-foreground ml-1 h-3 w-3" /> - </Button> - </DropdownMenuTrigger> + <ChevronsUpDownIcon className="text-muted-foreground ml-1 h-3 w-3" /> + </Button> + } + /> <DropdownMenuContent> <DropdownMenuCheckboxItem @@ -886,38 +889,41 @@ function Summary({ appState }: { appState: AppEnvState }) { <TooltipProvider> <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size={'sm'} - onClick={() => { - let data = ''; + <TooltipTrigger + render={ + <Button + variant="outline" + size={'sm'} + onClick={() => { + let data = ''; - const groups = getGroups(appState, () => true); + const groups = getGroups(appState, () => true); - groups.forEach((group) => { - data += `# ${group.category}\n`; + groups.forEach((group) => { + data += `# ${group.category}\n`; - group.variables.forEach((variable) => { - data += `${variable.key}=${variable.effectiveValue}\n`; + group.variables.forEach((variable) => { + data += `${variable.key}=${variable.effectiveValue}\n`; + }); + + data += '\n'; }); - data += '\n'; - }); + const promise = copyToClipboard(data); - const promise = copyToClipboard(data); - - toast.promise(promise, { - loading: 'Copying environment variables...', - success: 'Environment variables copied to clipboard.', - error: 'Failed to copy environment variables to clipboard', - }); - }} - > - <CopyIcon className={'mr-2 h-4 w-4'} /> - <span>Copy env file to clipboard</span> - </Button> - </TooltipTrigger> + toast.promise(promise, { + loading: 'Copying environment variables...', + success: 'Environment variables copied to clipboard.', + error: + 'Failed to copy environment variables to clipboard', + }); + }} + > + <CopyIcon className={'mr-2 h-4 w-4'} /> + <span>Copy env file to clipboard</span> + </Button> + } + /> <TooltipContent> Copy environment variables to clipboard. You can place it in your diff --git a/apps/dev-tool/app/variables/lib/server-actions.ts b/apps/dev-tool/app/variables/lib/server-actions.ts index bfef031a7..e6beb0d1e 100644 --- a/apps/dev-tool/app/variables/lib/server-actions.ts +++ b/apps/dev-tool/app/variables/lib/server-actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache'; -import { z } from 'zod'; +import * as z from 'zod'; import { createKitEnvDeps, diff --git a/apps/dev-tool/app/variables/page.tsx b/apps/dev-tool/app/variables/page.tsx index ca312dd43..5a28ce550 100644 --- a/apps/dev-tool/app/variables/page.tsx +++ b/apps/dev-tool/app/variables/page.tsx @@ -1,7 +1,5 @@ import { use } from 'react'; -import { EnvMode } from '@/app/variables/lib/types'; - import { createKitEnvDeps, createKitEnvService, @@ -11,6 +9,8 @@ import { Page, PageBody, PageHeader } from '@kit/ui/page'; import { AppEnvironmentVariablesManager } from './components/app-environment-variables-manager'; +import { EnvMode } from '@/app/variables/lib/types'; + type VariablesPageProps = { searchParams: Promise<{ mode?: EnvMode }>; }; diff --git a/apps/dev-tool/components/app-layout.tsx b/apps/dev-tool/components/app-layout.tsx index c327a3e52..0186c3ab5 100644 --- a/apps/dev-tool/components/app-layout.tsx +++ b/apps/dev-tool/components/app-layout.tsx @@ -1,13 +1,13 @@ -import { DevToolSidebar } from '@/components/app-sidebar'; +import { SidebarInset, SidebarProvider } from '@kit/ui/sidebar'; -import { SidebarInset, SidebarProvider } from '@kit/ui/shadcn-sidebar'; +import { DevToolSidebar } from '@/components/app-sidebar'; export function DevToolLayout(props: React.PropsWithChildren) { return ( <SidebarProvider> <DevToolSidebar /> - <SidebarInset>{props.children}</SidebarInset> + <SidebarInset className="px-4">{props.children}</SidebarInset> </SidebarProvider> ); } diff --git a/apps/dev-tool/components/app-sidebar.tsx b/apps/dev-tool/components/app-sidebar.tsx index f47652cb6..507ee0369 100644 --- a/apps/dev-tool/components/app-sidebar.tsx +++ b/apps/dev-tool/components/app-sidebar.tsx @@ -24,7 +24,7 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, -} from '@kit/ui/shadcn-sidebar'; +} from '@kit/ui/sidebar'; import { isRouteActive } from '@kit/ui/utils'; const routes = [ @@ -92,14 +92,14 @@ export function DevToolSidebar({ {route.children.map((child) => ( <SidebarMenuSubItem key={child.path}> <SidebarMenuSubButton - asChild + render={ + <Link href={child.path}> + <child.Icon className="h-4 w-4" /> + <span>{child.label}</span> + </Link> + } isActive={isRouteActive(child.path, pathname, false)} - > - <Link href={child.path}> - <child.Icon className="h-4 w-4" /> - <span>{child.label}</span> - </Link> - </SidebarMenuSubButton> + /> </SidebarMenuSubItem> ))} </SidebarMenuSub> @@ -107,13 +107,13 @@ export function DevToolSidebar({ ) : ( <SidebarMenuButton isActive={isRouteActive(route.path, pathname, false)} - asChild - > - <Link href={route.path}> - <route.Icon className="h-4 w-4" /> - <span>{route.label}</span> - </Link> - </SidebarMenuButton> + render={ + <Link href={route.path}> + <route.Icon className="h-4 w-4" /> + <span>{route.label}</span> + </Link> + } + /> )} </SidebarMenuItem> ))} diff --git a/apps/dev-tool/components/env-mode-selector.tsx b/apps/dev-tool/components/env-mode-selector.tsx index 4c5b6b320..0f727d7a2 100644 --- a/apps/dev-tool/components/env-mode-selector.tsx +++ b/apps/dev-tool/components/env-mode-selector.tsx @@ -2,8 +2,6 @@ import { useRouter } from 'next/navigation'; -import { EnvMode } from '@/app/variables/lib/types'; - import { Select, SelectContent, @@ -12,6 +10,8 @@ import { SelectValue, } from '@kit/ui/select'; +import { EnvMode } from '@/app/variables/lib/types'; + export function EnvModeSelector({ mode }: { mode: EnvMode }) { const router = useRouter(); diff --git a/apps/dev-tool/components/iframe.tsx b/apps/dev-tool/components/iframe.tsx index d6fcb544e..44f6e4e03 100644 --- a/apps/dev-tool/components/iframe.tsx +++ b/apps/dev-tool/components/iframe.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; - import { createPortal } from 'react-dom'; export const IFrame: React.FC< diff --git a/apps/dev-tool/components/root-providers.tsx b/apps/dev-tool/components/root-providers.tsx index 81d84c29c..f72e681b9 100644 --- a/apps/dev-tool/components/root-providers.tsx +++ b/apps/dev-tool/components/root-providers.tsx @@ -3,18 +3,18 @@ import { useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { AbstractIntlMessages } from 'next-intl'; -import { I18nProvider } from '@kit/i18n/provider'; +import { I18nClientProvider } from '@kit/i18n/provider'; import { Toaster } from '@kit/ui/sonner'; -import { i18nResolver } from '../lib/i18n/i18n.resolver'; -import { getI18nSettings } from '../lib/i18n/i18n.settings'; - -export function RootProviders(props: React.PropsWithChildren) { +export function RootProviders( + props: React.PropsWithChildren<{ messages: AbstractIntlMessages }>, +) { return ( - <I18nProvider settings={getI18nSettings('en')} resolver={i18nResolver}> + <I18nClientProvider locale="en" messages={props.messages}> <ReactQueryProvider>{props.children}</ReactQueryProvider> - </I18nProvider> + </I18nClientProvider> ); } diff --git a/apps/dev-tool/components/status-tile.tsx b/apps/dev-tool/components/status-tile.tsx index 3d3048d7c..9b3134d7e 100644 --- a/apps/dev-tool/components/status-tile.tsx +++ b/apps/dev-tool/components/status-tile.tsx @@ -33,7 +33,7 @@ interface ServiceCardProps { export const ServiceCard = ({ name, status }: ServiceCardProps) => { return ( <Card className="w-full max-w-2xl"> - <CardContent className="p-4"> + <CardContent> <div className="space-y-4"> <div className="flex items-center justify-between"> <div className="flex items-center space-x-4"> diff --git a/apps/dev-tool/i18n/request.ts b/apps/dev-tool/i18n/request.ts new file mode 100644 index 000000000..66e6fd28a --- /dev/null +++ b/apps/dev-tool/i18n/request.ts @@ -0,0 +1,26 @@ +import { getRequestConfig } from 'next-intl/server'; + +import account from '../../web/i18n/messages/en/account.json'; +import auth from '../../web/i18n/messages/en/auth.json'; +import billing from '../../web/i18n/messages/en/billing.json'; +import common from '../../web/i18n/messages/en/common.json'; +import marketing from '../../web/i18n/messages/en/marketing.json'; +import teams from '../../web/i18n/messages/en/teams.json'; + +export default getRequestConfig(async () => { + return { + locale: 'en', + messages: { + common, + auth, + account, + teams, + billing, + marketing, + }, + timeZone: 'UTC', + getMessageFallback(info) { + return info.key; + }, + }; +}); diff --git a/apps/dev-tool/lib/i18n/with-i18n.tsx b/apps/dev-tool/lib/i18n/with-i18n.tsx deleted file mode 100644 index 78f8994f5..000000000 --- a/apps/dev-tool/lib/i18n/with-i18n.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createI18nServerInstance } from './i18n.server'; - -type LayoutOrPageComponent<Params> = React.ComponentType<Params>; - -export function withI18n<Params extends object>( - Component: LayoutOrPageComponent<Params>, -) { - return async function I18nServerComponentWrapper(params: Params) { - await createI18nServerInstance(); - - return <Component {...params} />; - }; -} diff --git a/apps/dev-tool/next.config.ts b/apps/dev-tool/next.config.ts index d26e2e010..5ed3eb772 100644 --- a/apps/dev-tool/next.config.ts +++ b/apps/dev-tool/next.config.ts @@ -1,8 +1,12 @@ import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); + const nextConfig: NextConfig = { reactStrictMode: true, - transpilePackages: ['@kit/ui', '@kit/shared'], + transpilePackages: ['@kit/ui', '@kit/shared', '@kit/i18n'], reactCompiler: true, devIndicators: { position: 'bottom-right', @@ -14,4 +18,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/apps/dev-tool/package.json b/apps/dev-tool/package.json index 9e12d89cf..724a0add9 100644 --- a/apps/dev-tool/package.json +++ b/apps/dev-tool/package.json @@ -4,44 +4,41 @@ "private": true, "scripts": { "clean": "git clean -xdf .next .turbo node_modules", - "dev": "next dev --port=3010 | pino-pretty -c", - "format": "prettier --check --write \"**/*.{ts,tsx}\" --ignore-path=\"../../.prettierignore\"" + "dev": "next dev --port=3010 | pino-pretty -c" }, "dependencies": { - "@faker-js/faker": "^10.2.0", - "@hookform/resolvers": "^5.2.2", + "@faker-js/faker": "catalog:", + "@hookform/resolvers": "catalog:", "@tanstack/react-query": "catalog:", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", "nodemailer": "catalog:", "react": "catalog:", "react-dom": "catalog:", - "rxjs": "^7.8.2" + "rxjs": "catalog:" }, "devDependencies": { "@kit/email-templates": "workspace:*", "@kit/i18n": "workspace:*", "@kit/mcp-server": "workspace:*", "@kit/next": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/shared": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", - "@tailwindcss/postcss": "^4.2.1", - "@types/node": "catalog:", + "@tailwindcss/postcss": "catalog:", "@types/nodemailer": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "babel-plugin-react-compiler": "1.0.0", - "pino-pretty": "13.0.0", + "babel-plugin-react-compiler": "catalog:", + "pino-pretty": "catalog:", "react-hook-form": "catalog:", - "recharts": "2.15.3", + "recharts": "catalog:", "tailwindcss": "catalog:", "tw-animate-css": "catalog:", - "typescript": "^5.9.3", + "typescript": "catalog:", "zod": "catalog:" }, - "prettier": "@kit/prettier-config", "browserslist": [ "last 1 versions", "> 0.7%", diff --git a/apps/dev-tool/styles/theme.css b/apps/dev-tool/styles/theme.css index 2baa9eff7..a244799e7 100644 --- a/apps/dev-tool/styles/theme.css +++ b/apps/dev-tool/styles/theme.css @@ -66,26 +66,6 @@ --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; - @keyframes accordion-down { - from { - height: 0; - } - - to { - height: var(--radix-accordion-content-height); - } - } - - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - - to { - height: 0; - } - } - @keyframes fade-up { 0% { opacity: 0; diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 78ef088c1..8c8cf5727 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -1,21 +1,20 @@ { "name": "web-e2e", "version": "1.0.0", + "author": "Makerkit", "main": "index.js", "scripts": { "report": "playwright show-report", "test": "playwright test --max-failures=1", "test:fast": "playwright test --max-failures=1 --workers=16", "test:setup": "playwright test tests/auth.setup.ts", - "test:ui": "playwright test --ui" + "test:ui": "ENABLE_BILLING_TESTS=true playwright test --ui" }, - "author": "Makerkit", "devDependencies": { - "@playwright/test": "^1.58.2", + "@playwright/test": "catalog:", "@supabase/supabase-js": "catalog:", - "@types/node": "catalog:", - "dotenv": "17.3.1", - "node-html-parser": "^7.0.2", - "totp-generator": "^2.0.1" + "dotenv": "catalog:", + "node-html-parser": "catalog:", + "totp-generator": "catalog:" } } diff --git a/apps/e2e/tests/account/account.spec.ts b/apps/e2e/tests/account/account.spec.ts index 49a869955..62fb3f552 100644 --- a/apps/e2e/tests/account/account.spec.ts +++ b/apps/e2e/tests/account/account.spec.ts @@ -38,10 +38,12 @@ test.describe('Account Settings', () => { await Promise.all([request, response]); + await page.locator('[data-test="workspace-dropdown-trigger"]').click(); + await expect(account.getProfileName()).toHaveText(name); }); - test('user can update their email', async ({ page }) => { + test('user can update their email', async () => { const email = account.auth.createRandomEmail(); await account.updateEmail(email); diff --git a/apps/e2e/tests/admin/admin.spec.ts b/apps/e2e/tests/admin/admin.spec.ts index b3a2754f5..b3d9e9324 100644 --- a/apps/e2e/tests/admin/admin.spec.ts +++ b/apps/e2e/tests/admin/admin.spec.ts @@ -34,17 +34,17 @@ test.describe('Admin', () => { await page.goto('/admin'); // Check all stat cards are present - await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible(); + await expect(page.getByText('Users', { exact: true })).toBeVisible(); await expect( - page.getByRole('heading', { name: 'Team Accounts' }), + page.getByText('Team Accounts', { exact: true }), ).toBeVisible(); await expect( - page.getByRole('heading', { name: 'Paying Customers' }), + page.getByText('Paying Customers', { exact: true }), ).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Trials' })).toBeVisible(); + await expect(page.getByText('Trials', { exact: true })).toBeVisible(); // Verify stat values are numbers const stats = await page.$$('.text-3xl.font-bold'); @@ -351,5 +351,5 @@ async function selectAccount(page: Page, email: string) { await link.click(); - await page.waitForURL(/\/admin\/accounts\/[^\/]+/); + await page.waitForURL(/\/admin\/accounts\/[^/]+/); } diff --git a/apps/e2e/tests/auth.setup.ts b/apps/e2e/tests/auth.setup.ts index f8df958a6..4eb753f84 100644 --- a/apps/e2e/tests/auth.setup.ts +++ b/apps/e2e/tests/auth.setup.ts @@ -1,9 +1,10 @@ import { test } from '@playwright/test'; -import { join } from 'node:path'; -import { cwd } from 'node:process'; import { AuthPageObject } from './authentication/auth.po'; +import { join } from 'node:path'; +import { cwd } from 'node:process'; + const testAuthFile = join(cwd(), '.auth/test@makerkit.dev.json'); const ownerAuthFile = join(cwd(), '.auth/owner@makerkit.dev.json'); const superAdminAuthFile = join(cwd(), '.auth/super-admin@makerkit.dev.json'); diff --git a/apps/e2e/tests/authentication/auth.po.ts b/apps/e2e/tests/authentication/auth.po.ts index 547f483d1..7d08d40bd 100644 --- a/apps/e2e/tests/authentication/auth.po.ts +++ b/apps/e2e/tests/authentication/auth.po.ts @@ -31,8 +31,17 @@ export class AuthPageObject { } async signOut() { - await this.page.click('[data-test="account-dropdown-trigger"]'); - await this.page.click('[data-test="account-dropdown-sign-out"]'); + const trigger = this.page.locator( + '[data-test="workspace-dropdown-trigger"], [data-test="account-dropdown-trigger"]', + ); + + await trigger.click(); + + const signOutButton = this.page.locator( + '[data-test="workspace-sign-out"], [data-test="account-dropdown-sign-out"]', + ); + + await signOutButton.click(); } async signIn(params: { email: string; password: string }) { diff --git a/apps/e2e/tests/healthcheck.spec.ts b/apps/e2e/tests/healthcheck.spec.ts index 66163fca2..8a60325b6 100644 --- a/apps/e2e/tests/healthcheck.spec.ts +++ b/apps/e2e/tests/healthcheck.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'; test.describe('Healthcheck endpoint', () => { test('returns healthy status', async ({ request }) => { - const response = await request.get('/healthcheck'); + const response = await request.get('/api/healthcheck'); expect(response.status()).toBe(200); diff --git a/apps/e2e/tests/invitations/invitations.po.ts b/apps/e2e/tests/invitations/invitations.po.ts index e1b8e2d62..f5ba9450e 100644 --- a/apps/e2e/tests/invitations/invitations.po.ts +++ b/apps/e2e/tests/invitations/invitations.po.ts @@ -46,7 +46,7 @@ export class InvitationsPageObject { `[data-test="invite-member-form-item"]:nth-child(${nth}) [data-test="role-selector-trigger"]`, ); - await this.page.click(`[data-test="role-option-${invite.role}"]`); + await this.page.getByRole('option', { name: invite.role }).click(); if (index < invites.length - 1) { await form.locator('[data-test="add-new-invite-button"]').click(); diff --git a/apps/e2e/tests/team-accounts/team-accounts.po.ts b/apps/e2e/tests/team-accounts/team-accounts.po.ts index 9d8db3964..9ec8adfaa 100644 --- a/apps/e2e/tests/team-accounts/team-accounts.po.ts +++ b/apps/e2e/tests/team-accounts/team-accounts.po.ts @@ -36,13 +36,13 @@ export class TeamAccountsPageObject { } getTeamFromSelector(teamName: string) { - return this.page.locator(`[data-test="account-selector-team"]`, { + return this.page.locator('[data-test="workspace-team-item"]', { hasText: teamName, }); } getTeams() { - return this.page.locator('[data-test="account-selector-team"]'); + return this.page.locator('[data-test="workspace-team-item"]'); } goToSettings() { @@ -83,10 +83,11 @@ export class TeamAccountsPageObject { openAccountsSelector() { return expect(async () => { - await this.page.click('[data-test="account-selector-trigger"]'); + await this.page.click('[data-test="workspace-dropdown-trigger"]'); + await this.page.click('[data-test="workspace-switch-submenu"]'); return expect( - this.page.locator('[data-test="account-selector-content"]'), + this.page.locator('[data-test="workspace-switch-content"]'), ).toBeVisible(); }).toPass(); } @@ -115,7 +116,7 @@ export class TeamAccountsPageObject { async createTeam({ teamName, slug } = this.createTeamName()) { await this.openAccountsSelector(); - await this.page.click('[data-test="create-team-account-trigger"]'); + await this.page.click('[data-test="create-team-trigger"]'); await this.page.fill( '[data-test="create-team-form"] [data-test="team-name-input"]', @@ -140,14 +141,13 @@ export class TeamAccountsPageObject { await this.openAccountsSelector(); await expect(this.getTeamFromSelector(teamName)).toBeVisible(); - // Close the selector - await this.page.keyboard.press('Escape'); + await this.closeAccountsSelector(); } async createTeamWithNonLatinName(teamName: string, slug: string) { await this.openAccountsSelector(); - await this.page.click('[data-test="create-team-account-trigger"]'); + await this.page.click('[data-test="create-team-trigger"]'); await this.page.fill( '[data-test="create-team-form"] [data-test="team-name-input"]', @@ -177,8 +177,15 @@ export class TeamAccountsPageObject { await this.openAccountsSelector(); await expect(this.getTeamFromSelector(teamName)).toBeVisible(); - // Close the selector - await this.page.keyboard.press('Escape'); + await this.closeAccountsSelector(); + } + + async closeAccountsSelector() { + await this.page.locator('body').click({ position: { x: 0, y: 0 } }); + + await expect( + this.page.locator('[data-test="workspace-switch-content"]'), + ).toBeHidden(); } getSlugField() { @@ -207,11 +214,10 @@ export class TeamAccountsPageObject { } async deleteAccount(email: string) { + await this.page.click('[data-test="delete-team-trigger"]'); + await this.otp.completeOtpVerification(email); + await expect(async () => { - await this.page.click('[data-test="delete-team-trigger"]'); - - await this.otp.completeOtpVerification(email); - const click = this.page.click( '[data-test="delete-team-form-confirm-button"]', ); diff --git a/apps/e2e/tests/team-accounts/team-accounts.spec.ts b/apps/e2e/tests/team-accounts/team-accounts.spec.ts index 7073be682..8fb9370f5 100644 --- a/apps/e2e/tests/team-accounts/team-accounts.spec.ts +++ b/apps/e2e/tests/team-accounts/team-accounts.spec.ts @@ -88,7 +88,7 @@ test.describe('Team Accounts', () => { await teamAccounts.createTeam(); await teamAccounts.openAccountsSelector(); - await page.click('[data-test="create-team-account-trigger"]'); + await page.click('[data-test="create-team-trigger"]'); await teamAccounts.tryCreateTeam('billing'); @@ -202,7 +202,7 @@ test.describe('Team Accounts', () => { // Use non-Latin name to trigger the slug field visibility await teamAccounts.openAccountsSelector(); - await page.click('[data-test="create-team-account-trigger"]'); + await page.click('[data-test="create-team-trigger"]'); await page.fill( '[data-test="create-team-form"] [data-test="team-name-input"]', diff --git a/apps/e2e/tests/team-billing/team-billing.spec.ts b/apps/e2e/tests/team-billing/team-billing.spec.ts index a3ea2c7fd..0fe050258 100644 --- a/apps/e2e/tests/team-billing/team-billing.spec.ts +++ b/apps/e2e/tests/team-billing/team-billing.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@playwright/test'; -import { AuthPageObject } from '../authentication/auth.po'; import { TeamBillingPageObject } from './team-billing.po'; test.describe('Team Billing', () => { diff --git a/apps/e2e/tests/utils/billing.po.ts b/apps/e2e/tests/utils/billing.po.ts index 569b2e264..33981e217 100644 --- a/apps/e2e/tests/utils/billing.po.ts +++ b/apps/e2e/tests/utils/billing.po.ts @@ -38,9 +38,9 @@ export class BillingPageObject { // wait a bit for the webhook to be processed await this.page.waitForTimeout(1000); - return this.page - .locator('[data-test="checkout-success-back-link"]') - .click(); + await this.page.locator('[data-test="checkout-success-back-link"]').click(); + + await this.page.waitForURL('**/billing'); } proceedToCheckout() { diff --git a/apps/web/.env b/apps/web/.env index 04d8845c2..d76c2c6ea 100644 --- a/apps/web/.env +++ b/apps/web/.env @@ -38,6 +38,7 @@ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true +NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY=false NEXT_PUBLIC_LANGUAGE_PRIORITY=application # NEXTJS diff --git a/apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx b/apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx deleted file mode 100644 index d3c5eeeab..000000000 --- a/apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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); diff --git a/apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx b/apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx deleted file mode 100644 index b8ff856cf..000000000 --- a/apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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); diff --git a/apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx b/apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx deleted file mode 100644 index ee7d0cb5a..000000000 --- a/apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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); diff --git a/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx b/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx deleted file mode 100644 index 53936bb64..000000000 --- a/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { useEffect, useEffectEvent, 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 = useEffectEvent( - () => body && (body.style.overflowY = ''), - ); - - const disableScrolling = useEffectEvent( - () => body && (body.style.overflowY = 'hidden'), - ); - - // enable/disable body scrolling when the docs are toggled - useEffect(() => { - if (isVisible) { - disableScrolling(); - } else { - enableScrolling(); - } - }, [isVisible]); - - // hide docs when navigating to another page - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - 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> - </> - ); -} diff --git a/apps/web/app/(marketing)/docs/layout.tsx b/apps/web/app/(marketing)/docs/layout.tsx deleted file mode 100644 index 2a5e3b914..000000000 --- a/apps/web/app/(marketing)/docs/layout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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 ( - <div className={'container h-[calc(100vh-56px)] overflow-y-hidden'}> - <SidebarProvider - className="lg:gap-x-6" - style={{ '--sidebar-width': '17em' } as React.CSSProperties} - > - <HideFooterStyles /> - - <DocsNavigation pages={tree} /> - - {children} - </SidebarProvider> - </div> - ); -} - -function HideFooterStyles() { - return ( - <style - dangerouslySetInnerHTML={{ - __html: ` - .site-footer { - display: none; - } - `, - }} - /> - ); -} - -export default DocsLayout; diff --git a/apps/web/app/(marketing)/docs/page.tsx b/apps/web/app/(marketing)/docs/page.tsx deleted file mode 100644 index f9b04057c..000000000 --- a/apps/web/app/(marketing)/docs/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 w-full flex-1 flex-col gap-y-6 xl:gap-y-8'}> - <SitePageHeader - title={t('marketing:documentation')} - subtitle={t('marketing:documentationSubtitle')} - /> - - <div className={'relative flex size-full justify-center overflow-y-auto'}> - <DocsCards cards={cards} /> - </div> - </div> - ); -} - -export default withI18n(DocsPage); diff --git a/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx b/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx new file mode 100644 index 000000000..12668463f --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx @@ -0,0 +1,30 @@ +import { getTranslations } from 'next-intl/server'; + +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; + +export async function generateMetadata() { + const t = await getTranslations('marketing'); + + return { + title: t('cookiePolicy'), + }; +} + +async function CookiePolicyPage() { + const t = await getTranslations('marketing'); + + 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 CookiePolicyPage; diff --git a/apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx b/apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx new file mode 100644 index 000000000..bf4afe278 --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx @@ -0,0 +1,30 @@ +import { getTranslations } from 'next-intl/server'; + +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; + +export async function generateMetadata() { + const t = await getTranslations('marketing'); + + return { + title: t('privacyPolicy'), + }; +} + +async function PrivacyPolicyPage() { + const t = await getTranslations('marketing'); + + return ( + <div> + <SitePageHeader + title={t('privacyPolicy')} + subtitle={t('privacyPolicyDescription')} + /> + + <div className={'container mx-auto py-8'}> + <div>Your terms of service content here</div> + </div> + </div> + ); +} + +export default PrivacyPolicyPage; diff --git a/apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx b/apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx new file mode 100644 index 000000000..2c81a9e2b --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx @@ -0,0 +1,30 @@ +import { getTranslations } from 'next-intl/server'; + +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; + +export async function generateMetadata() { + const t = await getTranslations('marketing'); + + return { + title: t('termsOfService'), + }; +} + +async function TermsOfServicePage() { + const t = await getTranslations('marketing'); + + 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 TermsOfServicePage; diff --git a/apps/web/app/(marketing)/_components/site-footer.tsx b/apps/web/app/[locale]/(marketing)/_components/site-footer.tsx similarity index 62% rename from apps/web/app/(marketing)/_components/site-footer.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-footer.tsx index bd8fdb4cd..aade27cfd 100644 --- a/apps/web/app/(marketing)/_components/site-footer.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/site-footer.tsx @@ -8,10 +8,10 @@ export function SiteFooter() { return ( <Footer logo={<AppLogo className="w-[85px] md:w-[95px]" />} - description={<Trans i18nKey="marketing:footerDescription" />} + description={<Trans i18nKey="marketing.footerDescription" />} copyright={ <Trans - i18nKey="marketing:copyright" + i18nKey="marketing.copyright" values={{ product: appConfig.name, year: new Date().getFullYear(), @@ -20,35 +20,35 @@ export function SiteFooter() { } sections={[ { - heading: <Trans i18nKey="marketing:about" />, + heading: <Trans i18nKey="marketing.about" />, links: [ - { href: '/blog', label: <Trans i18nKey="marketing:blog" /> }, - { href: '/contact', label: <Trans i18nKey="marketing:contact" /> }, + { href: '/blog', label: <Trans i18nKey="marketing.blog" /> }, + { href: '/contact', label: <Trans i18nKey="marketing.contact" /> }, ], }, { - heading: <Trans i18nKey="marketing:product" />, + heading: <Trans i18nKey="marketing.product" />, links: [ { href: '/docs', - label: <Trans i18nKey="marketing:documentation" />, + label: <Trans i18nKey="marketing.documentation" />, }, ], }, { - heading: <Trans i18nKey="marketing:legal" />, + heading: <Trans i18nKey="marketing.legal" />, links: [ { href: '/terms-of-service', - label: <Trans i18nKey="marketing:termsOfService" />, + label: <Trans i18nKey="marketing.termsOfService" />, }, { href: '/privacy-policy', - label: <Trans i18nKey="marketing:privacyPolicy" />, + label: <Trans i18nKey="marketing.privacyPolicy" />, }, { href: '/cookie-policy', - label: <Trans i18nKey="marketing:cookiePolicy" />, + label: <Trans i18nKey="marketing.cookiePolicy" />, }, ], }, diff --git a/apps/web/app/(marketing)/_components/site-header-account-section.tsx b/apps/web/app/[locale]/(marketing)/_components/site-header-account-section.tsx similarity index 82% rename from apps/web/app/(marketing)/_components/site-header-account-section.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-header-account-section.tsx index ac688079a..b341dc8dd 100644 --- a/apps/web/app/(marketing)/_components/site-header-account-section.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/site-header-account-section.tsx @@ -31,6 +31,7 @@ const MobileModeToggle = dynamic( const paths = { home: pathsConfig.app.home, + profileSettings: pathsConfig.app.personalAccountSettings, }; const features = { @@ -78,26 +79,28 @@ function AuthButtons() { <div className={'flex items-center gap-x-2'}> <Button + nativeButton={false} className={'hidden md:flex md:text-sm'} - asChild + render={ + <Link href={pathsConfig.auth.signIn}> + <Trans i18nKey={'auth.signIn'} /> + </Link> + } variant={'outline'} size={'sm'} - > - <Link href={pathsConfig.auth.signIn}> - <Trans i18nKey={'auth:signIn'} /> - </Link> - </Button> + /> <Button - asChild + nativeButton={false} + render={ + <Link href={pathsConfig.auth.signUp}> + <Trans i18nKey={'auth.signUp'} /> + </Link> + } className="text-xs md:text-sm" variant={'default'} size={'sm'} - > - <Link href={pathsConfig.auth.signUp}> - <Trans i18nKey={'auth:signUp'} /> - </Link> - </Button> + /> </div> </div> ); diff --git a/apps/web/app/(marketing)/_components/site-header.tsx b/apps/web/app/[locale]/(marketing)/_components/site-header.tsx similarity index 88% rename from apps/web/app/(marketing)/_components/site-header.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-header.tsx index 333c21f9c..1c525ddaa 100644 --- a/apps/web/app/(marketing)/_components/site-header.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/site-header.tsx @@ -9,7 +9,7 @@ import { SiteNavigation } from './site-navigation'; export function SiteHeader(props: { user?: JWTUserData | null }) { return ( <Header - logo={<AppLogo />} + logo={<AppLogo className="mx-auto sm:mx-0" href="/" />} navigation={<SiteNavigation />} actions={<SiteHeaderAccountSection user={props.user ?? null} />} /> diff --git a/apps/web/app/(marketing)/_components/site-navigation-item.tsx b/apps/web/app/[locale]/(marketing)/_components/site-navigation-item.tsx similarity index 100% rename from apps/web/app/(marketing)/_components/site-navigation-item.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-navigation-item.tsx diff --git a/apps/web/app/(marketing)/_components/site-navigation.tsx b/apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx similarity index 80% rename from apps/web/app/(marketing)/_components/site-navigation.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx index c8f055de9..96cd48aeb 100644 --- a/apps/web/app/(marketing)/_components/site-navigation.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx @@ -15,23 +15,23 @@ import { SiteNavigationItem } from './site-navigation-item'; const links = { Blog: { - label: 'marketing:blog', + label: 'marketing.blog', path: '/blog', }, Changelog: { - label: 'marketing:changelog', + label: 'marketing.changelog', path: '/changelog', }, Docs: { - label: 'marketing:documentation', + label: 'marketing.documentation', path: '/docs', }, Pricing: { - label: 'marketing:pricing', + label: 'marketing.pricing', path: '/pricing', }, FAQ: { - label: 'marketing:faq', + label: 'marketing.faq', path: '/faq', }, }; @@ -74,11 +74,14 @@ function MobileDropdown() { 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> + <DropdownMenuItem + key={item.path} + render={ + <Link className={className} href={item.path}> + <Trans i18nKey={item.label} /> + </Link> + } + /> ); })} </DropdownMenuContent> diff --git a/apps/web/app/(marketing)/_components/site-page-header.tsx b/apps/web/app/[locale]/(marketing)/_components/site-page-header.tsx similarity index 100% rename from apps/web/app/(marketing)/_components/site-page-header.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-page-header.tsx diff --git a/apps/web/app/(marketing)/blog/[slug]/page.tsx b/apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx similarity index 94% rename from apps/web/app/(marketing)/blog/[slug]/page.tsx rename to apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx index 8c4ce1c69..a9e3ee22a 100644 --- a/apps/web/app/(marketing)/blog/[slug]/page.tsx +++ b/apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx @@ -1,13 +1,10 @@ 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 { @@ -75,4 +72,4 @@ async function BlogPost({ params }: BlogPageProps) { ); } -export default withI18n(BlogPost); +export default BlogPost; diff --git a/apps/web/app/(marketing)/blog/_components/blog-pagination.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx similarity index 91% rename from apps/web/app/(marketing)/blog/_components/blog-pagination.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx index 60d7b5aee..e9bde4ad1 100644 --- a/apps/web/app/(marketing)/blog/_components/blog-pagination.tsx +++ b/apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx @@ -25,7 +25,7 @@ export function BlogPagination(props: { }} > <ArrowLeft className={'mr-2 h-4'} /> - <Trans i18nKey={'marketing:blogPaginationPrevious'} /> + <Trans i18nKey={'marketing.blogPaginationPrevious'} /> </Button> </If> @@ -36,7 +36,7 @@ export function BlogPagination(props: { navigate(props.currentPage + 1); }} > - <Trans i18nKey={'marketing:blogPaginationNext'} /> + <Trans i18nKey={'marketing.blogPaginationNext'} /> <ArrowRight className={'ml-2 h-4'} /> </Button> </If> diff --git a/apps/web/app/(marketing)/blog/_components/cover-image.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/cover-image.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/cover-image.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/cover-image.tsx diff --git a/apps/web/app/(marketing)/blog/_components/date-formatter.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/date-formatter.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/date-formatter.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/date-formatter.tsx diff --git a/apps/web/app/(marketing)/blog/_components/draft-post-badge.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/draft-post-badge.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/draft-post-badge.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/draft-post-badge.tsx diff --git a/apps/web/app/(marketing)/blog/_components/post-header.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/post-header.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/post-header.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/post-header.tsx diff --git a/apps/web/app/(marketing)/blog/_components/post-preview.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/post-preview.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/post-preview.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/post-preview.tsx diff --git a/apps/web/app/(marketing)/blog/_components/post.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/post.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/post.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/post.tsx diff --git a/apps/web/app/(marketing)/blog/page.tsx b/apps/web/app/[locale]/(marketing)/blog/page.tsx similarity index 83% rename from apps/web/app/(marketing)/blog/page.tsx rename to apps/web/app/[locale]/(marketing)/blog/page.tsx index a7a210042..7aa30f767 100644 --- a/apps/web/app/(marketing)/blog/page.tsx +++ b/apps/web/app/[locale]/(marketing)/blog/page.tsx @@ -2,14 +2,13 @@ import { cache } from 'react'; import type { Metadata } from 'next'; +import { getLocale, getTranslations } from 'next-intl/server'; + 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'; @@ -24,7 +23,8 @@ const BLOG_POSTS_PER_PAGE = 10; export const generateMetadata = async ( props: BlogPageProps, ): Promise<Metadata> => { - const { t, resolvedLanguage } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); + const resolvedLanguage = await getLocale(); const searchParams = await props.searchParams; const limit = BLOG_POSTS_PER_PAGE; @@ -34,8 +34,8 @@ export const generateMetadata = async ( const { total } = await getContentItems(resolvedLanguage, limit, offset); return { - title: t('marketing:blog'), - description: t('marketing:blogSubtitle'), + title: t('blog'), + description: t('blogSubtitle'), pagination: { previous: page > 0 ? `/blog?page=${page - 1}` : undefined, next: offset + limit < total ? `/blog?page=${page + 1}` : undefined, @@ -67,7 +67,8 @@ const getContentItems = cache( ); async function BlogPage(props: BlogPageProps) { - const { t, resolvedLanguage: language } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); + const language = await getLocale(); const searchParams = await props.searchParams; const limit = BLOG_POSTS_PER_PAGE; @@ -82,15 +83,12 @@ async function BlogPage(props: BlogPageProps) { return ( <> - <SitePageHeader - title={t('marketing:blog')} - subtitle={t('marketing:blogSubtitle')} - /> + <SitePageHeader title={t('blog')} subtitle={t('blogSubtitle')} /> <div className={'container flex flex-col space-y-6 py-8'}> <If condition={posts.length > 0} - fallback={<Trans i18nKey="marketing:noPosts" />} + fallback={<Trans i18nKey="marketing.noPosts" />} > <PostsGridList> {posts.map((post, idx) => { @@ -111,7 +109,7 @@ async function BlogPage(props: BlogPageProps) { ); } -export default withI18n(BlogPage); +export default BlogPage; function PostsGridList({ children }: React.PropsWithChildren) { return ( diff --git a/apps/web/app/(marketing)/changelog/[slug]/page.tsx b/apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx similarity index 96% rename from apps/web/app/(marketing)/changelog/[slug]/page.tsx rename to apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx index 5a700a790..6f9fa8b81 100644 --- a/apps/web/app/(marketing)/changelog/[slug]/page.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx @@ -1,13 +1,10 @@ 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 { ChangelogDetail } from '../_components/changelog-detail'; interface ChangelogEntryPageProps { @@ -107,4 +104,4 @@ async function ChangelogEntryPage({ params }: ChangelogEntryPageProps) { ); } -export default withI18n(ChangelogEntryPage); +export default ChangelogEntryPage; diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-detail.tsx similarity index 100% rename from apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-detail.tsx diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-entry.tsx similarity index 100% rename from apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-entry.tsx diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-header.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx similarity index 97% rename from apps/web/app/(marketing)/changelog/_components/changelog-header.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx index 3e689a317..60ce630c3 100644 --- a/apps/web/app/(marketing)/changelog/_components/changelog-header.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx @@ -22,7 +22,7 @@ export function ChangelogHeader({ entry }: { entry: Cms.ContentItem }) { className="text-muted-foreground hover:text-primary flex items-center gap-1.5 text-sm font-medium transition-colors" > <ChevronLeft className="h-4 w-4" /> - <Trans i18nKey="marketing:changelog" /> + <Trans i18nKey="marketing.changelog" /> </Link> </div> </div> diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx similarity index 96% rename from apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx index 3cb115ed2..308cfa6e3 100644 --- a/apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx @@ -24,8 +24,8 @@ function NavLink({ entry, direction }: NavLinkProps) { const Icon = isPrevious ? ChevronLeft : ChevronRight; const i18nKey = isPrevious - ? 'marketing:changelogNavigationPrevious' - : 'marketing:changelogNavigationNext'; + ? 'marketing.changelogNavigationPrevious' + : 'marketing.changelogNavigationNext'; return ( <Link diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-pagination.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-pagination.tsx similarity index 53% rename from apps/web/app/(marketing)/changelog/_components/changelog-pagination.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-pagination.tsx index 700684a38..491a12a4e 100644 --- a/apps/web/app/(marketing)/changelog/_components/changelog-pagination.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-pagination.tsx @@ -22,24 +22,29 @@ export function ChangelogPagination({ return ( <div className="flex justify-end gap-2"> {canGoToPreviousPage && ( - <Button asChild variant="outline" size="sm"> - <Link href={`/changelog?page=${previousPage}`}> - <ArrowLeft className="mr-2 h-3 w-3" /> - <span> - <Trans i18nKey="marketing:changelogPaginationPrevious" /> - </span> - </Link> + <Button + render={<Link href={`/changelog?page=${previousPage}`} />} + variant="outline" + size="sm" + > + <ArrowLeft className="mr-2 h-3 w-3" /> + + <span> + <Trans i18nKey="marketing.changelogPaginationPrevious" /> + </span> </Button> )} {canGoToNextPage && ( - <Button asChild variant="outline" size="sm"> - <Link href={`/changelog?page=${nextPage}`}> - <span> - <Trans i18nKey="marketing:changelogPaginationNext" /> - </span> - <ArrowRight className="ml-2 h-3 w-3" /> - </Link> + <Button + render={<Link href={`/changelog?page=${nextPage}`} />} + variant="outline" + size="sm" + > + <span> + <Trans i18nKey="marketing.changelogPaginationNext" /> + </span> + <ArrowRight className="ml-2 h-3 w-3" /> </Button> )} </div> diff --git a/apps/web/app/(marketing)/changelog/_components/date-badge.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/date-badge.tsx similarity index 100% rename from apps/web/app/(marketing)/changelog/_components/date-badge.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/date-badge.tsx diff --git a/apps/web/app/(marketing)/changelog/page.tsx b/apps/web/app/[locale]/(marketing)/changelog/page.tsx similarity index 83% rename from apps/web/app/(marketing)/changelog/page.tsx rename to apps/web/app/[locale]/(marketing)/changelog/page.tsx index 024f830f2..0fe252efb 100644 --- a/apps/web/app/(marketing)/changelog/page.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/page.tsx @@ -2,14 +2,13 @@ import { cache } from 'react'; import type { Metadata } from 'next'; +import { getLocale, getTranslations } from 'next-intl/server'; + 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'; - import { SitePageHeader } from '../_components/site-page-header'; import { ChangelogEntry } from './_components/changelog-entry'; import { ChangelogPagination } from './_components/changelog-pagination'; @@ -23,7 +22,8 @@ const CHANGELOG_ENTRIES_PER_PAGE = 50; export const generateMetadata = async ( props: ChangelogPageProps, ): Promise<Metadata> => { - const { t, resolvedLanguage } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); + const resolvedLanguage = await getLocale(); const searchParams = await props.searchParams; const limit = CHANGELOG_ENTRIES_PER_PAGE; @@ -33,8 +33,8 @@ export const generateMetadata = async ( const { total } = await getContentItems(resolvedLanguage, limit, offset); return { - title: t('marketing:changelog'), - description: t('marketing:changelogSubtitle'), + title: t('changelog'), + description: t('changelogSubtitle'), pagination: { previous: page > 0 ? `/changelog?page=${page - 1}` : undefined, next: offset + limit < total ? `/changelog?page=${page + 1}` : undefined, @@ -66,7 +66,8 @@ const getContentItems = cache( ); async function ChangelogPage(props: ChangelogPageProps) { - const { t, resolvedLanguage: language } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); + const language = await getLocale(); const searchParams = await props.searchParams; const limit = CHANGELOG_ENTRIES_PER_PAGE; @@ -82,14 +83,14 @@ async function ChangelogPage(props: ChangelogPageProps) { return ( <> <SitePageHeader - title={t('marketing:changelog')} - subtitle={t('marketing:changelogSubtitle')} + title={t('changelog')} + subtitle={t('changelogSubtitle')} /> <div className="container flex max-w-4xl flex-col space-y-12 py-12"> <If condition={entries.length > 0} - fallback={<Trans i18nKey="marketing:noChangelogEntries" />} + fallback={<Trans i18nKey="marketing.noChangelogEntries" />} > <div className="space-y-0"> {entries.map((entry, index) => { @@ -114,4 +115,4 @@ async function ChangelogPage(props: ChangelogPageProps) { ); } -export default withI18n(ChangelogPage); +export default ChangelogPage; diff --git a/apps/web/app/(marketing)/contact/_components/contact-form.tsx b/apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx similarity index 76% rename from apps/web/app/(marketing)/contact/_components/contact-form.tsx rename to apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx index c3d91d608..880bc3cac 100644 --- a/apps/web/app/(marketing)/contact/_components/contact-form.tsx +++ b/apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -23,13 +24,20 @@ import { ContactEmailSchema } from '~/(marketing)/contact/_lib/contact-email.sch 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 { execute, isPending } = useAction(sendContactEmail, { + onSuccess: () => { + setState({ success: true, error: false }); + }, + onError: () => { + setState({ error: true, success: false }); + }, + }); + const form = useForm({ resolver: zodResolver(ContactEmailSchema), defaultValues: { @@ -52,15 +60,7 @@ export function ContactForm() { <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 }); - } - }); + execute(data); })} > <FormField @@ -69,7 +69,7 @@ export function ContactForm() { return ( <FormItem> <FormLabel> - <Trans i18nKey={'marketing:contactName'} /> + <Trans i18nKey={'marketing.contactName'} /> </FormLabel> <FormControl> @@ -88,7 +88,7 @@ export function ContactForm() { return ( <FormItem> <FormLabel> - <Trans i18nKey={'marketing:contactEmail'} /> + <Trans i18nKey={'marketing.contactEmail'} /> </FormLabel> <FormControl> @@ -107,7 +107,7 @@ export function ContactForm() { return ( <FormItem> <FormLabel> - <Trans i18nKey={'marketing:contactMessage'} /> + <Trans i18nKey={'marketing.contactMessage'} /> </FormLabel> <FormControl> @@ -124,8 +124,8 @@ export function ContactForm() { }} /> - <Button disabled={pending} type={'submit'}> - <Trans i18nKey={'marketing:sendMessage'} /> + <Button disabled={isPending} type={'submit'}> + <Trans i18nKey={'marketing.sendMessage'} /> </Button> </form> </Form> @@ -136,11 +136,11 @@ function SuccessAlert() { return ( <Alert variant={'success'}> <AlertTitle> - <Trans i18nKey={'marketing:contactSuccess'} /> + <Trans i18nKey={'marketing.contactSuccess'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'marketing:contactSuccessDescription'} /> + <Trans i18nKey={'marketing.contactSuccessDescription'} /> </AlertDescription> </Alert> ); @@ -150,11 +150,11 @@ function ErrorAlert() { return ( <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'marketing:contactError'} /> + <Trans i18nKey={'marketing.contactError'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'marketing:contactErrorDescription'} /> + <Trans i18nKey={'marketing.contactErrorDescription'} /> </AlertDescription> </Alert> ); diff --git a/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts b/apps/web/app/[locale]/(marketing)/contact/_lib/contact-email.schema.ts similarity index 85% rename from apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts rename to apps/web/app/[locale]/(marketing)/contact/_lib/contact-email.schema.ts index 4e629db2e..26f9233b1 100644 --- a/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts +++ b/apps/web/app/[locale]/(marketing)/contact/_lib/contact-email.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const ContactEmailSchema = z.object({ name: z.string().min(1).max(200), diff --git a/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts b/apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts similarity index 68% rename from apps/web/app/(marketing)/contact/_lib/server/server-actions.ts rename to apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts index 0ce5e5f29..060023a60 100644 --- a/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts +++ b/apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts @@ -1,30 +1,29 @@ 'use server'; -import { z } from 'zod'; +import * as z from 'zod'; import { getMailer } from '@kit/mailers'; -import { enhanceAction } from '@kit/next/actions'; +import { publicActionClient } from '@kit/next/safe-action'; import { ContactEmailSchema } from '../contact-email.schema'; const contactEmail = z .string({ - description: `The email where you want to receive the contact form submissions.`, - required_error: + 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: + error: 'Sender email is required. Please use the environment variable EMAIL_SENDER.', }) .parse(process.env.EMAIL_SENDER); -export const sendContactEmail = enhanceAction( - async (data) => { +export const sendContactEmail = publicActionClient + .inputSchema(ContactEmailSchema) + .action(async ({ parsedInput: data }) => { const mailer = await getMailer(); await mailer.sendEmail({ @@ -43,9 +42,4 @@ export const sendContactEmail = enhanceAction( }); return {}; - }, - { - schema: ContactEmailSchema, - auth: false, - }, -); + }); diff --git a/apps/web/app/(marketing)/contact/page.tsx b/apps/web/app/[locale]/(marketing)/contact/page.tsx similarity index 62% rename from apps/web/app/(marketing)/contact/page.tsx rename to apps/web/app/[locale]/(marketing)/contact/page.tsx index 7cf1107e8..8e7a6fed3 100644 --- a/apps/web/app/(marketing)/contact/page.tsx +++ b/apps/web/app/[locale]/(marketing)/contact/page.tsx @@ -1,28 +1,25 @@ +import { getTranslations } from 'next-intl/server'; + 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(); + const t = await getTranslations('marketing'); return { - title: t('marketing:contact'), + title: t('contact'), }; } async function ContactPage() { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); return ( <div> - <SitePageHeader - title={t(`marketing:contact`)} - subtitle={t(`marketing:contactDescription`)} - /> + <SitePageHeader title={t(`contact`)} subtitle={t(`contactDescription`)} /> <div className={'container mx-auto'}> <div @@ -35,11 +32,11 @@ async function ContactPage() { > <div> <Heading level={3}> - <Trans i18nKey={'marketing:contactHeading'} /> + <Trans i18nKey={'marketing.contactHeading'} /> </Heading> <p className={'text-muted-foreground'}> - <Trans i18nKey={'marketing:contactSubheading'} /> + <Trans i18nKey={'marketing.contactSubheading'} /> </p> </div> @@ -51,4 +48,4 @@ async function ContactPage() { ); } -export default withI18n(ContactPage); +export default ContactPage; diff --git a/apps/web/app/(marketing)/faq/page.tsx b/apps/web/app/[locale]/(marketing)/faq/page.tsx similarity index 81% rename from apps/web/app/(marketing)/faq/page.tsx rename to apps/web/app/[locale]/(marketing)/faq/page.tsx index 5798723e1..a5808616a 100644 --- a/apps/web/app/(marketing)/faq/page.tsx +++ b/apps/web/app/[locale]/(marketing)/faq/page.tsx @@ -1,31 +1,30 @@ import Link from 'next/link'; import { ArrowRight, ChevronDown } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; 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(); + const t = await getTranslations('marketing'); return { - title: t('marketing:faq'), + title: t('faq'), }; }; async function FAQPage() { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); // replace this content with translations const faqItems = [ { - // or: t('marketing:faq.question1') + // or: t('faq.question1') question: `Do you offer a free trial?`, - // or: t('marketing:faq.answer1') + // or: t('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.`, }, { @@ -74,10 +73,7 @@ async function FAQPage() { /> <div className={'flex flex-col space-y-4 xl:space-y-8'}> - <SitePageHeader - title={t('marketing:faq')} - subtitle={t('marketing:faqSubtitle')} - /> + <SitePageHeader title={t('faq')} subtitle={t('faqSubtitle')} /> <div className={'container flex flex-col items-center space-y-8 pb-16'}> <div className="divide-border flex w-full max-w-xl flex-col divide-y divide-dashed rounded-md border"> @@ -87,14 +83,16 @@ async function FAQPage() { </div> <div> - <Button asChild variant={'outline'}> - <Link href={'/contact'}> - <span> - <Trans i18nKey={'marketing:contactFaq'} /> - </span> + <Button + nativeButton={false} + render={<Link href={'/contact'} />} + variant={'link'} + > + <span> + <Trans i18nKey={'marketing.contactFaq'} /> + </span> - <ArrowRight className={'ml-2 w-4'} /> - </Link> + <ArrowRight className={'ml-2 w-4'} /> </Button> </div> </div> @@ -103,7 +101,7 @@ async function FAQPage() { ); } -export default withI18n(FAQPage); +export default FAQPage; function FaqItem({ item, diff --git a/apps/web/app/(marketing)/layout.tsx b/apps/web/app/[locale]/(marketing)/layout.tsx similarity index 87% rename from apps/web/app/(marketing)/layout.tsx rename to apps/web/app/[locale]/(marketing)/layout.tsx index 0c2e5d282..25f662cad 100644 --- a/apps/web/app/(marketing)/layout.tsx +++ b/apps/web/app/[locale]/(marketing)/layout.tsx @@ -3,7 +3,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { SiteFooter } from '~/(marketing)/_components/site-footer'; import { SiteHeader } from '~/(marketing)/_components/site-header'; -import { withI18n } from '~/lib/i18n/with-i18n'; async function SiteLayout(props: React.PropsWithChildren) { const client = getSupabaseServerClient(); @@ -20,4 +19,4 @@ async function SiteLayout(props: React.PropsWithChildren) { ); } -export default withI18n(SiteLayout); +export default SiteLayout; diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/[locale]/(marketing)/page.tsx similarity index 94% rename from apps/web/app/(marketing)/page.tsx rename to apps/web/app/[locale]/(marketing)/page.tsx index 91889c6c9..ab320ca08 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/[locale]/(marketing)/page.tsx @@ -20,7 +20,6 @@ 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 ( @@ -30,11 +29,13 @@ function Home() { 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> + <PillActionButton + render={ + <Link href={'/auth/sign-up'}> + <ArrowRightIcon className={'h-4 w-4'} /> + </Link> + } + /> </Pill> } title={ @@ -170,7 +171,7 @@ function Home() { ); } -export default withI18n(Home); +export default Home; function MainCallToActionButton() { return ( @@ -179,7 +180,7 @@ function MainCallToActionButton() { <Link href={'/auth/sign-up'}> <span className={'flex items-center space-x-0.5'}> <span> - <Trans i18nKey={'common:getStarted'} /> + <Trans i18nKey={'common.getStarted'} /> </span> <ArrowRightIcon @@ -194,7 +195,7 @@ function MainCallToActionButton() { <CtaButton variant={'link'} className="h-10 text-sm"> <Link href={'/pricing'}> - <Trans i18nKey={'common:pricing'} /> + <Trans i18nKey={'common.pricing'} /> </Link> </CtaButton> </div> diff --git a/apps/web/app/(marketing)/pricing/page.tsx b/apps/web/app/[locale]/(marketing)/pricing/page.tsx similarity index 61% rename from apps/web/app/(marketing)/pricing/page.tsx rename to apps/web/app/[locale]/(marketing)/pricing/page.tsx index 87356579f..b16b2fe97 100644 --- a/apps/web/app/(marketing)/pricing/page.tsx +++ b/apps/web/app/[locale]/(marketing)/pricing/page.tsx @@ -1,16 +1,16 @@ +import { getTranslations } from 'next-intl/server'; + 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(); + const t = await getTranslations('marketing'); return { - title: t('marketing:pricing'), + title: t('pricing'), }; }; @@ -20,14 +20,11 @@ const paths = { }; async function PricingPage() { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); return ( <div className={'flex flex-col space-y-8'}> - <SitePageHeader - title={t('marketing:pricing')} - subtitle={t('marketing:pricingSubtitle')} - /> + <SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} /> <div className={'container mx-auto pb-8 xl:pb-16'}> <PricingTable paths={paths} config={billingConfig} /> @@ -36,4 +33,4 @@ async function PricingPage() { ); } -export default withI18n(PricingPage); +export default PricingPage; diff --git a/apps/web/app/admin/AGENTS.md b/apps/web/app/[locale]/admin/AGENTS.md similarity index 100% rename from apps/web/app/admin/AGENTS.md rename to apps/web/app/[locale]/admin/AGENTS.md diff --git a/apps/web/app/admin/CLAUDE.md b/apps/web/app/[locale]/admin/CLAUDE.md similarity index 100% rename from apps/web/app/admin/CLAUDE.md rename to apps/web/app/[locale]/admin/CLAUDE.md diff --git a/apps/web/app/admin/_components/admin-sidebar.tsx b/apps/web/app/[locale]/admin/_components/admin-sidebar.tsx similarity index 64% rename from apps/web/app/admin/_components/admin-sidebar.tsx rename to apps/web/app/[locale]/admin/_components/admin-sidebar.tsx index d7d655ce7..8133f2619 100644 --- a/apps/web/app/admin/_components/admin-sidebar.tsx +++ b/apps/web/app/[locale]/admin/_components/admin-sidebar.tsx @@ -15,7 +15,7 @@ import { SidebarHeader, SidebarMenu, SidebarMenuButton, -} from '@kit/ui/shadcn-sidebar'; +} from '@kit/ui/sidebar'; import { AppLogo } from '~/components/app-logo'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; @@ -24,7 +24,7 @@ export function AdminSidebar() { const path = usePathname(); return ( - <Sidebar collapsible="icon"> + <Sidebar variant="floating" collapsible="icon"> <SidebarHeader className={'m-2'}> <AppLogo href={'/admin'} className="max-w-full" /> </SidebarHeader> @@ -35,25 +35,26 @@ export function AdminSidebar() { <SidebarGroupContent> <SidebarMenu> - <SidebarMenuButton isActive={path === '/admin'} asChild> - <Link className={'flex gap-2.5'} href={'/admin'}> - <LayoutDashboard className={'h-4'} /> - <span>Dashboard</span> - </Link> + <SidebarMenuButton + isActive={path === '/admin'} + render={<Link className={'flex gap-2.5'} href={'/admin'} />} + > + <LayoutDashboard className={'h-4'} /> + <span>Dashboard</span> </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> + render={ + <Link + className={'flex size-full gap-2.5'} + href={'/admin/accounts'} + > + <Users className={'h-4'} /> + <span>Accounts</span> + </Link> + } + /> </SidebarMenu> </SidebarGroupContent> </SidebarGroup> diff --git a/apps/web/app/admin/_components/mobile-navigation.tsx b/apps/web/app/[locale]/admin/_components/mobile-navigation.tsx similarity index 100% rename from apps/web/app/admin/_components/mobile-navigation.tsx rename to apps/web/app/[locale]/admin/_components/mobile-navigation.tsx diff --git a/apps/web/app/admin/accounts/[id]/page.tsx b/apps/web/app/[locale]/admin/accounts/[id]/page.tsx similarity index 100% rename from apps/web/app/admin/accounts/[id]/page.tsx rename to apps/web/app/[locale]/admin/accounts/[id]/page.tsx diff --git a/apps/web/app/admin/accounts/loading.tsx b/apps/web/app/[locale]/admin/accounts/loading.tsx similarity index 100% rename from apps/web/app/admin/accounts/loading.tsx rename to apps/web/app/[locale]/admin/accounts/loading.tsx diff --git a/apps/web/app/admin/accounts/page.tsx b/apps/web/app/[locale]/admin/accounts/page.tsx similarity index 56% rename from apps/web/app/admin/accounts/page.tsx rename to apps/web/app/[locale]/admin/accounts/page.tsx index d83191bf2..f75471d25 100644 --- a/apps/web/app/admin/accounts/page.tsx +++ b/apps/web/app/[locale]/admin/accounts/page.tsx @@ -28,7 +28,7 @@ async function AccountsPage(props: AdminAccountsPageProps) { const page = searchParams.page ? parseInt(searchParams.page) : 1; return ( - <> + <PageBody> <PageHeader description={<AppBreadcrumbs />}> <div className="flex justify-end"> <AdminCreateUserDialog> @@ -37,42 +37,40 @@ async function AccountsPage(props: AdminAccountsPageProps) { </div> </PageHeader> - <PageBody> - <ServerDataLoader - table={'accounts'} - client={client} - page={page} - where={(queryBuilder) => { - const { account_type: type, query } = searchParams; + <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 (type && type !== 'all') { + queryBuilder.eq('is_personal_account', type === 'personal'); + } - if (query) { - queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%`); - } + 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> - </> + 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> ); } diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/[locale]/admin/layout.tsx similarity index 84% rename from apps/web/app/admin/layout.tsx rename to apps/web/app/[locale]/admin/layout.tsx index 41f8df01d..d0d9663a7 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/[locale]/admin/layout.tsx @@ -3,7 +3,7 @@ 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 { SidebarProvider } from '@kit/ui/sidebar'; import { AdminSidebar } from '~/admin/_components/admin-sidebar'; import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation'; @@ -36,9 +36,9 @@ export default function AdminLayout(props: React.PropsWithChildren) { async function getLayoutState() { const cookieStore = await cookies(); - const sidebarOpenCookie = cookieStore.get('sidebar:state'); + const sidebarOpenCookie = cookieStore.get('sidebar_state'); return { - open: sidebarOpenCookie?.value !== 'true', + open: sidebarOpenCookie?.value === 'true', }; } diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/[locale]/admin/page.tsx similarity index 80% rename from apps/web/app/admin/page.tsx rename to apps/web/app/[locale]/admin/page.tsx index 8be8bd84c..10894d279 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/[locale]/admin/page.tsx @@ -4,13 +4,11 @@ import { PageBody, PageHeader } from '@kit/ui/page'; function AdminPage() { return ( - <> + <PageBody> <PageHeader description={`Super Admin`} /> - <PageBody> - <AdminDashboard /> - </PageBody> - </> + <AdminDashboard /> + </PageBody> ); } diff --git a/apps/web/app/auth/callback/error/page.tsx b/apps/web/app/[locale]/auth/callback/error/page.tsx similarity index 80% rename from apps/web/app/auth/callback/error/page.tsx rename to apps/web/app/[locale]/auth/callback/error/page.tsx index ef7084620..a471cb242 100644 --- a/apps/web/app/auth/callback/error/page.tsx +++ b/apps/web/app/[locale]/auth/callback/error/page.tsx @@ -8,7 +8,6 @@ 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<{ @@ -28,11 +27,11 @@ async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) { <div className={'flex flex-col space-y-4 py-4'}> <Alert variant={'warning'}> <AlertTitle> - <Trans i18nKey={'auth:authenticationErrorAlertHeading'} /> + <Trans i18nKey={'auth.authenticationErrorAlertHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={error ?? 'auth:authenticationErrorAlertBody'} /> + <Trans i18nKey={error ?? 'auth.authenticationErrorAlertBody'} /> </AlertDescription> </Alert> @@ -53,6 +52,7 @@ function AuthCallbackForm(props: { switch (props.code) { case 'otp_expired': return <ResendAuthLinkForm redirectPath={props.redirectPath} />; + default: return <SignInButton signInPath={props.signInPath} />; } @@ -60,12 +60,15 @@ function AuthCallbackForm(props: { function SignInButton(props: { signInPath: string }) { return ( - <Button className={'w-full'} asChild> - <Link href={props.signInPath}> - <Trans i18nKey={'auth:signIn'} /> - </Link> - </Button> + <Button + className={'w-full'} + render={ + <Link href={props.signInPath}> + <Trans i18nKey={'auth.signIn'} /> + </Link> + } + /> ); } -export default withI18n(AuthCallbackErrorPage); +export default AuthCallbackErrorPage; diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/[locale]/auth/callback/route.ts similarity index 100% rename from apps/web/app/auth/callback/route.ts rename to apps/web/app/[locale]/auth/callback/route.ts diff --git a/apps/web/app/auth/confirm/route.ts b/apps/web/app/[locale]/auth/confirm/route.ts similarity index 100% rename from apps/web/app/auth/confirm/route.ts rename to apps/web/app/[locale]/auth/confirm/route.ts diff --git a/apps/web/app/auth/layout.tsx b/apps/web/app/[locale]/auth/layout.tsx similarity index 100% rename from apps/web/app/auth/layout.tsx rename to apps/web/app/[locale]/auth/layout.tsx diff --git a/apps/web/app/auth/loading.tsx b/apps/web/app/[locale]/auth/loading.tsx similarity index 100% rename from apps/web/app/auth/loading.tsx rename to apps/web/app/[locale]/auth/loading.tsx diff --git a/apps/web/app/auth/password-reset/page.tsx b/apps/web/app/[locale]/auth/password-reset/page.tsx similarity index 62% rename from apps/web/app/auth/password-reset/page.tsx rename to apps/web/app/[locale]/auth/password-reset/page.tsx index 14473732b..ec2704e41 100644 --- a/apps/web/app/auth/password-reset/page.tsx +++ b/apps/web/app/[locale]/auth/password-reset/page.tsx @@ -1,19 +1,19 @@ import Link from 'next/link'; +import { getTranslations } from 'next-intl/server'; + 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(); + const t = await getTranslations('auth'); return { - title: t('auth:passwordResetLabel'), + title: t('passwordResetLabel'), }; }; @@ -25,11 +25,11 @@ function PasswordResetPage() { <> <div className={'flex flex-col items-center gap-1'}> <Heading level={4} className={'tracking-tight'}> - <Trans i18nKey={'auth:passwordResetLabel'} /> + <Trans i18nKey={'auth.passwordResetLabel'} /> </Heading> <p className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'auth:passwordResetSubheading'} /> + <Trans i18nKey={'auth.passwordResetSubheading'} /> </p> </div> @@ -37,15 +37,20 @@ function PasswordResetPage() { <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> + <Button + nativeButton={false} + variant={'link'} + size={'sm'} + render={ + <Link href={signIn}> + <Trans i18nKey={'auth.passwordRecoveredQuestion'} /> + </Link> + } + /> </div> </div> </> ); } -export default withI18n(PasswordResetPage); +export default PasswordResetPage; diff --git a/apps/web/app/auth/sign-in/page.tsx b/apps/web/app/[locale]/auth/sign-in/page.tsx similarity index 69% rename from apps/web/app/auth/sign-in/page.tsx rename to apps/web/app/[locale]/auth/sign-in/page.tsx index ccb552613..1ec3c9263 100644 --- a/apps/web/app/auth/sign-in/page.tsx +++ b/apps/web/app/[locale]/auth/sign-in/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +import { getTranslations } from 'next-intl/server'; + import { SignInMethodsContainer } from '@kit/auth/sign-in'; import { getSafeRedirectPath } from '@kit/shared/utils'; import { Button } from '@kit/ui/button'; @@ -8,8 +10,6 @@ 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<{ @@ -18,10 +18,10 @@ interface SignInPageProps { } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('auth'); return { - title: i18n.t('auth:signIn'), + title: t('signIn'), }; }; @@ -38,11 +38,11 @@ async function SignInPage({ searchParams }: SignInPageProps) { <> <div className={'flex flex-col items-center gap-1'}> <Heading level={4} className={'tracking-tight'}> - <Trans i18nKey={'auth:signInHeading'} /> + <Trans i18nKey={'auth.signInHeading'} /> </Heading> <p className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'auth:signInSubheading'} /> + <Trans i18nKey={'auth.signInSubheading'} /> </p> </div> @@ -53,14 +53,19 @@ async function SignInPage({ searchParams }: SignInPageProps) { /> <div className={'flex justify-center'}> - <Button asChild variant={'link'} size={'sm'}> - <Link href={pathsConfig.auth.signUp} prefetch={true}> - <Trans i18nKey={'auth:doNotHaveAccountYet'} /> - </Link> - </Button> + <Button + nativeButton={false} + variant={'link'} + size={'sm'} + render={ + <Link href={pathsConfig.auth.signUp} prefetch={true}> + <Trans i18nKey={'auth.doNotHaveAccountYet'} /> + </Link> + } + /> </div> </> ); } -export default withI18n(SignInPage); +export default SignInPage; diff --git a/apps/web/app/auth/sign-up/page.tsx b/apps/web/app/[locale]/auth/sign-up/page.tsx similarity index 65% rename from apps/web/app/auth/sign-up/page.tsx rename to apps/web/app/[locale]/auth/sign-up/page.tsx index 7e47c883b..0b68c39d2 100644 --- a/apps/web/app/auth/sign-up/page.tsx +++ b/apps/web/app/[locale]/auth/sign-up/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +import { getTranslations } from 'next-intl/server'; + import { SignUpMethodsContainer } from '@kit/auth/sign-up'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; @@ -7,14 +9,12 @@ 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(); + const t = await getTranslations('auth'); return { - title: i18n.t('auth:signUp'), + title: t('signUp'), }; }; @@ -28,11 +28,11 @@ async function SignUpPage() { <> <div className={'flex flex-col items-center gap-1'}> <Heading level={4} className={'tracking-tight'}> - <Trans i18nKey={'auth:signUpHeading'} /> + <Trans i18nKey={'auth.signUpHeading'} /> </Heading> <p className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'auth:signUpSubheading'} /> + <Trans i18nKey={'auth.signUpSubheading'} /> </p> </div> @@ -44,14 +44,19 @@ async function SignUpPage() { /> <div className={'flex justify-center'}> - <Button asChild variant={'link'} size={'sm'}> - <Link href={pathsConfig.auth.signIn} prefetch={true}> - <Trans i18nKey={'auth:alreadyHaveAnAccount'} /> - </Link> - </Button> + <Button + render={ + <Link href={pathsConfig.auth.signIn} prefetch={true}> + <Trans i18nKey={'auth.alreadyHaveAnAccount'} /> + </Link> + } + variant={'link'} + size={'sm'} + nativeButton={false} + /> </div> </> ); } -export default withI18n(SignUpPage); +export default SignUpPage; diff --git a/apps/web/app/auth/verify/page.tsx b/apps/web/app/[locale]/auth/verify/page.tsx similarity index 82% rename from apps/web/app/auth/verify/page.tsx rename to apps/web/app/[locale]/auth/verify/page.tsx index c7b731502..fd16f9361 100644 --- a/apps/web/app/auth/verify/page.tsx +++ b/apps/web/app/[locale]/auth/verify/page.tsx @@ -1,13 +1,13 @@ import { redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; + import { MultiFactorChallengeContainer } from '@kit/auth/mfa'; import { getSafeRedirectPath } from '@kit/shared/utils'; 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<{ @@ -16,10 +16,10 @@ interface Props { } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('auth'); return { - title: i18n.t('auth:signIn'), + title: t('signIn'), }; }; @@ -51,4 +51,4 @@ async function VerifyPage(props: Props) { ); } -export default withI18n(VerifyPage); +export default VerifyPage; diff --git a/apps/web/app/(marketing)/docs/[...slug]/page.tsx b/apps/web/app/[locale]/docs/[...slug]/page.tsx similarity index 63% rename from apps/web/app/(marketing)/docs/[...slug]/page.tsx rename to apps/web/app/[locale]/docs/[...slug]/page.tsx index 3b030aba3..73493be0d 100644 --- a/apps/web/app/(marketing)/docs/[...slug]/page.tsx +++ b/apps/web/app/[locale]/docs/[...slug]/page.tsx @@ -1,17 +1,11 @@ import { cache } from 'react'; +import type { Metadata } from 'next'; 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'; - const getPageBySlug = cache(pageLoader); interface DocumentationPageProps { @@ -24,7 +18,9 @@ async function pageLoader(slug: string) { return client.getContentItemBySlug({ slug, collection: 'documentation' }); } -export const generateMetadata = async ({ params }: DocumentationPageProps) => { +export async function generateMetadata({ + params, +}: DocumentationPageProps): Promise<Metadata> { const slug = (await params).slug.join('/'); const page = await getPageBySlug(slug); @@ -38,7 +34,7 @@ export const generateMetadata = async ({ params }: DocumentationPageProps) => { title, description, }; -}; +} async function DocumentationPage({ params }: DocumentationPageProps) { const slug = (await params).slug.join('/'); @@ -51,17 +47,13 @@ async function DocumentationPage({ params }: DocumentationPageProps) { const description = page?.description ?? ''; return ( - <div className={'flex flex-1 flex-col gap-y-4 overflow-y-hidden'}> - <div className={'flex size-full overflow-y-hidden'}> - <div className="relative size-full"> + <div className={'container flex flex-1 flex-col gap-y-4'}> + <div className={'flex flex-1'}> + <div className="relative mx-auto max-w-3xl flex-1 flex-col overflow-x-hidden"> <article - className={cn( - 'absolute size-full w-full gap-y-12 overflow-y-auto pt-4 pb-36', - )} + className={cn('mx-auto h-full w-full flex-1 gap-y-12 pt-4 pb-36')} > - <section - className={'flex flex-col gap-y-1 border-b border-dashed pb-4'} - > + <section className={'mt-4 flex flex-col gap-y-1 pb-4'}> <h1 className={ 'text-foreground text-3xl font-semibold tracking-tighter' @@ -81,14 +73,8 @@ async function DocumentationPage({ params }: DocumentationPageProps) { </article> </div> </div> - - <If condition={page.children.length > 0}> - <Separator /> - - <DocsCards cards={page.children ?? []} /> - </If> </div> ); } -export default withI18n(DocumentationPage); +export default DocumentationPage; diff --git a/apps/web/app/[locale]/docs/_components/docs-back-button.tsx b/apps/web/app/[locale]/docs/_components/docs-back-button.tsx new file mode 100644 index 000000000..603164744 --- /dev/null +++ b/apps/web/app/[locale]/docs/_components/docs-back-button.tsx @@ -0,0 +1,33 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; + +import { ArrowLeftIcon } from 'lucide-react'; + +import { getSafeRedirectPath } from '@kit/shared/utils'; +import { Button } from '@kit/ui/button'; +import { Trans } from '@kit/ui/trans'; + +import appConfig from '~/config/app.config'; + +export function DocsBackButton() { + const searchParams = useSearchParams(); + const returnPath = searchParams.get('returnPath'); + const parsedPath = getSafeRedirectPath(returnPath, '/'); + + return ( + <Button + nativeButton={false} + variant="link" + render={ + <Link href={parsedPath || '/'}> + <ArrowLeftIcon className="size-4" />{' '} + <span className={'hidden sm:block'}> + <Trans i18nKey="common.back" values={{ product: appConfig.name }} /> + </span> + </Link> + } + /> + ); +} diff --git a/apps/web/app/(marketing)/docs/_components/docs-card.tsx b/apps/web/app/[locale]/docs/_components/docs-card.tsx similarity index 100% rename from apps/web/app/(marketing)/docs/_components/docs-card.tsx rename to apps/web/app/[locale]/docs/_components/docs-card.tsx diff --git a/apps/web/app/(marketing)/docs/_components/docs-cards.tsx b/apps/web/app/[locale]/docs/_components/docs-cards.tsx similarity index 100% rename from apps/web/app/(marketing)/docs/_components/docs-cards.tsx rename to apps/web/app/[locale]/docs/_components/docs-cards.tsx diff --git a/apps/web/app/[locale]/docs/_components/docs-header.tsx b/apps/web/app/[locale]/docs/_components/docs-header.tsx new file mode 100644 index 000000000..39774acd4 --- /dev/null +++ b/apps/web/app/[locale]/docs/_components/docs-header.tsx @@ -0,0 +1,36 @@ +import Link from 'next/link'; + +import { Header } from '@kit/ui/marketing'; +import { Separator } from '@kit/ui/separator'; +import { Trans } from '@kit/ui/trans'; + +import { AppLogo } from '~/components/app-logo'; + +import { DocsBackButton } from './docs-back-button'; + +export function DocsHeader() { + return ( + <Header + logo={ + <div className={'flex w-full flex-1 justify-between'}> + <div className="flex items-center gap-x-4"> + <AppLogo href="/" /> + + <Separator orientation="vertical" /> + + <Link + href="/help" + className="font-semibold tracking-tight hover:underline" + > + <Trans i18nKey="marketing.documentation" /> + </Link> + </div> + + <DocsBackButton /> + </div> + } + centered={false} + className="border-border/50 border-b px-4" + /> + ); +} diff --git a/apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx b/apps/web/app/[locale]/docs/_components/docs-nav-link.tsx similarity index 64% rename from apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx rename to apps/web/app/[locale]/docs/_components/docs-nav-link.tsx index 4e0bbc516..b11a34887 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx +++ b/apps/web/app/[locale]/docs/_components/docs-nav-link.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/shadcn-sidebar'; +import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/sidebar'; import { cn, isRouteActive } from '@kit/ui/utils'; export function DocsNavLink({ @@ -12,20 +12,18 @@ export function DocsNavLink({ children, }: React.PropsWithChildren<{ label: string; url: string }>) { const currentPath = usePathname(); - const isCurrent = isRouteActive(url, currentPath, true); + const isCurrent = isRouteActive(url, currentPath); return ( <SidebarMenuItem> <SidebarMenuButton - asChild + render={<Link href={url} />} isActive={isCurrent} className={cn('text-secondary-foreground transition-all')} > - <Link href={url}> - <span className="block max-w-full truncate">{label}</span> + <span className="block max-w-full truncate">{label}</span> - {children} - </Link> + {children} </SidebarMenuButton> </SidebarMenuItem> ); diff --git a/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx b/apps/web/app/[locale]/docs/_components/docs-navigation-collapsible.tsx similarity index 91% rename from apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx rename to apps/web/app/[locale]/docs/_components/docs-navigation-collapsible.tsx index 9bd35f05f..1480101df 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx +++ b/apps/web/app/[locale]/docs/_components/docs-navigation-collapsible.tsx @@ -16,7 +16,7 @@ export function DocsNavigationCollapsible( const prefix = props.prefix; const isChildActive = props.node.children.some((child) => - isRouteActive(prefix + '/' + child.url, currentPath, false), + isRouteActive(prefix + '/' + child.url, currentPath), ); return ( diff --git a/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx b/apps/web/app/[locale]/docs/_components/docs-navigation.tsx similarity index 68% rename from apps/web/app/(marketing)/docs/_components/docs-navigation.tsx rename to apps/web/app/[locale]/docs/_components/docs-navigation.tsx index d07eb6dd9..a91d85690 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx +++ b/apps/web/app/[locale]/docs/_components/docs-navigation.tsx @@ -10,12 +10,11 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, -} from '@kit/ui/shadcn-sidebar'; +} from '@kit/ui/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'; +import { DocsNavLink } from '../_components/docs-nav-link'; +import { DocsNavigationCollapsible } from '../_components/docs-navigation-collapsible'; +import { FloatingDocumentationNavigationButton } from './floating-docs-navigation-button'; function Node({ node, @@ -85,13 +84,11 @@ function NodeTrigger({ }) { 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 render={<SidebarMenuItem />}> + <SidebarMenuButton> + {label} + <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" /> + </SidebarMenuButton> </CollapsibleTrigger> ); } @@ -136,13 +133,8 @@ export function DocsNavigation({ }) { return ( <> - <Sidebar - variant={'ghost'} - className={ - 'border-border/50 sticky z-1 mt-4 max-h-full overflow-y-auto pr-4' - } - > - <SidebarGroup className="p-0"> + <Sidebar variant={'floating'}> + <SidebarGroup> <SidebarGroupContent> <SidebarMenu className={'pb-48'}> <Tree pages={pages} level={0} prefix={prefix} /> @@ -151,17 +143,7 @@ export function DocsNavigation({ </SidebarGroup> </Sidebar> - <div className={'lg:hidden'}> - <FloatingDocumentationNavigation> - <SidebarGroup> - <SidebarGroupContent> - <SidebarMenu> - <Tree pages={pages} level={0} prefix={prefix} /> - </SidebarMenu> - </SidebarGroupContent> - </SidebarGroup> - </FloatingDocumentationNavigation> - </div> + <FloatingDocumentationNavigationButton /> </> ); } diff --git a/apps/web/app/[locale]/docs/_components/floating-docs-navigation-button.tsx b/apps/web/app/[locale]/docs/_components/floating-docs-navigation-button.tsx new file mode 100644 index 000000000..da2c5cc21 --- /dev/null +++ b/apps/web/app/[locale]/docs/_components/floating-docs-navigation-button.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Menu } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; +import { useSidebar } from '@kit/ui/sidebar'; + +export function FloatingDocumentationNavigationButton() { + const { toggleSidebar } = useSidebar(); + return ( + <Button + size="custom" + variant="custom" + className={ + 'bg-primary fixed right-5 bottom-5 z-10 h-16! w-16! rounded-full! lg:hidden' + } + onClick={toggleSidebar} + > + <Menu className={'text-primary-foreground size-6'} /> + </Button> + ); +} diff --git a/apps/web/app/(marketing)/docs/_lib/server/docs.loader.ts b/apps/web/app/[locale]/docs/_lib/server/docs.loader.ts similarity index 100% rename from apps/web/app/(marketing)/docs/_lib/server/docs.loader.ts rename to apps/web/app/[locale]/docs/_lib/server/docs.loader.ts diff --git a/apps/web/app/(marketing)/docs/_lib/utils.ts b/apps/web/app/[locale]/docs/_lib/utils.ts similarity index 100% rename from apps/web/app/(marketing)/docs/_lib/utils.ts rename to apps/web/app/[locale]/docs/_lib/utils.ts diff --git a/apps/web/app/[locale]/docs/layout.tsx b/apps/web/app/[locale]/docs/layout.tsx new file mode 100644 index 000000000..4d3bbe014 --- /dev/null +++ b/apps/web/app/[locale]/docs/layout.tsx @@ -0,0 +1,49 @@ +import { getLocale } from 'next-intl/server'; + +import { SidebarInset, SidebarProvider } from '@kit/ui/sidebar'; + +import { DocsHeader } from './_components/docs-header'; +// local imports +import { DocsNavigation } from './_components/docs-navigation'; +import { getDocs } from './_lib/server/docs.loader'; +import { buildDocumentationTree } from './_lib/utils'; + +type DocsLayoutProps = React.PropsWithChildren<{ + params: Promise<{ locale?: string }>; +}>; + +async function DocsLayout({ children, params }: DocsLayoutProps) { + let { locale } = await params; + + if (!locale) { + locale = await getLocale(); + } + + return ( + <SidebarProvider + defaultOpen={true} + style={ + { + '--sidebar-width': '300px', + } as React.CSSProperties + } + > + <DocsSidebar locale={locale} /> + + <SidebarInset className="h-screen overflow-y-auto overscroll-y-none"> + <DocsHeader /> + + {children} + </SidebarInset> + </SidebarProvider> + ); +} + +async function DocsSidebar({ locale }: { locale: string }) { + const pages = await getDocs(locale); + const tree = buildDocumentationTree(pages); + + return <DocsNavigation pages={tree} />; +} + +export default DocsLayout; diff --git a/apps/web/app/[locale]/docs/page.tsx b/apps/web/app/[locale]/docs/page.tsx new file mode 100644 index 000000000..06586b533 --- /dev/null +++ b/apps/web/app/[locale]/docs/page.tsx @@ -0,0 +1,54 @@ +import { getLocale, getTranslations } from 'next-intl/server'; + +import { SitePageHeader } from '../(marketing)/_components/site-page-header'; +import { DocsCards } from './_components/docs-cards'; +import { getDocs } from './_lib/server/docs.loader'; + +type DocsPageProps = { + params: Promise<{ locale?: string }>; +}; + +export const generateMetadata = async () => { + const t = await getTranslations('marketing'); + + return { + title: t('documentation'), + }; +}; + +async function DocsPage({ params }: DocsPageProps) { + const t = await getTranslations('marketing'); + let { locale } = await params; + + if (!locale) { + locale = await getLocale(); + } + + return ( + <div className={'flex w-full flex-1 flex-col gap-y-6 xl:gap-y-8'}> + <SitePageHeader + title={t('documentation')} + subtitle={t('documentationSubtitle')} + /> + + <div + className={ + 'relative container flex size-full justify-center overflow-y-auto' + } + > + <DocaCardsList locale={locale} /> + </div> + </div> + ); +} + +async function DocaCardsList({ locale }: { locale: string }) { + const items = await getDocs(locale); + + // Filter out any docs that have a parentId, as these are children of other docs + const cards = items.filter((item) => !item.parentId); + + return <DocsCards cards={cards} />; +} + +export default DocsPage; diff --git a/apps/web/app/error.tsx b/apps/web/app/[locale]/error.tsx similarity index 78% rename from apps/web/app/error.tsx rename to apps/web/app/[locale]/error.tsx index 0fd615f64..7e87d6c83 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/[locale]/error.tsx @@ -22,10 +22,10 @@ const ErrorPage = ({ <SiteHeader user={user.data} /> <ErrorPageContent - statusCode={'common:errorPageHeading'} - heading={'common:genericError'} - subtitle={'common:genericErrorSubHeading'} - backLabel={'common:goBack'} + statusCode={'common.errorPageHeading'} + heading={'common.genericError'} + subtitle={'common.genericErrorSubHeading'} + backLabel={'common.goBack'} reset={reset} /> </div> diff --git a/apps/web/app/home/(user)/_components/home-account-selector.tsx b/apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx similarity index 84% rename from apps/web/app/home/(user)/_components/home-account-selector.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx index 315b3b800..710f37d5c 100644 --- a/apps/web/app/home/(user)/_components/home-account-selector.tsx +++ b/apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx @@ -5,7 +5,7 @@ import { useContext } from 'react'; import { useRouter } from 'next/navigation'; import { AccountSelector } from '@kit/accounts/account-selector'; -import { SidebarContext } from '@kit/ui/shadcn-sidebar'; +import { SidebarContext } from '@kit/ui/sidebar'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; @@ -22,7 +22,6 @@ export function HomeAccountSelector(props: { }>; userId: string; - collisionPadding?: number; }) { const router = useRouter(); const context = useContext(SidebarContext); @@ -30,12 +29,13 @@ export function HomeAccountSelector(props: { return ( <AccountSelector collapsed={!context?.open} - collisionPadding={props.collisionPadding ?? 20} accounts={props.accounts} features={features} userId={props.userId} onAccountChange={(value) => { if (value) { + document.cookie = `last-selected-team=${encodeURIComponent(value)}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`; + const path = pathsConfig.app.accountHome.replace('[account]', value); router.replace(path); } diff --git a/apps/web/app/home/(user)/_components/home-accounts-list.tsx b/apps/web/app/[locale]/home/(user)/_components/home-accounts-list.tsx similarity index 66% rename from apps/web/app/home/(user)/_components/home-accounts-list.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-accounts-list.tsx index 7c8e30dbb..a729b83e2 100644 --- a/apps/web/app/home/(user)/_components/home-accounts-list.tsx +++ b/apps/web/app/[locale]/home/(user)/_components/home-accounts-list.tsx @@ -31,13 +31,16 @@ export function HomeAccountsList() { <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> + <CardButton + key={account.value} + render={ + <Link href={`/home/${account.value}`}> + <CardButtonHeader> + <CardButtonTitle>{account.label}</CardButtonTitle> + </CardButtonHeader> + </Link> + } + /> ))} </div> </div> @@ -50,17 +53,21 @@ function HomeAccountsListEmptyState(props: { return ( <div className={'flex flex-1'}> <EmptyState> - <EmptyStateButton asChild> - <HomeAddAccountButton - className={'mt-4'} - canCreateTeamAccount={props.canCreateTeamAccount} - /> - </EmptyStateButton> + <EmptyStateButton + render={ + <HomeAddAccountButton + className={'mt-4'} + canCreateTeamAccount={props.canCreateTeamAccount} + /> + } + /> + <EmptyStateHeading> - <Trans i18nKey={'account:noTeamsYet'} /> + <Trans i18nKey={'account.noTeamsYet'} /> </EmptyStateHeading> + <EmptyStateText> - <Trans i18nKey={'account:createTeam'} /> + <Trans i18nKey={'account.createTeam'} /> </EmptyStateText> </EmptyState> </div> diff --git a/apps/web/app/home/(user)/_components/home-add-account-button.tsx b/apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx similarity index 87% rename from apps/web/app/home/(user)/_components/home-add-account-button.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx index 5d94cf5be..6881eb6d7 100644 --- a/apps/web/app/home/(user)/_components/home-add-account-button.tsx +++ b/apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx @@ -32,7 +32,7 @@ export function HomeAddAccountButton(props: HomeAddAccountButtonProps) { onClick={() => setIsAddingAccount(true)} disabled={!canCreate} > - <Trans i18nKey={'account:createTeamButtonLabel'} /> + <Trans i18nKey={'account.createTeamButtonLabel'} /> </Button> ); @@ -41,9 +41,10 @@ export function HomeAddAccountButton(props: HomeAddAccountButtonProps) { {!canCreate && reason ? ( <TooltipProvider> <Tooltip> - <TooltipTrigger asChild> - <span className="cursor-not-allowed">{button}</span> - </TooltipTrigger> + <TooltipTrigger + render={<span className="cursor-not-allowed">{button}</span>} + /> + <TooltipContent> <Trans i18nKey={reason} defaults={reason} /> </TooltipContent> diff --git a/apps/web/app/home/(user)/_components/home-menu-navigation.tsx b/apps/web/app/[locale]/home/(user)/_components/home-menu-navigation.tsx similarity index 93% rename from apps/web/app/home/(user)/_components/home-menu-navigation.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-menu-navigation.tsx index d91e4981c..d1eff9f5b 100644 --- a/apps/web/app/home/(user)/_components/home-menu-navigation.tsx +++ b/apps/web/app/[locale]/home/(user)/_components/home-menu-navigation.tsx @@ -49,7 +49,9 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) { </div> <div className={'flex justify-end space-x-2.5'}> - <UserNotifications userId={user.id} /> + <If condition={featuresFlagConfig.enableNotifications}> + <UserNotifications userId={user.id} /> + </If> <If condition={featuresFlagConfig.enableTeamAccounts}> <HomeAccountSelector userId={user.id} accounts={accounts} /> diff --git a/apps/web/app/home/(user)/_components/home-mobile-navigation.tsx b/apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx similarity index 84% rename from apps/web/app/home/(user)/_components/home-mobile-navigation.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx index bd3a50260..84908a6e2 100644 --- a/apps/web/app/home/(user)/_components/home-mobile-navigation.tsx +++ b/apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx @@ -56,13 +56,12 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) { <If condition={featuresFlagConfig.enableTeamAccounts}> <DropdownMenuGroup> <DropdownMenuLabel> - <Trans i18nKey={'common:yourAccounts'} /> + <Trans i18nKey={'common.yourAccounts'} /> </DropdownMenuLabel> <HomeAccountSelector userId={props.workspace.user.id} accounts={props.workspace.accounts} - collisionPadding={0} /> </DropdownMenuGroup> @@ -87,18 +86,21 @@ function DropdownLink( }>, ) { return ( - <DropdownMenuItem asChild key={props.path}> - <Link - href={props.path} - className={'flex h-12 w-full items-center space-x-4'} - > - {props.Icon} + <DropdownMenuItem + render={ + <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> + <span> + <Trans i18nKey={props.label} defaults={props.label} /> + </span> + </Link> + } + key={props.path} + /> ); } @@ -115,7 +117,7 @@ function SignOutDropdownItem( <LogOut className={'h-6'} /> <span> - <Trans i18nKey={'common:signOut'} defaults={'Sign out'} /> + <Trans i18nKey={'common.signOut'} defaults={'Sign out'} /> </span> </DropdownMenuItem> ); diff --git a/apps/web/app/home/(user)/_components/home-page-header.tsx b/apps/web/app/[locale]/home/(user)/_components/home-page-header.tsx similarity index 100% rename from apps/web/app/home/(user)/_components/home-page-header.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-page-header.tsx diff --git a/apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx b/apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx new file mode 100644 index 000000000..89f28d4fd --- /dev/null +++ b/apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx @@ -0,0 +1,44 @@ +import { If } from '@kit/ui/if'; +import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar'; +import { SidebarNavigation } from '@kit/ui/sidebar-navigation'; + +import { WorkspaceDropdown } from '~/components/workspace-dropdown'; +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'; + +interface HomeSidebarProps { + workspace: UserWorkspace; +} + +export function HomeSidebar(props: HomeSidebarProps) { + const { workspace, user, accounts } = props.workspace; + const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle; + + return ( + <Sidebar variant="floating" collapsible={collapsible}> + <SidebarHeader className={'h-16 justify-center'}> + <div className={'flex items-center justify-between gap-x-1'}> + <WorkspaceDropdown + user={user} + accounts={accounts} + workspace={workspace} + /> + + <If condition={featuresFlagConfig.enableNotifications}> + <div className={'group-data-[collapsible=icon]:hidden'}> + <UserNotifications userId={user.id} /> + </div> + </If> + </div> + </SidebarHeader> + + <SidebarContent> + <SidebarNavigation config={personalAccountNavigationConfig} /> + </SidebarContent> + </Sidebar> + ); +} diff --git a/apps/web/app/home/(user)/_components/user-notifications.tsx b/apps/web/app/[locale]/home/(user)/_components/user-notifications.tsx similarity index 100% rename from apps/web/app/home/(user)/_components/user-notifications.tsx rename to apps/web/app/[locale]/home/(user)/_components/user-notifications.tsx diff --git a/apps/web/app/home/(user)/_lib/server/load-user-workspace.ts b/apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts similarity index 100% rename from apps/web/app/home/(user)/_lib/server/load-user-workspace.ts rename to apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts diff --git a/apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx similarity index 68% rename from apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx rename to apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx index 28e83fe1e..1ecc5b217 100644 --- a/apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import dynamic from 'next/dynamic'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; import { PlanPicker } from '@kit/billing-gateway/components'; import { useAppEvents } from '@kit/shared/events'; @@ -39,7 +40,6 @@ const EmbeddedCheckout = dynamic( export function PersonalAccountCheckoutForm(props: { customerId: string | null | undefined; }) { - const [pending, startTransition] = useTransition(); const [error, setError] = useState(false); const appEvents = useAppEvents(); @@ -47,6 +47,20 @@ export function PersonalAccountCheckoutForm(props: { undefined, ); + const { execute, isPending } = useAction( + createPersonalAccountCheckoutSession, + { + onSuccess: ({ data }) => { + if (data?.checkoutToken) { + setCheckoutToken(data.checkoutToken); + } + }, + onError: () => { + setError(true); + }, + }, + ); + // only allow trial if the user is not already a customer const canStartTrial = !props.customerId; @@ -67,11 +81,11 @@ export function PersonalAccountCheckoutForm(props: { <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'common:planCardLabel'} /> + <Trans i18nKey={'billing.planCardLabel'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'common:planCardDescription'} /> + <Trans i18nKey={'billing.planCardDescription'} /> </CardDescription> </CardHeader> @@ -81,27 +95,18 @@ export function PersonalAccountCheckoutForm(props: { </If> <PlanPicker - pending={pending} + pending={isPending} config={billingConfig} canStartTrial={canStartTrial} onSubmit={({ planId, productId }) => { - startTransition(async () => { - try { - appEvents.emit({ - type: 'checkout.started', - payload: { planId }, - }); + appEvents.emit({ + type: 'checkout.started', + payload: { planId }, + }); - const { checkoutToken } = - await createPersonalAccountCheckoutSession({ - planId, - productId, - }); - - setCheckoutToken(checkoutToken); - } catch { - setError(true); - } + execute({ + planId, + productId, }); }} /> @@ -114,14 +119,14 @@ export function PersonalAccountCheckoutForm(props: { function ErrorAlert() { return ( <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'common:planPickerAlertErrorTitle'} /> + <Trans i18nKey={'billing.planPickerAlertErrorTitle'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'common:planPickerAlertErrorDescription'} /> + <Trans i18nKey={'billing.planPickerAlertErrorDescription'} /> </AlertDescription> </Alert> ); diff --git a/apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx b/apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx new file mode 100644 index 000000000..8e0cfc4ce --- /dev/null +++ b/apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useAction } from 'next-safe-action/hooks'; + +import { BillingPortalCard } from '@kit/billing-gateway/components'; + +import { createPersonalAccountBillingPortalSession } from '../_lib/server/server-actions'; + +export function PersonalBillingPortalForm() { + const { execute } = useAction(createPersonalAccountBillingPortalSession); + + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + execute(); + }} + > + <BillingPortalCard /> + </form> + ); +} diff --git a/apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts b/apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts similarity index 82% rename from apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts rename to apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts index bc218227a..5a938ec3a 100644 --- a/apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts +++ b/apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const PersonalAccountCheckoutSchema = z.object({ planId: z.string().min(1), diff --git a/apps/web/app/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts b/apps/web/app/[locale]/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts similarity index 99% rename from apps/web/app/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts rename to apps/web/app/[locale]/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts index 77fb20ae7..0d358e748 100644 --- a/apps/web/app/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts +++ b/apps/web/app/[locale]/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { cache } from 'react'; import { createAccountsApi } from '@kit/accounts/api'; diff --git a/apps/web/app/home/(user)/billing/_lib/server/server-actions.ts b/apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts similarity index 79% rename from apps/web/app/home/(user)/billing/_lib/server/server-actions.ts rename to apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts index c029d1b32..3dc5bfdaa 100644 --- a/apps/web/app/home/(user)/billing/_lib/server/server-actions.ts +++ b/apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import featureFlagsConfig from '~/config/feature-flags.config'; @@ -20,8 +20,9 @@ const enabled = featureFlagsConfig.enablePersonalAccountBilling; * @name createPersonalAccountCheckoutSession * @description Creates a checkout session for a personal account. */ -export const createPersonalAccountCheckoutSession = enhanceAction( - async function (data) { +export const createPersonalAccountCheckoutSession = authActionClient + .inputSchema(PersonalAccountCheckoutSchema) + .action(async ({ parsedInput: data }) => { if (!enabled) { throw new Error('Personal account billing is not enabled'); } @@ -30,18 +31,14 @@ export const createPersonalAccountCheckoutSession = enhanceAction( 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 () => { +export const createPersonalAccountBillingPortalSession = + authActionClient.action(async () => { if (!enabled) { throw new Error('Personal account billing is not enabled'); } @@ -52,7 +49,5 @@ export const createPersonalAccountBillingPortalSession = enhanceAction( // get url to billing portal const url = await service.createBillingPortalSession(); - return redirect(url); - }, - {}, -); + redirect(url); + }); diff --git a/apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts b/apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts similarity index 98% rename from apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts rename to apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts index 7ba77b38b..f72ea5e75 100644 --- a/apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts +++ b/apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts @@ -1,8 +1,7 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { createAccountsApi } from '@kit/accounts/api'; import { getProductPlanPair } from '@kit/billing'; @@ -39,7 +38,7 @@ class UserBillingService { async createCheckoutSession({ planId, productId, - }: z.infer<typeof PersonalAccountCheckoutSchema>) { + }: z.output<typeof PersonalAccountCheckoutSchema>) { // get the authenticated user const { data: user, error } = await requireUser(this.client); diff --git a/apps/web/app/home/(user)/billing/error.tsx b/apps/web/app/[locale]/home/(user)/billing/error.tsx similarity index 100% rename from apps/web/app/home/(user)/billing/error.tsx rename to apps/web/app/[locale]/home/(user)/billing/error.tsx diff --git a/apps/web/app/home/(user)/billing/layout.tsx b/apps/web/app/[locale]/home/(user)/billing/layout.tsx similarity index 100% rename from apps/web/app/home/(user)/billing/layout.tsx rename to apps/web/app/[locale]/home/(user)/billing/layout.tsx diff --git a/apps/web/app/[locale]/home/(user)/billing/page.tsx b/apps/web/app/[locale]/home/(user)/billing/page.tsx new file mode 100644 index 000000000..eb5c819b2 --- /dev/null +++ b/apps/web/app/[locale]/home/(user)/billing/page.tsx @@ -0,0 +1,105 @@ +import { getTranslations } from 'next-intl/server'; + +import { resolveProductPlan } from '@kit/billing-gateway'; +import { + 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 { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; + +// local imports +import { HomeLayoutPageHeader } from '../_components/home-page-header'; +import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form'; +import { PersonalBillingPortalForm } from './_components/personal-billing-portal-form'; +import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader'; + +export const generateMetadata = async () => { + const t = await getTranslations('account'); + const title = t('billingTab'); + + return { + title, + }; +}; + +async function PersonalAccountBillingPage() { + const user = await requireUserInServerComponent(); + + const [subscription, order, customerId] = + await loadPersonalAccountBillingPageData(user.id); + + const subscriptionVariantId = subscription?.items[0]?.variant_id; + const orderVariantId = order?.items[0]?.variant_id; + + const subscriptionProductPlan = + subscription && subscriptionVariantId + ? await resolveProductPlan( + billingConfig, + subscriptionVariantId, + subscription.currency, + ) + : undefined; + + const orderProductPlan = + order && orderVariantId + ? await resolveProductPlan(billingConfig, orderVariantId, order.currency) + : undefined; + + const hasBillingData = subscription || order; + + return ( + <PageBody> + <HomeLayoutPageHeader + title={<Trans i18nKey={'common.routes.billing'} />} + description={<AppBreadcrumbs />} + /> + + <div className={'flex max-w-2xl flex-col space-y-4'}> + <If + condition={hasBillingData} + fallback={ + <> + <PersonalAccountCheckoutForm customerId={customerId} /> + </> + } + > + <div className={'flex w-full flex-col space-y-6'}> + <If condition={subscription}> + {(subscription) => { + return ( + <CurrentSubscriptionCard + subscription={subscription} + product={subscriptionProductPlan!.product} + plan={subscriptionProductPlan!.plan} + /> + ); + }} + </If> + + <If condition={order}> + {(order) => { + return ( + <CurrentLifetimeOrderCard + order={order} + product={orderProductPlan!.product} + plan={orderProductPlan!.plan} + /> + ); + }} + </If> + </div> + </If> + + <If condition={customerId}>{() => <PersonalBillingPortalForm />}</If> + </div> + </PageBody> + ); +} + +export default PersonalAccountBillingPage; diff --git a/apps/web/app/home/(user)/billing/return/page.tsx b/apps/web/app/[locale]/home/(user)/billing/return/page.tsx similarity index 100% rename from apps/web/app/home/(user)/billing/return/page.tsx rename to apps/web/app/[locale]/home/(user)/billing/return/page.tsx diff --git a/apps/web/app/home/(user)/layout.tsx b/apps/web/app/[locale]/home/(user)/layout.tsx similarity index 71% rename from apps/web/app/home/(user)/layout.tsx rename to apps/web/app/[locale]/home/(user)/layout.tsx index 3af422bb0..805052b26 100644 --- a/apps/web/app/home/(user)/layout.tsx +++ b/apps/web/app/[locale]/home/(user)/layout.tsx @@ -3,15 +3,16 @@ import { use } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; -import { z } from 'zod'; +import * as 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 { SidebarProvider } from '@kit/ui/sidebar'; import { AppLogo } from '~/components/app-logo'; +import featuresFlagConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config'; -import { withI18n } from '~/lib/i18n/with-i18n'; // home imports import { HomeMenuNavigation } from './_components/home-menu-navigation'; @@ -29,7 +30,7 @@ function UserHomeLayout({ children }: React.PropsWithChildren) { return <HeaderLayout>{children}</HeaderLayout>; } -export default withI18n(UserHomeLayout); +export default UserHomeLayout; async function SidebarLayout({ children }: React.PropsWithChildren) { const [workspace, state] = await Promise.all([ @@ -41,6 +42,8 @@ async function SidebarLayout({ children }: React.PropsWithChildren) { redirect('/'); } + await redirectIfTeamsOnly(workspace); + return ( <UserWorkspaceContextProvider value={workspace}> <SidebarProvider defaultOpen={state.open}> @@ -60,8 +63,10 @@ async function SidebarLayout({ children }: React.PropsWithChildren) { ); } -function HeaderLayout({ children }: React.PropsWithChildren) { - const workspace = use(loadUserWorkspace()); +async function HeaderLayout({ children }: React.PropsWithChildren) { + const workspace = await loadUserWorkspace(); + + await redirectIfTeamsOnly(workspace); return ( <UserWorkspaceContextProvider value={workspace}> @@ -94,16 +99,39 @@ function MobileNavigation({ ); } +async function redirectIfTeamsOnly( + workspace: Awaited<ReturnType<typeof loadUserWorkspace>>, +) { + if (featuresFlagConfig.enableTeamsOnly) { + const firstTeam = workspace.accounts[0]; + + if (firstTeam?.value) { + const cookieStore = await cookies(); + const lastSelected = cookieStore.get('last-selected-team')?.value; + + const preferred = lastSelected + ? workspace.accounts.find((a) => a.value === lastSelected) + : undefined; + + const team = preferred ?? firstTeam; + + redirect(pathsConfig.app.accountHome.replace('[account]', team.value!)); + } else { + redirect(pathsConfig.app.createTeam); + } + } +} + 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 sidebarOpenCookie = cookieStore.get('sidebar_state'); const sidebarOpen = sidebarOpenCookie - ? sidebarOpenCookie.value === 'false' + ? sidebarOpenCookie.value === 'true' : !personalAccountNavigationConfig.sidebarCollapsed; const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value); diff --git a/apps/web/app/home/(user)/loading.tsx b/apps/web/app/[locale]/home/(user)/loading.tsx similarity index 100% rename from apps/web/app/home/(user)/loading.tsx rename to apps/web/app/[locale]/home/(user)/loading.tsx diff --git a/apps/web/app/[locale]/home/(user)/page.tsx b/apps/web/app/[locale]/home/(user)/page.tsx new file mode 100644 index 000000000..e7900c5ab --- /dev/null +++ b/apps/web/app/[locale]/home/(user)/page.tsx @@ -0,0 +1,29 @@ +import { getTranslations } from 'next-intl/server'; + +import { PageBody } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +// local imports +import { HomeLayoutPageHeader } from './_components/home-page-header'; + +export const generateMetadata = async () => { + const t = await getTranslations('account'); + const title = t('homePage'); + + return { + title, + }; +}; + +function UserHomePage() { + return ( + <PageBody> + <HomeLayoutPageHeader + title={<Trans i18nKey={'common.routes.home'} />} + description={<Trans i18nKey={'common.homeTabDescription'} />} + /> + </PageBody> + ); +} + +export default UserHomePage; diff --git a/apps/web/app/home/(user)/settings/layout.tsx b/apps/web/app/[locale]/home/(user)/settings/layout.tsx similarity index 68% rename from apps/web/app/home/(user)/settings/layout.tsx rename to apps/web/app/[locale]/home/(user)/settings/layout.tsx index 4972df9cb..6b73d00da 100644 --- a/apps/web/app/home/(user)/settings/layout.tsx +++ b/apps/web/app/[locale]/home/(user)/settings/layout.tsx @@ -1,22 +1,21 @@ import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { PageBody } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; -import { withI18n } from '~/lib/i18n/with-i18n'; - // local imports import { HomeLayoutPageHeader } from '../_components/home-page-header'; function UserSettingsLayout(props: React.PropsWithChildren) { return ( - <> + <PageBody> <HomeLayoutPageHeader - title={<Trans i18nKey={'account:routes.settings'} />} + title={<Trans i18nKey={'account.routes.settings'} />} description={<AppBreadcrumbs />} /> {props.children} - </> + </PageBody> ); } -export default withI18n(UserSettingsLayout); +export default UserSettingsLayout; diff --git a/apps/web/app/home/(user)/settings/page.tsx b/apps/web/app/[locale]/home/(user)/settings/page.tsx similarity index 67% rename from apps/web/app/home/(user)/settings/page.tsx rename to apps/web/app/[locale]/home/(user)/settings/page.tsx index 46cd18a28..11a52da74 100644 --- a/apps/web/app/home/(user)/settings/page.tsx +++ b/apps/web/app/[locale]/home/(user)/settings/page.tsx @@ -1,13 +1,12 @@ import { use } from 'react'; +import { getTranslations } from 'next-intl/server'; + import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings'; -import { PageBody } from '@kit/ui/page'; import authConfig from '~/config/auth.config'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.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'; // Show email option if password, magic link, or OTP is enabled @@ -33,8 +32,8 @@ const paths = { }; export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('account:settingsTab'); + const t = await getTranslations('account'); + const title = t('settingsTab'); return { title, @@ -45,17 +44,15 @@ function PersonalAccountSettingsPage() { const user = use(requireUserInServerComponent()); return ( - <PageBody> - <div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}> - <PersonalAccountSettingsContainer - userId={user.id} - features={features} - paths={paths} - providers={providers} - /> - </div> - </PageBody> + <div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}> + <PersonalAccountSettingsContainer + userId={user.id} + features={features} + paths={paths} + providers={providers} + /> + </div> ); } -export default withI18n(PersonalAccountSettingsPage); +export default PersonalAccountSettingsPage; diff --git a/apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx b/apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx similarity index 77% rename from apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx rename to apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx index 0054ec532..32e250284 100644 --- a/apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx @@ -29,6 +29,7 @@ import { ChartTooltip, ChartTooltipContent, } from '@kit/ui/chart'; +import { useIsMobile } from '@kit/ui/hooks/use-mobile'; import { Table, TableBody, @@ -205,7 +206,7 @@ function Chart( /> <ChartTooltip cursor={false} - content={<ChartTooltipContent hideLabel />} + content={(props) => <ChartTooltipContent hideLabel {...props} />} /> <Line dataKey="value" @@ -497,6 +498,8 @@ function Trend( } export function VisitorsChart() { + const isMobile = useIsMobile(); + const chartData = useMemo( () => [ { date: '2024-04-01', desktop: 222, mobile: 150 }, @@ -618,14 +621,17 @@ export function VisitorsChart() { </CardHeader> <CardContent> - <ChartContainer className={'h-64 w-full'} config={chartConfig}> - <AreaChart accessibilityLayer data={chartData}> + <ChartContainer + config={chartConfig} + className="aspect-auto h-[250px] w-full" + > + <AreaChart data={chartData}> <defs> <linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-desktop)" - stopOpacity={0.8} + stopOpacity={1.0} /> <stop offset="95%" @@ -648,21 +654,41 @@ export function VisitorsChart() { </defs> <CartesianGrid vertical={false} /> <XAxis - dataKey="month" + dataKey="date" tickLine={false} axisLine={false} tickMargin={8} - tickFormatter={(value: string) => value.slice(0, 3)} + minTickGap={32} + tickFormatter={(value) => { + const date = new Date(value); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }} /> + <ChartTooltip cursor={false} - content={<ChartTooltipContent indicator="dot" />} + defaultIndex={isMobile ? -1 : 10} + content={(props) => ( + <ChartTooltipContent + {...props} + labelFormatter={(value) => { + return new Date(value).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }} + indicator="dot" + /> + )} /> + <Area dataKey="mobile" type="natural" fill="url(#fillMobile)" - fillOpacity={0.4} stroke="var(--color-mobile)" stackId="a" /> @@ -670,7 +696,6 @@ export function VisitorsChart() { dataKey="desktop" type="natural" fill="url(#fillDesktop)" - fillOpacity={0.4} stroke="var(--color-desktop)" stackId="a" /> @@ -698,100 +723,102 @@ export function PageViewsChart() { const [activeChart, setActiveChart] = useState<keyof typeof chartConfig>('desktop'); - // eslint-disable-next-line react-hooks/exhaustive-deps - const chartData = [ - { date: '2024-04-01', desktop: 222, mobile: 150 }, - { date: '2024-04-02', desktop: 97, mobile: 180 }, - { date: '2024-04-03', desktop: 167, mobile: 120 }, - { date: '2024-04-04', desktop: 242, mobile: 260 }, - { date: '2024-04-05', desktop: 373, mobile: 290 }, - { date: '2024-04-06', desktop: 301, mobile: 340 }, - { date: '2024-04-07', desktop: 245, mobile: 180 }, - { date: '2024-04-08', desktop: 409, mobile: 320 }, - { date: '2024-04-09', desktop: 59, mobile: 110 }, - { date: '2024-04-10', desktop: 261, mobile: 190 }, - { date: '2024-04-11', desktop: 327, mobile: 350 }, - { date: '2024-04-12', desktop: 292, mobile: 210 }, - { date: '2024-04-13', desktop: 342, mobile: 380 }, - { date: '2024-04-14', desktop: 137, mobile: 220 }, - { date: '2024-04-15', desktop: 120, mobile: 170 }, - { date: '2024-04-16', desktop: 138, mobile: 190 }, - { date: '2024-04-17', desktop: 446, mobile: 360 }, - { date: '2024-04-18', desktop: 364, mobile: 410 }, - { date: '2024-04-19', desktop: 243, mobile: 180 }, - { date: '2024-04-20', desktop: 89, mobile: 150 }, - { date: '2024-04-21', desktop: 137, mobile: 200 }, - { date: '2024-04-22', desktop: 224, mobile: 170 }, - { date: '2024-04-23', desktop: 138, mobile: 230 }, - { date: '2024-04-24', desktop: 387, mobile: 290 }, - { date: '2024-04-25', desktop: 215, mobile: 250 }, - { date: '2024-04-26', desktop: 75, mobile: 130 }, - { date: '2024-04-27', desktop: 383, mobile: 420 }, - { date: '2024-04-28', desktop: 122, mobile: 180 }, - { date: '2024-04-29', desktop: 315, mobile: 240 }, - { date: '2024-04-30', desktop: 454, mobile: 380 }, - { date: '2024-05-01', desktop: 165, mobile: 220 }, - { date: '2024-05-02', desktop: 293, mobile: 310 }, - { date: '2024-05-03', desktop: 247, mobile: 190 }, - { date: '2024-05-04', desktop: 385, mobile: 420 }, - { date: '2024-05-05', desktop: 481, mobile: 390 }, - { date: '2024-05-06', desktop: 498, mobile: 520 }, - { date: '2024-05-07', desktop: 388, mobile: 300 }, - { date: '2024-05-08', desktop: 149, mobile: 210 }, - { date: '2024-05-09', desktop: 227, mobile: 180 }, - { date: '2024-05-10', desktop: 293, mobile: 330 }, - { date: '2024-05-11', desktop: 335, mobile: 270 }, - { date: '2024-05-12', desktop: 197, mobile: 240 }, - { date: '2024-05-13', desktop: 197, mobile: 160 }, - { date: '2024-05-14', desktop: 448, mobile: 490 }, - { date: '2024-05-15', desktop: 473, mobile: 380 }, - { date: '2024-05-16', desktop: 338, mobile: 400 }, - { date: '2024-05-17', desktop: 499, mobile: 420 }, - { date: '2024-05-18', desktop: 315, mobile: 350 }, - { date: '2024-05-19', desktop: 235, mobile: 180 }, - { date: '2024-05-20', desktop: 177, mobile: 230 }, - { date: '2024-05-21', desktop: 82, mobile: 140 }, - { date: '2024-05-22', desktop: 81, mobile: 120 }, - { date: '2024-05-23', desktop: 252, mobile: 290 }, - { date: '2024-05-24', desktop: 294, mobile: 220 }, - { date: '2024-05-25', desktop: 201, mobile: 250 }, - { date: '2024-05-26', desktop: 213, mobile: 170 }, - { date: '2024-05-27', desktop: 420, mobile: 460 }, - { date: '2024-05-28', desktop: 233, mobile: 190 }, - { date: '2024-05-29', desktop: 78, mobile: 130 }, - { date: '2024-05-30', desktop: 340, mobile: 280 }, - { date: '2024-05-31', desktop: 178, mobile: 230 }, - { date: '2024-06-01', desktop: 178, mobile: 200 }, - { date: '2024-06-02', desktop: 470, mobile: 410 }, - { date: '2024-06-03', desktop: 103, mobile: 160 }, - { date: '2024-06-04', desktop: 439, mobile: 380 }, - { date: '2024-06-05', desktop: 88, mobile: 140 }, - { date: '2024-06-06', desktop: 294, mobile: 250 }, - { date: '2024-06-07', desktop: 323, mobile: 370 }, - { date: '2024-06-08', desktop: 385, mobile: 320 }, - { date: '2024-06-09', desktop: 438, mobile: 480 }, - { date: '2024-06-10', desktop: 155, mobile: 200 }, - { date: '2024-06-11', desktop: 92, mobile: 150 }, - { date: '2024-06-12', desktop: 492, mobile: 420 }, - { date: '2024-06-13', desktop: 81, mobile: 130 }, - { date: '2024-06-14', desktop: 426, mobile: 380 }, - { date: '2024-06-15', desktop: 307, mobile: 350 }, - { date: '2024-06-16', desktop: 371, mobile: 310 }, - { date: '2024-06-17', desktop: 475, mobile: 520 }, - { date: '2024-06-18', desktop: 107, mobile: 170 }, - { date: '2024-06-19', desktop: 341, mobile: 290 }, - { date: '2024-06-20', desktop: 408, mobile: 450 }, - { date: '2024-06-21', desktop: 169, mobile: 210 }, - { date: '2024-06-22', desktop: 317, mobile: 270 }, - { date: '2024-06-23', desktop: 480, mobile: 530 }, - { date: '2024-06-24', desktop: 132, mobile: 180 }, - { date: '2024-06-25', desktop: 141, mobile: 190 }, - { date: '2024-06-26', desktop: 434, mobile: 380 }, - { date: '2024-06-27', desktop: 448, mobile: 490 }, - { date: '2024-06-28', desktop: 149, mobile: 200 }, - { date: '2024-06-29', desktop: 103, mobile: 160 }, - { date: '2024-06-30', desktop: 446, mobile: 400 }, - ]; + const chartData = useMemo( + () => [ + { date: '2024-04-01', desktop: 222, mobile: 150 }, + { date: '2024-04-02', desktop: 97, mobile: 180 }, + { date: '2024-04-03', desktop: 167, mobile: 120 }, + { date: '2024-04-04', desktop: 242, mobile: 260 }, + { date: '2024-04-05', desktop: 373, mobile: 290 }, + { date: '2024-04-06', desktop: 301, mobile: 340 }, + { date: '2024-04-07', desktop: 245, mobile: 180 }, + { date: '2024-04-08', desktop: 409, mobile: 320 }, + { date: '2024-04-09', desktop: 59, mobile: 110 }, + { date: '2024-04-10', desktop: 261, mobile: 190 }, + { date: '2024-04-11', desktop: 327, mobile: 350 }, + { date: '2024-04-12', desktop: 292, mobile: 210 }, + { date: '2024-04-13', desktop: 342, mobile: 380 }, + { date: '2024-04-14', desktop: 137, mobile: 220 }, + { date: '2024-04-15', desktop: 120, mobile: 170 }, + { date: '2024-04-16', desktop: 138, mobile: 190 }, + { date: '2024-04-17', desktop: 446, mobile: 360 }, + { date: '2024-04-18', desktop: 364, mobile: 410 }, + { date: '2024-04-19', desktop: 243, mobile: 180 }, + { date: '2024-04-20', desktop: 89, mobile: 150 }, + { date: '2024-04-21', desktop: 137, mobile: 200 }, + { date: '2024-04-22', desktop: 224, mobile: 170 }, + { date: '2024-04-23', desktop: 138, mobile: 230 }, + { date: '2024-04-24', desktop: 387, mobile: 290 }, + { date: '2024-04-25', desktop: 215, mobile: 250 }, + { date: '2024-04-26', desktop: 75, mobile: 130 }, + { date: '2024-04-27', desktop: 383, mobile: 420 }, + { date: '2024-04-28', desktop: 122, mobile: 180 }, + { date: '2024-04-29', desktop: 315, mobile: 240 }, + { date: '2024-04-30', desktop: 454, mobile: 380 }, + { date: '2024-05-01', desktop: 165, mobile: 220 }, + { date: '2024-05-02', desktop: 293, mobile: 310 }, + { date: '2024-05-03', desktop: 247, mobile: 190 }, + { date: '2024-05-04', desktop: 385, mobile: 420 }, + { date: '2024-05-05', desktop: 481, mobile: 390 }, + { date: '2024-05-06', desktop: 498, mobile: 520 }, + { date: '2024-05-07', desktop: 388, mobile: 300 }, + { date: '2024-05-08', desktop: 149, mobile: 210 }, + { date: '2024-05-09', desktop: 227, mobile: 180 }, + { date: '2024-05-10', desktop: 293, mobile: 330 }, + { date: '2024-05-11', desktop: 335, mobile: 270 }, + { date: '2024-05-12', desktop: 197, mobile: 240 }, + { date: '2024-05-13', desktop: 197, mobile: 160 }, + { date: '2024-05-14', desktop: 448, mobile: 490 }, + { date: '2024-05-15', desktop: 473, mobile: 380 }, + { date: '2024-05-16', desktop: 338, mobile: 400 }, + { date: '2024-05-17', desktop: 499, mobile: 420 }, + { date: '2024-05-18', desktop: 315, mobile: 350 }, + { date: '2024-05-19', desktop: 235, mobile: 180 }, + { date: '2024-05-20', desktop: 177, mobile: 230 }, + { date: '2024-05-21', desktop: 82, mobile: 140 }, + { date: '2024-05-22', desktop: 81, mobile: 120 }, + { date: '2024-05-23', desktop: 252, mobile: 290 }, + { date: '2024-05-24', desktop: 294, mobile: 220 }, + { date: '2024-05-25', desktop: 201, mobile: 250 }, + { date: '2024-05-26', desktop: 213, mobile: 170 }, + { date: '2024-05-27', desktop: 420, mobile: 460 }, + { date: '2024-05-28', desktop: 233, mobile: 190 }, + { date: '2024-05-29', desktop: 78, mobile: 130 }, + { date: '2024-05-30', desktop: 340, mobile: 280 }, + { date: '2024-05-31', desktop: 178, mobile: 230 }, + { date: '2024-06-01', desktop: 178, mobile: 200 }, + { date: '2024-06-02', desktop: 470, mobile: 410 }, + { date: '2024-06-03', desktop: 103, mobile: 160 }, + { date: '2024-06-04', desktop: 439, mobile: 380 }, + { date: '2024-06-05', desktop: 88, mobile: 140 }, + { date: '2024-06-06', desktop: 294, mobile: 250 }, + { date: '2024-06-07', desktop: 323, mobile: 370 }, + { date: '2024-06-08', desktop: 385, mobile: 320 }, + { date: '2024-06-09', desktop: 438, mobile: 480 }, + { date: '2024-06-10', desktop: 155, mobile: 200 }, + { date: '2024-06-11', desktop: 92, mobile: 150 }, + { date: '2024-06-12', desktop: 492, mobile: 420 }, + { date: '2024-06-13', desktop: 81, mobile: 130 }, + { date: '2024-06-14', desktop: 426, mobile: 380 }, + { date: '2024-06-15', desktop: 307, mobile: 350 }, + { date: '2024-06-16', desktop: 371, mobile: 310 }, + { date: '2024-06-17', desktop: 475, mobile: 520 }, + { date: '2024-06-18', desktop: 107, mobile: 170 }, + { date: '2024-06-19', desktop: 341, mobile: 290 }, + { date: '2024-06-20', desktop: 408, mobile: 450 }, + { date: '2024-06-21', desktop: 169, mobile: 210 }, + { date: '2024-06-22', desktop: 317, mobile: 270 }, + { date: '2024-06-23', desktop: 480, mobile: 530 }, + { date: '2024-06-24', desktop: 132, mobile: 180 }, + { date: '2024-06-25', desktop: 141, mobile: 190 }, + { date: '2024-06-26', desktop: 434, mobile: 380 }, + { date: '2024-06-27', desktop: 448, mobile: 490 }, + { date: '2024-06-28', desktop: 149, mobile: 200 }, + { date: '2024-06-29', desktop: 103, mobile: 160 }, + { date: '2024-06-30', desktop: 446, mobile: 400 }, + ], + [], + ); const chartConfig = { views: { @@ -870,8 +897,9 @@ export function PageViewsChart() { }} /> <ChartTooltip - content={ + content={(props) => ( <ChartTooltipContent + {...props} className="w-[150px]" nameKey="views" labelFormatter={(value) => { @@ -882,7 +910,7 @@ export function PageViewsChart() { }); }} /> - } + )} /> <Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} /> </BarChart> diff --git a/apps/web/app/home/[account]/_components/dashboard-demo.tsx b/apps/web/app/[locale]/home/[account]/_components/dashboard-demo.tsx similarity index 100% rename from apps/web/app/home/[account]/_components/dashboard-demo.tsx rename to apps/web/app/[locale]/home/[account]/_components/dashboard-demo.tsx diff --git a/apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx similarity index 71% rename from apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx index e1da4772b..c67e437ad 100644 --- a/apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx @@ -1,11 +1,9 @@ '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 { useSidebar } from '@kit/ui/sidebar'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; @@ -25,7 +23,7 @@ export function TeamAccountAccountsSelector(params: { }>; }) { const router = useRouter(); - const ctx = useContext(SidebarContext); + const ctx = useSidebar(); return ( <AccountSelector @@ -34,7 +32,16 @@ export function TeamAccountAccountsSelector(params: { userId={params.userId} collapsed={!ctx?.open} features={features} + showPersonalAccount={!featureFlagsConfig.enableTeamsOnly} onAccountChange={(value) => { + if (!value && featureFlagsConfig.enableTeamsOnly) { + return; + } + + if (value) { + document.cookie = `last-selected-team=${encodeURIComponent(value)}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`; + } + const path = value ? pathsConfig.app.accountHome.replace('[account]', value) : pathsConfig.app.home; diff --git a/apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx similarity index 79% rename from apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx index 3c34b7fdd..dc162fc99 100644 --- a/apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx @@ -99,18 +99,20 @@ function DropdownLink( }>, ) { return ( - <DropdownMenuItem asChild> - <Link - href={props.path} - className={'flex h-12 w-full items-center gap-x-3 px-3'} - > - {props.Icon} + <DropdownMenuItem + render={ + <Link + href={props.path} + className={'flex h-12 w-full items-center gap-x-3 px-3'} + > + {props.Icon} - <span> - <Trans i18nKey={props.label} defaults={props.label} /> - </span> - </Link> - </DropdownMenuItem> + <span> + <Trans i18nKey={props.label} defaults={props.label} /> + </span> + </Link> + } + /> ); } @@ -127,7 +129,7 @@ function SignOutDropdownItem( <LogOut className={'h-4'} /> <span> - <Trans i18nKey={'common:signOut'} /> + <Trans i18nKey={'common.signOut'} /> </span> </DropdownMenuItem> ); @@ -142,35 +144,40 @@ function TeamAccountsModal(props: { return ( <Dialog> - <DialogTrigger asChild> - <DropdownMenuItem - className={'flex h-12 w-full items-center space-x-2'} - onSelect={(e) => e.preventDefault()} - > - <Home className={'h-4'} /> + <DialogTrigger + render={ + <DropdownMenuItem + className={'flex h-12 w-full items-center space-x-2'} + onSelect={(e) => e.preventDefault()} + > + <Home className={'h-4'} /> - <span> - <Trans i18nKey={'common:yourAccounts'} /> - </span> - </DropdownMenuItem> - </DialogTrigger> + <span> + <Trans i18nKey={'common.yourAccounts'} /> + </span> + </DropdownMenuItem> + } + /> <DialogContent> <DialogHeader> <DialogTitle> - <Trans i18nKey={'common:yourAccounts'} /> + <Trans i18nKey={'common.yourAccounts'} /> </DialogTitle> </DialogHeader> <div className={'py-6'}> <AccountSelector className={'w-full max-w-full'} - collisionPadding={0} userId={props.userId} accounts={props.accounts} features={features} selectedAccount={props.account} onAccountChange={(value) => { + if (value) { + document.cookie = `last-selected-team=${encodeURIComponent(value)}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`; + } + const path = value ? pathsConfig.app.accountHome.replace('[account]', value) : pathsConfig.app.home; diff --git a/apps/web/app/home/[account]/_components/team-account-layout-page-header.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-page-header.tsx similarity index 100% rename from apps/web/app/home/[account]/_components/team-account-layout-page-header.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-layout-page-header.tsx diff --git a/apps/web/app/home/[account]/_components/team-account-layout-sidebar-navigation.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar-navigation.tsx similarity index 60% rename from apps/web/app/home/[account]/_components/team-account-layout-sidebar-navigation.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar-navigation.tsx index 9e1eeb456..02dcc95bb 100644 --- a/apps/web/app/home/[account]/_components/team-account-layout-sidebar-navigation.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar-navigation.tsx @@ -1,12 +1,12 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; -import { SidebarNavigation } from '@kit/ui/shadcn-sidebar'; +import { SidebarNavigation } from '@kit/ui/sidebar-navigation'; export function TeamAccountLayoutSidebarNavigation({ config, }: React.PropsWithChildren<{ - config: z.infer<typeof NavigationConfigSchema>; + config: z.output<typeof NavigationConfigSchema>; }>) { return <SidebarNavigation config={config} />; } diff --git a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx new file mode 100644 index 000000000..6eae946fc --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx @@ -0,0 +1,50 @@ +import { JWTUserData } from '@kit/supabase/types'; +import { If } from '@kit/ui/if'; +import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar'; + +import type { AccountModel } from '~/components/workspace-dropdown'; +import { WorkspaceDropdown } from '~/components/workspace-dropdown'; +import featureFlagsConfig from '~/config/feature-flags.config'; +import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; +import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications'; + +import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation'; + +export function TeamAccountLayoutSidebar(props: { + account: string; + accountId: string; + accounts: AccountModel[]; + user: JWTUserData; +}) { + const { account, accounts, user } = props; + + const config = getTeamAccountSidebarConfig(account); + const collapsible = config.sidebarCollapsedStyle; + + return ( + <Sidebar variant="floating" collapsible={collapsible}> + <SidebarHeader className={'h-16 justify-center'}> + <div className={'flex items-center justify-between gap-x-1'}> + <WorkspaceDropdown + user={user} + accounts={accounts} + selectedAccount={account} + /> + + <If condition={featureFlagsConfig.enableNotifications}> + <div className={'group-data-[collapsible=icon]:hidden'}> + <TeamAccountNotifications + userId={user.id} + accountId={props.accountId} + /> + </div> + </If> + </div> + </SidebarHeader> + + <SidebarContent className="h-[calc(100%-160px)] overflow-y-auto"> + <TeamAccountLayoutSidebarNavigation config={config} /> + </SidebarContent> + </Sidebar> + ); +} diff --git a/apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx similarity index 87% rename from apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx index b7ed8f43a..500701d72 100644 --- a/apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx @@ -2,9 +2,11 @@ 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 featureFlagsConfig from '~/config/feature-flags.config'; import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; import { TeamAccountAccountsSelector } from '~/home/[account]/_components/team-account-accounts-selector'; @@ -49,7 +51,9 @@ export function TeamAccountNavigationMenu(props: { </div> <div className={'flex items-center justify-end space-x-2.5'}> - <TeamAccountNotifications accountId={account.id} userId={user.id} /> + <If condition={featureFlagsConfig.enableNotifications}> + <TeamAccountNotifications accountId={account.id} userId={user.id} /> + </If> <TeamAccountAccountsSelector userId={user.id} @@ -65,6 +69,7 @@ export function TeamAccountNavigationMenu(props: { <ProfileAccountDropdownContainer user={user} showProfileName={false} + accountSlug={account.slug} /> </div> </div> diff --git a/apps/web/app/home/[account]/_components/team-account-notifications.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-notifications.tsx similarity index 100% rename from apps/web/app/home/[account]/_components/team-account-notifications.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-notifications.tsx diff --git a/apps/web/app/home/[account]/_lib/server/team-account-billing-page.loader.ts b/apps/web/app/[locale]/home/[account]/_lib/server/team-account-billing-page.loader.ts similarity index 99% rename from apps/web/app/home/[account]/_lib/server/team-account-billing-page.loader.ts rename to apps/web/app/[locale]/home/[account]/_lib/server/team-account-billing-page.loader.ts index 890b1b643..736f3200a 100644 --- a/apps/web/app/home/[account]/_lib/server/team-account-billing-page.loader.ts +++ b/apps/web/app/[locale]/home/[account]/_lib/server/team-account-billing-page.loader.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { cache } from 'react'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; diff --git a/apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts b/apps/web/app/[locale]/home/[account]/_lib/server/team-account-workspace.loader.ts similarity index 99% rename from apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts rename to apps/web/app/[locale]/home/[account]/_lib/server/team-account-workspace.loader.ts index 4a389946d..5c0b9f980 100644 --- a/apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts +++ b/apps/web/app/[locale]/home/[account]/_lib/server/team-account-workspace.loader.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { cache } from 'react'; import { redirect } from 'next/navigation'; diff --git a/apps/web/app/home/[account]/billing/_components/embedded-checkout-form.tsx b/apps/web/app/[locale]/home/[account]/billing/_components/embedded-checkout-form.tsx similarity index 100% rename from apps/web/app/home/[account]/billing/_components/embedded-checkout-form.tsx rename to apps/web/app/[locale]/home/[account]/billing/_components/embedded-checkout-form.tsx diff --git a/apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx b/apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx similarity index 55% rename from apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx rename to apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx index 8bfc54cf7..5a4945541 100644 --- a/apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx +++ b/apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx @@ -1,12 +1,16 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import dynamic from 'next/dynamic'; import { useParams } from 'next/navigation'; +import { TriangleAlertIcon } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; + import { PlanPicker } from '@kit/billing-gateway/components'; import { useAppEvents } from '@kit/shared/events'; +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Card, CardContent, @@ -14,6 +18,7 @@ import { CardHeader, CardTitle, } from '@kit/ui/card'; +import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; import billingConfig from '~/config/billing.config'; @@ -38,12 +43,23 @@ export function TeamAccountCheckoutForm(params: { customerId: string | null | undefined; }) { const routeParams = useParams(); - const [pending, startTransition] = useTransition(); const appEvents = useAppEvents(); const [checkoutToken, setCheckoutToken] = useState<string | undefined>( undefined, ); + const [error, setError] = useState(false); + + const { execute, isPending } = useAction(createTeamAccountCheckoutSession, { + onSuccess: ({ data }) => { + if (data?.checkoutToken) { + setCheckoutToken(data.checkoutToken); + } + }, + onError: () => { + setError(true); + }, + }); // If the checkout token is set, render the embedded checkout component if (checkoutToken) { @@ -64,39 +80,49 @@ export function TeamAccountCheckoutForm(params: { <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'billing:manageTeamPlan'} /> + <Trans i18nKey={'billing.manageTeamPlan'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'billing:manageTeamPlanDescription'} /> + <Trans i18nKey={'billing.manageTeamPlanDescription'} /> </CardDescription> </CardHeader> - <CardContent> + <CardContent className={'space-y-4'}> + <If condition={error}> + <Alert variant={'destructive'}> + <TriangleAlertIcon className={'h-4'} /> + + <AlertTitle> + <Trans i18nKey={'billing.planPickerAlertErrorTitle'} /> + </AlertTitle> + + <AlertDescription> + <Trans i18nKey={'billing.planPickerAlertErrorDescription'} /> + </AlertDescription> + </Alert> + </If> + <PlanPicker - pending={pending} + pending={isPending} config={billingConfig} canStartTrial={canStartTrial} onSubmit={({ planId, productId }) => { - startTransition(async () => { - const slug = routeParams.account as string; + const slug = routeParams.account as string; - appEvents.emit({ - type: 'checkout.started', - payload: { - planId, - account: slug, - }, - }); - - const { checkoutToken } = await createTeamAccountCheckoutSession({ + appEvents.emit({ + type: 'checkout.started', + payload: { planId, - productId, - slug, - accountId: params.accountId, - }); + account: slug, + }, + }); - setCheckoutToken(checkoutToken); + execute({ + planId, + productId, + slug, + accountId: params.accountId, }); }} /> diff --git a/apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx b/apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx new file mode 100644 index 000000000..448948226 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useAction } from 'next-safe-action/hooks'; + +import { BillingPortalCard } from '@kit/billing-gateway/components'; + +import { createBillingPortalSession } from '../_lib/server/server-actions'; + +export function TeamBillingPortalForm({ + accountId, + slug, +}: { + accountId: string; + slug: string; +}) { + const { execute } = useAction(createBillingPortalSession); + + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + execute({ accountId, slug }); + }} + > + <BillingPortalCard /> + </form> + ); +} diff --git a/apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts b/apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts similarity index 91% rename from apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts rename to apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts index 3d8f045da..56c90eb20 100644 --- a/apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts +++ b/apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const TeamBillingPortalSchema = z.object({ accountId: z.string().uuid(), diff --git a/apps/web/app/home/[account]/billing/_lib/server/server-actions.ts b/apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts similarity index 77% rename from apps/web/app/home/[account]/billing/_lib/server/server-actions.ts rename to apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts index 443bfa896..9e7a62d62 100644 --- a/apps/web/app/home/[account]/billing/_lib/server/server-actions.ts +++ b/apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import featureFlagsConfig from '~/config/feature-flags.config'; @@ -24,8 +24,9 @@ const enabled = featureFlagsConfig.enableTeamAccountBilling; * @name createTeamAccountCheckoutSession * @description Creates a checkout session for a team account. */ -export const createTeamAccountCheckoutSession = enhanceAction( - async (data) => { +export const createTeamAccountCheckoutSession = authActionClient + .inputSchema(TeamCheckoutSchema) + .action(async ({ parsedInput: data }) => { if (!enabled) { throw new Error('Team account billing is not enabled'); } @@ -34,32 +35,25 @@ export const createTeamAccountCheckoutSession = enhanceAction( const service = createTeamBillingService(client); return service.createCheckout(data); - }, - { - schema: TeamCheckoutSchema, - }, -); + }); /** * @name createBillingPortalSession * @description Creates a Billing Session Portal and redirects the user to the * provider's hosted instance */ -export const createBillingPortalSession = enhanceAction( - async (formData: FormData) => { +export const createBillingPortalSession = authActionClient + .inputSchema(TeamBillingPortalSchema) + .action(async ({ parsedInput: params }) => { if (!enabled) { throw new Error('Team account billing is not enabled'); } - const params = TeamBillingPortalSchema.parse(Object.fromEntries(formData)); - const client = getSupabaseServerClient(); const service = createTeamBillingService(client); // get url to billing portal const url = await service.createBillingPortalSession(params); - return redirect(url); - }, - {}, -); + redirect(url); + }); diff --git a/apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts b/apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts similarity index 98% rename from apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts rename to apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts index af435f4ba..8d61337a0 100644 --- a/apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts +++ b/apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts @@ -1,8 +1,7 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { LineItemSchema } from '@kit/billing'; import { getBillingGatewayProvider } from '@kit/billing-gateway'; @@ -35,7 +34,7 @@ class TeamBillingService { * @name createCheckout * @description Creates a checkout session for a Team account */ - async createCheckout(params: z.infer<typeof TeamCheckoutSchema>) { + async createCheckout(params: z.output<typeof TeamCheckoutSchema>) { // we require the user to be authenticated const { data: user } = await requireUser(this.client); @@ -242,7 +241,7 @@ class TeamBillingService { * Retrieves variant quantities for line items. */ private async getVariantQuantities( - lineItems: z.infer<typeof LineItemSchema>[], + lineItems: z.output<typeof LineItemSchema>[], accountId: string, ) { const variantQuantities: Array<{ diff --git a/apps/web/app/home/[account]/billing/error.tsx b/apps/web/app/[locale]/home/[account]/billing/error.tsx similarity index 76% rename from apps/web/app/home/[account]/billing/error.tsx rename to apps/web/app/[locale]/home/[account]/billing/error.tsx index 974e826f3..9679c16ce 100644 --- a/apps/web/app/home/[account]/billing/error.tsx +++ b/apps/web/app/[locale]/home/[account]/billing/error.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; import { useCaptureException } from '@kit/monitoring/hooks'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -25,20 +25,20 @@ export default function BillingErrorPage({ <PageBody> <div className={'flex flex-col space-y-4'}> <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'billing:planPickerAlertErrorTitle'} /> + <Trans i18nKey={'billing.planPickerAlertErrorTitle'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'billing:planPickerAlertErrorDescription'} /> + <Trans i18nKey={'billing.planPickerAlertErrorDescription'} /> </AlertDescription> </Alert> <div> <Button variant={'outline'} onClick={reset}> - <Trans i18nKey={'common:retry'} /> + <Trans i18nKey={'common.retry'} /> </Button> </div> </div> diff --git a/apps/web/app/home/[account]/billing/layout.tsx b/apps/web/app/[locale]/home/[account]/billing/layout.tsx similarity index 100% rename from apps/web/app/home/[account]/billing/layout.tsx rename to apps/web/app/[locale]/home/[account]/billing/layout.tsx diff --git a/apps/web/app/home/[account]/billing/page.tsx b/apps/web/app/[locale]/home/[account]/billing/page.tsx similarity index 51% rename from apps/web/app/home/[account]/billing/page.tsx rename to apps/web/app/[locale]/home/[account]/billing/page.tsx index cd291cfbd..2bc6ac81c 100644 --- a/apps/web/app/home/[account]/billing/page.tsx +++ b/apps/web/app/[locale]/home/[account]/billing/page.tsx @@ -1,8 +1,8 @@ -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; import { resolveProductPlan } from '@kit/billing-gateway'; import { - BillingPortalCard, CurrentLifetimeOrderCard, CurrentSubscriptionCard, } from '@kit/billing-gateway/components'; @@ -14,23 +14,21 @@ import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; import billingConfig from '~/config/billing.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; // local imports import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; import { loadTeamAccountBillingPage } from '../_lib/server/team-account-billing-page.loader'; import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader'; import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form'; -import { createBillingPortalSession } from './_lib/server/server-actions'; +import { TeamBillingPortalForm } from './_components/team-billing-portal-form'; interface TeamAccountBillingPageProps { params: Promise<{ account: string }>; } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('teams:billing.pageTitle'); + const t = await getTranslations('teams'); + const title = t('billing.pageTitle'); return { title, @@ -64,91 +62,72 @@ async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) { const shouldShowBillingPortal = canManageBilling && customerId; return ( - <> + <PageBody> <TeamAccountLayoutPageHeader account={account} - title={<Trans i18nKey={'common:routes.billing'} />} + title={<Trans i18nKey={'common.routes.billing'} />} description={<AppBreadcrumbs />} /> - <PageBody> - <div className={cn(`flex max-w-2xl flex-col space-y-4`)}> - <If condition={!hasBillingData}> - <If - condition={canManageBilling} - fallback={<CannotManageBillingAlert />} - > - <TeamAccountCheckoutForm - customerId={customerId} - accountId={accountId} + <div className={cn(`flex max-w-2xl flex-col space-y-4`)}> + <If condition={!hasBillingData}> + <If + condition={canManageBilling} + fallback={<CannotManageBillingAlert />} + > + <TeamAccountCheckoutForm + customerId={customerId} + accountId={accountId} + /> + </If> + </If> + + <If condition={subscription}> + {(subscription) => { + return ( + <CurrentSubscriptionCard + subscription={subscription} + product={subscriptionProductPlan!.product} + plan={subscriptionProductPlan!.plan} /> - </If> - </If> + ); + }} + </If> - <If condition={subscription}> - {(subscription) => { - return ( - <CurrentSubscriptionCard - subscription={subscription} - product={subscriptionProductPlan!.product} - plan={subscriptionProductPlan!.plan} - /> - ); - }} - </If> + <If condition={order}> + {(order) => { + return ( + <CurrentLifetimeOrderCard + order={order} + product={orderProductPlan!.product} + plan={orderProductPlan!.plan} + /> + ); + }} + </If> - <If condition={order}> - {(order) => { - return ( - <CurrentLifetimeOrderCard - order={order} - product={orderProductPlan!.product} - plan={orderProductPlan!.plan} - /> - ); - }} - </If> - - {shouldShowBillingPortal ? ( - <BillingPortalForm accountId={accountId} account={account} /> - ) : null} - </div> - </PageBody> - </> + {shouldShowBillingPortal ? ( + <TeamBillingPortalForm accountId={accountId} slug={account} /> + ) : null} + </div> + </PageBody> ); } -export default withI18n(TeamAccountBillingPage); +export default TeamAccountBillingPage; function CannotManageBillingAlert() { return ( <Alert variant={'warning'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'billing:cannotManageBillingAlertTitle'} /> + <Trans i18nKey={'billing.cannotManageBillingAlertTitle'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'billing:cannotManageBillingAlertDescription'} /> + <Trans i18nKey={'billing.cannotManageBillingAlertDescription'} /> </AlertDescription> </Alert> ); } - -function BillingPortalForm({ - accountId, - account, -}: { - accountId: string; - account: string; -}) { - return ( - <form action={createBillingPortalSession}> - <input type="hidden" name={'accountId'} value={accountId} /> - <input type="hidden" name={'slug'} value={account} /> - - <BillingPortalCard /> - </form> - ); -} diff --git a/apps/web/app/home/[account]/billing/return/page.tsx b/apps/web/app/[locale]/home/[account]/billing/return/page.tsx similarity index 95% rename from apps/web/app/home/[account]/billing/return/page.tsx rename to apps/web/app/[locale]/home/[account]/billing/return/page.tsx index b9b031084..a0d747098 100644 --- a/apps/web/app/home/[account]/billing/return/page.tsx +++ b/apps/web/app/[locale]/home/[account]/billing/return/page.tsx @@ -5,7 +5,6 @@ import { BillingSessionStatus } from '@kit/billing-gateway/components'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import billingConfig from '~/config/billing.config'; -import { withI18n } from '~/lib/i18n/with-i18n'; import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; import { EmbeddedCheckoutForm } from '../_components/embedded-checkout-form'; @@ -48,7 +47,7 @@ async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) { ); } -export default withI18n(ReturnCheckoutSessionPage); +export default ReturnCheckoutSessionPage; function BlurryBackdrop() { return ( diff --git a/apps/web/app/home/[account]/layout.tsx b/apps/web/app/[locale]/home/[account]/layout.tsx similarity index 81% rename from apps/web/app/home/[account]/layout.tsx rename to apps/web/app/[locale]/home/[account]/layout.tsx index dd6fcb897..3ce320f9d 100644 --- a/apps/web/app/home/[account]/layout.tsx +++ b/apps/web/app/[locale]/home/[account]/layout.tsx @@ -3,15 +3,14 @@ import { use } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; -import { z } from 'zod'; +import * as z from 'zod'; import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; -import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; +import { SidebarProvider } from '@kit/ui/sidebar'; import { AppLogo } from '~/components/app-logo'; import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; -import { withI18n } from '~/lib/i18n/with-i18n'; // local imports import { TeamAccountLayoutMobileNavigation } from './_components/team-account-layout-mobile-navigation'; @@ -55,6 +54,8 @@ async function SidebarLayout({ image: picture_url, })); + console.log(state); + return ( <TeamAccountWorkspaceContextProvider value={data}> <SidebarProvider defaultOpen={state.open}> @@ -95,12 +96,6 @@ function HeaderLayout({ }>) { const data = use(loadTeamWorkspace(account)); - const accounts = data.accounts.map(({ name, slug, picture_url }) => ({ - label: name, - value: slug, - image: picture_url, - })); - return ( <TeamAccountWorkspaceContextProvider value={data}> <Page style={'header'}> @@ -108,18 +103,6 @@ function HeaderLayout({ <TeamAccountNavigationMenu workspace={data} /> </PageNavigation> - <PageMobileNavigation className={'flex items-center justify-between'}> - <AppLogo /> - - <div className={'group-data-[mobile:hidden]'}> - <TeamAccountLayoutMobileNavigation - userId={data.user.id} - accounts={accounts} - account={account} - /> - </div> - </PageMobileNavigation> - {children} </Page> </TeamAccountWorkspaceContextProvider> @@ -134,13 +117,13 @@ async function getLayoutState(account: string) { .enum(['sidebar', 'header', 'custom']) .default(config.style); - const sidebarOpenCookie = cookieStore.get('sidebar:state'); + const sidebarOpenCookie = cookieStore.get('sidebar_state'); const layoutCookie = cookieStore.get('layout-style'); const layoutStyle = LayoutStyleSchema.safeParse(layoutCookie?.value); const sidebarOpenCookieValue = sidebarOpenCookie - ? sidebarOpenCookie.value === 'false' + ? sidebarOpenCookie.value === 'true' : !config.sidebarCollapsed; const style = layoutStyle.success ? layoutStyle.data : config.style; @@ -151,4 +134,4 @@ async function getLayoutState(account: string) { }; } -export default withI18n(TeamWorkspaceLayout); +export default TeamWorkspaceLayout; diff --git a/apps/web/app/home/[account]/loading.tsx b/apps/web/app/[locale]/home/[account]/loading.tsx similarity index 100% rename from apps/web/app/home/[account]/loading.tsx rename to apps/web/app/[locale]/home/[account]/loading.tsx diff --git a/apps/web/app/home/[account]/members/_lib/server/members-page.loader.ts b/apps/web/app/[locale]/home/[account]/members/_lib/server/members-page.loader.ts similarity index 99% rename from apps/web/app/home/[account]/members/_lib/server/members-page.loader.ts rename to apps/web/app/[locale]/home/[account]/members/_lib/server/members-page.loader.ts index 4db49b5dd..6d250f435 100644 --- a/apps/web/app/home/[account]/members/_lib/server/members-page.loader.ts +++ b/apps/web/app/[locale]/home/[account]/members/_lib/server/members-page.loader.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader'; diff --git a/apps/web/app/[locale]/home/[account]/members/page.tsx b/apps/web/app/[locale]/home/[account]/members/page.tsx new file mode 100644 index 000000000..29f52c96d --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/members/page.tsx @@ -0,0 +1,131 @@ +import { PlusCircle } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { + AccountInvitationsTable, + AccountMembersTable, + InviteMembersDialogContainer, +} from '@kit/team-accounts/components'; +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { Button } from '@kit/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { If } from '@kit/ui/if'; +import { PageBody } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +// local imports +import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; +import { loadMembersPageData } from './_lib/server/members-page.loader'; + +interface TeamAccountMembersPageProps { + params: Promise<{ account: string }>; +} + +export const generateMetadata = async () => { + const t = await getTranslations('teams'); + const title = t('members.pageTitle'); + + return { + title, + }; +}; + +async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) { + const client = getSupabaseServerClient(); + const slug = (await params).account; + + const [members, invitations, canAddMember, { user, account }] = + await loadMembersPageData(client, slug); + + const canManageRoles = account.permissions.includes('roles.manage'); + const canManageInvitations = account.permissions.includes('invites.manage'); + + const isPrimaryOwner = account.primary_owner_user_id === user.id; + const currentUserRoleHierarchy = account.role_hierarchy_level; + + return ( + <PageBody> + <TeamAccountLayoutPageHeader + title={<Trans i18nKey={'common.routes.members'} />} + description={<AppBreadcrumbs />} + account={account.slug} + /> + + <div className={'flex w-full max-w-4xl flex-col space-y-4 pb-32'}> + <Card> + <CardHeader className={'flex flex-row justify-between'}> + <div className={'flex flex-col space-y-1.5'}> + <CardTitle> + <Trans i18nKey={'common.accountMembers'} /> + </CardTitle> + + <CardDescription> + <Trans i18nKey={'common.membersTabDescription'} /> + </CardDescription> + </div> + + <If condition={canManageInvitations && canAddMember}> + <InviteMembersDialogContainer + userRoleHierarchy={currentUserRoleHierarchy} + accountSlug={account.slug} + > + <Button size={'sm'} data-test={'invite-members-form-trigger'}> + <PlusCircle className={'w-4'} /> + + <span> + <Trans i18nKey={'teams.inviteMembersButton'} /> + </span> + </Button> + </InviteMembersDialogContainer> + </If> + </CardHeader> + + <CardContent> + <AccountMembersTable + userRoleHierarchy={currentUserRoleHierarchy} + currentUserId={user.id} + currentAccountId={account.id} + members={members} + isPrimaryOwner={isPrimaryOwner} + canManageRoles={canManageRoles} + /> + </CardContent> + </Card> + + <Card> + <CardHeader className={'flex flex-row justify-between'}> + <div className={'flex flex-col space-y-1.5'}> + <CardTitle> + <Trans i18nKey={'teams.pendingInvitesHeading'} /> + </CardTitle> + + <CardDescription> + <Trans i18nKey={'teams.pendingInvitesDescription'} /> + </CardDescription> + </div> + </CardHeader> + + <CardContent> + <AccountInvitationsTable + permissions={{ + canUpdateInvitation: canManageRoles, + canRemoveInvitation: canManageRoles, + currentUserRoleHierarchy, + }} + invitations={invitations} + /> + </CardContent> + </Card> + </div> + </PageBody> + ); +} + +export default TeamAccountMembersPage; diff --git a/apps/web/app/home/[account]/members/policies/route.ts b/apps/web/app/[locale]/home/[account]/members/policies/route.ts similarity index 98% rename from apps/web/app/home/[account]/members/policies/route.ts rename to apps/web/app/[locale]/home/[account]/members/policies/route.ts index 45f66cd97..2b283a0e3 100644 --- a/apps/web/app/home/[account]/members/policies/route.ts +++ b/apps/web/app/[locale]/home/[account]/members/policies/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import { z } from 'zod'; +import * as z from 'zod'; import { enhanceRouteHandler } from '@kit/next/routes'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; diff --git a/apps/web/app/home/[account]/page.tsx b/apps/web/app/[locale]/home/[account]/page.tsx similarity index 64% rename from apps/web/app/home/[account]/page.tsx rename to apps/web/app/[locale]/home/[account]/page.tsx index 6ab1da5b2..5ab93f17e 100644 --- a/apps/web/app/home/[account]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/page.tsx @@ -1,12 +1,11 @@ import { use } from 'react'; +import { getTranslations } from 'next-intl/server'; + import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; 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'; - import { DashboardDemo } from './_components/dashboard-demo'; import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header'; @@ -15,8 +14,8 @@ interface TeamAccountHomePageProps { } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('teams:home.pageTitle'); + const t = await getTranslations('teams'); + const title = t('home.pageTitle'); return { title, @@ -27,18 +26,16 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { const account = use(params).account; return ( - <> + <PageBody> <TeamAccountLayoutPageHeader account={account} - title={<Trans i18nKey={'common:routes.dashboard'} />} + title={<Trans i18nKey={'common.routes.dashboard'} />} description={<AppBreadcrumbs />} /> - <PageBody> - <DashboardDemo /> - </PageBody> - </> + <DashboardDemo /> + </PageBody> ); } -export default withI18n(TeamAccountHomePage); +export default TeamAccountHomePage; diff --git a/apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx b/apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx new file mode 100644 index 000000000..0711996b7 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { + BorderedNavigationMenu, + BorderedNavigationMenuItem, +} from '@kit/ui/bordered-navigation-menu'; + +import pathsConfig from '~/config/paths.config'; + +export function SettingsSubNavigation(props: { account: string }) { + const settingsPath = pathsConfig.app.accountSettings.replace( + '[account]', + props.account, + ); + + const profilePath = pathsConfig.app.accountProfileSettings.replace( + '[account]', + props.account, + ); + + return ( + <BorderedNavigationMenu> + <BorderedNavigationMenuItem + path={settingsPath} + label={'common.routes.settings'} + highlightMatch={`/home/${props.account}/settings$`} + /> + + <BorderedNavigationMenuItem + path={profilePath} + label={'common.routes.profile'} + /> + </BorderedNavigationMenu> + ); +} diff --git a/apps/web/app/[locale]/home/[account]/settings/layout.tsx b/apps/web/app/[locale]/home/[account]/settings/layout.tsx new file mode 100644 index 000000000..2706a5d3e --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/settings/layout.tsx @@ -0,0 +1,39 @@ +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { PageBody } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +import featuresFlagConfig from '~/config/feature-flags.config'; + +import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; +import { SettingsSubNavigation } from './_components/settings-sub-navigation'; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ account: string }>; +} + +async function SettingsLayout({ children, params }: SettingsLayoutProps) { + const { account } = await params; + + return ( + <PageBody> + <div> + <TeamAccountLayoutPageHeader + account={account} + title={<Trans i18nKey={'teams.settings.pageTitle'} />} + description={<AppBreadcrumbs />} + /> + + {featuresFlagConfig.enableTeamsOnly && ( + <div className="mb-8"> + <SettingsSubNavigation account={account} /> + </div> + )} + </div> + + {children} + </PageBody> + ); +} + +export default SettingsLayout; diff --git a/apps/web/app/home/[account]/settings/page.tsx b/apps/web/app/[locale]/home/[account]/settings/page.tsx similarity index 57% rename from apps/web/app/home/[account]/settings/page.tsx rename to apps/web/app/[locale]/home/[account]/settings/page.tsx index 3a1a49273..544250761 100644 --- a/apps/web/app/home/[account]/settings/page.tsx +++ b/apps/web/app/[locale]/home/[account]/settings/page.tsx @@ -1,20 +1,15 @@ +import { getTranslations } from 'next-intl/server'; + import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createTeamAccountsApi } from '@kit/team-accounts/api'; import { TeamAccountSettingsContainer } from '@kit/team-accounts/components'; -import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; -import { PageBody } from '@kit/ui/page'; -import { Trans } from '@kit/ui/trans'; import featuresFlagConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; - -// local imports -import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('teams:settings:pageTitle'); + const t = await getTranslations('teams'); + const title = t('settings.pageTitle'); return { title, @@ -47,23 +42,13 @@ async function TeamAccountSettingsPage(props: TeamAccountSettingsPageProps) { }; return ( - <> - <TeamAccountLayoutPageHeader - account={account.slug} - title={<Trans i18nKey={'teams:settings.pageTitle'} />} - description={<AppBreadcrumbs />} + <div className={'flex max-w-2xl flex-1 flex-col'}> + <TeamAccountSettingsContainer + account={account} + paths={paths} + features={features} /> - - <PageBody> - <div className={'flex max-w-2xl flex-1 flex-col'}> - <TeamAccountSettingsContainer - account={account} - paths={paths} - features={features} - /> - </div> - </PageBody> - </> + </div> ); } diff --git a/apps/web/app/[locale]/home/[account]/settings/profile/page.tsx b/apps/web/app/[locale]/home/[account]/settings/profile/page.tsx new file mode 100644 index 000000000..042e700bd --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/settings/profile/page.tsx @@ -0,0 +1,66 @@ +import { getTranslations } from 'next-intl/server'; + +import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings'; + +import authConfig from '~/config/auth.config'; +import featureFlagsConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; +import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; + +const showEmailOption = + authConfig.providers.password || + authConfig.providers.magicLink || + authConfig.providers.otp; + +const features = { + showLinkEmailOption: showEmailOption, + enablePasswordUpdate: authConfig.providers.password, + enableAccountDeletion: featureFlagsConfig.enableAccountDeletion, + enableAccountLinking: authConfig.enableIdentityLinking, +}; + +const providers = authConfig.providers.oAuth; + +export const generateMetadata = async () => { + const t = await getTranslations('account'); + const title = t('settingsTab'); + + return { + title, + }; +}; + +interface TeamProfileSettingsPageProps { + params: Promise<{ account: string }>; +} + +async function TeamProfileSettingsPage({ + params, +}: TeamProfileSettingsPageProps) { + const [user, { account }] = await Promise.all([ + requireUserInServerComponent(), + params, + ]); + + const profilePath = pathsConfig.app.accountProfileSettings.replace( + '[account]', + account, + ); + + const paths = { + callback: pathsConfig.auth.callback + `?next=${profilePath}`, + }; + + return ( + <div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}> + <PersonalAccountSettingsContainer + userId={user.id} + features={features} + paths={paths} + providers={providers} + /> + </div> + ); +} + +export default TeamProfileSettingsPage; diff --git a/apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx b/apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx new file mode 100644 index 000000000..c03bdfeba --- /dev/null +++ b/apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { CreateTeamAccountForm } from '@kit/team-accounts/components'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { Trans } from '@kit/ui/trans'; + +export function CreateFirstTeamForm() { + return ( + <Card> + <CardHeader> + <CardTitle> + <Trans i18nKey={'teams.createFirstTeamHeading'} /> + </CardTitle> + + <CardDescription> + <Trans i18nKey={'teams.createFirstTeamDescription'} /> + </CardDescription> + </CardHeader> + + <CardContent> + <CreateTeamAccountForm submitLabel={'teams.getStarted'} /> + </CardContent> + </Card> + ); +} diff --git a/apps/web/app/[locale]/home/create-team/page.tsx b/apps/web/app/[locale]/home/create-team/page.tsx new file mode 100644 index 000000000..a22918ac6 --- /dev/null +++ b/apps/web/app/[locale]/home/create-team/page.tsx @@ -0,0 +1,52 @@ +import { redirect } from 'next/navigation'; + +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { AppLogo } from '~/components/app-logo'; +import featuresFlagConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; +import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; + +import { CreateFirstTeamForm } from './_components/create-first-team-form'; + +async function CreateTeamPage() { + const data = await loadData(); + + if (data.redirectTo) { + redirect(data.redirectTo); + } + + return ( + <div className="flex min-h-screen flex-col items-center justify-center gap-y-8"> + <AppLogo /> + + <CreateFirstTeamForm /> + </div> + ); +} + +export default CreateTeamPage; + +async function loadData() { + await requireUserInServerComponent(); + + if (!featuresFlagConfig.enableTeamsOnly) { + return { redirectTo: pathsConfig.app.home }; + } + + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + const accounts = await api.loadUserAccounts(); + + if (accounts.length > 0 && accounts[0]?.value) { + return { + redirectTo: pathsConfig.app.accountHome.replace( + '[account]', + accounts[0].value, + ), + }; + } + + return { redirectTo: null }; +} diff --git a/apps/web/app/home/loading.tsx b/apps/web/app/[locale]/home/loading.tsx similarity index 100% rename from apps/web/app/home/loading.tsx rename to apps/web/app/[locale]/home/loading.tsx diff --git a/apps/web/app/identities/_components/identities-step-wrapper.tsx b/apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx similarity index 85% rename from apps/web/app/identities/_components/identities-step-wrapper.tsx rename to apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx index 472580ddb..abbcb33ac 100644 --- a/apps/web/app/identities/_components/identities-step-wrapper.tsx +++ b/apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx @@ -119,35 +119,43 @@ export function IdentitiesStepWrapper(props: IdentitiesStepWrapperProps) { onProviderLinked={() => setHasLinkedProvider(true)} /> - <Button asChild data-test="continue-button"> - <Link href={props.nextPath} onClick={handleContinueClick}> - <Trans i18nKey={'common:continueKey'} /> - </Link> - </Button> + <Button + nativeButton={false} + render={ + <Link href={props.nextPath} onClick={handleContinueClick}> + <Trans i18nKey={'common.continueKey'} /> + </Link> + } + data-test="continue-button" + /> </div> <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <AlertDialogContent data-test="no-auth-method-dialog"> <AlertDialogHeader> <AlertDialogTitle data-test="no-auth-dialog-title"> - <Trans i18nKey={'auth:noIdentityLinkedTitle'} /> + <Trans i18nKey={'auth.noIdentityLinkedTitle'} /> </AlertDialogTitle> <AlertDialogDescription data-test="no-auth-dialog-description"> - <Trans i18nKey={'auth:noIdentityLinkedDescription'} /> + <Trans i18nKey={'auth.noIdentityLinkedDescription'} /> </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel data-test="no-auth-dialog-cancel"> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> - <AlertDialogAction asChild data-test="no-auth-dialog-continue"> - <Link href={props.nextPath}> - <Trans i18nKey={'common:continueKey'} /> - </Link> - </AlertDialogAction> + <AlertDialogAction + nativeButton={false} + render={ + <Link href={props.nextPath}> + <Trans i18nKey={'common.continueKey'} /> + </Link> + } + data-test="no-auth-dialog-continue" + /> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> diff --git a/apps/web/app/identities/page.tsx b/apps/web/app/[locale]/identities/page.tsx similarity index 90% rename from apps/web/app/identities/page.tsx rename to apps/web/app/[locale]/identities/page.tsx index 8f6dcd400..4a077f50f 100644 --- a/apps/web/app/identities/page.tsx +++ b/apps/web/app/[locale]/identities/page.tsx @@ -1,7 +1,8 @@ import { Metadata } from 'next'; - import { redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; + import { AuthLayoutShell } from '@kit/auth/shared'; import { getSafeRedirectPath } from '@kit/shared/utils'; import { requireUser } from '@kit/supabase/require-user'; @@ -12,16 +13,14 @@ import { Trans } from '@kit/ui/trans'; import { AppLogo } from '~/components/app-logo'; 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'; import { IdentitiesStepWrapper } from './_components/identities-step-wrapper'; export const meta = async (): Promise<Metadata> => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('auth'); return { - title: i18n.t('auth:setupAccount'), + title: t('setupAccount'), }; }; @@ -59,7 +58,7 @@ async function IdentitiesPage(props: IdentitiesPageProps) { className="text-center" data-test="identities-page-heading" > - <Trans i18nKey={'auth:linkAccountToSignIn'} /> + <Trans i18nKey={'auth.linkAccountToSignIn'} /> </Heading> <Heading @@ -67,7 +66,7 @@ async function IdentitiesPage(props: IdentitiesPageProps) { className={'text-muted-foreground text-center text-sm'} data-test="identities-page-description" > - <Trans i18nKey={'auth:linkAccountToSignInDescription'} /> + <Trans i18nKey={'auth.linkAccountToSignInDescription'} /> </Heading> </div> @@ -84,7 +83,7 @@ async function IdentitiesPage(props: IdentitiesPageProps) { ); } -export default withI18n(IdentitiesPage); +export default IdentitiesPage; async function fetchData(props: IdentitiesPageProps) { const searchParams = await props.searchParams; diff --git a/apps/web/app/join/accept/route.ts b/apps/web/app/[locale]/join/accept/route.ts similarity index 100% rename from apps/web/app/join/accept/route.ts rename to apps/web/app/[locale]/join/accept/route.ts diff --git a/apps/web/app/join/page.tsx b/apps/web/app/[locale]/join/page.tsx similarity index 90% rename from apps/web/app/join/page.tsx rename to apps/web/app/[locale]/join/page.tsx index 5cba7cc79..e5296f039 100644 --- a/apps/web/app/join/page.tsx +++ b/apps/web/app/[locale]/join/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { notFound, redirect } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; import { AuthLayoutShell } from '@kit/auth/shared'; import { MultiFactorAuthError, requireUser } from '@kit/supabase/require-user'; @@ -16,8 +17,6 @@ import { Trans } from '@kit/ui/trans'; import { AppLogo } from '~/components/app-logo'; 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 JoinTeamAccountPageProps { searchParams: Promise<{ @@ -29,10 +28,10 @@ interface JoinTeamAccountPageProps { } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('teams'); return { - title: i18n.t('teams:joinTeamAccount'), + title: t('joinTeamAccount'), }; }; @@ -178,25 +177,29 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) { ); } -export default withI18n(JoinTeamAccountPage); +export default JoinTeamAccountPage; function InviteNotFoundOrExpired() { return ( <div className={'flex flex-col space-y-4'}> <Heading level={6}> - <Trans i18nKey={'teams:inviteNotFoundOrExpired'} /> + <Trans i18nKey={'teams.inviteNotFoundOrExpired'} /> </Heading> <p className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'teams:inviteNotFoundOrExpiredDescription'} /> + <Trans i18nKey={'teams.inviteNotFoundOrExpiredDescription'} /> </p> - <Button asChild className={'w-full'} variant={'outline'}> - <Link href={pathsConfig.app.home}> - <ArrowLeft className={'mr-2 w-4'} /> - <Trans i18nKey={'teams:backToHome'} /> - </Link> - </Button> + <Button + render={ + <Link href={pathsConfig.app.home}> + <ArrowLeft className={'mr-2 w-4'} /> + <Trans i18nKey={'teams.backToHome'} /> + </Link> + } + className={'w-full'} + variant={'outline'} + /> </div> ); } diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx new file mode 100644 index 000000000..7011924bf --- /dev/null +++ b/apps/web/app/[locale]/layout.tsx @@ -0,0 +1,79 @@ +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import { hasLocale } from 'next-intl'; +import { getMessages } from 'next-intl/server'; +import { PublicEnvScript } from 'next-runtime-env'; + +import { routing } from '@kit/i18n/routing'; +import { Toaster } from '@kit/ui/sonner'; +import { cn } from '@kit/ui/utils'; + +import { RootProviders } from '~/components/root-providers'; +import { getFontsClassName } from '~/lib/fonts'; +import { generateRootMetadata } from '~/lib/root-metadata'; +import { getRootTheme } from '~/lib/root-theme'; + +export const generateMetadata = () => { + return generateRootMetadata(); +}; + +interface LocaleLayoutProps { + children: React.ReactNode; + params: Promise<{ locale: string }>; +} + +export default async function LocaleLayout({ + children, + params, +}: LocaleLayoutProps) { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + const [theme, nonce, messages] = await Promise.all([ + getRootTheme(), + getCspNonce(), + getMessages({ locale }), + ]); + + const className = getRootClassName(theme); + + return ( + <html lang={locale} className={className} suppressHydrationWarning> + <head> + <PublicEnvScript nonce={nonce} /> + </head> + + <body> + <RootProviders + theme={theme} + locale={locale} + nonce={nonce} + messages={messages} + > + {children} + + <Toaster richColors={true} theme={theme} position="top-center" /> + </RootProviders> + </body> + </html> + ); +} + +function getRootClassName(theme: string) { + const fontsClassName = getFontsClassName(theme); + + return cn( + 'bg-background min-h-screen antialiased md:overscroll-y-none', + fontsClassName, + ); +} + +async function getCspNonce() { + const headersStore = await headers(); + + return headersStore.get('x-nonce') ?? undefined; +} diff --git a/apps/web/app/[locale]/not-found.tsx b/apps/web/app/[locale]/not-found.tsx new file mode 100644 index 000000000..0d2adb674 --- /dev/null +++ b/apps/web/app/[locale]/not-found.tsx @@ -0,0 +1,26 @@ +import { getTranslations } from 'next-intl/server'; + +import { ErrorPageContent } from '~/components/error-page-content'; + +export const generateMetadata = async () => { + const t = await getTranslations('common'); + const title = t('notFound'); + + return { + title, + }; +}; + +const NotFoundPage = async () => { + return ( + <div className={'flex h-screen flex-1 flex-col'}> + <ErrorPageContent + statusCode={'common.pageNotFoundHeading'} + heading={'common.pageNotFound'} + subtitle={'common.pageNotFoundSubHeading'} + /> + </div> + ); +}; + +export default NotFoundPage; diff --git a/apps/web/app/update-password/page.tsx b/apps/web/app/[locale]/update-password/page.tsx similarity index 82% rename from apps/web/app/update-password/page.tsx rename to apps/web/app/[locale]/update-password/page.tsx index 6750c5c0f..0023c22dd 100644 --- a/apps/web/app/update-password/page.tsx +++ b/apps/web/app/[locale]/update-password/page.tsx @@ -1,5 +1,7 @@ import { redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; + import { UpdatePasswordForm } from '@kit/auth/password-reset'; import { AuthLayoutShell } from '@kit/auth/shared'; import { getSafeRedirectPath } from '@kit/shared/utils'; @@ -8,14 +10,12 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AppLogo } from '~/components/app-logo'; 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(); + const t = await getTranslations('auth'); return { - title: t('auth:updatePassword'), + title: t('updatePassword'), }; }; @@ -48,4 +48,4 @@ async function UpdatePasswordPage(props: UpdatePasswordPageProps) { ); } -export default withI18n(UpdatePasswordPage); +export default UpdatePasswordPage; diff --git a/apps/web/app/healthcheck/route.ts b/apps/web/app/api/healthcheck/route.ts similarity index 100% rename from apps/web/app/healthcheck/route.ts rename to apps/web/app/api/healthcheck/route.ts diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx index c87bd5eca..8e7e8eff2 100644 --- a/apps/web/app/global-error.tsx +++ b/apps/web/app/global-error.tsx @@ -19,7 +19,7 @@ const GlobalErrorPage = ({ return ( <html lang="en"> <body> - <RootProviders> + <RootProviders messages={{}}> <GlobalErrorContent reset={reset} /> </RootProviders> </body> @@ -35,10 +35,10 @@ function GlobalErrorContent({ reset }: { reset: () => void }) { <SiteHeader user={user.data} /> <ErrorPageContent - statusCode={'common:errorPageHeading'} - heading={'common:genericError'} - subtitle={'common:genericErrorSubHeading'} - backLabel={'common:goBack'} + statusCode={'common.errorPageHeading'} + heading={'common.genericError'} + subtitle={'common.genericErrorSubHeading'} + backLabel={'common.goBack'} reset={reset} /> </div> diff --git a/apps/web/app/home/(user)/_components/home-sidebar.tsx b/apps/web/app/home/(user)/_components/home-sidebar.tsx deleted file mode 100644 index 21b88988f..000000000 --- a/apps/web/app/home/(user)/_components/home-sidebar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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]/sidebar:max-w-full group-data-[minimized=true]/sidebar:py-0', - )} - /> - } - > - <HomeAccountSelector userId={user.id} accounts={accounts} /> - </If> - - <div className={'group-data-[minimized=true]/sidebar:hidden'}> - <UserNotifications userId={user.id} /> - </div> - </div> - </SidebarHeader> - - <SidebarContent> - <SidebarNavigation config={personalAccountNavigationConfig} /> - </SidebarContent> - - <SidebarFooter> - <ProfileAccountDropdownContainer user={user} account={workspace} /> - </SidebarFooter> - </Sidebar> - ); -} diff --git a/apps/web/app/home/(user)/billing/page.tsx b/apps/web/app/home/(user)/billing/page.tsx deleted file mode 100644 index 21d31b118..000000000 --- a/apps/web/app/home/(user)/billing/page.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { resolveProductPlan } from '@kit/billing-gateway'; -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 [subscription, order, customerId] = - await loadPersonalAccountBillingPageData(user.id); - - const subscriptionVariantId = subscription?.items[0]?.variant_id; - const orderVariantId = order?.items[0]?.variant_id; - - const subscriptionProductPlan = - subscription && subscriptionVariantId - ? await resolveProductPlan( - billingConfig, - subscriptionVariantId, - subscription.currency, - ) - : undefined; - - const orderProductPlan = - order && orderVariantId - ? await resolveProductPlan(billingConfig, orderVariantId, order.currency) - : undefined; - - const hasBillingData = subscription || order; - - return ( - <> - <HomeLayoutPageHeader - title={<Trans i18nKey={'common:routes.billing'} />} - description={<AppBreadcrumbs />} - /> - - <PageBody> - <div className={'flex max-w-2xl flex-col space-y-4'}> - <If - condition={hasBillingData} - fallback={ - <> - <PersonalAccountCheckoutForm customerId={customerId} /> - </> - } - > - <div className={'flex w-full flex-col space-y-6'}> - <If condition={subscription}> - {(subscription) => { - return ( - <CurrentSubscriptionCard - subscription={subscription} - product={subscriptionProductPlan!.product} - plan={subscriptionProductPlan!.plan} - /> - ); - }} - </If> - - <If condition={order}> - {(order) => { - return ( - <CurrentLifetimeOrderCard - order={order} - product={orderProductPlan!.product} - plan={orderProductPlan!.plan} - /> - ); - }} - </If> - </div> - </If> - - <If condition={customerId}>{() => <CustomerBillingPortalForm />}</If> - </div> - </PageBody> - </> - ); -} - -export default withI18n(PersonalAccountBillingPage); - -function CustomerBillingPortalForm() { - return ( - <form action={createPersonalAccountBillingPortalSession}> - <BillingPortalCard /> - </form> - ); -} diff --git a/apps/web/app/home/(user)/page.tsx b/apps/web/app/home/(user)/page.tsx deleted file mode 100644 index 3327e1f2f..000000000 --- a/apps/web/app/home/(user)/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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); diff --git a/apps/web/app/home/[account]/_components/team-account-layout-sidebar.tsx b/apps/web/app/home/[account]/_components/team-account-layout-sidebar.tsx deleted file mode 100644 index ee49fdbc7..000000000 --- a/apps/web/app/home/[account]/_components/team-account-layout-sidebar.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { JWTUserData } from '@kit/supabase/types'; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, -} from '@kit/ui/shadcn-sidebar'; - -import { ProfileAccountDropdownContainer } from '~/components//personal-account-dropdown-container'; -import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; -import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications'; - -import { TeamAccountAccountsSelector } from '../_components/team-account-accounts-selector'; -import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation'; - -type AccountModel = { - label: string | null; - value: string | null; - image: string | null; -}; - -export function TeamAccountLayoutSidebar(props: { - account: string; - accountId: string; - accounts: AccountModel[]; - user: JWTUserData; -}) { - return ( - <SidebarContainer - account={props.account} - accountId={props.accountId} - accounts={props.accounts} - user={props.user} - /> - ); -} - -function SidebarContainer(props: { - account: string; - accountId: string; - accounts: AccountModel[]; - user: JWTUserData; -}) { - const { account, accounts, user } = props; - const userId = user.id; - - const config = getTeamAccountSidebarConfig(account); - const collapsible = config.sidebarCollapsedStyle; - - return ( - <Sidebar collapsible={collapsible}> - <SidebarHeader className={'h-16 justify-center'}> - <div className={'flex items-center justify-between gap-x-3'}> - <TeamAccountAccountsSelector - userId={userId} - selectedAccount={account} - accounts={accounts} - /> - - <div className={'group-data-[minimized=true]/sidebar:hidden'}> - <TeamAccountNotifications - userId={userId} - accountId={props.accountId} - /> - </div> - </div> - </SidebarHeader> - - <SidebarContent className={`mt-5 h-[calc(100%-160px)] overflow-y-auto`}> - <TeamAccountLayoutSidebarNavigation config={config} /> - </SidebarContent> - - <SidebarFooter> - <SidebarContent> - <ProfileAccountDropdownContainer user={props.user} /> - </SidebarContent> - </SidebarFooter> - </Sidebar> - ); -} diff --git a/apps/web/app/home/[account]/members/page.tsx b/apps/web/app/home/[account]/members/page.tsx deleted file mode 100644 index bb471bf87..000000000 --- a/apps/web/app/home/[account]/members/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { PlusCircle } from 'lucide-react'; - -import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { - AccountInvitationsTable, - AccountMembersTable, - InviteMembersDialogContainer, -} from '@kit/team-accounts/components'; -import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; -import { Button } from '@kit/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@kit/ui/card'; -import { If } from '@kit/ui/if'; -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 { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; -import { loadMembersPageData } from './_lib/server/members-page.loader'; - -interface TeamAccountMembersPageProps { - params: Promise<{ account: string }>; -} - -export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('teams:members.pageTitle'); - - return { - title, - }; -}; - -async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) { - const client = getSupabaseServerClient(); - const slug = (await params).account; - - const [members, invitations, canAddMember, { user, account }] = - await loadMembersPageData(client, slug); - - const canManageRoles = account.permissions.includes('roles.manage'); - const canManageInvitations = account.permissions.includes('invites.manage'); - - const isPrimaryOwner = account.primary_owner_user_id === user.id; - const currentUserRoleHierarchy = account.role_hierarchy_level; - - return ( - <> - <TeamAccountLayoutPageHeader - title={<Trans i18nKey={'common:routes.members'} />} - description={<AppBreadcrumbs />} - account={account.slug} - /> - - <PageBody> - <div className={'flex w-full max-w-4xl flex-col space-y-4 pb-32'}> - <Card> - <CardHeader className={'flex flex-row justify-between'}> - <div className={'flex flex-col space-y-1.5'}> - <CardTitle> - <Trans i18nKey={'common:accountMembers'} /> - </CardTitle> - - <CardDescription> - <Trans i18nKey={'common:membersTabDescription'} /> - </CardDescription> - </div> - - <If condition={canManageInvitations && canAddMember}> - <InviteMembersDialogContainer - userRoleHierarchy={currentUserRoleHierarchy} - accountSlug={account.slug} - > - <Button size={'sm'} data-test={'invite-members-form-trigger'}> - <PlusCircle className={'mr-2 w-4'} /> - - <span> - <Trans i18nKey={'teams:inviteMembersButton'} /> - </span> - </Button> - </InviteMembersDialogContainer> - </If> - </CardHeader> - - <CardContent> - <AccountMembersTable - userRoleHierarchy={currentUserRoleHierarchy} - currentUserId={user.id} - currentAccountId={account.id} - members={members} - isPrimaryOwner={isPrimaryOwner} - canManageRoles={canManageRoles} - /> - </CardContent> - </Card> - - <Card> - <CardHeader className={'flex flex-row justify-between'}> - <div className={'flex flex-col space-y-1.5'}> - <CardTitle> - <Trans i18nKey={'teams:pendingInvitesHeading'} /> - </CardTitle> - - <CardDescription> - <Trans i18nKey={'teams:pendingInvitesDescription'} /> - </CardDescription> - </div> - </CardHeader> - - <CardContent> - <AccountInvitationsTable - permissions={{ - canUpdateInvitation: canManageRoles, - canRemoveInvitation: canManageRoles, - currentUserRoleHierarchy, - }} - invitations={invitations} - /> - </CardContent> - </Card> - </div> - </PageBody> - </> - ); -} - -export default withI18n(TeamAccountMembersPage); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8477a7e69..a2d9d4194 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,58 +1,5 @@ -import { headers } from 'next/headers'; - -import { Toaster } from '@kit/ui/sonner'; -import { cn } from '@kit/ui/utils'; - -import { RootProviders } from '~/components/root-providers'; -import { getFontsClassName } from '~/lib/fonts'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { generateRootMetadata } from '~/lib/root-metadata'; -import { getRootTheme } from '~/lib/root-theme'; - import '../styles/globals.css'; -export const generateMetadata = () => { - return generateRootMetadata(); -}; - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - const [theme, nonce, i18n] = await Promise.all([ - getRootTheme(), - getCspNonce(), - createI18nServerInstance(), - ]); - - const className = getRootClassName(theme); - const language = i18n.language; - - return ( - <html lang={language} className={className}> - <body> - <RootProviders theme={theme} lang={language} nonce={nonce}> - {children} - </RootProviders> - - <Toaster richColors={true} theme={theme} position="top-center" /> - </body> - </html> - ); -} - -function getRootClassName(theme: string) { - const fontsClassName = getFontsClassName(theme); - - return cn( - 'bg-background min-h-screen antialiased md:overscroll-y-none', - fontsClassName, - ); -} - -async function getCspNonce() { - const headersStore = await headers(); - - return headersStore.get('x-nonce') ?? undefined; +export default function RootLayout({ children }: React.PropsWithChildren) { + return children; } diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index 60573fb67..6fd69231b 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,10 +1,16 @@ +import { cookies } from 'next/headers'; + +import { getMessages, getTranslations } from 'next-intl/server'; + +import { routing } from '@kit/i18n'; +import { I18nClientProvider } from '@kit/i18n/provider'; + import { ErrorPageContent } from '~/components/error-page-content'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; +import { getRootTheme } from '~/lib/root-theme'; export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('common:notFound'); + const t = await getTranslations('common'); + const title = t('notFound'); return { title, @@ -12,15 +18,26 @@ export const generateMetadata = async () => { }; const NotFoundPage = async () => { + const theme = await getRootTheme(); + const cookieStore = await cookies(); + const locale = cookieStore.get('lang')?.value || routing.defaultLocale; + const messages = await getMessages({ locale }); + return ( - <div className={'flex h-screen flex-1 flex-col'}> - <ErrorPageContent - statusCode={'common:pageNotFoundHeading'} - heading={'common:pageNotFound'} - subtitle={'common:pageNotFoundSubHeading'} - /> - </div> + <html lang="en" className={theme}> + <body className="bg-background"> + <div className={'flex h-screen flex-1 flex-col'}> + <I18nClientProvider locale={locale} messages={messages}> + <ErrorPageContent + statusCode={'common.pageNotFoundHeading'} + heading={'common.pageNotFound'} + subtitle={'common.pageNotFoundSubHeading'} + /> + </I18nClientProvider> + </div> + </body> + </html> ); }; -export default withI18n(NotFoundPage); +export default NotFoundPage; diff --git a/apps/web/components/app-logo.tsx b/apps/web/components/app-logo.tsx index f5b354a26..360717334 100644 --- a/apps/web/components/app-logo.tsx +++ b/apps/web/components/app-logo.tsx @@ -2,7 +2,13 @@ import Link from 'next/link'; import { cn } from '@kit/ui/utils'; -function LogoImage({ +/** + * App Logo Image - modify this with your own logo + * @param className - The class name to apply to the logo + * @param width - The width of the logo + * @returns + */ +export function LogoImage({ className, width = 105, }: { @@ -12,7 +18,7 @@ function LogoImage({ return ( <svg width={width} - className={cn(`w-[80px] lg:w-[95px]`, className)} + className={cn(`w-20 lg:w-[95px]`, className)} viewBox="0 0 733 140" fill="none" xmlns="http://www.w3.org/2000/svg" @@ -40,7 +46,12 @@ export function AppLogo({ } return ( - <Link aria-label={label ?? 'Home Page'} href={href ?? '/'} prefetch={true}> + <Link + aria-label={label ?? 'Home Page'} + href={href ?? '/'} + prefetch={true} + className="mx-auto md:mx-0" + > <LogoImage className={className} /> </Link> ); diff --git a/apps/web/components/error-page-content.tsx b/apps/web/components/error-page-content.tsx index f3efe1260..26c78aac6 100644 --- a/apps/web/components/error-page-content.tsx +++ b/apps/web/components/error-page-content.tsx @@ -13,8 +13,8 @@ export function ErrorPageContent({ subtitle, reset, backLink = '/', - backLabel = 'common:backToHomePage', - contactLabel = 'common:contactUs', + backLabel = 'common.backToHomePage', + contactLabel = 'common.contactUs', }: { statusCode: string; heading: string; @@ -67,20 +67,27 @@ export function ErrorPageContent({ <Trans i18nKey={backLabel} /> </Button> ) : ( - <Button asChild> - <Link href={backLink}> - <ArrowLeft className={'mr-1 h-4 w-4'} /> - <Trans i18nKey={backLabel} /> - </Link> - </Button> + <Button + nativeButton={false} + render={ + <Link href={backLink}> + <ArrowLeft className={'mr-1 h-4 w-4'} /> + <Trans i18nKey={backLabel} /> + </Link> + } + /> )} - <Button asChild variant={'ghost'}> - <Link href={'/contact'}> - <MessageCircleQuestion className={'mr-1 h-4 w-4'} /> - <Trans i18nKey={contactLabel} /> - </Link> - </Button> + <Button + nativeButton={false} + render={ + <Link href={'/contact'}> + <MessageCircleQuestion className={'mr-1 h-4 w-4'} /> + <Trans i18nKey={contactLabel} /> + </Link> + } + variant={'ghost'} + /> </div> </div> </div> diff --git a/apps/web/components/personal-account-dropdown-container.tsx b/apps/web/components/personal-account-dropdown-container.tsx index f8df43a35..9b96c56ee 100644 --- a/apps/web/components/personal-account-dropdown-container.tsx +++ b/apps/web/components/personal-account-dropdown-container.tsx @@ -8,10 +8,6 @@ import { JWTUserData } from '@kit/supabase/types'; import featuresFlagConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; -const paths = { - home: pathsConfig.app.home, -}; - const features = { enableThemeToggle: featuresFlagConfig.enableThemeToggle, }; @@ -19,6 +15,7 @@ const features = { export function ProfileAccountDropdownContainer(props: { user?: JWTUserData | null; showProfileName?: boolean; + accountSlug?: string; account?: { id: string | null; @@ -34,10 +31,15 @@ export function ProfileAccountDropdownContainer(props: { return null; } + const homePath = + featuresFlagConfig.enableTeamsOnly && props.accountSlug + ? pathsConfig.app.accountHome.replace('[account]', props.accountSlug) + : pathsConfig.app.home; + return ( <PersonalAccountDropdown className={'w-full'} - paths={paths} + paths={{ home: homePath }} features={features} user={userData} account={props.account} diff --git a/apps/web/components/root-providers.tsx b/apps/web/components/root-providers.tsx index ee4a5215f..07044eaec 100644 --- a/apps/web/components/root-providers.tsx +++ b/apps/web/components/root-providers.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useMemo } from 'react'; - +import type { AbstractIntlMessages } from 'next-intl'; import { ThemeProvider } from 'next-themes'; -import { I18nProvider } from '@kit/i18n/provider'; +import { I18nClientProvider } from '@kit/i18n/provider'; import { MonitoringProvider } from '@kit/monitoring/components'; import { AppEventsProvider } from '@kit/shared/events'; +import { CSPProvider } from '@kit/ui/csp-provider'; import { If } from '@kit/ui/if'; import { VersionUpdater } from '@kit/ui/version-updater'; @@ -14,52 +14,52 @@ import { AnalyticsProvider } from '~/components/analytics-provider'; import { AuthProvider } from '~/components/auth-provider'; import appConfig from '~/config/app.config'; import featuresFlagConfig from '~/config/feature-flags.config'; -import { i18nResolver } from '~/lib/i18n/i18n.resolver'; -import { getI18nSettings } from '~/lib/i18n/i18n.settings'; import { ReactQueryProvider } from './react-query-provider'; type RootProvidersProps = React.PropsWithChildren<{ // The language to use for the app (optional) - lang?: string; + locale?: string; // The theme (light or dark or system) (optional) theme?: string; // The CSP nonce to pass to scripts (optional) nonce?: string; + messages: AbstractIntlMessages; }>; export function RootProviders({ - lang, + locale = 'en', + messages, theme = appConfig.theme, nonce, children, }: RootProvidersProps) { - const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]); - return ( <MonitoringProvider> <AppEventsProvider> <AnalyticsProvider> - <ReactQueryProvider> - <I18nProvider settings={i18nSettings} resolver={i18nResolver}> - <AuthProvider> - <ThemeProvider - attribute="class" - enableSystem - disableTransitionOnChange - defaultTheme={theme} - enableColorScheme={false} - nonce={nonce} - > - {children} - </ThemeProvider> - </AuthProvider> + <CSPProvider nonce={nonce}> + <ReactQueryProvider> + <I18nClientProvider locale={locale!} messages={messages}> + <AuthProvider> + <ThemeProvider + attribute="class" + enableSystem + disableTransitionOnChange + defaultTheme={theme} + enableColorScheme={false} + nonce={nonce} + > + {children} + </ThemeProvider> + </AuthProvider> - <If condition={featuresFlagConfig.enableVersionUpdater}> - <VersionUpdater /> - </If> - </I18nProvider> - </ReactQueryProvider> + <If condition={featuresFlagConfig.enableVersionUpdater}> + <VersionUpdater /> + </If> + </I18nClientProvider> + </ReactQueryProvider> + </CSPProvider> </AnalyticsProvider> </AppEventsProvider> </MonitoringProvider> diff --git a/apps/web/components/workspace-dropdown.tsx b/apps/web/components/workspace-dropdown.tsx new file mode 100644 index 000000000..db4f94f03 --- /dev/null +++ b/apps/web/components/workspace-dropdown.tsx @@ -0,0 +1,381 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { + Check, + ChevronsUpDown, + LogOut, + MessageCircleQuestion, + Plus, + Settings, + Shield, + User, + Users, +} from 'lucide-react'; + +import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data'; +import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; +import { JWTUserData } from '@kit/supabase/types'; +import { CreateTeamAccountDialog } from '@kit/team-accounts/components'; +import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar'; +import { Button } from '@kit/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; +import { If } from '@kit/ui/if'; +import { SubMenuModeToggle } from '@kit/ui/mode-toggle'; +import { ProfileAvatar } from '@kit/ui/profile-avatar'; +import { useSidebar } from '@kit/ui/sidebar'; +import { Trans } from '@kit/ui/trans'; + +import featuresFlagConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; + +export type AccountModel = { + label: string | null; + value: string | null; + image: string | null; +}; + +interface WorkspaceDropdownProps { + user: JWTUserData; + accounts: AccountModel[]; + selectedAccount?: string; + workspace?: { + id: string | null; + name: string | null; + picture_url: string | null; + }; +} + +export function WorkspaceDropdown({ + user, + accounts, + selectedAccount, + workspace, +}: WorkspaceDropdownProps) { + const router = useRouter(); + const { open: isSidebarOpen } = useSidebar(); + const signOutMutation = useSignOut(); + + const [isCreatingTeam, setIsCreatingTeam] = useState(false); + + const collapsed = !isSidebarOpen; + const isTeamContext = !!selectedAccount; + + const { data: personalAccountData } = usePersonalAccountData( + user.id, + workspace, + ); + + const displayName = personalAccountData?.name ?? user.email ?? ''; + const userEmail = user.email ?? ''; + + const isSuperAdmin = + user.app_metadata.role === 'super-admin' && user.aal === 'aal2'; + + const currentTeam = accounts.find((a) => a.value === selectedAccount); + + const currentLabel = isTeamContext + ? (currentTeam?.label ?? selectedAccount) + : displayName; + + const currentAvatar = isTeamContext + ? (currentTeam?.image ?? null) + : (personalAccountData?.picture_url ?? null); + + const settingsPath = selectedAccount + ? pathsConfig.app.accountSettings.replace('[account]', selectedAccount) + : pathsConfig.app.personalAccountSettings; + + const switchToPersonal = () => { + if (!featuresFlagConfig.enableTeamsOnly) { + router.replace(pathsConfig.app.home); + } + }; + + const switchToTeam = (slug: string) => { + router.replace(pathsConfig.app.accountHome.replace('[account]', slug)); + }; + + return ( + <div className="min-w-0 flex-1"> + <DropdownMenu> + {collapsed ? ( + <div className="flex flex-col items-center justify-center"> + <DropdownMenuTrigger + render={ + <Button + data-test="workspace-dropdown-trigger" + variant="secondary" + size="icon" + className="border-border hover:shadow" + > + <Avatar className="size-8"> + <AvatarImage + className="rounded-md!" + src={currentAvatar ?? undefined} + alt={currentLabel ?? ''} + /> + <AvatarFallback> + {isTeamContext ? ( + (currentLabel ?? '').charAt(0).toUpperCase() + ) : ( + <User className="text-secondary-foreground size-4" /> + )} + </AvatarFallback> + </Avatar> + </Button> + } + /> + </div> + ) : ( + <DropdownMenuTrigger + render={ + <Button + data-test="workspace-dropdown-trigger" + variant="ghost" + className="hover:bg-accent/40 active:bg-accent border-border/50! hover:border-border h-11 w-full justify-start gap-x-1 rounded-md border px-1 transition-colors hover:shadow-xs" + > + <span className="flex aspect-square size-8 items-center justify-center"> + <Avatar className="size-6"> + <AvatarImage + src={currentAvatar ?? undefined} + alt={currentLabel ?? ''} + /> + <AvatarFallback> + {isTeamContext ? ( + (currentLabel ?? '').charAt(0).toUpperCase() + ) : ( + <User className="size-4" /> + )} + </AvatarFallback> + </Avatar> + </span> + + <span className="grid flex-1 text-left text-sm leading-tight"> + <span className="max-w-md truncate">{currentLabel}</span> + </span> + + <ChevronsUpDown className="ml-auto size-4 transition-opacity duration-300" /> + </Button> + } + /> + )} + + <DropdownMenuContent + className="min-w-60!" + align="center" + side={isSidebarOpen ? 'bottom' : 'inline-end'} + sideOffset={4} + alignOffset={8} + > + <div className="flex items-center justify-start gap-2 py-1.5"> + <div className="w-2/12"> + <ProfileAvatar + className="size-6" + displayName={displayName} + pictureUrl={personalAccountData?.picture_url} + /> + </div> + + <div className="flex w-10/12 flex-col text-left text-sm"> + <span + className="max-w-max truncate font-medium" + data-test="account-dropdown-display-name" + > + {displayName} + </span> + + <span className="text-muted-foreground max-w-max truncate text-xs"> + {userEmail} + </span> + </div> + </div> + + <DropdownMenuSeparator /> + + <If condition={featuresFlagConfig.enableTeamAccounts}> + <DropdownMenuSub> + <DropdownMenuSubTrigger data-test="workspace-switch-submenu"> + <Users className="size-4" /> + <span> + <Trans i18nKey={'teams.switchWorkspace'} /> + </span> + </DropdownMenuSubTrigger> + + <DropdownMenuSubContent + className="max-h-[50vh] overflow-y-auto" + data-test="workspace-switch-content" + > + <If condition={!featuresFlagConfig.enableTeamsOnly}> + <DropdownMenuItem + data-test="personal-workspace-item" + className="flex gap-2" + onClick={switchToPersonal} + > + <div className="flex size-8 items-center justify-center rounded-sm border"> + <User className="size-4" /> + </div> + + <span className="flex-1"> + <Trans i18nKey={'teams.personalAccount'} /> + </span> + + {!isTeamContext && <Check className="ml-auto size-4" />} + </DropdownMenuItem> + </If> + + {accounts.length > 0 && ( + <> + <If condition={!featuresFlagConfig.enableTeamsOnly}> + <DropdownMenuSeparator /> + </If> + + {accounts.map((account) => ( + <DropdownMenuItem + key={account.value} + data-test="workspace-team-item" + data-name={account.label} + data-slug={account.value} + className="flex gap-2" + onClick={() => { + if ( + account.value && + account.value !== selectedAccount + ) { + switchToTeam(account.value); + } + }} + > + <Avatar className="size-8"> + <AvatarImage + className="rounded-md!" + src={account.image ?? undefined} + alt={account.label ?? ''} + /> + <AvatarFallback className="rounded-md! text-xs"> + {(account.label ?? '').charAt(0).toUpperCase()} + </AvatarFallback> + </Avatar> + + <div className="flex-1"> + <div className="font-medium">{account.label}</div> + </div> + + {selectedAccount === account.value && ( + <Check className="ml-auto size-4" /> + )} + </DropdownMenuItem> + ))} + </> + )} + + <If condition={featuresFlagConfig.enableTeamCreation}> + <DropdownMenuItem + onClick={() => setIsCreatingTeam(true)} + data-test="create-team-trigger" + className="bg-background/50 sticky bottom-0 mt-1 flex h-10 w-full gap-2 border backdrop-blur-lg" + > + <Plus className="size-4" /> + + <span> + <Trans i18nKey={'teams.createTeam'} /> + </span> + </DropdownMenuItem> + </If> + </DropdownMenuSubContent> + </DropdownMenuSub> + + <DropdownMenuSeparator /> + </If> + + <DropdownMenuItem + render={ + <Link + className="flex items-center gap-x-2" + href={settingsPath} + data-test="workspace-settings-link" + > + <Settings className="size-4" /> + + <span> + <Trans i18nKey={'common.routes.settings'} /> + </span> + </Link> + } + /> + + <If condition={isSuperAdmin}> + <DropdownMenuItem + render={ + <Link + className="flex items-center gap-x-2 text-yellow-700 hover:text-yellow-600 dark:text-yellow-500" + href="/admin" + data-test="workspace-admin-link" + > + <Shield className="size-4" /> + + <span>Super Admin</span> + </Link> + } + /> + </If> + + <DropdownMenuSeparator /> + + <DropdownMenuItem + render={ + <Link className="flex items-center gap-x-2" href="/docs"> + <MessageCircleQuestion className="size-4" /> + + <span> + <Trans i18nKey={'common.documentation'} /> + </span> + </Link> + } + /> + + <DropdownMenuSeparator /> + + <If condition={featuresFlagConfig.enableThemeToggle}> + <SubMenuModeToggle /> + + <DropdownMenuSeparator /> + </If> + + <DropdownMenuItem + disabled={signOutMutation.isPending} + className="flex items-center gap-x-2" + data-test="workspace-sign-out" + onClick={() => signOutMutation.mutate()} + > + <LogOut className="size-4" /> + + <span> + <Trans i18nKey={'auth.signOut'} /> + </span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + <If condition={featuresFlagConfig.enableTeamCreation}> + <CreateTeamAccountDialog + isOpen={isCreatingTeam} + setIsOpen={setIsCreatingTeam} + /> + </If> + </div> + ); +} diff --git a/apps/web/config/app.config.ts b/apps/web/config/app.config.ts index 7c9c6ca6b..48fbf1642 100644 --- a/apps/web/config/app.config.ts +++ b/apps/web/config/app.config.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const production = process.env.NODE_ENV === 'production'; @@ -6,31 +6,23 @@ const AppConfigSchema = z .object({ name: z .string({ - description: `This is the name of your SaaS. Ex. "Makerkit"`, - required_error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`, + error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`, }) .min(1), title: z .string({ - description: `This is the default title tag of your SaaS.`, - required_error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`, + error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`, }) .min(1), description: z.string({ - description: `This is the default description of your SaaS.`, - required_error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`, + error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`, + }), + url: z.url({ + message: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`, }), - url: z - .string({ - required_error: `Please provide the variable NEXT_PUBLIC_SITE_URL`, - }) - .url({ - message: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`, - }), locale: z .string({ - description: `This is the default locale of your SaaS.`, - required_error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`, + error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`, }) .default('en'), theme: z.enum(['light', 'dark', 'system']), diff --git a/apps/web/config/auth.config.ts b/apps/web/config/auth.config.ts index 0e2c9dee8..6502a1ca1 100644 --- a/apps/web/config/auth.config.ts +++ b/apps/web/config/auth.config.ts @@ -1,36 +1,17 @@ import type { Provider } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; const providers: z.ZodType<Provider> = getProviders(); const AuthConfigSchema = z.object({ - captchaTokenSiteKey: z - .string({ - description: 'The reCAPTCHA site key.', - }) - .optional(), - displayTermsCheckbox: z - .boolean({ - description: 'Whether to display the terms checkbox during sign-up.', - }) - .optional(), - enableIdentityLinking: z - .boolean({ - description: 'Allow linking and unlinking of auth identities.', - }) - .optional() - .default(false), + captchaTokenSiteKey: z.string().optional(), + displayTermsCheckbox: z.boolean().optional(), + enableIdentityLinking: z.boolean().optional().default(false), providers: z.object({ - password: z.boolean({ - description: 'Enable password authentication.', - }), - magicLink: z.boolean({ - description: 'Enable magic link authentication.', - }), - otp: z.boolean({ - description: 'Enable one-time password authentication.', - }), + password: z.boolean(), + magicLink: z.boolean(), + otp: z.boolean(), oAuth: providers.array(), }), }); @@ -57,7 +38,7 @@ const authConfig = AuthConfigSchema.parse({ otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true', oAuth: ['google'], }, -} satisfies z.infer<typeof AuthConfigSchema>); +} satisfies z.output<typeof AuthConfigSchema>); export default authConfig; diff --git a/apps/web/config/feature-flags.config.ts b/apps/web/config/feature-flags.config.ts index 647e117c9..fafc86878 100644 --- a/apps/web/config/feature-flags.config.ts +++ b/apps/web/config/feature-flags.config.ts @@ -1,58 +1,45 @@ -import { z } from 'zod'; +import * as z from 'zod'; type LanguagePriority = 'user' | 'application'; const FeatureFlagsSchema = z.object({ enableThemeToggle: z.boolean({ - description: 'Enable theme toggle in the user interface.', - required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE', }), enableAccountDeletion: z.boolean({ - description: 'Enable personal account deletion.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', }), enableTeamDeletion: z.boolean({ - description: 'Enable team deletion.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION', }), enableTeamAccounts: z.boolean({ - description: 'Enable team accounts.', - required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', }), enableTeamCreation: z.boolean({ - description: 'Enable team creation.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', }), enablePersonalAccountBilling: z.boolean({ - description: 'Enable personal account billing.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', }), enableTeamAccountBilling: z.boolean({ - description: 'Enable team account billing.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', }), languagePriority: z .enum(['user', 'application'], { - required_error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY', - description: `If set to user, use the user's preferred language. If set to application, use the application's default language.`, + error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY', }) .default('application'), enableNotifications: z.boolean({ - description: 'Enable notifications functionality', - required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS', }), realtimeNotifications: z.boolean({ - description: 'Enable realtime for the notifications functionality', - required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS', + error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS', }), enableVersionUpdater: z.boolean({ - description: 'Enable version updater', - required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER', + }), + enableTeamsOnly: z.boolean({ + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY', }), }); @@ -99,7 +86,11 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({ process.env.NEXT_PUBLIC_ENABLE_VERSION_UPDATER, false, ), -} satisfies z.infer<typeof FeatureFlagsSchema>); + enableTeamsOnly: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY, + false, + ), +} satisfies z.output<typeof FeatureFlagsSchema>); export default featuresFlagConfig; diff --git a/apps/web/config/paths.config.ts b/apps/web/config/paths.config.ts index ad0bf1201..0695ba46e 100644 --- a/apps/web/config/paths.config.ts +++ b/apps/web/config/paths.config.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const PathsSchema = z.object({ auth: z.object({ @@ -19,6 +19,8 @@ const PathsSchema = z.object({ accountBilling: z.string().min(1), accountMembers: z.string().min(1), accountBillingReturn: z.string().min(1), + accountProfileSettings: z.string().min(1), + createTeam: z.string().min(1), joinTeam: z.string().min(1), }), }); @@ -42,8 +44,10 @@ const pathsConfig = PathsSchema.parse({ accountBilling: `/home/[account]/billing`, accountMembers: `/home/[account]/members`, accountBillingReturn: `/home/[account]/billing/return`, + accountProfileSettings: `/home/[account]/settings/profile`, + createTeam: '/home/create-team', joinTeam: '/join', }, -} satisfies z.infer<typeof PathsSchema>); +} satisfies z.output<typeof PathsSchema>); export default pathsConfig; diff --git a/apps/web/config/personal-account-navigation.config.tsx b/apps/web/config/personal-account-navigation.config.tsx index 35ec49d4c..049b8efe5 100644 --- a/apps/web/config/personal-account-navigation.config.tsx +++ b/apps/web/config/personal-account-navigation.config.tsx @@ -1,5 +1,5 @@ import { CreditCard, Home, User } from 'lucide-react'; -import { z } from 'zod'; +import * as z from 'zod'; import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; @@ -10,34 +10,34 @@ const iconClasses = 'w-4'; const routes = [ { - label: 'common:routes.application', + label: 'common.routes.application', children: [ { - label: 'common:routes.home', + label: 'common.routes.home', path: pathsConfig.app.home, Icon: <Home className={iconClasses} />, - end: true, + highlightMatch: `${pathsConfig.app.home}$`, }, ], }, { - label: 'common:routes.settings', + label: 'common.routes.settings', children: [ { - label: 'common:routes.profile', + label: 'common.routes.profile', path: pathsConfig.app.personalAccountSettings, Icon: <User className={iconClasses} />, }, featureFlagsConfig.enablePersonalAccountBilling ? { - label: 'common:routes.billing', + label: 'common.routes.billing', path: pathsConfig.app.personalAccountBilling, Icon: <CreditCard className={iconClasses} />, } : undefined, ].filter((route) => !!route), }, -] satisfies z.infer<typeof NavigationConfigSchema>['routes']; +] satisfies z.output<typeof NavigationConfigSchema>['routes']; export const personalAccountNavigationConfig = NavigationConfigSchema.parse({ routes, diff --git a/apps/web/config/team-account-navigation.config.tsx b/apps/web/config/team-account-navigation.config.tsx index 7462320d5..c6d505ddd 100644 --- a/apps/web/config/team-account-navigation.config.tsx +++ b/apps/web/config/team-account-navigation.config.tsx @@ -9,33 +9,33 @@ const iconClasses = 'w-4'; const getRoutes = (account: string) => [ { - label: 'common:routes.application', + label: 'common.routes.application', children: [ { - label: 'common:routes.dashboard', + label: 'common.routes.dashboard', path: pathsConfig.app.accountHome.replace('[account]', account), Icon: <LayoutDashboard className={iconClasses} />, - end: true, + highlightMatch: `${pathsConfig.app.home}$`, }, ], }, { - label: 'common:routes.settings', + label: 'common.routes.settings', collapsible: false, children: [ { - label: 'common:routes.settings', + label: 'common.routes.settings', path: createPath(pathsConfig.app.accountSettings, account), Icon: <Settings className={iconClasses} />, }, { - label: 'common:routes.members', + label: 'common.routes.members', path: createPath(pathsConfig.app.accountMembers, account), Icon: <Users className={iconClasses} />, }, featureFlagsConfig.enableTeamAccountBilling ? { - label: 'common:routes.billing', + label: 'common.routes.billing', path: createPath(pathsConfig.app.accountBilling, account), Icon: <CreditCard className={iconClasses} />, } diff --git a/apps/web/content/documentation/authentication/email-password.mdoc b/apps/web/content/documentation/authentication/email-password.mdoc index 597e5006e..d40dc8135 100644 --- a/apps/web/content/documentation/authentication/email-password.mdoc +++ b/apps/web/content/documentation/authentication/email-password.mdoc @@ -37,7 +37,7 @@ const result = await signUpAction({ 'use server'; import { enhanceAction } from '@kit/next/actions'; -import { z } from 'zod'; +import * as z from 'zod'; const SignUpSchema = z.object({ email: z.string().email(), diff --git a/apps/web/content/documentation/authentication/magic-links.mdoc b/apps/web/content/documentation/authentication/magic-links.mdoc index 3059418cc..1cb5737a3 100644 --- a/apps/web/content/documentation/authentication/magic-links.mdoc +++ b/apps/web/content/documentation/authentication/magic-links.mdoc @@ -81,7 +81,7 @@ export function MagicLinkForm() { import { enhanceAction } from '@kit/next/actions'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { z } from 'zod'; +import * as z from 'zod'; export const sendMagicLinkAction = enhanceAction( async (data) => { diff --git a/apps/web/content/documentation/authentication/oauth-providers.mdoc b/apps/web/content/documentation/authentication/oauth-providers.mdoc index e86289dc2..632740a5a 100644 --- a/apps/web/content/documentation/authentication/oauth-providers.mdoc +++ b/apps/web/content/documentation/authentication/oauth-providers.mdoc @@ -102,7 +102,7 @@ export function OAuthButtons() { import { enhanceAction } from '@kit/next/actions'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { z } from 'zod'; +import * as z from 'zod'; const OAuthProviderSchema = z.enum([ 'google', diff --git a/apps/web/content/documentation/getting-started/configuration.mdoc b/apps/web/content/documentation/getting-started/configuration.mdoc index 6f3ddcc59..0afaae10f 100644 --- a/apps/web/content/documentation/getting-started/configuration.mdoc +++ b/apps/web/content/documentation/getting-started/configuration.mdoc @@ -337,7 +337,7 @@ Configs are automatically loaded but you can validate: ```typescript // lib/config/validate-config.ts -import { z } from 'zod'; +import * as z from 'zod'; const ConfigSchema = z.object({ apiUrl: z.string().url(), diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs deleted file mode 100644 index cfb94b1bf..000000000 --- a/apps/web/eslint.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import eslintConfigApps from '@kit/eslint-config/apps.js'; -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default [...eslintConfigBase, ...eslintConfigApps]; diff --git a/apps/web/public/locales/en/account.json b/apps/web/i18n/messages/en/account.json similarity index 98% rename from apps/web/public/locales/en/account.json rename to apps/web/i18n/messages/en/account.json index c1a5e7dfe..889f93dc3 100644 --- a/apps/web/public/locales/en/account.json +++ b/apps/web/i18n/messages/en/account.json @@ -114,7 +114,7 @@ "createTeamButtonLabel": "Create a Team", "linkedAccounts": "Linked Accounts", "linkedAccountsDescription": "Connect other authentication providers", - "unlinkAccountButton": "Unlink {{provider}}", + "unlinkAccountButton": "Unlink {provider}", "unlinkAccountSuccess": "Account unlinked", "unlinkAccountError": "Unlinking failed", "linkAccountSuccess": "Account linked", @@ -137,7 +137,7 @@ "linkEmailPassword": "Email & Password", "linkEmailPasswordDescription": "Add password authentication to your account", "noAccountsAvailable": "No other method is available at this time", - "linkAccountDescription": "Link account to sign in with {{provider}}", + "linkAccountDescription": "Link account to sign in with {provider}", "updatePasswordDescription": "Add password authentication to your account", "setEmailAddress": "Set Email Address", "setEmailDescription": "Add an email address to your account", diff --git a/apps/web/public/locales/en/auth.json b/apps/web/i18n/messages/en/auth.json similarity index 90% rename from apps/web/public/locales/en/auth.json rename to apps/web/i18n/messages/en/auth.json index a6a91423b..a9050b631 100644 --- a/apps/web/public/locales/en/auth.json +++ b/apps/web/i18n/messages/en/auth.json @@ -14,7 +14,7 @@ "doNotHaveAccountYet": "Do not have an account yet?", "alreadyHaveAnAccount": "Already have an account?", "signUpToAcceptInvite": "Please sign in/up to accept the invite", - "clickToAcceptAs": "Click the button below to accept the invite with as <b>{{email}}</b>", + "clickToAcceptAs": "Click the button below to accept the invite with as <b>{email}</b>", "acceptInvite": "Accept invite", "acceptingInvite": "Accepting Invite...", "acceptInviteSuccess": "Invite successfully accepted", @@ -22,12 +22,12 @@ "acceptInviteWithDifferentAccount": "Want to accept the invite with a different account?", "alreadyHaveAccountStatement": "I already have an account, I want to sign in instead", "doNotHaveAccountStatement": "I do not have an account, I want to sign up instead", - "signInWithProvider": "Sign in with {{provider}}", + "signInWithProvider": "Sign in with {provider}", "signInWithPhoneNumber": "Sign in with Phone Number", "signInWithEmail": "Sign in with Email", "signUpWithEmail": "Sign up with Email", "passwordHint": "Ensure it's at least 8 characters", - "repeatPasswordHint": "Type your password again", + "repeatPasswordDescription": "Type your password again", "repeatPassword": "Repeat password", "passwordForgottenQuestion": "Forgot Password?", "passwordResetLabel": "Reset Password", @@ -76,17 +76,21 @@ "methodOtp": "OTP code", "methodMagicLink": "email link", "methodOauth": "social sign-in", - "methodOauthWithProvider": "<provider>{{provider}}</provider>", + "methodOauthWithProvider": "<provider>{provider}</provider>", "methodDefault": "another method", - "existingAccountHint": "You previously signed in with <method>{{method}}</method>. <signInLink>Already have an account?</signInLink>", + "existingAccountHint": "You previously signed in with <method>{method}</method>. <signInLink>Already have an account?</signInLink>", "linkAccountToSignIn": "Link account to sign in", "linkAccountToSignInDescription": "Add one or more sign-in methods to your account", "noIdentityLinkedTitle": "No authentication method added", "noIdentityLinkedDescription": "You haven't added any authentication methods yet. Are you sure you want to continue? You can set up sign-in methods later in your personal account settings.", "errors": { + "invalid_credentials": "The credentials entered are invalid", "Invalid login credentials": "The credentials entered are invalid", + "user_already_exists": "This credential is already in use. Please try with another one.", "User already registered": "This credential is already in use. Please try with another one.", + "email_not_confirmed": "Please confirm your email address before signing in", "Email not confirmed": "Please confirm your email address before signing in", + "user_banned": "This account has been banned. Please contact support.", "default": "We have encountered an error. Please ensure you have a working internet connection and try again", "generic": "Sorry, we weren't able to authenticate you. Please try again.", "linkTitle": "Sign in failed", @@ -96,6 +100,7 @@ "passwordsDoNotMatch": "The passwords do not match", "minPasswordNumbers": "Password must contain at least one number", "minPasswordSpecialChars": "Password must contain at least one special character", + "signup_disabled": "Signups are not currently allowed. Please contact support.", "Signups not allowed for otp": "OTP is disabled. Please enable it in your account settings.", "uppercasePassword": "Password must contain at least one uppercase letter", "insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action", diff --git a/apps/web/public/locales/en/billing.json b/apps/web/i18n/messages/en/billing.json similarity index 84% rename from apps/web/public/locales/en/billing.json rename to apps/web/i18n/messages/en/billing.json index be79d61e0..2ee0f6090 100644 --- a/apps/web/public/locales/en/billing.json +++ b/apps/web/i18n/messages/en/billing.json @@ -6,20 +6,20 @@ "subscriptionTabSubheading": "Manage your Subscription and Billing", "planCardTitle": "Your Plan", "planCardDescription": "Below are the details of your current plan. You can change your plan or cancel your subscription at any time.", - "planRenewal": "Renews every {{interval}} at {{price}}", + "planRenewal": "Renews every {interval} at {price}", "planDetails": "Plan Details", "checkout": "Proceed to Checkout", "trialAlertTitle": "Your Trial is ending soon", - "trialAlertDescription": "Your trial ends on {{date}}. Upgrade to a paid plan to continue using all features.", + "trialAlertDescription": "Your trial ends on {date}. Upgrade to a paid plan to continue using all features.", "billingPortalCardButton": "Visit Billing Portal", "billingPortalCardTitle": "Manage your Billing Details", "billingPortalCardDescription": "Visit your Billing Portal to manage your subscription and billing. You can update or cancel your plan, or download your invoices.", - "cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.", - "renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}", + "cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {endDate}.", + "renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {endDate}", "noPermissionsAlertHeading": "You don't have permissions to change the billing settings", "noPermissionsAlertBody": "Please contact your account owner to change the billing settings for your account.", "checkoutSuccessTitle": "Done! You're all set.", - "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {{ customerEmail }}.", + "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {customerEmail}.", "checkoutSuccessBackButton": "Proceed to App", "cannotManageBillingAlertTitle": "You cannot manage billing", "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account owner.", @@ -34,23 +34,23 @@ "perMonth": "month", "custom": "Custom Plan", "lifetime": "Lifetime", - "trialPeriod": "{{period}} day trial", - "perPeriod": "per {{period}}", + "trialPeriod": "{period} day trial", + "perPeriod": "per {period}", "redirectingToPayment": "Redirecting to checkout. Please wait...", "proceedToPayment": "Proceed to Payment", "startTrial": "Start Trial", "perTeamMember": "Per team member", - "perUnitShort": "Per {{unit}}", - "perUnit": "Per {{unit}} usage", + "perUnitShort": "Per {unit}", + "perUnit": "Per {unit} usage", "teamMembers": "Team Members", - "includedUpTo": "Up to {{upTo}} {{unit}} included in the plan", - "fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unitPlural }}", - "andAbove": "above {{ previousTier }} {{ unit }}", - "startingAtPriceUnit": "Starting at {{price}}/{{unit}}", - "priceUnit": "{{price}}/{{unit}}", - "forEveryUnit": "for every {{ unit }}", - "setupFee": "plus a {{ setupFee }} setup fee", - "perUnitIncluded": "({{included}} included)", + "includedUpTo": "Up to {upTo} {unit} included in the plan", + "fromPreviousTierUpTo": "for each {unit} for the next {upTo} {unitPlural}", + "andAbove": "above {previousTier} {unit}", + "startingAtPriceUnit": "Starting at {price}/{unit}", + "priceUnit": "{price}/{unit}", + "forEveryUnit": "for every {unit}", + "setupFee": "plus a {setupFee} setup fee", + "perUnitIncluded": "({included} included)", "features": "Features", "featuresLabel": "Features", "detailsLabel": "Details", @@ -59,7 +59,7 @@ "planPickerAlertErrorTitle": "Error requesting checkout", "planPickerAlertErrorDescription": "There was an error requesting checkout. Please try again later.", "subscriptionCancelled": "Subscription Cancelled", - "cancelSubscriptionDate": "Your subscription will be cancelled at the end of the billing period on {{date}}", + "cancelSubscriptionDate": "Your subscription will be cancelled at the end of the billing period on {date}", "noPlanChosen": "Please choose a plan", "noIntervalPlanChosen": "Please choose a billing interval", "status": { diff --git a/apps/web/public/locales/en/common.json b/apps/web/i18n/messages/en/common.json similarity index 88% rename from apps/web/public/locales/en/common.json rename to apps/web/i18n/messages/en/common.json index 5cde4ff6f..60ef1a87d 100644 --- a/apps/web/public/locales/en/common.json +++ b/apps/web/i18n/messages/en/common.json @@ -35,8 +35,9 @@ "expandSidebar": "Expand Sidebar", "collapseSidebar": "Collapse Sidebar", "documentation": "Documentation", + "pricing": "Pricing", "getStarted": "Get Started", - "getStartedWithPlan": "Get Started with {{plan}}", + "getStartedWithPlan": "Get Started with {plan}", "retry": "Retry", "contactUs": "Contact Us", "loading": "Loading. Please wait...", @@ -45,8 +46,8 @@ "skip": "Skip", "info": "Info", "signedInAs": "Signed in as", - "pageOfPages": "Page {{page}} of {{total}}", - "showingRecordCount": "Showing {{pageSize}} of {{totalCount}} rows", + "pageOfPages": "Page {page} of {total}", + "showingRecordCount": "Showing {pageSize} of {totalCount} rows", "noData": "No data available", "pageNotFoundHeading": "404", "errorPageHeading": "500", @@ -77,11 +78,11 @@ }, "otp": { "requestVerificationCode": "Request Verification Code", - "requestVerificationCodeDescription": "We must verify your identity to continue with this action. We'll send a verification code to the email address {{email}}.", + "requestVerificationCodeDescription": "We must verify your identity to continue with this action. We'll send a verification code to the email address {email}.", "sendingCode": "Sending Code...", "sendVerificationCode": "Send Verification Code", "enterVerificationCode": "Enter Verification Code", - "codeSentToEmail": "We've sent a verification code to the email address {{email}}.", + "codeSentToEmail": "We've sent a verification code to the email address {email}.", "verificationCode": "Verification Code", "enterCodeFromEmail": "Enter the 6-digit code we sent to your email.", "verifying": "Verifying...", @@ -96,17 +97,17 @@ "accept": "Accept" }, "dropzone": { - "success": "Successfully uploaded {{count}} file(s)", - "error": "Error uploading {{count}} file(s)", + "success": "Successfully uploaded {count} file(s)", + "error": "Error uploading {count} file(s)", "errorMessageUnknown": "An unknown error occurred.", "errorMessageFileUnknown": "Unknown file", "errorMessageFileSizeUnknown": "Unknown file size", "errorMessageFileSizeTooSmall": "File size is too small", "errorMessageFileSizeTooLarge": "File size is too large", "uploading": "Uploading...", - "uploadFiles": "Upload {{count}} file(s)", - "maxFileSize": "Maximum file size: {{size}}", - "maxFiles": "You may upload only up to {{count}} files, please remove {{files}} files.", + "uploadFiles": "Upload {count} file(s)", + "maxFileSize": "Maximum file size: {size}", + "maxFiles": "You may upload only up to {count} files, please remove {files} files.", "dragAndDrop": "Drag and drop or", "select": "select files", "toUpload": "to upload" diff --git a/apps/web/public/locales/en/marketing.json b/apps/web/i18n/messages/en/marketing.json similarity index 96% rename from apps/web/public/locales/en/marketing.json rename to apps/web/i18n/messages/en/marketing.json index acb3cd502..7354ff1c3 100644 --- a/apps/web/public/locales/en/marketing.json +++ b/apps/web/i18n/messages/en/marketing.json @@ -42,5 +42,5 @@ "contactSuccessDescription": "We have received your message and will get back to you as soon as possible", "contactErrorDescription": "An error occurred while sending your message. Please try again later", "footerDescription": "Here you can add a description about your company or product", - "copyright": "© Copyright {{year}} {{product}}. All Rights Reserved." + "copyright": "© Copyright {year} {product}. All Rights Reserved." } diff --git a/apps/web/public/locales/en/teams.json b/apps/web/i18n/messages/en/teams.json similarity index 92% rename from apps/web/public/locales/en/teams.json rename to apps/web/i18n/messages/en/teams.json index 39cd287aa..0c0923ac0 100644 --- a/apps/web/public/locales/en/teams.json +++ b/apps/web/i18n/messages/en/teams.json @@ -18,7 +18,8 @@ "billing": { "pageTitle": "Billing" }, - "yourTeams": "Your Teams ({{teamsCount}})", + "switchWorkspace": "Switch Workspace", + "yourTeams": "Your Teams ({teamsCount})", "createTeam": "Create a Team", "creatingTeam": "Creating Team...", "personalAccount": "Personal Account", @@ -37,6 +38,9 @@ "teamNameLabel": "Team Name", "teamNameDescription": "Your team name should be unique and descriptive", "createTeamSubmitLabel": "Create Team", + "createFirstTeamHeading": "Create your first team", + "createFirstTeamDescription": "Create your first team and start collaborating with your teammates.", + "getStarted": "Get Started", "createTeamSuccess": "Team created successfully", "createTeamError": "Team not created. Please try again.", "createTeamLoading": "Creating team...", @@ -77,8 +81,8 @@ "deleteInviteSuccessMessage": "Invite deleted successfully", "deleteInviteErrorMessage": "Invite not deleted. Please try again.", "deleteInviteLoadingMessage": "Deleting invite. Please wait...", - "confirmDeletingMemberInvite": "You are deleting the invite to <b>{{ email }}</b>", - "transferOwnershipDisclaimer": "You are transferring ownership of the selected team to <b>{{ member }}</b>.", + "confirmDeletingMemberInvite": "You are deleting the invite to <b>{email}</b>", + "transferOwnershipDisclaimer": "You are transferring ownership of the selected team to <b>{member}</b>.", "transferringOwnership": "Transferring ownership...", "transferOwnershipSuccess": "Ownership successfully transferred", "transferOwnershipError": "Sorry, we could not transfer ownership to the selected member. Please try again.", @@ -116,14 +120,14 @@ "deleteTeamDescription": "This action cannot be undone. All data associated with this team will be deleted.", "deletingTeam": "Deleting team", "deleteTeamModalHeading": "Deleting Team", - "deletingTeamDescription": "You are about to delete the team {{ teamName }}. This action cannot be undone.", + "deletingTeamDescription": "You are about to delete the team {teamName}. This action cannot be undone.", "deleteTeamInputField": "Type the name of the team to confirm", "leaveTeam": "Leave Team", "leavingTeamModalHeading": "Leaving Team", "leavingTeamModalDescription": "You are about to leave this team. You will no longer have access to it.", "leaveTeamDescription": "Click the button below to leave the team. Remember, you will no longer have access to it and will need to be re-invited to join.", - "deleteTeamDisclaimer": "You are deleting the team {{ teamName }}. This action cannot be undone.", - "leaveTeamDisclaimer": "You are leaving the team {{ teamName }}. You will no longer have access to it.", + "deleteTeamDisclaimer": "You are deleting the team {teamName}. This action cannot be undone.", + "leaveTeamDisclaimer": "You are leaving the team {teamName}. You will no longer have access to it.", "deleteTeamErrorHeading": "Sorry, we couldn't delete your team.", "leaveTeamErrorHeading": "Sorry, we couldn't leave your team.", "searchMembersPlaceholder": "Search members", @@ -146,14 +150,14 @@ "inviteNotFoundOrExpired": "Invite not found or expired", "inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the team owner to renew the invite.", "backToHome": "Back to Home", - "renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the team.", + "renewInvitationDialogDescription": "You are about to renew the invitation to {email}. The user will be able to join the team.", "renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.", "renewInvitationErrorDescription": "We encountered an error renewing the invitation. Please try again.", "signInWithDifferentAccount": "Sign in with a different account", "signInWithDifferentAccountDescription": "If you wish to accept the invitation with a different account, please sign out and back in with the account you wish to use.", - "acceptInvitationHeading": "Join {{accountName}}", - "acceptInvitationDescription": "Click the button below to accept the invitation to join {{accountName}}", - "continueAs": "Continue as {{email}}", + "acceptInvitationHeading": "Join {accountName}", + "acceptInvitationDescription": "Click the button below to accept the invitation to join {accountName}", + "continueAs": "Continue as {email}", "joinTeamAccount": "Join Team", "joiningTeam": "Joining team...", "leaveTeamInputLabel": "Please type LEAVE to confirm leaving the team.", diff --git a/apps/web/i18n/request.ts b/apps/web/i18n/request.ts new file mode 100644 index 000000000..980d68a68 --- /dev/null +++ b/apps/web/i18n/request.ts @@ -0,0 +1,82 @@ +/** + * App-specific i18n request configuration. + * Loads translation messages from the app's messages directory. + * + * Supports two loading strategies: + * 1. Single file: messages/${locale}.json (all namespaces in one file) + * 2. Multiple files: messages/${locale}/*.json (namespaces in separate files for lazy loading) + * + */ +import { getRequestConfig } from 'next-intl/server'; + +import { routing } from '@kit/i18n/routing'; + +// Define the namespaces to load +const namespaces = [ + 'common', + 'auth', + 'account', + 'teams', + 'billing', + 'marketing', +] as const; + +const isDevelopment = process.env.NODE_ENV === 'development'; + +export default getRequestConfig(async ({ requestLocale }) => { + // Get the locale from the request (provided by middleware) + let locale = await requestLocale; + + // Validate that the locale is supported, fallback to default if not + if (!locale || !routing.locales.includes(locale as never)) { + locale = routing.defaultLocale; + } + + // Load all namespace files and merge them + const messages = await loadMessages(locale); + + return { + locale, + messages, + timeZone: 'UTC', + onError(error) { + if (isDevelopment) { + // Missing translations are expected and should only log an error + console.warn(`[Dev Only] i18n error: ${error.message}`); + } + }, + getMessageFallback(info) { + return info.key; + }, + }; +}); + +/** + * Loads translation messages for all namespaces. + * Each namespace is loaded from a separate file for better code splitting. + */ +async function loadMessages(locale: string) { + const loadedMessages: Record<string, unknown> = {}; + + // Load each namespace file + await Promise.all( + namespaces.map(async (namespace) => { + try { + const namespaceMessages = await import( + `./messages/${locale}/${namespace}.json` + ); + + loadedMessages[namespace] = namespaceMessages.default; + } catch (error) { + console.warn( + `Failed to load namespace "${namespace}" for locale "${locale}":`, + error, + ); + // Set empty object as fallback + loadedMessages[namespace] = {}; + } + }), + ); + + return loadedMessages; +} diff --git a/apps/web/lib/i18n/i18n.resolver.ts b/apps/web/lib/i18n/i18n.resolver.ts deleted file mode 100644 index 46d70fe2d..000000000 --- a/apps/web/lib/i18n/i18n.resolver.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getLogger } from '@kit/shared/logger'; - -/** - * @name i18nResolver - * @description Resolve the translation file for the given language and namespace in the current application. - * @param language - * @param namespace - */ -export async function i18nResolver(language: string, namespace: string) { - const logger = await getLogger(); - - try { - const data = await import( - `../../public/locales/${language}/${namespace}.json` - ); - - return data as Record<string, string>; - } catch (error) { - console.group( - `Error while loading translation file: ${language}/${namespace}`, - ); - logger.error(error instanceof Error ? error.message : error); - logger.warn( - `Please create a translation file for this language at "public/locales/${language}/${namespace}.json"`, - ); - console.groupEnd(); - - // return an empty object if the file could not be loaded to avoid loops - return {}; - } -} diff --git a/apps/web/lib/i18n/i18n.server.ts b/apps/web/lib/i18n/i18n.server.ts deleted file mode 100644 index 9074d2b02..000000000 --- a/apps/web/lib/i18n/i18n.server.ts +++ /dev/null @@ -1,98 +0,0 @@ -import 'server-only'; - -import { cache } from 'react'; - -import { cookies, headers } from 'next/headers'; - -import { z } from 'zod'; - -import { - initializeServerI18n, - parseAcceptLanguageHeader, -} from '@kit/i18n/server'; - -import featuresFlagConfig from '~/config/feature-flags.config'; -import { - I18N_COOKIE_NAME, - getI18nSettings, - languages, -} from '~/lib/i18n/i18n.settings'; - -import { i18nResolver } from './i18n.resolver'; - -/** - * @name priority - * @description The language priority setting from the feature flag configuration. - */ -const priority = featuresFlagConfig.languagePriority; - -/** - * @name createI18nServerInstance - * @description Creates an instance of the i18n server. - * It uses the language from the cookie if it exists, otherwise it uses the language from the accept-language header. - * If neither is available, it will default to the provided environment variable. - * - * Initialize the i18n instance for every RSC server request (eg. each page/layout) - */ -async function createInstance() { - const cookieStore = await cookies(); - const langCookieValue = cookieStore.get(I18N_COOKIE_NAME)?.value; - - let selectedLanguage: string | undefined = undefined; - - // if the cookie is set, use the language from the cookie - if (langCookieValue) { - selectedLanguage = getLanguageOrFallback(langCookieValue); - } - - // if not, check if the language priority is set to user and - // use the user's preferred language - if (!selectedLanguage && priority === 'user') { - const userPreferredLanguage = await getPreferredLanguageFromBrowser(); - - selectedLanguage = getLanguageOrFallback(userPreferredLanguage); - } - - const settings = getI18nSettings(selectedLanguage); - - return initializeServerI18n(settings, i18nResolver); -} - -export const createI18nServerInstance = cache(createInstance); - -/** - * @name getPreferredLanguageFromBrowser - * Get the user's preferred language from the accept-language header. - */ -async function getPreferredLanguageFromBrowser() { - const headersStore = await headers(); - const acceptLanguage = headersStore.get('accept-language'); - - // no accept-language header, return - if (!acceptLanguage) { - return; - } - - return parseAcceptLanguageHeader(acceptLanguage, languages)[0]; -} - -/** - * @name getLanguageOrFallback - * Get the language or fallback to the default language. - * @param selectedLanguage - */ -function getLanguageOrFallback(selectedLanguage: string | undefined) { - const language = z - .enum(languages as [string, ...string[]]) - .safeParse(selectedLanguage); - - if (language.success) { - return language.data; - } - - console.warn( - `The language passed is invalid. Defaulted back to "${languages[0]}"`, - ); - - return languages[0]; -} diff --git a/apps/web/lib/i18n/i18n.settings.ts b/apps/web/lib/i18n/i18n.settings.ts deleted file mode 100644 index 4be9cf367..000000000 --- a/apps/web/lib/i18n/i18n.settings.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createI18nSettings } from '@kit/i18n'; - -/** - * The default language of the application. - * This is used as a fallback language when the selected language is not supported. - * - */ -const defaultLanguage = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en'; - -/** - * The list of supported languages. - * By default, only the default language is supported. - * Add more languages here if needed. - */ -export const languages: string[] = [defaultLanguage]; - -/** - * The name of the cookie that stores the selected language. - */ -export const I18N_COOKIE_NAME = 'lang'; - -/** - * The default array of Internationalization (i18n) namespaces. - * These namespaces are commonly used in the application for translation purposes. - * - * Add your own namespaces here - **/ -export const defaultI18nNamespaces = [ - 'common', - 'auth', - 'account', - 'teams', - 'billing', - 'marketing', -]; - -/** - * Get the i18n settings for the given language and namespaces. - * If the language is not supported, it will fall back to the default language. - * @param language - * @param ns - */ -export function getI18nSettings( - language: string | undefined, - ns: string | string[] = defaultI18nNamespaces, -) { - let lng = language ?? defaultLanguage; - - if (!languages.includes(lng)) { - console.warn( - `Language "${lng}" is not supported. Falling back to "${defaultLanguage}"`, - ); - - lng = defaultLanguage; - } - - return createI18nSettings({ - language: lng, - namespaces: ns, - languages, - }); -} diff --git a/apps/web/lib/i18n/with-i18n.tsx b/apps/web/lib/i18n/with-i18n.tsx deleted file mode 100644 index 78f8994f5..000000000 --- a/apps/web/lib/i18n/with-i18n.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createI18nServerInstance } from './i18n.server'; - -type LayoutOrPageComponent<Params> = React.ComponentType<Params>; - -export function withI18n<Params extends object>( - Component: LayoutOrPageComponent<Params>, -) { - return async function I18nServerComponentWrapper(params: Params) { - await createI18nServerInstance(); - - return <Component {...params} />; - }; -} diff --git a/apps/web/lib/root-metadata.ts b/apps/web/lib/root-metadata.ts index ab70a57d7..739600795 100644 --- a/apps/web/lib/root-metadata.ts +++ b/apps/web/lib/root-metadata.ts @@ -1,5 +1,4 @@ import { Metadata } from 'next'; - import { headers } from 'next/headers'; import appConfig from '~/config/app.config'; diff --git a/apps/web/lib/root-theme.ts b/apps/web/lib/root-theme.ts index a7aba8bfa..ab79acba3 100644 --- a/apps/web/lib/root-theme.ts +++ b/apps/web/lib/root-theme.ts @@ -1,14 +1,12 @@ import { cookies } from 'next/headers'; -import { z } from 'zod'; +import * as z from 'zod'; /** * @name Theme * @description The theme mode enum. */ -const Theme = z.enum(['light', 'dark', 'system'], { - description: 'The theme mode', -}); +const Theme = z.enum(['light', 'dark', 'system']); /** * @name appDefaultThemeMode diff --git a/apps/web/lib/server/require-user-in-server-component.ts b/apps/web/lib/server/require-user-in-server-component.ts index 8b121e869..4bf16330e 100644 --- a/apps/web/lib/server/require-user-in-server-component.ts +++ b/apps/web/lib/server/require-user-in-server-component.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { cache } from 'react'; import { redirect } from 'next/navigation'; diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 6ee5f40ba..09c162f03 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,4 +1,8 @@ import withBundleAnalyzer from '@next/bundle-analyzer'; +import createNextIntlPlugin from 'next-intl/plugin'; + +// Create next-intl plugin with the request config path +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); const IS_PRODUCTION = process.env.NODE_ENV === 'production'; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; @@ -57,9 +61,6 @@ const config = { optimizePackageImports: [ 'recharts', 'lucide-react', - '@radix-ui/react-icons', - '@radix-ui/react-avatar', - '@radix-ui/react-select', 'date-fns', ...INTERNAL_PACKAGES, ], @@ -75,7 +76,7 @@ const config = { export default withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true', -})(config); +})(withNextIntl(config)); /** @returns {import('next').NextConfig['images']} */ function getImagesConfig() { diff --git a/apps/web/package.json b/apps/web/package.json index 14b5a6f0b..cf3b38e64 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -2,17 +2,14 @@ "name": "web", "version": "0.1.0", "private": true, - "sideEffects": false, "type": "module", + "sideEffects": false, "scripts": { "analyze": "ANALYZE=true pnpm run build", "build": "next build", "build:test": "NODE_ENV=test next build", "clean": "git clean -xdf .next .turbo node_modules", "dev": "next dev | pino-pretty -c", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "format": "prettier --check \"**/*.{ts,tsx}\" --ignore-path=\"../../.prettierignore\"", "start": "next start", "start:test": "NODE_ENV=test next start", "typecheck": "tsc --noEmit", @@ -31,8 +28,7 @@ "supabase:db:dump:local": "supabase db dump --local --data-only" }, "dependencies": { - "@edge-csrf/nextjs": "2.5.3-cloudflare-rc1", - "@hookform/resolvers": "^5.2.2", + "@hookform/resolvers": "catalog:", "@kit/accounts": "workspace:*", "@kit/admin": "workspace:*", "@kit/analytics": "workspace:*", @@ -51,47 +47,43 @@ "@kit/supabase": "workspace:*", "@kit/team-accounts": "workspace:*", "@kit/ui": "workspace:*", - "@makerkit/data-loader-supabase-core": "^0.0.10", - "@makerkit/data-loader-supabase-nextjs": "^1.2.5", - "@marsidev/react-turnstile": "^1.4.2", - "@nosecone/next": "1.1.0", - "@radix-ui/react-icons": "^1.3.2", + "@makerkit/data-loader-supabase-core": "catalog:", + "@makerkit/data-loader-supabase-nextjs": "catalog:", + "@marsidev/react-turnstile": "catalog:", + "@nosecone/next": "catalog:", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", - "@tanstack/react-table": "^8.21.3", - "date-fns": "^4.1.0", + "@tanstack/react-table": "catalog:", + "date-fns": "catalog:", "lucide-react": "catalog:", "next": "catalog:", - "next-sitemap": "^4.2.3", - "next-themes": "0.4.6", + "next-intl": "catalog:", + "next-runtime-env": "catalog:", + "next-safe-action": "catalog:", + "next-sitemap": "catalog:", + "next-themes": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", - "recharts": "2.15.3", - "tailwind-merge": "^3.5.0", + "recharts": "catalog:", + "tailwind-merge": "catalog:", "tw-animate-css": "catalog:", - "urlpattern-polyfill": "^10.1.0", + "urlpattern-polyfill": "catalog:", "zod": "catalog:" }, "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/tsconfig": "workspace:*", "@next/bundle-analyzer": "catalog:", "@tailwindcss/postcss": "catalog:", - "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "babel-plugin-react-compiler": "1.0.0", - "cssnano": "^7.1.2", - "pino-pretty": "13.0.0", - "prettier": "^3.8.1", + "babel-plugin-react-compiler": "catalog:", + "cssnano": "catalog:", + "pino-pretty": "catalog:", "supabase": "catalog:", "tailwindcss": "catalog:", - "typescript": "^5.9.3" + "typescript": "catalog:" }, - "prettier": "@kit/prettier-config", "browserslist": [ "last 1 versions", "> 0.7%", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index f9cf4d054..9feaa37b8 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,46 +1,51 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -import { CsrfError, createCsrfProtect } from '@edge-csrf/nextjs'; +import createNextIntlMiddleware from 'next-intl/middleware'; import { isSuperAdmin } from '@kit/admin'; +import { routing } from '@kit/i18n/routing'; import { getSafeRedirectPath } from '@kit/shared/utils'; import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { createMiddlewareClient } from '@kit/supabase/middleware-client'; -import appConfig from '~/config/app.config'; import pathsConfig from '~/config/paths.config'; -const CSRF_SECRET_COOKIE = 'csrfSecret'; const NEXT_ACTION_HEADER = 'next-action'; export const config = { matcher: ['/((?!_next/static|_next/image|images|locales|assets|api/*).*)'], }; +// create i18n middleware once at module scope +const handleI18nRouting = createNextIntlMiddleware(routing); + const getUser = (request: NextRequest, response: NextResponse) => { const supabase = createMiddlewareClient(request, response); return supabase.auth.getClaims(); }; -export async function proxy(request: NextRequest) { - const secureHeaders = await createResponseWithSecureHeaders(); - const response = NextResponse.next(secureHeaders); +export default async function proxy(request: NextRequest) { + // run next-intl middleware first to get the i18n-aware response + const response = handleI18nRouting(request); + + // apply secure headers on top of the i18n response + const secureHeadersResponse = await createResponseWithSecureHeaders(response); // set a unique request ID for each request // this helps us log and trace requests setRequestId(request); - // apply CSRF protection for mutating requests - const csrfResponse = await withCsrfMiddleware(request, response); - // handle patterns for specific routes const handlePattern = await matchUrlPattern(request.url); // if a pattern handler exists, call it if (handlePattern) { - const patternHandlerResponse = await handlePattern(request, csrfResponse); + const patternHandlerResponse = await handlePattern( + request, + secureHeadersResponse, + ); // if a pattern handler returns a response, return it if (patternHandlerResponse) { @@ -51,45 +56,15 @@ export async function proxy(request: NextRequest) { // append the action path to the request headers // which is useful for knowing the action path in server actions if (isServerAction(request)) { - csrfResponse.headers.set('x-action-path', request.nextUrl.pathname); + secureHeadersResponse.headers.set( + 'x-action-path', + request.nextUrl.pathname, + ); } // if no pattern handler returned a response, // return the session response - return csrfResponse; -} - -async function withCsrfMiddleware( - request: NextRequest, - response: NextResponse, -) { - // set up CSRF protection - const csrfProtect = createCsrfProtect({ - cookie: { - secure: appConfig.production, - name: CSRF_SECRET_COOKIE, - }, - // ignore CSRF errors for server actions since protection is built-in - ignoreMethods: isServerAction(request) - ? ['POST'] - : // always ignore GET, HEAD, and OPTIONS requests - ['GET', 'HEAD', 'OPTIONS'], - }); - - try { - await csrfProtect(request, response); - - return response; - } catch (error) { - // if there is a CSRF error, return a 403 response - if (error instanceof CsrfError) { - return NextResponse.json('Invalid CSRF token', { - status: 401, - }); - } - - throw error; - } + return secureHeadersResponse; } function isServerAction(request: NextRequest) { @@ -230,15 +205,23 @@ function setRequestId(request: Request) { * @description Create a middleware with enhanced headers applied (if applied). * This is disabled by default. To enable set ENABLE_STRICT_CSP=true */ -async function createResponseWithSecureHeaders() { +async function createResponseWithSecureHeaders(response: NextResponse) { const enableStrictCsp = process.env.ENABLE_STRICT_CSP ?? 'false'; // we disable ENABLE_STRICT_CSP by default if (enableStrictCsp === 'false') { - return {}; + return response; } const { createCspResponse } = await import('./lib/create-csp-response'); + const cspResponse = await createCspResponse(); - return createCspResponse(); + // set the CSP headers on the i18n response + if (cspResponse) { + for (const [key, value] of cspResponse.headers.entries()) { + response.headers.set(key, value); + } + } + + return response; } diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index 9c86be47f..730b9bddd 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -6,14 +6,13 @@ /* Tailwind CSS */ @import 'tailwindcss'; +@import 'tw-animate-css'; /* local styles - update the below if you add a new style */ @import './theme.css'; -@import './theme.utilities.css'; @import './shadcn-ui.css'; @import './markdoc.css'; @import './makerkit.css'; -@import 'tw-animate-css'; /* content sources - update the below if you add a new path */ @source '../../../packages/*/src/**/*.{ts,tsx}'; @@ -23,9 +22,6 @@ @source '../../../packages/cms/*/src/**/*.{ts,tsx}'; @source '../{app,components,config,lib}/**/*.{ts,tsx}'; -/* variants - update the below if you add a new variant */ -@variant dark (&:where(.dark, .dark *)); - @layer base { body { @apply bg-background text-foreground; diff --git a/apps/web/styles/makerkit.css b/apps/web/styles/makerkit.css index 76e0f9d7c..7d9872d8f 100644 --- a/apps/web/styles/makerkit.css +++ b/apps/web/styles/makerkit.css @@ -4,22 +4,6 @@ * Makerkit-specific global styles * Use this file to add any global styles that are specific to Makerkit's components */ - -/* -Optimize dropdowns for mobile - */ -[data-radix-popper-content-wrapper] { - @apply w-full md:w-auto; -} - -[data-radix-menu-content] { - @apply w-full rounded-none md:w-auto md:rounded-lg; -} - -[data-radix-menu-content] [role='menuitem'] { - @apply min-h-12 md:min-h-0; -} - .site-header > .container:before, .site-footer > .container:before { background: radial-gradient( diff --git a/apps/web/styles/shadcn-ui.css b/apps/web/styles/shadcn-ui.css index 1469de123..b51174012 100644 --- a/apps/web/styles/shadcn-ui.css +++ b/apps/web/styles/shadcn-ui.css @@ -1,104 +1,43 @@ -/* -* shadcn-ui.css -* -* Update the below to customize your Shadcn UI CSS Colors. -* Refer to https://ui.shadcn.com/themes for applying new colors. -* NB: apply the hsl function to the colors copied from the theme. - */ - -@layer base { - :root { - --font-sans: -apple-system, BlinkMacSystemFont, var(--font-sans-fallback); - --font-heading: var(--font-sans); - - --background: var(--color-white); - --foreground: var(--color-neutral-950); - - --card: var(--color-white); - --card-foreground: var(--color-neutral-950); - - --popover: var(--color-white); - --popover-foreground: var(--color-neutral-950); - - --primary: var(--color-neutral-950); - --primary-foreground: var(--color-white); - - --secondary: oklch(96.76% 0.0013 286.38); - --secondary-foreground: oklch(21.03% 0.0318 264.65); - - --muted: oklch(96.71% 0.0029 264.54); - --muted-foreground: oklch(55.13% 0.0233 264.36); - - --accent: oklch(96.76% 0.0013 286.38); - --accent-foreground: oklch(21.03% 0.0318 264.65); - - --destructive: var(--color-red-500); - --destructive-foreground: var(--color-white); - - --border: var(--color-gray-100); - --input: var(--color-gray-200); - --ring: var(--color-neutral-800); - - --radius: 0.5rem; - - --chart-1: var(--color-orange-400); - --chart-2: var(--color-teal-600); - --chart-3: var(--color-green-800); - --chart-4: var(--color-yellow-200); - --chart-5: var(--color-orange-200); - - --sidebar-background: var(--color-neutral-50); - --sidebar-foreground: oklch(37.05% 0.012 285.8); - --sidebar-primary: var(--color-neutral-950); - --sidebar-primary-foreground: var(--color-white); - --sidebar-accent: var(--color-neutral-100); - --sidebar-accent-foreground: var(--color-neutral-950); - --sidebar-border: var(--border); - --sidebar-ring: var(--color-blue-500); - } - - .dark { - --background: var(--color-neutral-900); - --foreground: var(--color-white); - - --card: var(--color-neutral-900); - --card-foreground: var(--color-white); - - --popover: var(--color-neutral-900); - --popover-foreground: var(--color-white); - - --primary: var(--color-white); - --primary-foreground: var(--color-neutral-900); - - --secondary: var(--color-neutral-800); - --secondary-foreground: oklch(98.43% 0.0017 247.84); - - --muted: var(--color-neutral-800); - --muted-foreground: oklch(71.19% 0.0129 286.07); - - --accent: var(--color-neutral-800); - --accent-foreground: oklch(98.48% 0 0); - - --destructive: var(--color-red-700); - --destructive-foreground: var(--color-white); - - --border: var(--color-neutral-800); - --input: var(--color-neutral-700); - --ring: oklch(87.09% 0.0055 286.29); - - --chart-1: var(--color-blue-600); - --chart-2: var(--color-emerald-400); - --chart-3: var(--color-orange-400); - --chart-4: var(--color-purple-500); - --chart-5: var(--color-pink-500); - - --sidebar-background: var(--color-neutral-900); - --sidebar-foreground: var(--color-white); - --sidebar-primary: var(--color-blue-500); - --sidebar-primary-foreground: var(--color-white); - --sidebar-accent: var(--color-neutral-800); - --sidebar-accent-foreground: var(--color-white); - --sidebar-border: var(--border); - --sidebar-ring: var(--color-blue-500); - } +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: -apple-system, BlinkMacSystemFont, var(--font-sans-fallback); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); } diff --git a/apps/web/styles/theme.css b/apps/web/styles/theme.css index 2ac60178e..2a9fd9bda 100644 --- a/apps/web/styles/theme.css +++ b/apps/web/styles/theme.css @@ -1,116 +1,92 @@ /* * theme.css * -* Shadcn UI theme -* Use this file to add any custom styles or override existing Shadcn UI styles +* Theme styles for the entire application */ +@custom-variant dark (&:is(.dark *)); -/* container utility */ +@utility container { + @apply mx-auto px-4 lg:px-8 xl:max-w-[80rem]; +} -/* Shadcn UI theme */ -@theme { - --color-background: var(--background); - --color-foreground: var(--foreground); - - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - - --radius-radius: var(--radius); - - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - - --font-sans: -apple-system, var(--font-sans); - --font-heading: var(--font-heading); - - --color-sidebar: var(--sidebar-background); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - - --animate-fade-up: fade-up 0.5s; - --animate-fade-down: fade-down 0.5s; - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; - - @keyframes accordion-down { - from { - height: 0; - } - - to { - height: var(--radix-accordion-content-height); - } +@layer base { + * { + @apply border-border outline-ring/50; } - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - - to { - height: 0; - } + body { + @apply bg-background text-foreground; } +} - @keyframes fade-up { - 0% { - opacity: 0; - transform: translateY(10px); - } - 80% { - opacity: 0.6; - } - 100% { - opacity: 1; - transform: translateY(0px); - } - } +/* + * Theme variables + */ +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.58 0.22 27); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} - @keyframes fade-down { - 0% { - opacity: 0; - transform: translateY(-10px); - } - 80% { - opacity: 0.6; - } - 100% { - opacity: 1; - transform: translateY(0px); - } - } -} \ No newline at end of file +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.16 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.16 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.87 0 0); + --primary-foreground: oklch(0.16 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.16 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} diff --git a/apps/web/styles/theme.utilities.css b/apps/web/styles/theme.utilities.css deleted file mode 100644 index 198772517..000000000 --- a/apps/web/styles/theme.utilities.css +++ /dev/null @@ -1,5 +0,0 @@ -@utility container { - margin-inline: auto; - - @apply px-4 lg:px-8 xl:max-w-[80rem]; -} diff --git a/apps/web/supabase/config.toml b/apps/web/supabase/config.toml index 62448667c..f8f5d2f40 100644 --- a/apps/web/supabase/config.toml +++ b/apps/web/supabase/config.toml @@ -42,7 +42,11 @@ file_size_limit = "50MiB" # in emails. site_url = "http://localhost:3000" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["http://localhost:3000", "http://localhost:3000/auth/callback", "http://localhost:3000/update-password"] +additional_redirect_urls = [ + "http://localhost:3000", + "http://localhost:3000/auth/callback", + "http://localhost:3000/update-password", +] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one # week). jwt_expiry = 3600 @@ -106,9 +110,7 @@ port = 54327 backend = "postgres" [db.migrations] -schema_paths = [ - "./schemas/*.sql", -] +schema_paths = ["./schemas/*.sql"] [db.seed] -sql_paths = ['seed.sql', './seeds/*.sql'] \ No newline at end of file +sql_paths = ['seed.sql', './seeds/*.sql'] diff --git a/apps/web/supabase/templates/change-email-address.html b/apps/web/supabase/templates/change-email-address.html index 0ff0f504f..949f27d20 100644 --- a/apps/web/supabase/templates/change-email-address.html +++ b/apps/web/supabase/templates/change-email-address.html @@ -1 +1,265 @@ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><meta name="color-scheme" content="only"/></head><body style="background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Confirm Change of Email | Makerkit<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424;width:100%"><tbody><tr style="width:100%"><td><table align="center" width="100%" class="undefined" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-left:auto;margin-right:auto;padding-left:20px;padding-right:20px;padding-top:40px;padding-bottom:40px;max-width:480px;background-color:#fafafa;margin:auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><h1 style="margin-left:0px;margin-right:0px;padding:0px;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:22px;font-weight:600;color:rgb(36,36,36)">Confirm Change of Email</h1></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" class="undefined" style="border-radius:0.75rem;background-color:rgb(255,255,255);padding-left:48px;padding-right:48px;padding-top:36px;padding-bottom:36px"><tbody><tr><td><p style="color:rgb(36,36,36);font-size:16px;line-height:20px;margin-top:16px;margin-bottom:16px">Follow this link to confirm the update of your email from<!-- --> <!-- -->{{ .Email }}<!-- --> to <!-- -->{{ .NewEmail }}</p><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px"><tbody><tr><td><a href="{{ .ConfirmationURL }}" style="width:100%;background-color:rgb(0,0,0);border-radius:0.25rem;color:rgb(255,255,255);font-size:16px;font-weight:600;text-decoration-line:none;text-align:center;padding-top:0.75rem;padding-bottom:0.75rem;line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;padding:12px 0px 12px 0px" target="_blank"><span><!--[if mso]><i style="mso-font-width:0%;mso-text-raise:18" hidden></i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Confirm Email Change</span><span><!--[if mso]><i style="mso-font-width:0%" hidden>​</i><![endif]--></span></a></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><p style="font-size:12px;line-height:20px;color:rgb(209,213,219);padding-left:1rem;padding-right:1rem;margin-top:16px;margin-bottom:16px">Makerkit</p></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table><!--7--><!--/$--></body></html> \ No newline at end of file +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html dir="ltr" lang="en"> + <head> + <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" /> + <meta name="x-apple-disable-message-reformatting" /> + <meta name="color-scheme" content="only" /> + </head> + <body + style=" + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + " + > + <!--$--> + <div + style=" + display: none; + overflow: hidden; + line-height: 1px; + opacity: 0; + max-height: 0; + max-width: 0; + " + data-skip-in-text="true" + > + Confirm Change of Email | Makerkit + <div> +  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ + </div> + </div> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + max-width: 37.5em; + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + width: 100%; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + class="undefined" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + margin-left: auto; + margin-right: auto; + padding-left: 20px; + padding-right: 20px; + padding-top: 40px; + padding-bottom: 40px; + max-width: 480px; + background-color: #fafafa; + margin: auto; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <h1 + style=" + margin-left: 0px; + margin-right: 0px; + padding: 0px; + font-family: + ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + font-size: 22px; + font-weight: 600; + color: rgb(36, 36, 36); + " + > + Confirm Change of Email + </h1> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + class="undefined" + style=" + border-radius: 0.75rem; + background-color: rgb(255, 255, 255); + padding-left: 48px; + padding-right: 48px; + padding-top: 36px; + padding-bottom: 36px; + " + > + <tbody> + <tr> + <td> + <p + style=" + color: rgb(36, 36, 36); + font-size: 16px; + line-height: 20px; + margin-top: 16px; + margin-bottom: 16px; + " + > + Follow this link to confirm the update of your + email from<!-- --> + <!-- -->{{ .Email }}<!-- --> + to + <!-- -->{{ .NewEmail }} + </p> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + text-align: center; + margin-top: 32px; + margin-bottom: 32px; + " + > + <tbody> + <tr> + <td> + <a + href="{{ .ConfirmationURL }}" + style=" + width: 100%; + background-color: rgb(0, 0, 0); + border-radius: 0.25rem; + color: rgb(255, 255, 255); + font-size: 16px; + font-weight: 600; + text-decoration-line: none; + text-align: center; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + line-height: 100%; + text-decoration: none; + display: inline-block; + max-width: 100%; + mso-padding-alt: 0px; + padding: 12px 0px 12px 0px; + " + target="_blank" + ><span + ><!--[if mso + ]><i + style=" + mso-font-width: 0%; + mso-text-raise: 18; + " + hidden + ></i><![endif]--></span + ><span + style=" + max-width: 100%; + display: inline-block; + line-height: 120%; + mso-padding-alt: 0px; + mso-text-raise: 9px; + " + >Confirm Email Change</span + ><span + ><!--[if mso + ]><i style="mso-font-width: 0%" hidden + >​</i + ><! + [endif]--></span + ></a + > + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <p + style=" + font-size: 12px; + line-height: 20px; + color: rgb(209, 213, 219); + padding-left: 1rem; + padding-right: 1rem; + margin-top: 16px; + margin-bottom: 16px; + " + > + Makerkit + </p> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--7--><!--/$--> + </body> +</html> diff --git a/apps/web/supabase/templates/confirm-email.html b/apps/web/supabase/templates/confirm-email.html index 162fe0afa..26b61a764 100644 --- a/apps/web/supabase/templates/confirm-email.html +++ b/apps/web/supabase/templates/confirm-email.html @@ -1 +1,263 @@ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><meta name="color-scheme" content="only"/></head><body style="background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Confirm your email - Makerkit<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424;width:100%"><tbody><tr style="width:100%"><td><table align="center" width="100%" class="undefined" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-left:auto;margin-right:auto;padding-left:20px;padding-right:20px;padding-top:40px;padding-bottom:40px;max-width:480px;background-color:#fafafa;margin:auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><h1 style="margin-left:0px;margin-right:0px;padding:0px;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:22px;font-weight:600;color:rgb(36,36,36)">Confirm your email to get started</h1></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" class="undefined" style="border-radius:0.75rem;background-color:rgb(255,255,255);padding-left:48px;padding-right:48px;padding-top:36px;padding-bottom:36px"><tbody><tr><td><p style="color:rgb(36,36,36);font-size:16px;line-height:20px;margin-top:16px;margin-bottom:16px">You're almost there! To complete your registration, please click the button below.</p><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px"><tbody><tr><td><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&callback={{ .RedirectTo }}" style="width:100%;background-color:rgb(0,0,0);border-radius:0.25rem;color:rgb(255,255,255);font-size:16px;font-weight:600;text-decoration-line:none;text-align:center;padding-top:0.75rem;padding-bottom:0.75rem;line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;padding:12px 0px 12px 0px" target="_blank"><span><!--[if mso]><i style="mso-font-width:0%;mso-text-raise:18" hidden></i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Login to <!-- -->Makerkit</span><span><!--[if mso]><i style="mso-font-width:0%" hidden>​</i><![endif]--></span></a></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><p style="font-size:12px;line-height:20px;color:rgb(209,213,219);padding-left:1rem;padding-right:1rem;margin-top:16px;margin-bottom:16px">Makerkit</p></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table><!--7--><!--/$--></body></html> \ No newline at end of file +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html dir="ltr" lang="en"> + <head> + <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" /> + <meta name="x-apple-disable-message-reformatting" /> + <meta name="color-scheme" content="only" /> + </head> + <body + style=" + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + " + > + <!--$--> + <div + style=" + display: none; + overflow: hidden; + line-height: 1px; + opacity: 0; + max-height: 0; + max-width: 0; + " + data-skip-in-text="true" + > + Confirm your email - Makerkit + <div> +  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ + </div> + </div> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + max-width: 37.5em; + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + width: 100%; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + class="undefined" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + margin-left: auto; + margin-right: auto; + padding-left: 20px; + padding-right: 20px; + padding-top: 40px; + padding-bottom: 40px; + max-width: 480px; + background-color: #fafafa; + margin: auto; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <h1 + style=" + margin-left: 0px; + margin-right: 0px; + padding: 0px; + font-family: + ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + font-size: 22px; + font-weight: 600; + color: rgb(36, 36, 36); + " + > + Confirm your email to get started + </h1> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + class="undefined" + style=" + border-radius: 0.75rem; + background-color: rgb(255, 255, 255); + padding-left: 48px; + padding-right: 48px; + padding-top: 36px; + padding-bottom: 36px; + " + > + <tbody> + <tr> + <td> + <p + style=" + color: rgb(36, 36, 36); + font-size: 16px; + line-height: 20px; + margin-top: 16px; + margin-bottom: 16px; + " + > + You're almost there! To complete your + registration, please click the button below. + </p> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + text-align: center; + margin-top: 32px; + margin-bottom: 32px; + " + > + <tbody> + <tr> + <td> + <a + href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&callback={{ .RedirectTo }}" + style=" + width: 100%; + background-color: rgb(0, 0, 0); + border-radius: 0.25rem; + color: rgb(255, 255, 255); + font-size: 16px; + font-weight: 600; + text-decoration-line: none; + text-align: center; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + line-height: 100%; + text-decoration: none; + display: inline-block; + max-width: 100%; + mso-padding-alt: 0px; + padding: 12px 0px 12px 0px; + " + target="_blank" + ><span + ><!--[if mso + ]><i + style=" + mso-font-width: 0%; + mso-text-raise: 18; + " + hidden + ></i><![endif]--></span + ><span + style=" + max-width: 100%; + display: inline-block; + line-height: 120%; + mso-padding-alt: 0px; + mso-text-raise: 9px; + " + >Login to + <!-- -->Makerkit</span + ><span + ><!--[if mso + ]><i style="mso-font-width: 0%" hidden + >​</i + ><! + [endif]--></span + ></a + > + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <p + style=" + font-size: 12px; + line-height: 20px; + color: rgb(209, 213, 219); + padding-left: 1rem; + padding-right: 1rem; + margin-top: 16px; + margin-bottom: 16px; + " + > + Makerkit + </p> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--7--><!--/$--> + </body> +</html> diff --git a/apps/web/supabase/templates/invite-user.html b/apps/web/supabase/templates/invite-user.html index 227b51c03..ee27fc299 100644 --- a/apps/web/supabase/templates/invite-user.html +++ b/apps/web/supabase/templates/invite-user.html @@ -1 +1,265 @@ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><meta name="color-scheme" content="only"/></head><body style="background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">You have bee invited to join - Makerkit<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424;width:100%"><tbody><tr style="width:100%"><td><table align="center" width="100%" class="undefined" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-left:auto;margin-right:auto;padding-left:20px;padding-right:20px;padding-top:40px;padding-bottom:40px;max-width:480px;background-color:#fafafa;margin:auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><h1 style="margin-left:0px;margin-right:0px;padding:0px;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:22px;font-weight:600;color:rgb(36,36,36)">You have been invited to <!-- -->Makerkit</h1></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" class="undefined" style="border-radius:0.75rem;background-color:rgb(255,255,255);padding-left:48px;padding-right:48px;padding-top:36px;padding-bottom:36px"><tbody><tr><td><p style="color:rgb(36,36,36);font-size:16px;line-height:20px;margin-top:16px;margin-bottom:16px">You have been invited to create a user on <!-- -->Makerkit<!-- -->. Follow this link to accept the invite:</p><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px"><tbody><tr><td><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=invite&callback={{ .RedirectTo }}" style="width:100%;background-color:rgb(0,0,0);border-radius:0.25rem;color:rgb(255,255,255);font-size:16px;font-weight:600;text-decoration-line:none;text-align:center;padding-top:0.75rem;padding-bottom:0.75rem;line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;padding:12px 0px 12px 0px" target="_blank"><span><!--[if mso]><i style="mso-font-width:0%;mso-text-raise:18" hidden></i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Accept invite to <!-- -->Makerkit</span><span><!--[if mso]><i style="mso-font-width:0%" hidden>​</i><![endif]--></span></a></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><p style="font-size:12px;line-height:20px;color:rgb(209,213,219);padding-left:1rem;padding-right:1rem;margin-top:16px;margin-bottom:16px">Makerkit</p></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table><!--7--><!--/$--></body></html> \ No newline at end of file +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html dir="ltr" lang="en"> + <head> + <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" /> + <meta name="x-apple-disable-message-reformatting" /> + <meta name="color-scheme" content="only" /> + </head> + <body + style=" + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + " + > + <!--$--> + <div + style=" + display: none; + overflow: hidden; + line-height: 1px; + opacity: 0; + max-height: 0; + max-width: 0; + " + data-skip-in-text="true" + > + You have bee invited to join - Makerkit + <div> +  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ + </div> + </div> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + max-width: 37.5em; + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + width: 100%; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + class="undefined" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + margin-left: auto; + margin-right: auto; + padding-left: 20px; + padding-right: 20px; + padding-top: 40px; + padding-bottom: 40px; + max-width: 480px; + background-color: #fafafa; + margin: auto; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <h1 + style=" + margin-left: 0px; + margin-right: 0px; + padding: 0px; + font-family: + ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + font-size: 22px; + font-weight: 600; + color: rgb(36, 36, 36); + " + > + You have been invited to + <!-- -->Makerkit + </h1> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + class="undefined" + style=" + border-radius: 0.75rem; + background-color: rgb(255, 255, 255); + padding-left: 48px; + padding-right: 48px; + padding-top: 36px; + padding-bottom: 36px; + " + > + <tbody> + <tr> + <td> + <p + style=" + color: rgb(36, 36, 36); + font-size: 16px; + line-height: 20px; + margin-top: 16px; + margin-bottom: 16px; + " + > + You have been invited to create a user on + <!-- -->Makerkit<!-- --> + . Follow this link to accept the invite: + </p> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + text-align: center; + margin-top: 32px; + margin-bottom: 32px; + " + > + <tbody> + <tr> + <td> + <a + href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=invite&callback={{ .RedirectTo }}" + style=" + width: 100%; + background-color: rgb(0, 0, 0); + border-radius: 0.25rem; + color: rgb(255, 255, 255); + font-size: 16px; + font-weight: 600; + text-decoration-line: none; + text-align: center; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + line-height: 100%; + text-decoration: none; + display: inline-block; + max-width: 100%; + mso-padding-alt: 0px; + padding: 12px 0px 12px 0px; + " + target="_blank" + ><span + ><!--[if mso + ]><i + style=" + mso-font-width: 0%; + mso-text-raise: 18; + " + hidden + ></i><![endif]--></span + ><span + style=" + max-width: 100%; + display: inline-block; + line-height: 120%; + mso-padding-alt: 0px; + mso-text-raise: 9px; + " + >Accept invite to + <!-- -->Makerkit</span + ><span + ><!--[if mso + ]><i style="mso-font-width: 0%" hidden + >​</i + ><! + [endif]--></span + ></a + > + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <p + style=" + font-size: 12px; + line-height: 20px; + color: rgb(209, 213, 219); + padding-left: 1rem; + padding-right: 1rem; + margin-top: 16px; + margin-bottom: 16px; + " + > + Makerkit + </p> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--7--><!--/$--> + </body> +</html> diff --git a/apps/web/supabase/templates/magic-link.html b/apps/web/supabase/templates/magic-link.html index 44fa1c80c..e06ce7756 100644 --- a/apps/web/supabase/templates/magic-link.html +++ b/apps/web/supabase/templates/magic-link.html @@ -1 +1,265 @@ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><meta name="color-scheme" content="only"/></head><body style="background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Your sign in link to Makerkit<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424;width:100%"><tbody><tr style="width:100%"><td><table align="center" width="100%" class="undefined" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-left:auto;margin-right:auto;padding-left:20px;padding-right:20px;padding-top:40px;padding-bottom:40px;max-width:480px;background-color:#fafafa;margin:auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><h1 style="margin-left:0px;margin-right:0px;padding:0px;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:22px;font-weight:600;color:rgb(36,36,36)">Login to <!-- -->Makerkit</h1></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" class="undefined" style="border-radius:0.75rem;background-color:rgb(255,255,255);padding-left:48px;padding-right:48px;padding-top:36px;padding-bottom:36px"><tbody><tr><td><p style="color:rgb(36,36,36);font-size:16px;line-height:20px;margin-top:16px;margin-bottom:16px">Hi, welcome to <!-- -->Makerkit<!-- -->! Click the button below to login.</p><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px"><tbody><tr><td><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink&callback={{ .RedirectTo }}" style="width:100%;background-color:rgb(0,0,0);border-radius:0.25rem;color:rgb(255,255,255);font-size:16px;font-weight:600;text-decoration-line:none;text-align:center;padding-top:0.75rem;padding-bottom:0.75rem;line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;padding:12px 0px 12px 0px" target="_blank"><span><!--[if mso]><i style="mso-font-width:0%;mso-text-raise:18" hidden></i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Login to <!-- -->Makerkit</span><span><!--[if mso]><i style="mso-font-width:0%" hidden>​</i><![endif]--></span></a></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><p style="font-size:12px;line-height:20px;color:rgb(209,213,219);padding-left:1rem;padding-right:1rem;margin-top:16px;margin-bottom:16px">Makerkit</p></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table><!--7--><!--/$--></body></html> \ No newline at end of file +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html dir="ltr" lang="en"> + <head> + <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" /> + <meta name="x-apple-disable-message-reformatting" /> + <meta name="color-scheme" content="only" /> + </head> + <body + style=" + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + " + > + <!--$--> + <div + style=" + display: none; + overflow: hidden; + line-height: 1px; + opacity: 0; + max-height: 0; + max-width: 0; + " + data-skip-in-text="true" + > + Your sign in link to Makerkit + <div> +  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ + </div> + </div> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + max-width: 37.5em; + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + width: 100%; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + class="undefined" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + margin-left: auto; + margin-right: auto; + padding-left: 20px; + padding-right: 20px; + padding-top: 40px; + padding-bottom: 40px; + max-width: 480px; + background-color: #fafafa; + margin: auto; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <h1 + style=" + margin-left: 0px; + margin-right: 0px; + padding: 0px; + font-family: + ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + font-size: 22px; + font-weight: 600; + color: rgb(36, 36, 36); + " + > + Login to + <!-- -->Makerkit + </h1> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + class="undefined" + style=" + border-radius: 0.75rem; + background-color: rgb(255, 255, 255); + padding-left: 48px; + padding-right: 48px; + padding-top: 36px; + padding-bottom: 36px; + " + > + <tbody> + <tr> + <td> + <p + style=" + color: rgb(36, 36, 36); + font-size: 16px; + line-height: 20px; + margin-top: 16px; + margin-bottom: 16px; + " + > + Hi, welcome to + <!-- -->Makerkit<!-- --> + ! Click the button below to login. + </p> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + text-align: center; + margin-top: 32px; + margin-bottom: 32px; + " + > + <tbody> + <tr> + <td> + <a + href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink&callback={{ .RedirectTo }}" + style=" + width: 100%; + background-color: rgb(0, 0, 0); + border-radius: 0.25rem; + color: rgb(255, 255, 255); + font-size: 16px; + font-weight: 600; + text-decoration-line: none; + text-align: center; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + line-height: 100%; + text-decoration: none; + display: inline-block; + max-width: 100%; + mso-padding-alt: 0px; + padding: 12px 0px 12px 0px; + " + target="_blank" + ><span + ><!--[if mso + ]><i + style=" + mso-font-width: 0%; + mso-text-raise: 18; + " + hidden + ></i><![endif]--></span + ><span + style=" + max-width: 100%; + display: inline-block; + line-height: 120%; + mso-padding-alt: 0px; + mso-text-raise: 9px; + " + >Login to + <!-- -->Makerkit</span + ><span + ><!--[if mso + ]><i style="mso-font-width: 0%" hidden + >​</i + ><! + [endif]--></span + ></a + > + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <p + style=" + font-size: 12px; + line-height: 20px; + color: rgb(209, 213, 219); + padding-left: 1rem; + padding-right: 1rem; + margin-top: 16px; + margin-bottom: 16px; + " + > + Makerkit + </p> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--7--><!--/$--> + </body> +</html> diff --git a/apps/web/supabase/templates/otp.html b/apps/web/supabase/templates/otp.html index 6bfd369e8..ab63f7fb9 100644 --- a/apps/web/supabase/templates/otp.html +++ b/apps/web/supabase/templates/otp.html @@ -1 +1,245 @@ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><meta name="color-scheme" content="only"/></head><body style="background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Your sign in link to Makerkit<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424;width:100%"><tbody><tr style="width:100%"><td><table align="center" width="100%" class="undefined" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-left:auto;margin-right:auto;padding-left:20px;padding-right:20px;padding-top:40px;padding-bottom:40px;max-width:480px;background-color:#fafafa;margin:auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><h1 style="margin-left:0px;margin-right:0px;padding:0px;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:22px;font-weight:600;color:rgb(36,36,36)">Login to <!-- -->Makerkit</h1></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" class="undefined" style="border-radius:0.75rem;background-color:rgb(255,255,255);padding-left:48px;padding-right:48px;padding-top:36px;padding-bottom:36px"><tbody><tr><td><p style="color:rgb(36,36,36);font-size:16px;line-height:20px;margin-top:0.5rem;margin-bottom:16px">Enter the code below to sign in to your <!-- -->Makerkit<!-- --> account:</p><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px;background-color:rgb(243,244,246);padding-left:0.25rem;padding-right:0.25rem;padding-top:1rem;padding-bottom:1rem"><tbody><tr><td><p style="font-weight:500;font-size:30px;line-height:24px;margin-top:16px;margin-bottom:16px">{{ .Token }}</p></td></tr></tbody></table><p style="color:rgb(36,36,36);font-size:12px;line-height:20px;margin-top:0.5rem;margin-bottom:16px">If you did not request this code, please ignore this email.</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><p style="font-size:12px;line-height:20px;color:rgb(209,213,219);padding-left:1rem;padding-right:1rem;margin-top:16px;margin-bottom:16px">Makerkit</p></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table><!--7--><!--/$--></body></html> \ No newline at end of file +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html dir="ltr" lang="en"> + <head> + <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" /> + <meta name="x-apple-disable-message-reformatting" /> + <meta name="color-scheme" content="only" /> + </head> + <body + style=" + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + " + > + <!--$--> + <div + style=" + display: none; + overflow: hidden; + line-height: 1px; + opacity: 0; + max-height: 0; + max-width: 0; + " + data-skip-in-text="true" + > + Your sign in link to Makerkit + <div> +  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ + </div> + </div> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + max-width: 37.5em; + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + width: 100%; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + class="undefined" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + margin-left: auto; + margin-right: auto; + padding-left: 20px; + padding-right: 20px; + padding-top: 40px; + padding-bottom: 40px; + max-width: 480px; + background-color: #fafafa; + margin: auto; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <h1 + style=" + margin-left: 0px; + margin-right: 0px; + padding: 0px; + font-family: + ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + font-size: 22px; + font-weight: 600; + color: rgb(36, 36, 36); + " + > + Login to + <!-- -->Makerkit + </h1> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + class="undefined" + style=" + border-radius: 0.75rem; + background-color: rgb(255, 255, 255); + padding-left: 48px; + padding-right: 48px; + padding-top: 36px; + padding-bottom: 36px; + " + > + <tbody> + <tr> + <td> + <p + style=" + color: rgb(36, 36, 36); + font-size: 16px; + line-height: 20px; + margin-top: 0.5rem; + margin-bottom: 16px; + " + > + Enter the code below to sign in to your + <!-- -->Makerkit<!-- --> + account: + </p> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + text-align: center; + margin-top: 32px; + margin-bottom: 32px; + background-color: rgb(243, 244, 246); + padding-left: 0.25rem; + padding-right: 0.25rem; + padding-top: 1rem; + padding-bottom: 1rem; + " + > + <tbody> + <tr> + <td> + <p + style=" + font-weight: 500; + font-size: 30px; + line-height: 24px; + margin-top: 16px; + margin-bottom: 16px; + " + > + {{ .Token }} + </p> + </td> + </tr> + </tbody> + </table> + <p + style=" + color: rgb(36, 36, 36); + font-size: 12px; + line-height: 20px; + margin-top: 0.5rem; + margin-bottom: 16px; + " + > + If you did not request this code, please ignore + this email. + </p> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <p + style=" + font-size: 12px; + line-height: 20px; + color: rgb(209, 213, 219); + padding-left: 1rem; + padding-right: 1rem; + margin-top: 16px; + margin-bottom: 16px; + " + > + Makerkit + </p> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--7--><!--/$--> + </body> +</html> diff --git a/apps/web/supabase/templates/reset-password.html b/apps/web/supabase/templates/reset-password.html index 856c19dd5..8762fe43e 100644 --- a/apps/web/supabase/templates/reset-password.html +++ b/apps/web/supabase/templates/reset-password.html @@ -1 +1,264 @@ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><meta name="color-scheme" content="only"/></head><body style="background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Reset your password | Makerkit<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;background-color:#fafafa;margin:auto;font-family:sans-serif;color:#242424;width:100%"><tbody><tr style="width:100%"><td><table align="center" width="100%" class="undefined" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-left:auto;margin-right:auto;padding-left:20px;padding-right:20px;padding-top:40px;padding-bottom:40px;max-width:480px;background-color:#fafafa;margin:auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><h1 style="margin-left:0px;margin-right:0px;padding:0px;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:22px;font-weight:600;color:rgb(36,36,36)">Reset your <!-- -->Makerkit<!-- --> password</h1></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" class="undefined" style="border-radius:0.75rem;background-color:rgb(255,255,255);padding-left:48px;padding-right:48px;padding-top:36px;padding-bottom:36px"><tbody><tr><td><p style="color:rgb(36,36,36);font-size:16px;line-height:20px;margin-top:16px;margin-bottom:16px">Please click the button below to reset your password.</p><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px"><tbody><tr><td><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next={{ .SiteURL }}/update-password" style="width:100%;background-color:rgb(0,0,0);border-radius:0.25rem;color:rgb(255,255,255);font-size:16px;font-weight:600;text-decoration-line:none;text-align:center;padding-top:0.75rem;padding-bottom:0.75rem;line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;padding:12px 0px 12px 0px" target="_blank"><span><!--[if mso]><i style="mso-font-width:0%;mso-text-raise:18" hidden></i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Reset Password</span><span><!--[if mso]><i style="mso-font-width:0%" hidden>​</i><![endif]--></span></a></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><p style="font-size:12px;line-height:20px;color:rgb(209,213,219);padding-left:1rem;padding-right:1rem;margin-top:16px;margin-bottom:16px">Makerkit</p></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table><!--7--><!--/$--></body></html> \ No newline at end of file +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html dir="ltr" lang="en"> + <head> + <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" /> + <meta name="x-apple-disable-message-reformatting" /> + <meta name="color-scheme" content="only" /> + </head> + <body + style=" + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + " + > + <!--$--> + <div + style=" + display: none; + overflow: hidden; + line-height: 1px; + opacity: 0; + max-height: 0; + max-width: 0; + " + data-skip-in-text="true" + > + Reset your password | Makerkit + <div> +  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ + </div> + </div> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + max-width: 37.5em; + background-color: #fafafa; + margin: auto; + font-family: sans-serif; + color: #242424; + width: 100%; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + class="undefined" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + margin-left: auto; + margin-right: auto; + padding-left: 20px; + padding-right: 20px; + padding-top: 40px; + padding-bottom: 40px; + max-width: 480px; + background-color: #fafafa; + margin: auto; + " + > + <tbody> + <tr style="width: 100%"> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <h1 + style=" + margin-left: 0px; + margin-right: 0px; + padding: 0px; + font-family: + ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + font-size: 22px; + font-weight: 600; + color: rgb(36, 36, 36); + " + > + Reset your + <!-- -->Makerkit<!-- --> + password + </h1> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + class="undefined" + style=" + border-radius: 0.75rem; + background-color: rgb(255, 255, 255); + padding-left: 48px; + padding-right: 48px; + padding-top: 36px; + padding-bottom: 36px; + " + > + <tbody> + <tr> + <td> + <p + style=" + color: rgb(36, 36, 36); + font-size: 16px; + line-height: 20px; + margin-top: 16px; + margin-bottom: 16px; + " + > + Please click the button below to reset your + password. + </p> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + text-align: center; + margin-top: 32px; + margin-bottom: 32px; + " + > + <tbody> + <tr> + <td> + <a + href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next={{ .SiteURL }}/update-password" + style=" + width: 100%; + background-color: rgb(0, 0, 0); + border-radius: 0.25rem; + color: rgb(255, 255, 255); + font-size: 16px; + font-weight: 600; + text-decoration-line: none; + text-align: center; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + line-height: 100%; + text-decoration: none; + display: inline-block; + max-width: 100%; + mso-padding-alt: 0px; + padding: 12px 0px 12px 0px; + " + target="_blank" + ><span + ><!--[if mso + ]><i + style=" + mso-font-width: 0%; + mso-text-raise: 18; + " + hidden + ></i><![endif]--></span + ><span + style=" + max-width: 100%; + display: inline-block; + line-height: 120%; + mso-padding-alt: 0px; + mso-text-raise: 9px; + " + >Reset Password</span + ><span + ><!--[if mso + ]><i style="mso-font-width: 0%" hidden + >​</i + ><! + [endif]--></span + ></a + > + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <table + align="center" + width="100%" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + > + <tbody> + <tr> + <td> + <p + style=" + font-size: 12px; + line-height: 20px; + color: rgb(209, 213, 219); + padding-left: 1rem; + padding-right: 1rem; + margin-top: 16px; + margin-bottom: 16px; + " + > + Makerkit + </p> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--7--><!--/$--> + </body> +</html> diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3140f2590..30267fe0c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,9 +1,8 @@ { "extends": "@kit/tsconfig/base.json", "compilerOptions": { - "baseUrl": ".", "paths": { - "~/*": ["./app/*"], + "~/*": ["./app/[locale]/*", "./app/*"], "~/config/*": ["./config/*"], "~/components/*": ["./components/*"], "~/lib/*": ["./lib/*"] diff --git a/docs/admin/adding-super-admin.mdoc b/docs/admin/adding-super-admin.mdoc new file mode 100644 index 000000000..ad4d2b858 --- /dev/null +++ b/docs/admin/adding-super-admin.mdoc @@ -0,0 +1,69 @@ +--- +status: "published" +label: "Adding a Super Admin" +title: "Adding a Super Admin to your Next.js Supabase application" +description: "In this post, you will learn how to set up a Super Admin in your Next.js Supabase application" +order: 0 +--- + +The Super Admin panel allows you to manage users and accounts. + +{% sequence title="Steps to add a Super Admin" description="Learn how to add a Super Admin to your Next.js Supabase application." %} + +[How to access the Super Admin panel](#how-to-access-the-super-admin-panel) + +[Testing the Super Admin locally](#testing-the-super-admin-locally) + +[Assigning a Super Admin role to a user](#assigning-a-super-admin-role-to-a-user) + +{% /sequence %} + +## How to access the Super Admin panel + +To access the super admin panel at `/admin`, you will need to assign a user as a super admin. In addition, you will need to enable MFA for the user from the normal user profile settings. + +## Testing the Super Admin locally + +By default, we seed the `auth.users` table with a super admin user. To login as this user, you can use the following credentials: + +```json +{ + "email": "super-admin@makerkit.dev", + "password": "testingpassword" +} +``` + +Since you require MFA for the Super Admin user, please use the following steps to pass MFA: + +1. **TOTP**: Use the following [TOTP generator](https://totp.danhersam.com/) to generate a TOTP code. +2. **Secret Key**: Use the test secret key `NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE` to generate a TOTP code. +3. **Verify**: Use the TOTP code and the secret key to verify the MFA code. +Make sure the TOTP code is not expired when you verify the MFA code. + +{% alert type="warning" title="These are test credentials" %} +The flow above is for testing purposes only. For production, you must use an authenticator app such as Google Authenticator, Authy, Bitwarden, Proton, or similar. +{% /alert %} + +## Assigning a Super Admin role to a user + +To add your own super admin user, you will need to: + +1. **Role**: Add the `super-admin` role to the user in your database +2. **Enable MFA**: Enable MFA for the user (mandatory) from the profile +settings of the user + +### Modify the `auth.users` record in your database + +To assign a user as a super admin, run the following SQL query from your Supabase SQL Query editor: + +```sql +UPDATE auth.users SET raw_app_meta_data = raw_app_meta_data || '{"role": "super-admin"}' WHERE id='<user_id>'; +``` + +Please replace `<user_id>` with the user ID you want to assign as a super admin. + +### Enable MFA for the Super Admin user + +Starting from version `2.5.0`, the Super Admin user will be required to use Multi-Factor Authentication (MFA) to access the Super Admin panel. + +Please navigate to the `/home/settings` page and enable MFA for the Super Admin user. \ No newline at end of file diff --git a/docs/analytics/analytics-and-events.mdoc b/docs/analytics/analytics-and-events.mdoc new file mode 100644 index 000000000..dc53f93bd --- /dev/null +++ b/docs/analytics/analytics-and-events.mdoc @@ -0,0 +1,363 @@ +--- +status: "published" +title: 'Understanding Analytics and App Events in MakerKit' +label: 'Analytics and Events' +description: 'Learn how the Analytics and App Events systems work together to provide centralized, maintainable event tracking in your MakerKit SaaS application.' +order: 0 +--- + +MakerKit separates event emission from analytics tracking through two interconnected systems: **App Events** for broadcasting important occurrences in your app, and **Analytics** for tracking user behavior. This separation keeps your components clean and your analytics logic centralized. + +## Why Centralized Analytics + +Scattering `analytics.trackEvent()` calls throughout your codebase creates maintenance problems. When you need to change event names, add properties, or switch providers, you hunt through dozens of files. + +The centralized approach solves this: + +```typescript +// Instead of this (scattered analytics) +function CheckoutButton() { + const handleClick = () => { + analytics.trackEvent('checkout_started', { plan: 'pro' }); + analytics.identify(userId); + mixpanel.track('Checkout Started'); + // More provider-specific code... + }; +} + +// Do this (centralized via App Events) +function CheckoutButton() { + const { emit } = useAppEvents(); + + const handleClick = () => { + emit({ type: 'checkout.started', payload: { planId: 'pro' } }); + }; +} +``` + +The analytics mapping lives in one place: `apps/web/components/analytics-provider.tsx`. + +## How It Works + +The system has three parts: + +1. **App Events Provider**: A React Context that provides `emit`, `on`, and `off` functions +2. **Analytics Provider**: Subscribes to App Events and maps them to analytics calls +3. **Analytics Manager**: Dispatches events to all registered analytics providers + +``` +Component Analytics Provider Providers + │ │ │ + │ emit('checkout.started') │ │ + │────────────────────────────▶│ │ + │ │ analytics.trackEvent │ + │ │────────────────────────▶│ + │ │ analytics.identify │ + │ │────────────────────────▶│ +``` + +## Emitting Events + +Use the `useAppEvents` hook to emit events from any component: + +```typescript +import { useAppEvents } from '@kit/shared/events'; + +function FeatureButton() { + const { emit } = useAppEvents(); + + const handleClick = () => { + emit({ + type: 'feature.used', + payload: { featureName: 'export' } + }); + }; + + return <button onClick={handleClick}>Export</button>; +} +``` + +The event is broadcast to all listeners, including the analytics provider. + +## Default Event Types + +MakerKit defines these base event types in `@kit/shared/events`: + +```typescript +interface BaseAppEventTypes { + 'user.signedIn': { userId: string }; + 'user.signedUp': { method: 'magiclink' | 'password' }; + 'user.updated': Record<string, never>; + 'checkout.started': { planId: string; account?: string }; +} +``` + +These events are emitted automatically by MakerKit components and mapped to analytics calls. + +### Event Descriptions + +| Event | When Emitted | Analytics Action | +|-------|--------------|------------------| +| `user.signedIn` | After successful login | `identify(userId)` | +| `user.signedUp` | After registration | `trackEvent('user.signedUp')` | +| `user.updated` | After profile update | `trackEvent('user.updated')` | +| `checkout.started` | When billing checkout begins | `trackEvent('checkout.started')` | + +**Note**: The `user.signedUp` event does not fire automatically for social/OAuth signups. You may need to emit it manually in your OAuth callback handler. + +## Creating Custom Events + +Define custom events by extending `ConsumerProvidedEventTypes`: + +```typescript {% title="lib/events/custom-events.ts" %} +import { ConsumerProvidedEventTypes } from '@kit/shared/events'; + +export interface MyAppEvents extends ConsumerProvidedEventTypes { + 'feature.used': { featureName: string; duration?: number }; + 'project.created': { projectId: string; template: string }; + 'export.completed': { format: 'csv' | 'json' | 'pdf'; rowCount: number }; +} +``` + +Use the typed hook in your components: + +```typescript +import { useAppEvents } from '@kit/shared/events'; +import type { MyAppEvents } from '~/lib/events/custom-events'; + +function ProjectForm() { + const { emit } = useAppEvents<MyAppEvents>(); + + const handleCreate = (project: Project) => { + emit({ + type: 'project.created', + payload: { + projectId: project.id, + template: project.template, + }, + }); + }; +} +``` + +TypeScript enforces the correct payload shape for each event type. + +## Mapping Events to Analytics + +The `AnalyticsProvider` component maps events to analytics calls. Add your custom events here: + +```typescript {% title="apps/web/components/analytics-provider.tsx" %} +const analyticsMapping: AnalyticsMapping = { + 'user.signedIn': (event) => { + const { userId, ...traits } = event.payload; + if (userId) { + return analytics.identify(userId, traits); + } + }, + 'user.signedUp': (event) => { + return analytics.trackEvent(event.type, event.payload); + }, + 'checkout.started': (event) => { + return analytics.trackEvent(event.type, event.payload); + }, + // Add custom event mappings + 'feature.used': (event) => { + return analytics.trackEvent('Feature Used', { + feature_name: event.payload.featureName, + duration: String(event.payload.duration ?? 0), + }); + }, + 'project.created': (event) => { + return analytics.trackEvent('Project Created', { + project_id: event.payload.projectId, + template: event.payload.template, + }); + }, +}; +``` + +This is the only place you need to modify when changing analytics behavior. + +## Listening to Events + +Beyond analytics, you can subscribe to events for other purposes: + +```typescript +import { useAppEvents } from '@kit/shared/events'; +import { useEffect } from 'react'; + +function NotificationListener() { + const { on, off } = useAppEvents(); + + useEffect(() => { + const handler = (event) => { + showToast(`Project ${event.payload.projectId} created!`); + }; + + on('project.created', handler); + return () => off('project.created', handler); + }, [on, off]); + + return null; +} +``` + +This pattern is useful for triggering side effects like notifications, confetti animations, or feature tours. + +## Direct Analytics API + +While centralized events are recommended, you can use the analytics API directly when needed: + +```typescript +import { analytics } from '@kit/analytics'; + +// Identify a user +void analytics.identify('user_123', { + email: 'user@example.com', + plan: 'pro', +}); + +// Track an event +void analytics.trackEvent('Button Clicked', { + button: 'submit', + page: 'settings', +}); + +// Track a page view (usually automatic) +void analytics.trackPageView('/dashboard'); +``` + +Use direct calls for one-off tracking that does not warrant an event type. + +## Automatic Page View Tracking + +The `AnalyticsProvider` automatically tracks page views when the Next.js route changes: + +```typescript +// This happens automatically in AnalyticsProvider +function useReportPageView(reportFn: (url: string) => unknown) { + const pathname = usePathname(); + + useEffect(() => { + const url = pathname; + reportFn(url); + }, [pathname]); +} +``` + +You do not need to manually track page views unless you have a specific use case. + +## Common Patterns + +### Track Form Submissions + +```typescript +function ContactForm() { + const { emit } = useAppEvents<MyAppEvents>(); + + const handleSubmit = async (data: FormData) => { + await submitForm(data); + + emit({ + type: 'form.submitted', + payload: { formName: 'contact', fields: Object.keys(data).length }, + }); + }; +} +``` + +### Track Feature Engagement + +```typescript +function AIAssistant() { + const { emit } = useAppEvents<MyAppEvents>(); + const startTime = useRef<number>(); + + const handleOpen = () => { + startTime.current = Date.now(); + }; + + const handleClose = () => { + const duration = Date.now() - (startTime.current ?? Date.now()); + + emit({ + type: 'feature.used', + payload: { featureName: 'ai-assistant', duration }, + }); + }; +} +``` + +### Track Errors + +```typescript +function ErrorBoundary({ children }) { + const { emit } = useAppEvents<MyAppEvents>(); + + const handleError = (error: Error) => { + emit({ + type: 'error.occurred', + payload: { + message: error.message, + stack: error.stack?.slice(0, 500), + }, + }); + }; +} +``` + +## Debugging Events + +During development, add logging to your event handlers to verify events are emitting correctly: + +```typescript {% title="apps/web/components/analytics-provider.tsx" %} +const analyticsMapping: AnalyticsMapping = { + 'user.signedIn': (event) => { + if (process.env.NODE_ENV === 'development') { + console.log('[Analytics Event]', event.type, event.payload); + } + + const { userId, ...traits } = event.payload; + if (userId) { + return analytics.identify(userId, traits); + } + }, + 'checkout.started': (event) => { + if (process.env.NODE_ENV === 'development') { + console.log('[Analytics Event]', event.type, event.payload); + } + + return analytics.trackEvent(event.type, event.payload); + }, + // Add logging to other handlers as needed +}; +``` + +You can also use your analytics provider's debug mode (PostHog and GA4 both offer live event views in their dashboards). + +## Best Practices + +1. **Use App Events for domain events**: Business-relevant events (signup, purchase, feature use) should go through App Events +2. **Keep payloads minimal**: Only include data you will actually analyze +3. **Use consistent naming**: Follow a pattern like `noun.verb` (user.signedUp, project.created) +4. **Type your events**: Define interfaces for compile-time safety +5. **Test event emission**: Verify critical events emit during integration tests +6. **Document your events**: Maintain a list of events and their purposes + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Can I use analytics without App Events?", "answer": "Yes. Import analytics from @kit/analytics and call trackEvent directly. However, the centralized approach through App Events is easier to maintain as your application grows."}, + {"question": "How do I track events on the server side?", "answer": "Import analytics from @kit/analytics/server. Note that only PostHog supports server-side analytics out of the box. The App Events system is client-side only."}, + {"question": "Are page views tracked automatically?", "answer": "Yes. The AnalyticsProvider component tracks page views whenever the Next.js route changes. You only need manual tracking for virtual page views in SPAs."}, + {"question": "How do I debug which events are firing?", "answer": "Add a wildcard handler in your analytics mapping that logs events in development mode. You can also use browser DevTools or your analytics provider's debug mode."}, + {"question": "Can I emit events from Server Components?", "answer": "No. App Events use React Context which requires a client component. Emit events from client components or use the server-side analytics API directly."}, + {"question": "What happens if no analytics provider is configured?", "answer": "Events dispatch to the NullAnalyticsService which silently ignores them. Your application continues to work without errors."} + ] +/%} + +## Next Steps + +- [Set up Google Analytics](google-analytics-provider) for marketing analytics +- [Set up PostHog](posthog-analytics-provider) for product analytics with feature flags +- [Create a custom provider](custom-analytics-provider) to integrate other services diff --git a/docs/analytics/custom-analytics-provider.mdoc b/docs/analytics/custom-analytics-provider.mdoc new file mode 100644 index 000000000..8ad37f7d2 --- /dev/null +++ b/docs/analytics/custom-analytics-provider.mdoc @@ -0,0 +1,360 @@ +--- +status: "published" +title: 'Creating a Custom Analytics Provider in MakerKit' +label: 'Custom Analytics Provider' +description: 'Build a custom analytics provider to integrate Mixpanel, Amplitude, Segment, or any analytics service with MakerKit unified analytics API.' +order: 5 +--- + +MakerKit's analytics system is provider-agnostic. If your preferred analytics service is not included (Google Analytics, PostHog, Umami), you can create a custom provider that integrates with the unified analytics API. Events dispatched through `analytics.trackEvent()` or App Events will automatically route to your custom provider alongside any other registered providers. + +## The AnalyticsService Interface + +Every analytics provider must implement the `AnalyticsService` interface: + +```typescript +interface AnalyticsService { + initialize(): Promise<unknown>; + identify(userId: string, traits?: Record<string, string>): Promise<unknown>; + trackPageView(path: string): Promise<unknown>; + trackEvent( + eventName: string, + eventProperties?: Record<string, string | string[]> + ): Promise<unknown>; +} +``` + +| Method | Purpose | +|--------|---------| +| `initialize()` | Load scripts, set up the SDK | +| `identify()` | Associate a user ID with subsequent events | +| `trackPageView()` | Record a page view | +| `trackEvent()` | Record a custom event with properties | + +All methods return Promises. Use `void` when calling from non-async contexts. + +## Example: Mixpanel Provider + +Here is a complete implementation for Mixpanel: + +```typescript {% title="packages/analytics/src/mixpanel-service.ts" %} +import { NullAnalyticsService } from './null-analytics-service'; +import type { AnalyticsService } from './types'; + +class MixpanelService implements AnalyticsService { + private mixpanel: typeof import('mixpanel-browser') | null = null; + private token: string; + + constructor(token: string) { + this.token = token; + } + + async initialize(): Promise<void> { + if (typeof window === 'undefined') { + return; + } + + const mixpanel = await import('mixpanel-browser'); + mixpanel.init(this.token, { + track_pageview: false, // We handle this manually + persistence: 'localStorage', + }); + + this.mixpanel = mixpanel; + } + + async identify(userId: string, traits?: Record<string, string>): Promise<void> { + if (!this.mixpanel) return; + + this.mixpanel.identify(userId); + + if (traits) { + this.mixpanel.people.set(traits); + } + } + + async trackPageView(path: string): Promise<void> { + if (!this.mixpanel) return; + + this.mixpanel.track('Page Viewed', { path }); + } + + async trackEvent( + eventName: string, + eventProperties?: Record<string, string | string[]> + ): Promise<void> { + if (!this.mixpanel) return; + + this.mixpanel.track(eventName, eventProperties); + } +} + +export function createMixpanelService(): AnalyticsService { + const token = process.env.NEXT_PUBLIC_MIXPANEL_TOKEN; + + if (!token) { + console.warn('Mixpanel token not configured'); + return new NullAnalyticsService(); + } + + return new MixpanelService(token); +} +``` + +Install the Mixpanel SDK: + +```bash +pnpm add mixpanel-browser --filter "@kit/analytics" +``` + +## Registering Your Provider + +Add your custom provider to the analytics manager: + +```typescript {% title="packages/analytics/src/index.ts" %} +import { createAnalyticsManager } from './analytics-manager'; +import { createMixpanelService } from './mixpanel-service'; +import type { AnalyticsManager } from './types'; + +export const analytics: AnalyticsManager = createAnalyticsManager({ + providers: { + mixpanel: createMixpanelService, + }, +}); +``` + +Add environment variables: + +```bash {% title=".env.local" %} +NEXT_PUBLIC_MIXPANEL_TOKEN=your_mixpanel_token +``` + +## Using Multiple Providers + +Register multiple providers to dispatch events to all of them: + +```typescript {% title="packages/analytics/src/index.ts" %} +import { createAnalyticsManager } from './analytics-manager'; +import { createMixpanelService } from './mixpanel-service'; +import { createPostHogAnalyticsService } from '@kit/posthog/client'; + +export const analytics = createAnalyticsManager({ + providers: { + mixpanel: createMixpanelService, + posthog: createPostHogAnalyticsService, + }, +}); +``` + +When you call `analytics.trackEvent()`, both Mixpanel and PostHog receive the event. + +## Example: Amplitude Provider + +Here is a skeleton for Amplitude: + +```typescript {% title="packages/analytics/src/amplitude-service.ts" %} +import type { AnalyticsService } from './types'; + +class AmplitudeService implements AnalyticsService { + private amplitude: typeof import('@amplitude/analytics-browser') | null = null; + + async initialize(): Promise<void> { + if (typeof window === 'undefined') return; + + const amplitude = await import('@amplitude/analytics-browser'); + const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY; + + if (apiKey) { + amplitude.init(apiKey); + this.amplitude = amplitude; + } + } + + async identify(userId: string, traits?: Record<string, string>): Promise<void> { + if (!this.amplitude) return; + + this.amplitude.setUserId(userId); + + if (traits) { + const identifyEvent = new this.amplitude.Identify(); + Object.entries(traits).forEach(([key, value]) => { + identifyEvent.set(key, value); + }); + this.amplitude.identify(identifyEvent); + } + } + + async trackPageView(path: string): Promise<void> { + if (!this.amplitude) return; + this.amplitude.track('Page Viewed', { path }); + } + + async trackEvent( + eventName: string, + eventProperties?: Record<string, string | string[]> + ): Promise<void> { + if (!this.amplitude) return; + this.amplitude.track(eventName, eventProperties); + } +} + +export function createAmplitudeService(): AnalyticsService { + return new AmplitudeService(); +} +``` + +## Example: Segment Provider + +Segment acts as a data router to multiple destinations: + +```typescript {% title="packages/analytics/src/segment-service.ts" %} +import type { AnalyticsService } from './types'; + +declare global { + interface Window { + analytics: { + identify: (userId: string, traits?: object) => void; + page: (name?: string, properties?: object) => void; + track: (event: string, properties?: object) => void; + }; + } +} + +class SegmentService implements AnalyticsService { + async initialize(): Promise<void> { + // Segment snippet is typically added via <Script> in layout + // This method can verify it's loaded + if (typeof window === 'undefined' || !window.analytics) { + console.warn('Segment analytics not loaded'); + } + } + + async identify(userId: string, traits?: Record<string, string>): Promise<void> { + window.analytics?.identify(userId, traits); + } + + async trackPageView(path: string): Promise<void> { + window.analytics?.page(undefined, { path }); + } + + async trackEvent( + eventName: string, + eventProperties?: Record<string, string | string[]> + ): Promise<void> { + window.analytics?.track(eventName, eventProperties); + } +} + +export function createSegmentService(): AnalyticsService { + return new SegmentService(); +} +``` + +## Server-Side Providers + +For server-side analytics, create a separate service file: + +```typescript {% title="packages/analytics/src/mixpanel-server.ts" %} +import Mixpanel from 'mixpanel'; +import type { AnalyticsService } from './types'; + +class MixpanelServerService implements AnalyticsService { + private mixpanel: Mixpanel.Mixpanel | null = null; + + async initialize(): Promise<void> { + const token = process.env.MIXPANEL_TOKEN; // Note: no NEXT_PUBLIC_ prefix + if (token) { + this.mixpanel = Mixpanel.init(token); + } + } + + async identify(userId: string, traits?: Record<string, string>): Promise<void> { + if (!this.mixpanel || !traits) return; + this.mixpanel.people.set(userId, traits); + } + + async trackPageView(path: string): Promise<void> { + // Server-side page views are uncommon + } + + async trackEvent( + eventName: string, + eventProperties?: Record<string, string | string[]> + ): Promise<void> { + if (!this.mixpanel) return; + this.mixpanel.track(eventName, eventProperties); + } +} +``` + +Register in `packages/analytics/src/server.ts`: + +```typescript {% title="packages/analytics/src/server.ts" %} +import 'server-only'; + +import { createAnalyticsManager } from './analytics-manager'; +import { createMixpanelServerService } from './mixpanel-server'; + +export const analytics = createAnalyticsManager({ + providers: { + mixpanel: createMixpanelServerService, + }, +}); +``` + +## The NullAnalyticsService + +When no providers are configured, MakerKit uses a null service that silently ignores all calls: + +```typescript +const NullAnalyticsService: AnalyticsService = { + initialize: () => Promise.resolve(), + identify: () => Promise.resolve(), + trackPageView: () => Promise.resolve(), + trackEvent: () => Promise.resolve(), +}; +``` + +Your provider factory can return this when misconfigured to avoid errors. + +## Best Practices + +1. **Dynamic imports**: Load SDKs dynamically to reduce bundle size +2. **Environment checks**: Always check `typeof window` before accessing browser APIs +3. **Graceful degradation**: Return early if the SDK fails to load +4. **Typed properties**: Define TypeScript interfaces for your event properties +5. **Consistent naming**: Use the same event names across all providers + +## Troubleshooting + +### Provider not receiving events + +- Verify the provider is registered in `createAnalyticsManager` +- Check that `initialize()` completes without errors +- Confirm environment variables are set + +### TypeScript errors + +- Ensure your class implements all methods in `AnalyticsService` +- Check that return types are `Promise<unknown>` or more specific + +### Events delayed or missing + +- Some providers batch events. Check provider-specific settings +- Verify the provider SDK is loaded before events are sent + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Can I use the same provider for client and server?", "answer": "It depends on the SDK. Some analytics SDKs (like PostHog) offer both client and server versions. Others (like Mixpanel) have separate packages. Create separate service files for each environment."}, + {"question": "How do I test my custom provider?", "answer": "Add console.log statements in each method during development. Most analytics dashboards also have a debug or live events view."}, + {"question": "Can I conditionally load providers?", "answer": "Yes. Your factory function can check environment variables or feature flags and return NullAnalyticsService when the provider should be disabled."}, + {"question": "How do I handle errors in providers?", "answer": "Wrap SDK calls in try-catch blocks. Log errors but do not throw them, as this would affect other providers in the chain."} + ] +/%} + +## Next Steps + +- [Learn about Analytics and Events](analytics-and-events) for event patterns +- [See Google Analytics](google-analytics-provider) as a reference implementation +- [Try PostHog](posthog-analytics-provider) for a full-featured option diff --git a/docs/analytics/google-analytics-provider.mdoc b/docs/analytics/google-analytics-provider.mdoc new file mode 100644 index 000000000..afaac746f --- /dev/null +++ b/docs/analytics/google-analytics-provider.mdoc @@ -0,0 +1,148 @@ +--- +status: "published" +title: 'Using the Google Analytics Provider in Next.js Supabase Turbo' +label: 'Google Analytics' +description: 'Add Google Analytics 4 (GA4) to your MakerKit application for page views, user tracking, and conversion measurement.' +order: 2 +--- + +Google Analytics 4 (GA4) provides web analytics focused on marketing attribution, conversion tracking, and audience insights. Use it when your marketing team needs Google's ecosystem for ad optimization and reporting. + +## Prerequisites + +Before starting, you need: + +- A Google Analytics 4 property ([create one here](https://analytics.google.com/)) +- Your GA4 Measurement ID (format: `G-XXXXXXXXXX`) + +Find your Measurement ID in GA4: **Admin > Data Streams > Select your stream > Measurement ID**. + +## Installation + +Install the Google Analytics plugin using the MakerKit CLI: + +```bash +npx @makerkit/cli@latest plugins add google-analytics +``` + +Our codemod will wire up the plugin in your project, so you don't have to do anything manually. Please review the changes with `git diff`. + +Please add your Measurement ID to environment variables: + +```bash {% title="apps/web/.env.local" %} +NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX +``` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Yes | Your GA4 Measurement ID | +| `NEXT_PUBLIC_GA_DISABLE_PAGE_VIEWS_TRACKING` | No | Set to `true` to disable automatic page view tracking | +| `NEXT_PUBLIC_GA_DISABLE_LOCALHOST_TRACKING` | No | Set to `true` to disable tracking on localhost | + +### Development Configuration + +Disable localhost tracking to avoid polluting your analytics during development: + +```bash {% title=".env.local" %} +NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX +NEXT_PUBLIC_GA_DISABLE_LOCALHOST_TRACKING=true +``` + +## Verification + +After configuration, verify the integration: + +1. Open your application in the browser +2. Open Chrome DevTools > Network tab +3. Filter by `google-analytics` or `gtag` +4. Navigate between pages and confirm requests are sent +5. Check GA4 Realtime reports to see your session + +## Using with Other Providers + +Google Analytics can run alongside other providers. Events dispatch to all registered providers: + +```typescript {% title="packages/analytics/src/index.ts" %} +import { createGoogleAnalyticsService } from '@kit/google-analytics'; +import { createPostHogAnalyticsService } from '@kit/posthog/client'; + +import { createAnalyticsManager } from './analytics-manager'; + +export const analytics = createAnalyticsManager({ + providers: { + 'google-analytics': createGoogleAnalyticsService, + posthog: createPostHogAnalyticsService, + }, +}); +``` + +This setup is common when marketing uses GA4 for attribution while product uses PostHog for behavior analysis. + +## Tracked Events + +With the default configuration, Google Analytics receives: + +- **Page views**: Automatically tracked on route changes +- **User identification**: When `analytics.identify()` is called +- **Custom events**: All events passed to `analytics.trackEvent()` + +Events from the App Events system (user.signedUp, checkout.started, etc.) are forwarded to GA4 through the analytics mapping. + +## GDPR Considerations + +Google Analytics sets cookies and requires user consent in the EU. Integrate with the [Cookie Banner component](/docs/next-supabase-turbo/components/cookie-banner) to manage consent: + +```typescript +import { useCookieConsent, ConsentStatus } from '@kit/ui/cookie-banner'; +import { analytics } from '@kit/analytics'; +import { useEffect } from 'react'; + +function AnalyticsGate({ children }) { + const { status } = useCookieConsent(); + + useEffect(() => { + if (status === ConsentStatus.Accepted) { + // GA is initialized automatically when consent is given + // You may want to delay initialization until consent + } + }, [status]); + + return children; +} +``` + +Consider using [Umami](umami-analytics-provider) for cookie-free, GDPR-compliant analytics. + +## Troubleshooting + +### Events not appearing in GA4 + +- Verify your Measurement ID is correct +- Check that `NEXT_PUBLIC_GA_DISABLE_LOCALHOST_TRACKING` is not `true` in production +- GA4 has a delay of up to 24-48 hours for some reports. Use Realtime for immediate verification + +### Duplicate page views + +- Ensure you have not called `trackPageView` manually. MakerKit tracks page views automatically + +### Ad blockers + +- Ad blockers often block Google Analytics. Consider using a proxy or server-side tracking for critical metrics + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Does MakerKit support Universal Analytics?", "answer": "No. Universal Analytics was sunset by Google in July 2023. MakerKit only supports Google Analytics 4 (GA4)."}, + {"question": "Can I use Google Tag Manager instead?", "answer": "Yes, but you would need to create a custom analytics provider. The built-in plugin uses gtag.js directly."}, + {"question": "How do I track conversions?", "answer": "Use analytics.trackEvent() with your conversion event name. Configure the event as a conversion in GA4 Admin > Events > Mark as conversion."}, + {"question": "Is server-side tracking supported?", "answer": "No. The Google Analytics plugin is client-side only. Use the Measurement Protocol API directly if you need server-side GA4 tracking."} + ] +/%} + +## Next Steps + +- [Learn about Analytics and Events](analytics-and-events) for custom event tracking +- [Add PostHog](posthog-analytics-provider) for product analytics +- [Create a custom provider](custom-analytics-provider) for other services diff --git a/docs/analytics/meshes-provider.mdoc b/docs/analytics/meshes-provider.mdoc new file mode 100644 index 000000000..f7da5487b --- /dev/null +++ b/docs/analytics/meshes-provider.mdoc @@ -0,0 +1,32 @@ +--- +status: "published" +title: 'Using the Meshes Analytics Provider in Next.js Supabase Turbo' +label: 'Meshes' +description: 'Add Meshes to your MakerKit application for event tracking and analytics.' +order: 6 +--- + +[Meshes](https://meshes.io/) is a platform for event tracking for user engagement and conversions. It captures custom events (signups, plan upgrades, feature usage) so you can measure what matters without building your own event pipeline. + +## Installation + +Install the Meshes plugin using the MakerKit CLI: + +```bash +npx @makerkit/cli@latest plugins add meshes-analytics +``` + +The Makerkit CLI will automatically wire up the plugin in your project, so you don't have to do anything manually + +The codemod is very complex, so please review the changes with `git diff` and commit them. + +## Environment Variables + +Set the analytics provider and Meshes configuration: + +```bash title=".env.local" +# Meshes configuration +NEXT_PUBLIC_MESHES_PUBLISHABLE_KEY=your_api_key_here +``` + +Please [read the Meshes documentation](https://meshes.io/docs) for more information on how to use the Meshes analytics provider. \ No newline at end of file diff --git a/docs/analytics/posthog-analytics-provider.mdoc b/docs/analytics/posthog-analytics-provider.mdoc new file mode 100644 index 000000000..72c5c120e --- /dev/null +++ b/docs/analytics/posthog-analytics-provider.mdoc @@ -0,0 +1,202 @@ +--- +status: "published" +title: 'Using the PostHog Analytics Provider in Next.js Supabase Turbo' +label: 'PostHog' +description: 'Add PostHog to your MakerKit application for product analytics, session replay, and feature flags with client-side and server-side support.' +order: 3 +--- + +PostHog provides product analytics, session replay, feature flags, and A/B testing in one platform. + +Unlike marketing-focused tools, PostHog helps you understand how users interact with your product. + +Posthog supports both client-side and server-side tracking, and can be self-hosted for full data control. + +## Prerequisites + +Before starting: + +- Create a PostHog account at [posthog.com](https://posthog.com) +- Note your Project API Key (starts with `phc_`) +- Choose your region: `eu.posthog.com` or `us.posthog.com` + +Find your API key in PostHog: **Project Settings > Project API Key**. + +## Installation + +Install the PostHog plugin using the MakerKit CLI: + +```bash +npx @makerkit/cli@latest plugins add posthog +``` + +Our codemod will wire up the plugin in your project, so you don't have to do anything manually. Please review the changes with `git diff`. + +Add environment variables: + +```bash {% title=".env.local" %} +NEXT_PUBLIC_POSTHOG_KEY=phc_your_key_here +NEXT_PUBLIC_POSTHOG_HOST=https://eu.posthog.com +``` + +Use `https://us.posthog.com` if your project is in the US region. + +## Server-Side Configuration + +PostHog supports server-side analytics for tracking events in API routes and Server Actions: + +Use server-side tracking in your code: + +```typescript +import { analytics } from '@kit/analytics/server'; + +export async function createProject(data: ProjectData) { + const project = await db.projects.create(data); + + await analytics.trackEvent('project.created', { + projectId: project.id, + userId: data.userId, + }); + + return project; +} +``` + +## Bypassing Ad Blockers with Ingestion Rewrites + +Ad blockers frequently block PostHog. Use Next.js rewrites to proxy requests through your domain: + +### Step 1: Add the Ingestion URL + +```bash {% title=".env.local" %} +NEXT_PUBLIC_POSTHOG_KEY=phc_your_key_here +NEXT_PUBLIC_POSTHOG_HOST=https://eu.posthog.com +NEXT_PUBLIC_POSTHOG_INGESTION_URL=http://localhost:3000/ingest +``` + +In production, replace `localhost:3000` with your domain. + +### Step 2: Configure Next.js Rewrites + +Add rewrites to your Next.js configuration: + +```javascript {% title="apps/web/next.config.mjs" %} +/** @type {import('next').NextConfig} */ +const config = { + // Required for PostHog trailing slash API requests + skipTrailingSlashRedirect: true, + + async rewrites() { + // Change 'eu' to 'us' if using the US region + return [ + { + source: '/ingest/static/:path*', + destination: 'https://eu-assets.i.posthog.com/static/:path*', + }, + { + source: '/ingest/:path*', + destination: 'https://eu.i.posthog.com/:path*', + }, + ]; + }, +}; + +export default config; +``` + +### Step 3: Exclude Ingestion Endpoint from Middleware + +Ensure the ingestion endpoint is excluded from the middleware matcher: + +```typescript {% title="apps/web/proxy.ts" %} +export const config = { + matcher: [ + '/((?!_next/static|_next/image|images|locales|assets|ingest/*|api/*).*)', + ], +}; +``` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_POSTHOG_KEY` | Yes | Your PostHog Project API Key | +| `NEXT_PUBLIC_POSTHOG_HOST` | Yes | PostHog host (`https://eu.posthog.com` or `https://us.posthog.com`) | +| `NEXT_PUBLIC_POSTHOG_INGESTION_URL` | No | Proxy URL to bypass ad blockers (e.g., `https://yourdomain.com/ingest`) | + +## Verification + +After configuration: + +1. Open your application +2. Navigate between pages +3. Open PostHog > Activity > Live Events +4. Confirm page views and events appear + +If using ingestion rewrites, check the Network tab for requests to `/ingest` instead of `posthog.com`. + +## Using with Other Providers + +PostHog works alongside other analytics providers: + +```typescript {% title="packages/analytics/src/index.ts" %} +import { createPostHogAnalyticsService } from '@kit/posthog/client'; +import { createGoogleAnalyticsService } from '@kit/google-analytics'; + +import { createAnalyticsManager } from './analytics-manager'; + +export const analytics = createAnalyticsManager({ + providers: { + posthog: createPostHogAnalyticsService, + 'google-analytics': createGoogleAnalyticsService, + }, +}); +``` + +## PostHog Features Beyond Analytics + +PostHog offers additional features beyond event tracking: + +- **Session Replay**: Watch user sessions to debug issues +- **Feature Flags**: Control feature rollouts +- **A/B Testing**: Run experiments on UI variants +- **Surveys**: Collect user feedback + +These features are available in the PostHog dashboard once you are capturing events. + +For monitoring features (error tracking), see the [PostHog Monitoring guide](/docs/next-supabase-turbo/monitoring/posthog). + +## Troubleshooting + +### Events not appearing + +- Verify your API key starts with `phc_` +- Confirm the host matches your project region (EU vs US) +- Check for ad blockers if not using ingestion rewrites + +### CORS errors with ingestion rewrites + +- Ensure `skipTrailingSlashRedirect: true` is set in next.config.mjs +- Verify the rewrite destination matches your region + +### Server-side events not appearing + +- Ensure you import from `@kit/analytics/server`, not `@kit/analytics` +- Server-side tracking requires the same environment variables + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Should I use the EU or US region?", "answer": "Use the EU region (eu.posthog.com) if you have European users and want GDPR-compliant data residency. The US region may have slightly lower latency for US-based users."}, + {"question": "Can I self-host PostHog?", "answer": "Yes. PostHog can be self-hosted using Docker. Update NEXT_PUBLIC_POSTHOG_HOST to your self-hosted instance URL."}, + {"question": "How do I enable session replay?", "answer": "Session replay is enabled by default in PostHog. Configure recording settings in PostHog > Project Settings > Session Replay."}, + {"question": "Do ingestion rewrites work on Vercel?", "answer": "Yes. The rewrites in next.config.mjs work on Vercel and other Next.js hosting platforms."}, + {"question": "Is PostHog GDPR compliant?", "answer": "PostHog can be GDPR compliant. Use the EU region for data residency, enable cookie-less tracking, and integrate with a consent management solution."} + ] +/%} + +## Next Steps + +- [Learn about Analytics and Events](analytics-and-events) for custom event tracking +- [Set up PostHog for monitoring](/docs/next-supabase-turbo/monitoring/posthog) +- [Try Umami](umami-analytics-provider) for simpler, privacy-focused analytics diff --git a/docs/analytics/umami-analytics-provider.mdoc b/docs/analytics/umami-analytics-provider.mdoc new file mode 100644 index 000000000..477b7bfc4 --- /dev/null +++ b/docs/analytics/umami-analytics-provider.mdoc @@ -0,0 +1,151 @@ +--- +status: "published" +title: 'Using the Umami Analytics Provider in Next.js Supabase Turbo' +label: 'Umami' +description: 'Add Umami to your MakerKit application for privacy-focused, cookie-free analytics that comply with GDPR without consent banners.' +order: 4 +--- + +Umami is a privacy-focused analytics platform that tracks page views and events without cookies. + +Because it does not use cookies or collect personal data, you can use Umami without displaying cookie consent banners in the EU. Umami can be self-hosted for complete data ownership or used via Umami Cloud. + +## Why Choose Umami + +| Feature | Umami | Google Analytics | +|---------|-------|------------------| +| Cookies | None | Yes | +| GDPR consent required | No | Yes | +| Self-hosting | Yes | No | +| Pricing | Free (self-hosted) or paid cloud | Free | +| Data ownership | Full | Google | +| Session replay | No | No | +| Feature flags | No | No | + +**Use Umami when**: You want simple, clean metrics without privacy concerns. Ideal for landing pages, documentation sites, and applications where marketing attribution is not critical. + +## Prerequisites + +Before starting: + +- Create an Umami account at [umami.is](https://umami.is) or self-host +- Create a website in your Umami dashboard +- Note your **Website ID** and **Script URL** + +In Umami Cloud, find these at: **Settings > Websites > Your Website > Edit**. + +## Installation + +Install the Umami plugin using the MakerKit CLI: + +```bash +npx @makerkit/cli@latest plugins add umami +``` + +Our codemod will wire up the plugin in your project, so you don't have to do anything manually. Please review the changes with `git diff`. + +Add environment variables: + +```bash {% title=".env.local" %} +NEXT_PUBLIC_UMAMI_HOST=https://cloud.umami.is/script.js +NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id +``` + +### Self-Hosted Configuration + +If self-hosting Umami, point to your instance: + +```bash {% title=".env.local" %} +NEXT_PUBLIC_UMAMI_HOST=https://analytics.yourdomain.com/script.js +NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id +``` + +Replace the URL with the path to your Umami instance's tracking script. + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_UMAMI_HOST` | Yes | URL to the Umami tracking script | +| `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | Yes | Your website ID from Umami | +| `NEXT_PUBLIC_UMAMI_DISABLE_LOCALHOST_TRACKING` | No | Set to `false` to enable localhost tracking | + +### Development Configuration + +By default, Umami does not track localhost. Enable it for development testing: + +```bash {% title=".env.local" %} +NEXT_PUBLIC_UMAMI_HOST=https://cloud.umami.is/script.js +NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id +NEXT_PUBLIC_UMAMI_DISABLE_LOCALHOST_TRACKING=false +``` + +## Verification + +After configuration: + +1. Deploy to a non-localhost environment (or enable localhost tracking) +2. Open your application and navigate between pages +3. Check your Umami dashboard > Realtime +4. Confirm page views appear + +## Custom Event Tracking + +Umami tracks page views automatically. For custom events: + +```typescript +import { analytics } from '@kit/analytics'; + +void analytics.trackEvent('Button Clicked', { + button: 'signup', + location: 'header', +}); +``` + +Events appear in Umami under **Events** in your website dashboard. + +## Using with Other Providers + +Umami can run alongside other providers: + +```typescript {% title="packages/analytics/src/index.ts" %} +import { createUmamiAnalyticsService } from '@kit/umami'; +import { createPostHogAnalyticsService } from '@kit/posthog/client'; + +import { createAnalyticsManager } from './analytics-manager'; + +export const analytics = createAnalyticsManager({ + providers: { + umami: createUmamiAnalyticsService, + posthog: createPostHogAnalyticsService, + }, +}); +``` + +This setup provides Umami's clean metrics alongside PostHog's product analytics. + +## Troubleshooting + +### No data appearing + +- Verify you are not on localhost (or enable localhost tracking) +- Check that the Website ID matches your Umami dashboard +- Confirm the script URL is correct and accessible + +### Ad blocker interference + +Umami is sometimes blocked by ad blockers. If this is an issue: + +1. Self-host Umami on a subdomain (e.g., `analytics.yourdomain.com`) +2. Use a generic script path (e.g., `/stats.js` instead of `/script.js`) + +### Events not tracked + +- Ensure event names and properties are strings +- Check that the event appears in Umami's Events tab, not just Page Views + +## Next Steps + +- [Learn about Analytics and Events](analytics-and-events) for event tracking patterns +- [Consider PostHog](posthog-analytics-provider) if you need user identification or feature flags +- [Create a custom provider](custom-analytics-provider) for other analytics services \ No newline at end of file diff --git a/docs/api/account-api.mdoc b/docs/api/account-api.mdoc new file mode 100644 index 000000000..55e1a9340 --- /dev/null +++ b/docs/api/account-api.mdoc @@ -0,0 +1,448 @@ +--- +status: "published" +label: "Account API" +order: 0 +title: "Account API | Next.js Supabase SaaS Kit" +description: "Complete reference for the Account API in MakerKit. Manage personal accounts, subscriptions, billing customer IDs, and workspace data with type-safe methods." +--- + +The Account API is MakerKit's server-side service for managing personal user accounts. It provides methods to fetch subscription data, billing customer IDs, and account switcher information. Use it when building billing portals, feature gates, or account selection UIs. All methods are type-safe and respect Supabase RLS policies. + +{% callout title="When to use Account API" %} +Use the Account API for: checking subscription status for feature gating, loading data for account switchers, accessing billing customer IDs for direct provider API calls. Use the Team Account API instead for team-based operations. +{% /callout %} + +{% sequence title="Account API Reference" description="Learn how to use the Account API in MakerKit" %} + +[Setup and initialization](#setup-and-initialization) + +[getAccountWorkspace](#getaccountworkspace) + +[loadUserAccounts](#loaduseraccounts) + +[getSubscription](#getsubscription) + +[getCustomerId](#getcustomerid) + +[getOrder](#getorder) + +[Real-world examples](#real-world-examples) + +{% /sequence %} + +## Setup and initialization + +Import `createAccountsApi` from `@kit/accounts/api` and pass a Supabase server client. The client handles authentication automatically through RLS. + +```tsx +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function ServerComponent() { + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + + // Use API methods +} +``` + +In Server Actions: + +```tsx +'use server'; + +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function myServerAction() { + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + + // Use API methods +} +``` + +{% callout title="Request-scoped clients" %} +Always create the Supabase client and API instance inside your request handler, not at module scope. The client is tied to the current user's session. +{% /callout %} + +## API Methods + +### getAccountWorkspace + +Returns the personal workspace data for the authenticated user. This includes account details, subscription status, and profile information. + +```tsx +const workspace = await api.getAccountWorkspace(); +``` + +**Returns:** + +```tsx +{ + id: string | null; + name: string | null; + picture_url: string | null; + public_data: Json | null; + subscription_status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused' | null; +} +``` + +**Usage notes:** + +- Called automatically in the `/home/(user)` layout +- Cached per-request, so multiple calls are deduplicated +- Returns `null` values if the user has no personal account + +--- + +### loadUserAccounts + +Loads all accounts the user belongs to, formatted for account switcher components. + +```tsx +const accounts = await api.loadUserAccounts(); +``` + +**Returns:** + +```tsx +Array<{ + label: string; // Account display name + value: string; // Account ID or slug + image: string | null; // Account picture URL +}> +``` + +**Example: Build an account switcher** + +```tsx +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function AccountSwitcher() { + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + const accounts = await api.loadUserAccounts(); + + return ( + <select> + {accounts.map((account) => ( + <option key={account.value} value={account.value}> + {account.label} + </option> + ))} + </select> + ); +} +``` + +--- + +### getSubscription + +Returns the subscription data for a given account, including all subscription items (line items). + +```tsx +const subscription = await api.getSubscription(accountId); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The account UUID | + +**Returns:** + +```tsx +{ + id: string; + account_id: string; + billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle'; + status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused'; + currency: string; + cancel_at_period_end: boolean; + period_starts_at: string; + period_ends_at: string; + trial_starts_at: string | null; + trial_ends_at: string | null; + items: Array<{ + id: string; + subscription_id: string; + product_id: string; + variant_id: string; + type: 'flat' | 'per_seat' | 'metered'; + quantity: number; + price_amount: number; + interval: 'month' | 'year'; + interval_count: number; + }>; +} | null +``` + +**Example: Check subscription access** + +```tsx +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function checkPlanAccess(accountId: string, requiredPlan: string) { + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + const subscription = await api.getSubscription(accountId); + + if (!subscription) { + return { hasAccess: false, reason: 'no_subscription' }; + } + + if (subscription.status !== 'active' && subscription.status !== 'trialing') { + return { hasAccess: false, reason: 'inactive_subscription' }; + } + + const hasRequiredPlan = subscription.items.some( + (item) => item.product_id === requiredPlan + ); + + if (!hasRequiredPlan) { + return { hasAccess: false, reason: 'wrong_plan' }; + } + + return { hasAccess: true }; +} +``` + +--- + +### getCustomerId + +Returns the billing provider customer ID for an account. Use this when integrating with Stripe, Paddle, or Lemon Squeezy APIs directly. + +```tsx +const customerId = await api.getCustomerId(accountId); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The account UUID | + +**Returns:** `string | null` + +**Example: Redirect to billing portal** + +```tsx +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + +async function createBillingPortalSession(accountId: string) { + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + const customerId = await api.getCustomerId(accountId); + + if (!customerId) { + throw new Error('No billing customer found'); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`, + }); + + return session.url; +} +``` + +--- + +### getOrder + +Returns one-time purchase order data for accounts using lifetime deals or credit-based billing. + +```tsx +const order = await api.getOrder(accountId); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The account UUID | + +**Returns:** + +```tsx +{ + id: string; + account_id: string; + billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle'; + status: 'pending' | 'completed' | 'refunded'; + currency: string; + total_amount: number; + items: Array<{ + product_id: string; + variant_id: string; + quantity: number; + price_amount: number; + }>; +} | null +``` + +--- + +## Real-world examples + +### Feature gating based on subscription + +```tsx +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +type FeatureAccess = { + allowed: boolean; + reason?: string; + upgradeUrl?: string; +}; + +export async function canAccessFeature( + accountId: string, + feature: 'ai_assistant' | 'export' | 'api_access' +): Promise<FeatureAccess> { + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + const subscription = await api.getSubscription(accountId); + + // No subscription means free tier + if (!subscription) { + const freeFeatures = ['export']; + if (freeFeatures.includes(feature)) { + return { allowed: true }; + } + return { + allowed: false, + reason: 'This feature requires a paid plan', + upgradeUrl: '/pricing', + }; + } + + // Check if subscription is active + const activeStatuses = ['active', 'trialing']; + if (!activeStatuses.includes(subscription.status)) { + return { + allowed: false, + reason: 'Your subscription is not active', + upgradeUrl: '/settings/billing', + }; + } + + // Map features to required product IDs + const featureRequirements: Record<string, string[]> = { + ai_assistant: ['pro', 'enterprise'], + export: ['starter', 'pro', 'enterprise'], + api_access: ['enterprise'], + }; + + const requiredProducts = featureRequirements[feature] || []; + const userProducts = subscription.items.map((item) => item.product_id); + const hasAccess = requiredProducts.some((p) => userProducts.includes(p)); + + if (!hasAccess) { + return { + allowed: false, + reason: 'This feature requires a higher plan', + upgradeUrl: '/pricing', + }; + } + + return { allowed: true }; +} +``` + +### Server Action with subscription check + +```tsx +'use server'; + +import * as z from 'zod'; +import { authActionClient } from '@kit/next/safe-action'; +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const GenerateReportSchema = z.object({ + accountId: z.string().uuid(), + reportType: z.enum(['summary', 'detailed', 'export']), +}); + +export const generateReport = authActionClient + .inputSchema(GenerateReportSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + + // Check subscription before expensive operation + const subscription = await api.getSubscription(data.accountId); + const isProUser = subscription?.items.some( + (item) => item.product_id === 'pro' || item.product_id === 'enterprise' + ); + + if (data.reportType === 'detailed' && !isProUser) { + return { + success: false, + error: 'Detailed reports require a Pro subscription', + }; + } + + // Generate report... + return { success: true, reportUrl: '/reports/123' }; + }); +``` + +## Common pitfalls + +### Creating client at module scope + +```tsx +// WRONG: Client created at module scope +const client = getSupabaseServerClient(); +const api = createAccountsApi(client); + +export async function handler() { + const subscription = await api.getSubscription(accountId); // Won't work +} + +// RIGHT: Client created in request context +export async function handler() { + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + const subscription = await api.getSubscription(accountId); +} +``` + +### Forgetting to handle null subscriptions + +```tsx +// WRONG: Assumes subscription exists +const subscription = await api.getSubscription(accountId); +const plan = subscription.items[0].product_id; // Crashes if null + +// RIGHT: Handle null case +const subscription = await api.getSubscription(accountId); +if (!subscription) { + return { plan: 'free' }; +} +const plan = subscription.items[0]?.product_id ?? 'free'; +``` + +### Confusing account ID with user ID + +The Account API expects account UUIDs, not user UUIDs. For personal accounts, the account ID is the same as the user ID, but for team accounts they differ. + +## Related documentation + +- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team account management +- [User Workspace API](/docs/next-supabase-turbo/api/user-workspace-api) - Workspace context for layouts +- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Stripe and payment setup diff --git a/docs/api/account-workspace-api.mdoc b/docs/api/account-workspace-api.mdoc new file mode 100644 index 000000000..bd0a014dc --- /dev/null +++ b/docs/api/account-workspace-api.mdoc @@ -0,0 +1,473 @@ +--- +status: "published" +label: "Team Workspace API" +order: 4 +title: "Team Workspace API | Next.js Supabase SaaS Kit" +description: "Access team account context in MakerKit layouts. Load team data, member permissions, subscription status, and role hierarchy with the Team Workspace API." +--- + +The Team Workspace API provides team account context for pages under `/home/[account]`. It loads team data, the user's role and permissions, subscription status, and all accounts the user belongs to, making this information available to both server and client components. + +{% sequence title="Team Workspace API Reference" description="Access team workspace data in layouts and components" %} + +[loadTeamWorkspace (Server)](#loadteamworkspace-server) + +[useTeamAccountWorkspace (Client)](#useteamaccountworkspace-client) + +[Data structure](#data-structure) + +[Usage patterns](#usage-patterns) + +{% /sequence %} + +## loadTeamWorkspace (Server) + +Loads the team workspace data for the specified team account. Use this in Server Components within the `/home/[account]` route group. + +```tsx +import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader'; + +export default async function TeamDashboard({ + params, +}: { + params: { account: string }; +}) { + const data = await loadTeamWorkspace(); + + return ( + <div> + <h1>{data.account.name}</h1> + <p>Your role: {data.account.role}</p> + </div> + ); +} +``` + +### Function signature + +```tsx +async function loadTeamWorkspace(): Promise<TeamWorkspaceData> +``` + +### How it works + +The loader reads the `account` parameter from the URL (the team slug) and fetches: + +1. Team account details from the database +2. Current user's role and permissions in this team +3. All accounts the user belongs to (for the account switcher) + +### Caching behavior + +The function uses React's `cache()` to deduplicate calls within a single request. You can call it multiple times in nested components without additional database queries. + +```tsx +// Both calls use the same cached data +const layout = await loadTeamWorkspace(); // First call: hits database +const page = await loadTeamWorkspace(); // Second call: returns cached data +``` + +{% callout title="Performance consideration" %} +While calls are deduplicated within a request, the data is fetched on every navigation. For frequently accessed data, the caching prevents redundant queries within a single page render. +{% /callout %} + +--- + +## useTeamAccountWorkspace (Client) + +Access the team workspace data in client components using the `useTeamAccountWorkspace` hook. The data is provided through React Context from the layout. + +```tsx +'use client'; + +import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace'; + +export function TeamHeader() { + const { account, user, accounts } = useTeamAccountWorkspace(); + + return ( + <header className="flex items-center justify-between p-4"> + <div className="flex items-center gap-3"> + {account.picture_url && ( + <img + src={account.picture_url} + alt={account.name} + className="h-8 w-8 rounded" + /> + )} + <div> + <h1 className="font-semibold">{account.name}</h1> + <p className="text-xs text-muted-foreground"> + {account.role} · {account.subscription_status || 'Free'} + </p> + </div> + </div> + </header> + ); +} +``` + +{% callout type="warning" title="Context requirement" %} +The `useTeamAccountWorkspace` hook only works within the `/home/[account]` route group where the context provider is set up. Using it outside this layout will throw an error. +{% /callout %} + +--- + +## Data structure + +### TeamWorkspaceData + +```tsx +import type { User } from '@supabase/supabase-js'; + +interface TeamWorkspaceData { + account: { + id: string; + name: string; + slug: string; + picture_url: string | null; + role: string; + role_hierarchy_level: number; + primary_owner_user_id: string; + subscription_status: SubscriptionStatus | null; + permissions: string[]; + }; + + user: User; + + accounts: Array<{ + id: string | null; + name: string | null; + picture_url: string | null; + role: string | null; + slug: string | null; + }>; +} +``` + +### account.role + +The user's role in this team. Default roles: + +| Role | Description | +|------|-------------| +| `owner` | Full access, can delete team | +| `admin` | Manage members and settings | +| `member` | Standard access | + +### account.role_hierarchy_level + +A numeric value where lower numbers indicate higher privilege. Use this for role comparisons: + +```tsx +const { account } = useTeamAccountWorkspace(); + +// Check if user can manage someone with role_level 2 +const canManage = account.role_hierarchy_level < 2; +``` + +### account.permissions + +An array of permission strings the user has in this team: + +```tsx +[ + 'billing.manage', + 'members.invite', + 'members.remove', + 'members.manage', + 'settings.manage', +] +``` + +### subscription_status values + +| Status | Description | +|--------|-------------| +| `active` | Active subscription | +| `trialing` | In trial period | +| `past_due` | Payment failed, grace period | +| `canceled` | Subscription canceled | +| `unpaid` | Payment required | +| `incomplete` | Setup incomplete | +| `incomplete_expired` | Setup expired | +| `paused` | Subscription paused | + +--- + +## Usage patterns + +### Permission-based rendering + +```tsx +'use client'; + +import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace'; + +interface PermissionGateProps { + children: React.ReactNode; + permission: string; + fallback?: React.ReactNode; +} + +export function PermissionGate({ + children, + permission, + fallback = null, +}: PermissionGateProps) { + const { account } = useTeamAccountWorkspace(); + + if (!account.permissions.includes(permission)) { + return <>{fallback}</>; + } + + return <>{children}</>; +} + +// Usage +function TeamSettingsPage() { + return ( + <div> + <h1>Team Settings</h1> + + <PermissionGate + permission="settings.manage" + fallback={<p>You don't have permission to manage settings.</p>} + > + <SettingsForm /> + </PermissionGate> + + <PermissionGate permission="billing.manage"> + <BillingSection /> + </PermissionGate> + </div> + ); +} +``` + +### Team dashboard with role checks + +```tsx +import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export default async function TeamDashboardPage() { + const { account, user } = await loadTeamWorkspace(); + const client = getSupabaseServerClient(); + + const isOwner = account.primary_owner_user_id === user.id; + const isAdmin = account.role === 'admin' || account.role === 'owner'; + + // Fetch team-specific data + const { data: projects } = await client + .from('projects') + .select('*') + .eq('account_id', account.id) + .order('created_at', { ascending: false }) + .limit(10); + + return ( + <div className="space-y-6"> + <header className="flex items-center justify-between"> + <div> + <h1 className="text-2xl font-bold">{account.name}</h1> + <p className="text-muted-foreground"> + {account.subscription_status === 'active' + ? 'Pro Plan' + : 'Free Plan'} + </p> + </div> + + {isAdmin && ( + <a + href={`/home/${account.slug}/settings`} + className="btn btn-secondary" + > + Team Settings + </a> + )} + </header> + + <section> + <h2 className="text-lg font-medium">Recent Projects</h2> + <ul className="mt-2 space-y-2"> + {projects?.map((project) => ( + <li key={project.id}> + <a href={`/home/${account.slug}/projects/${project.id}`}> + {project.name} + </a> + </li> + ))} + </ul> + </section> + + {isOwner && ( + <section className="rounded-lg border border-destructive/20 bg-destructive/5 p-4"> + <h2 className="font-medium text-destructive">Danger Zone</h2> + <p className="mt-1 text-sm text-muted-foreground"> + Only the team owner can delete this team. + </p> + <button className="mt-3 btn btn-destructive">Delete Team</button> + </section> + )} + </div> + ); +} +``` + +### Team members list with permissions + +```tsx +import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export default async function TeamMembersPage() { + const { account } = await loadTeamWorkspace(); + const client = getSupabaseServerClient(); + + const canManageMembers = account.permissions.includes('members.manage'); + const canRemoveMembers = account.permissions.includes('members.remove'); + const canInviteMembers = account.permissions.includes('members.invite'); + + const { data: members } = await client + .from('accounts_memberships') + .select(` + user_id, + role, + created_at, + users:user_id ( + email, + user_metadata + ) + `) + .eq('account_id', account.id); + + return ( + <div> + <header className="flex items-center justify-between"> + <h1>Team Members</h1> + {canInviteMembers && ( + <a href={`/home/${account.slug}/settings/members/invite`}> + Invite Member + </a> + )} + </header> + + <table className="w-full"> + <thead> + <tr> + <th>Member</th> + <th>Role</th> + <th>Joined</th> + {(canManageMembers || canRemoveMembers) && <th>Actions</th>} + </tr> + </thead> + <tbody> + {members?.map((member) => ( + <tr key={member.user_id}> + <td>{member.users?.email}</td> + <td>{member.role}</td> + <td>{new Date(member.created_at).toLocaleDateString()}</td> + {(canManageMembers || canRemoveMembers) && ( + <td> + {canManageMembers && member.user_id !== account.primary_owner_user_id && ( + <button>Change Role</button> + )} + {canRemoveMembers && member.user_id !== account.primary_owner_user_id && ( + <button>Remove</button> + )} + </td> + )} + </tr> + ))} + </tbody> + </table> + </div> + ); +} +``` + +### Client-side permission hook + +```tsx +'use client'; + +import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace'; + +export function useTeamPermissions() { + const { account } = useTeamAccountWorkspace(); + + return { + canManageSettings: account.permissions.includes('settings.manage'), + canManageBilling: account.permissions.includes('billing.manage'), + canInviteMembers: account.permissions.includes('members.invite'), + canRemoveMembers: account.permissions.includes('members.remove'), + canManageMembers: account.permissions.includes('members.manage'), + isOwner: account.role === 'owner', + isAdmin: account.role === 'admin' || account.role === 'owner', + role: account.role, + roleLevel: account.role_hierarchy_level, + }; +} + +// Usage +function TeamActions() { + const permissions = useTeamPermissions(); + + return ( + <div className="flex gap-2"> + {permissions.canInviteMembers && ( + <button>Invite Member</button> + )} + {permissions.canManageSettings && ( + <button>Settings</button> + )} + {permissions.canManageBilling && ( + <button>Billing</button> + )} + </div> + ); +} +``` + +### Subscription-gated features + +```tsx +'use client'; + +import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace'; + +export function PremiumFeature({ children }: { children: React.ReactNode }) { + const { account } = useTeamAccountWorkspace(); + + const hasActiveSubscription = + account.subscription_status === 'active' || + account.subscription_status === 'trialing'; + + if (!hasActiveSubscription) { + return ( + <div className="rounded-lg border-2 border-dashed p-6 text-center"> + <h3 className="font-medium">Premium Feature</h3> + <p className="mt-1 text-sm text-muted-foreground"> + Upgrade to access this feature + </p> + <a + href={`/home/${account.slug}/settings/billing`} + className="mt-3 inline-block btn btn-primary" + > + Upgrade Plan + </a> + </div> + ); + } + + return <>{children}</>; +} +``` + +## Related documentation + +- [User Workspace API](/docs/next-supabase-turbo/api/user-workspace-api) - Personal account context +- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team operations +- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication +- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing diff --git a/docs/api/authentication-api.mdoc b/docs/api/authentication-api.mdoc new file mode 100644 index 000000000..785d70d61 --- /dev/null +++ b/docs/api/authentication-api.mdoc @@ -0,0 +1,531 @@ +--- +status: "published" +label: "Authentication API" +order: 2 +title: "Authentication API | Next.js Supabase SaaS Kit" +description: "Complete reference for authentication in MakerKit. Use requireUser for server-side auth checks, handle MFA verification, and access user data in client components." +--- + +The Authentication API verifies user identity, handles MFA (Multi-Factor Authentication), and provides user data to your components. Use `requireUser` on the server for protected routes and `useUser` on the client for reactive user state. + +{% sequence title="Authentication API Reference" description="Learn how to authenticate users in MakerKit" %} + +[requireUser (Server)](#requireuser-server) + +[useUser (Client)](#useuser-client) + +[useSupabase (Client)](#usesupabase-client) + +[MFA handling](#mfa-handling) + +[Common patterns](#common-patterns) + +{% /sequence %} + +## requireUser (Server) + +The `requireUser` function checks authentication status in Server Components, Server Actions, and Route Handlers. It handles both standard auth and MFA verification in a single call. + +```tsx +import { redirect } from 'next/navigation'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function ProtectedPage() { + const client = getSupabaseServerClient(); + const auth = await requireUser(client); + + if (auth.error) { + redirect(auth.redirectTo); + } + + const user = auth.data; + + return <div>Welcome, {user.email}</div>; +} +``` + +### Function signature + +```tsx +function requireUser( + client: SupabaseClient, + options?: { + verifyMfa?: boolean; // Default: true + } +): Promise<RequireUserResponse> +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `client` | `SupabaseClient` | required | Supabase server client | +| `options.verifyMfa` | `boolean` | `true` | Check MFA status | + +### Response types + +**Success response:** + +```tsx +{ + data: { + id: string; // User UUID + email: string; // User email + phone: string; // User phone (if set) + is_anonymous: boolean; // Anonymous auth flag + aal: 'aal1' | 'aal2'; // Auth Assurance Level + app_metadata: Record<string, unknown>; + user_metadata: Record<string, unknown>; + amr: AMREntry[]; // Auth Methods Reference + }; + error: null; +} +``` + +**Error response:** + +```tsx +{ + data: null; + error: AuthenticationError | MultiFactorAuthError; + redirectTo: string; // Where to redirect the user +} +``` + +### Auth Assurance Levels (AAL) + +| Level | Meaning | +|-------|---------| +| `aal1` | Basic authentication (password, magic link, OAuth) | +| `aal2` | MFA verified (TOTP app, etc.) | + +### Error types + +| Error | Cause | Redirect | +|-------|-------|----------| +| `AuthenticationError` | User not logged in | Sign-in page | +| `MultiFactorAuthError` | MFA required but not verified | MFA verification page | + +### Usage in Server Components + +```tsx +import { redirect } from 'next/navigation'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export default async function DashboardPage() { + const client = getSupabaseServerClient(); + const auth = await requireUser(client); + + if (auth.error) { + redirect(auth.redirectTo); + } + + return ( + <div> + <h1>Dashboard</h1> + <p>Logged in as: {auth.data.email}</p> + <p>MFA status: {auth.data.aal === 'aal2' ? 'Verified' : 'Not verified'}</p> + </div> + ); +} +``` + +### Usage in Server Actions + +```tsx +'use server'; + +import { redirect } from 'next/navigation'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function updateProfile(formData: FormData) { + const client = getSupabaseServerClient(); + const auth = await requireUser(client); + + if (auth.error) { + redirect(auth.redirectTo); + } + + const name = formData.get('name') as string; + + await client + .from('profiles') + .update({ name }) + .eq('id', auth.data.id); + + return { success: true }; +} +``` + +### Skipping MFA verification + +For pages that don't require full MFA verification: + +```tsx +const auth = await requireUser(client, { verifyMfa: false }); +``` + +{% callout type="warning" title="MFA security" %} +Only disable MFA verification for non-sensitive pages. Always verify MFA for billing, account deletion, and other high-risk operations. +{% /callout %} + +--- + +## useUser (Client) + +The `useUser` hook provides reactive access to user data in client components. It reads from the auth context and updates automatically on auth state changes. + +```tsx +'use client'; + +import { useUser } from '@kit/supabase/hooks/use-user'; + +function UserMenu() { + const user = useUser(); + + if (!user) { + return <div>Loading...</div>; + } + + return ( + <div> + <span>{user.email}</span> + <img src={user.user_metadata.avatar_url} alt="Avatar" /> + </div> + ); +} +``` + +### Return type + +```tsx +User | null +``` + +The `User` type from Supabase includes: + +```tsx +{ + id: string; + email: string; + phone: string; + created_at: string; + updated_at: string; + app_metadata: { + provider: string; + providers: string[]; + }; + user_metadata: { + avatar_url?: string; + full_name?: string; + // Custom metadata fields + }; + aal?: 'aal1' | 'aal2'; +} +``` + +### Conditional rendering + +```tsx +'use client'; + +import { useUser } from '@kit/supabase/hooks/use-user'; + +function ConditionalContent() { + const user = useUser(); + + // Show loading state + if (user === undefined) { + return <Skeleton />; + } + + // Not authenticated + if (!user) { + return <LoginPrompt />; + } + + // Authenticated + return <UserDashboard user={user} />; +} +``` + +--- + +## useSupabase (Client) + +The `useSupabase` hook provides the Supabase browser client for client-side operations. + +```tsx +'use client'; + +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; +import { useQuery } from '@tanstack/react-query'; + +function TaskList() { + const supabase = useSupabase(); + + const { data: tasks } = useQuery({ + queryKey: ['tasks'], + queryFn: async () => { + const { data, error } = await supabase + .from('tasks') + .select('*') + .order('created_at', { ascending: false }); + + if (error) throw error; + return data; + }, + }); + + return ( + <ul> + {tasks?.map((task) => ( + <li key={task.id}>{task.title}</li> + ))} + </ul> + ); +} +``` + +--- + +## MFA handling + +MakerKit automatically handles MFA verification through the `requireUser` function. + +### How it works + +1. User logs in with password/OAuth (reaches `aal1`) +2. If MFA is enabled, `requireUser` checks AAL +3. If `aal1` but MFA required, redirects to MFA verification +4. After TOTP verification, user reaches `aal2` +5. Protected pages now accessible + +### MFA flow diagram + +``` +Login → aal1 → requireUser() → MFA enabled? + ↓ + Yes: redirect to /auth/verify + ↓ + User enters TOTP + ↓ + aal2 → Access granted +``` + +### Checking MFA status + +```tsx +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function checkMfaStatus() { + const client = getSupabaseServerClient(); + const auth = await requireUser(client, { verifyMfa: false }); + + if (auth.error) { + return { authenticated: false }; + } + + return { + authenticated: true, + mfaEnabled: auth.data.aal === 'aal2', + authMethods: auth.data.amr.map((m) => m.method), + }; +} +``` + +--- + +## Common patterns + +### Protected API Route Handler + +```tsx +// app/api/user/route.ts +import { NextResponse } from 'next/server'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function GET() { + const client = getSupabaseServerClient(); + const auth = await requireUser(client); + + if (auth.error) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { data: profile } = await client + .from('profiles') + .select('*') + .eq('id', auth.data.id) + .single(); + + return NextResponse.json({ user: auth.data, profile }); +} +``` + +### Using authActionClient (recommended) + +The `authActionClient` utility handles authentication automatically: + +```tsx +'use server'; + +import * as z from 'zod'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const UpdateProfileSchema = z.object({ + name: z.string().min(2), +}); + +export const updateProfile = authActionClient + .inputSchema(UpdateProfileSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // user is automatically available and typed + const client = getSupabaseServerClient(); + + await client + .from('profiles') + .update({ name: data.name }) + .eq('id', user.id); + + return { success: true }; + }); +``` + +### Public actions (no auth) + +```tsx +import { publicActionClient } from '@kit/next/safe-action'; + +export const submitContactForm = publicActionClient + .inputSchema(ContactFormSchema) + .action(async ({ parsedInput: data }) => { + // No user context in public actions + await sendEmail(data); + return { success: true }; + }); +``` + +### Role-based access control + +Combine authentication with role checks: + +```tsx +import { redirect } from 'next/navigation'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { isSuperAdmin } from '@kit/admin'; + +async function AdminPage() { + const client = getSupabaseServerClient(); + const auth = await requireUser(client); + + if (auth.error) { + redirect(auth.redirectTo); + } + + const isAdmin = await isSuperAdmin(client); + + if (!isAdmin) { + redirect('/home'); + } + + return <AdminDashboard />; +} +``` + +### Auth state listener (Client) + +For real-time auth state changes: + +```tsx +'use client'; + +import { useEffect } from 'react'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +function AuthStateListener({ onAuthChange }) { + const supabase = useSupabase(); + + useEffect(() => { + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((event, session) => { + if (event === 'SIGNED_IN') { + onAuthChange({ type: 'signed_in', user: session?.user }); + } else if (event === 'SIGNED_OUT') { + onAuthChange({ type: 'signed_out' }); + } else if (event === 'TOKEN_REFRESHED') { + onAuthChange({ type: 'token_refreshed' }); + } + }); + + return () => subscription.unsubscribe(); + }, [supabase, onAuthChange]); + + return null; +} +``` + +## Common mistakes + +### Creating client at module scope + +```tsx +// WRONG: Client created at module scope +const client = getSupabaseServerClient(); + +export async function handler() { + const auth = await requireUser(client); // Won't work +} + +// RIGHT: Client created in request context +export async function handler() { + const client = getSupabaseServerClient(); + const auth = await requireUser(client); +} +``` + +### Ignoring the redirectTo property + +```tsx +// WRONG: Not using redirectTo +if (auth.error) { + redirect('/login'); // MFA users sent to wrong page +} + +// RIGHT: Use the provided redirectTo +if (auth.error) { + redirect(auth.redirectTo); // Correct handling for auth + MFA +} +``` + +### Using useUser for server-side checks + +```tsx +// WRONG: useUser is client-only +export async function ServerComponent() { + const user = useUser(); // Won't work +} + +// RIGHT: Use requireUser on server +export async function ServerComponent() { + const client = getSupabaseServerClient(); + const auth = await requireUser(client); +} +``` + +## Related documentation + +- [Account API](/docs/next-supabase-turbo/api/account-api) - Personal account operations +- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) - Using authActionClient +- [Route Handlers](/docs/next-supabase-turbo/data-fetching/route-handlers) - API authentication diff --git a/docs/api/otp-api.mdoc b/docs/api/otp-api.mdoc new file mode 100644 index 000000000..03d995578 --- /dev/null +++ b/docs/api/otp-api.mdoc @@ -0,0 +1,302 @@ +--- +status: "published" +label: "OTP API" +order: 5 +title: "OTP API | Next.js Supabase SaaS Kit" +description: "Generate and verify one-time passwords for secure operations in MakerKit. Use the OTP API for account deletion, ownership transfers, and other high-risk actions." +--- + +The OTP API generates and verifies one-time passwords for secure operations like account deletion, ownership transfers, and email verification. It uses Supabase for secure token storage with automatic expiration and verification tracking. + +{% sequence title="How to use the OTP API" description="Learn how to use the OTP API in Makerkit" %} + +[OTP API - What is it for?](#otp-api---what-is-it-for) + +[Installation](#installation) + +[Basic Usage](#basic-usage) + +[Server Actions](#server-actions) + +[Verification UI Component](#verification-ui-component) + +[API Reference](#api-reference) + +[Database Schema](#database-schema) + +[Best Practices](#best-practices) + +[Example Use Cases](#example-use-cases) + +{% /sequence %} + +It is used for various destructive actions in the SaaS Kit, such as deleting +accounts, deleting teams, and deleting users. However, you can use it for a +variety of other purposes as well, such as: + +- Your custom destructive actions +- oAuth account connections +- etc. + +## OTP API - What is it for? + +The OTP package offers: + +- **Secure Token Generation**: Create time-limited tokens with configurable expiration +- **Email Delivery**: Send OTP codes via email with customizable templates +- **Verification UI**: Ready-to-use verification form component +- **Token Management**: Revoke, verify, and check token status + +## Installation + +If you're using Makerkit, this package is already included. For manual installation: + +```bash +pnpm add @kit/otp +``` + +## Basic Usage + +### Creating and Sending an OTP + +To create and send an OTP, you can use the `createToken` method: + +```typescript +import { createOtpApi } from '@kit/otp/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +// Create the API instance +const client = getSupabaseServerClient(); +const api = createOtpApi(client); + +// Generate and send an OTP email +await api.createToken({ + userId: user.id, + purpose: 'email-verification', + expiresInSeconds: 3600, // 1 hour + metadata: { redirectTo: '/verify-email' } +}); + +// Send the email with the OTP +await api.sendOtpEmail({ + email: userEmail, + otp: token.token +}); +``` + +### Verifying an OTP + +To verify an OTP, you can use the `verifyToken` method: + +```typescript +// Verify the token +const result = await api.verifyToken({ + token: submittedToken, + purpose: 'email-verification' +}); + +if (result.valid) { + // Token is valid, proceed with the operation + const { userId, metadata } = result; + // Handle successful verification +} else { + // Token is invalid or expired + // Handle verification failure +} +``` + +## Server Actions + +The package includes a ready-to-use server action for sending OTP emails: + +```typescript +import { sendOtpEmailAction } from '@kit/otp/server/server-actions'; + +// In a form submission handler +const result = await sendOtpEmailAction({ + email: userEmail, + purpose: 'password-reset', + expiresInSeconds: 1800 // 30 minutes +}); + +if (result.success) { + // OTP was sent successfully +} else { + // Handle error +} +``` + +**Note:** The `email` parameter is only used as verification mechanism, the actual email address being used is the one associated with the user. + +## Verification UI Component + +The package includes a ready-to-use OTP verification form: + +```tsx +import { VerifyOtpForm } from '@kit/otp/components'; + +function MyVerificationPage() { + return ( + <VerifyOtpForm + purpose="password-reset" + email={userEmail} + onSuccess={(otp) => { + // Handle successful verification + // Use the OTP for verification on the server + }} + CancelButton={ + <Button variant="outline" onClick={handleCancel}> + Cancel + </Button> + } + /> + ); +} +``` + +## API Reference + +### `createOtpApi(client)` + +Creates an instance of the OTP API. + +**Parameters**: +- `client`: A Supabase client instance +- **Returns**: OTP API instance with the following methods: + +### `api.createToken(params)` + +Creates a new one-time token. + +**Parameters**: +- `params.userId` (optional): User ID to associate with the token +- `params.purpose`: Purpose of the token (e.g., 'password-reset') +- `params.expiresInSeconds` (optional): Token expiration time in seconds (default: 3600) +- `params.metadata` (optional): Additional data to store with the token +- `params.description` (optional): Description of the token +- `params.tags` (optional): Array of string tags +- `params.scopes` (optional): Array of permission scopes +- `params.revokePrevious` (optional): Whether to revoke previous tokens with the same purpose (default: true) + +**Returns**: +```typescript +{ + id: string; // Database ID of the token + token: string; // The actual token to send to the user + expiresAt: string; // Expiration timestamp + revokedPreviousCount: number; // Number of previously revoked tokens +} + ``` + +### `api.verifyToken(params)` + +Verifies a one-time token. + +**Parameters**: +- `params.token`: The token to verify +- `params.purpose`: Purpose of the token (must match the purpose used when creating) +- `params.userId` (optional): User ID for additional verification +- `params.requiredScopes` (optional): Array of required permission scopes +- `params.maxVerificationAttempts` (optional): Maximum allowed verification attempts + +**Returns**: +```typescript +{ + valid: boolean; // Whether the token is valid + userId?: string; // User ID associated with the token (if valid) + metadata?: object; // Metadata associated with the token (if valid) + message?: string; // Error message (if invalid) + scopes?: string[]; // Permission scopes (if valid) + purpose?: string; // Token purpose (if valid) +} + ``` + +### `api.revokeToken(params)` + +Revokes a token to prevent its future use. + +**Parameters**: +- `params.id`: ID of the token to revoke +- `params.reason` (optional): Reason for revocation + +**Returns**: +```typescript +{ + success: boolean; // Whether the token was successfully revoked +} + ``` + +### `api.getTokenStatus(params)` + +Gets the status of a token. + +**Parameters**: +- `params.id`: ID of the token + +**Returns**: +```typescript +{ + exists: boolean; // Whether the token exists + purpose?: string; // Token purpose + userId?: string; // User ID associated with the token + createdAt?: string; // Creation timestamp + expiresAt?: string; // Expiration timestamp + usedAt?: string; // When the token was used (if used) + revoked?: boolean; // Whether the token is revoked + revokedReason?: string; // Reason for revocation (if revoked) + verificationAttempts?: number; // Number of verification attempts + lastVerificationAt?: string; // Last verification attempt timestamp + lastVerificationIp?: string; // IP address of last verification attempt + isValid?: boolean; // Whether the token is still valid +} + ``` + +### `api.sendOtpEmail(params)` + +Sends an email containing the OTP code. + +**Parameters**: +- `params.email`: Email address to send to +- `params.otp`: OTP code to include in the email + +**Returns**: Promise that resolves when the email is sent + +## Database Schema + +The package uses a `nonces` table in your Supabase database with the following structure: + +- `id`: UUID primary key +- `client_token`: Hashed token sent to client +- `nonce`: Securely stored token hash +- `user_id`: Optional reference to auth.users +- `purpose`: Purpose identifier (e.g., 'password-reset') +- Status fields: `expires_at`, `created_at`, `used_at`, etc. +- Audit fields: `verification_attempts`, `last_verification_at`, etc. +- Extensibility fields: `metadata`, `scopes` + +## Best Practices + +1. **Use Specific Purposes**: Always use descriptive, specific purpose identifiers for your tokens. +2. **Short Expiration Times**: Set token expiration times to the minimum necessary for your use case. +3. **Handle Verification Failures**: Provide clear error messages when verification fails. +4. **Secure Your Tokens**: Never log or expose tokens in client-side code or URLs. + +## Example Use Cases + +- Email verification +- Two-factor authentication +- Account deletion confirmation +- Important action verification + +Each use case should use a distinct purpose identifier. The purpose will +always need to match the one used when creating the token. + +When you need to assign a specific data to a token, you can modify the +purpose with a unique identifier, such as `email-verification-12345`. + +## Related documentation + +- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication and session handling +- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team management for ownership transfers +- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration) - Configure email delivery for OTP codes +- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) - Use OTP verification in server actions \ No newline at end of file diff --git a/docs/api/policies-api.mdoc b/docs/api/policies-api.mdoc new file mode 100644 index 000000000..6ac281841 --- /dev/null +++ b/docs/api/policies-api.mdoc @@ -0,0 +1,551 @@ +--- +label: "Feature Policies API" +title: "Feature Policies API | Next.js Supabase SaaS Kit" +order: 7 +status: "published" +description: "Build declarative business rules with MakerKit's Feature Policies API. Validate team invitations, enforce subscription limits, and create custom authorization flows." +--- + +The Feature Policy API isolates validation and authorization logic from application code so every feature can reuse consistent, auditable policies. + +**Makerkit is built for extensibility**: customers should expand features without patching internals unless they are opting into special cases. The Feature Policy API delivers that promise by turning customization into additive policies instead of edits to core flows. + +**Important**: Feature Policies operates at the API and application surface level. It orchestrates business logic, user experience flows, and feature access decisions. For data integrity and security enforcement, **continue using database constraints, Supabase RLS policies, and transactional safeguards as your source of truth**. + +## What It's For + +- **Application logic**: User flows, feature access, business rule validation +- **API orchestration**: Request processing, workflow coordination, conditional routing +- **User experience**: Dynamic UI behavior, progressive disclosure, personalization +- **Integration patterns**: Third-party service coordination, webhook processing + +## What It's NOT For + +- **Data integrity**: Use database constraints and foreign keys +- **Security enforcement**: Use Supabase RLS policies and authentication +- **Performance-critical paths**: Use database indexes and query optimization +- **Transactional consistency**: Use database transactions and ACID guarantees + +## Key Benefits + +- Apply nuanced rules without coupling them to route handlers or services +- Share policy logic across server actions, mutations, and background jobs +- Test policies in isolation while keeping runtime orchestration predictable +- Layer customer-specific extensions on top of Makerkit defaults + +## How We Use It Today + +Makerkit currently uses the Feature Policy API for team invitation flows to validate **when a team can send invitations**. While supporting customers implement various flows for invitations, it was clear that the SaaS Starter Kit could not assume what rules you want to apply to invitations. + +- Some customers wanted to validate the email address of the invited user (ex. validate they all shared the same domain) +- A set of customers wanted only users on a specific plan to be able to invite users (ex. only Pro users can invite users) +- Others simply wanted to limit how many invitations can be sent on a per-plan basis (ex. only 5 invitations can be sent on on a free plan, 20 on a paid plan, etc.) + +These rules required a more declarative approach - which is why we created the Policies API - so that users can layer their own requirements without the need to rewrite internals. + +Additional features can opt in to the same registry pattern to unlock the shared orchestration and extension tooling. + +## Why Feature Policies? + +A SaaS starter kit must adapt to **diverse customer requirements** without creating divergent forks. + +Imperative checks embedded in controllers quickly become brittle: every variation requires new conditionals, feature flags, or early returns scattered across files. + +The Feature Policy API keeps the rule set declarative and centralized, **so product teams can swap, reorder, or extend policies without rewriting the baseline flow**. + +Registries turn policy changes into configuration instead of refactors, making it safer for customers to customize logic while continuing to receive upstream updates from Makerkit. + +## Overview + +The Feature Policy API provides: + +- **Feature-specific registries** for organized policy management per feature +- **Configuration support** so policies can accept typed configuration objects +- **Stage-aware evaluation** enabling policies to be filtered by execution stage +- **Immutable contexts** that keep policy execution safe and predictable +- **Perfect DX** through a unified API that just works + +## Quick Start + +### 1. Create a Feature-Specific Registry + +```typescript +import { + createPolicyRegistry, + definePolicy, + createPoliciesEvaluator, + allow, + deny, +} from '@kit/policies'; + +// Create feature-specific registry +const invitationPolicyRegistry = createPolicyRegistry(); + +// Register policies +invitationPolicyRegistry.registerPolicy( + definePolicy({ + id: 'email-validation', + stages: ['preliminary', 'submission'], + evaluate: async (context) => { + if (!context.invitations.some((inv) => inv.email?.includes('@'))) { + return deny({ + code: 'INVALID_EMAIL_FORMAT', + message: 'Invalid email format', + remediation: 'Please provide a valid email address', + }); + } + + return allow(); + }, + }), +); + +// Register configurable policy +invitationPolicyRegistry.registerPolicy( + definePolicy({ + id: 'max-invitations', + stages: ['preliminary', 'submission'], + configSchema: z.object({ + maxInvitations: z.number().positive(), + }), + evaluate: async (context, config = { maxInvitations: 5 }) => { + if (context.invitations.length > config.maxInvitations) { + return deny({ + code: 'MAX_INVITATIONS_EXCEEDED', + message: `Cannot invite more than ${config.maxInvitations} members`, + remediation: `Reduce invitations to ${config.maxInvitations} or fewer`, + }); + } + + return allow(); + }, + }), +); +``` + +### 2. Create a Feature Policy Evaluator + +```typescript +export function createInvitationsPolicyEvaluator() { + const evaluator = createPoliciesEvaluator(); + + return { + async hasPoliciesForStage(stage: 'preliminary' | 'submission') { + return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage); + }, + + async canInvite(context, stage: 'preliminary' | 'submission') { + return evaluator.evaluate(invitationPolicyRegistry, context, 'ALL', stage); + }, + }; +} +``` + +### 3. Use the Policy Evaluator + +```typescript +import { createInvitationsPolicyEvaluator } from './your-policies'; + +async function validateInvitations(context) { + const evaluator = createInvitationsPolicyEvaluator(); + + // Performance optimization: only build context if policies exist + const hasPolicies = await evaluator.hasPoliciesForStage('submission'); + + if (!hasPolicies) { + return; // No policies to evaluate + } + + const result = await evaluator.canInvite(context, 'submission'); + + if (!result.allowed) { + throw new Error(result.reasons.join(', ')); + } +} +``` + +## Error Handling + +The `deny()` helper supports both simple strings and structured errors. + +### String Errors (Simple) + +```typescript +return deny('Email validation failed'); +``` + +### Structured Errors (Enhanced) + +```typescript +return deny({ + code: 'INVALID_EMAIL_FORMAT', + message: 'Email validation failed', + remediation: 'Please provide a valid email address', + metadata: { fieldName: 'email' }, +}); +``` + +### Accessing Error Details + +```typescript +const result = await evaluator.canInvite(context, 'submission'); + +if (!result.allowed) { + console.log('Reasons:', result.reasons); + + result.results.forEach((policyResult) => { + if (!policyResult.allowed && policyResult.metadata) { + console.log('Error code:', policyResult.metadata.code); + console.log('Remediation:', policyResult.metadata.remediation); + } + }); +} +``` + +## Performance Optimizations + +### 1. Lazy Context Building + +Only build expensive context when policies exist: + +```typescript +const hasPolicies = await evaluator.hasPoliciesForStage('submission'); + +if (!hasPolicies) { + return; // Skip expensive operations +} + +// Build context now that policies need to run +const context = await buildExpensiveContext(); +const result = await evaluator.canInvite(context, 'submission'); +``` + +### 2. Stage-Aware Evaluation + +Filter policies by execution stage: + +```typescript +// Fast preliminary checks +const prelimResult = await evaluator.canInvite(context, 'preliminary'); + +// Full submission validation +const submitResult = await evaluator.canInvite(context, 'submission'); +``` + +### 3. AND/OR Logic + +Control evaluation behavior: + +```typescript +// ALL: Every policy must pass (default) +const result = await evaluator.evaluate(registry, context, 'ALL', stage); + +// ANY: At least one policy must pass +const result = await evaluator.evaluate(registry, context, 'ANY', stage); +``` + +## Real-World Example: Team Invitations + +Makerkit uses the Feature Policy API to power team invitation rules. + +```typescript +// packages/features/team-accounts/src/server/policies/invitation-policies.ts +import { allow, definePolicy, deny } from '@kit/policies'; +import { createPolicyRegistry } from '@kit/policies'; + +import { FeaturePolicyInvitationContext } from './feature-policy-invitation-context'; + +/** + * Feature-specific registry for invitation policies + */ +export const invitationPolicyRegistry = createPolicyRegistry(); + +/** + * Subscription required policy + * Checks if the account has an active subscription + */ +export const subscriptionRequiredInvitationsPolicy = + definePolicy<FeaturePolicyInvitationContext>({ + id: 'subscription-required', + stages: ['preliminary', 'submission'], + evaluate: async ({ subscription }) => { + if (!subscription || !subscription.active) { + return deny({ + code: 'SUBSCRIPTION_REQUIRED', + message: 'teams.policyErrors.subscriptionRequired', + remediation: 'teams.policyRemediation.subscriptionRequired', + }); + } + + return allow(); + }, + }); + +/** + * Paddle billing policy + * Checks if the account has a paddle subscription and is in a trial period + */ +export const paddleBillingInvitationsPolicy = + definePolicy<FeaturePolicyInvitationContext>({ + id: 'paddle-billing', + stages: ['preliminary', 'submission'], + evaluate: async ({ subscription }) => { + // combine with subscriptionRequiredPolicy if subscription must be required + if (!subscription) { + return allow(); + } + + // Paddle specific constraint: cannot update subscription items during trial + if ( + subscription.provider === 'paddle' && + subscription.status === 'trialing' + ) { + const hasPerSeatItems = subscription.items.some( + (item) => item.type === 'per_seat', + ); + + if (hasPerSeatItems) { + return deny({ + code: 'PADDLE_TRIAL_RESTRICTION', + message: 'teams.policyErrors.paddleTrialRestriction', + remediation: 'teams.policyRemediation.paddleTrialRestriction', + }); + } + } + + return allow(); + }, + }); + +// Register policies to apply them +invitationPolicyRegistry.registerPolicy(subscriptionRequiredInvitationsPolicy); +invitationPolicyRegistry.registerPolicy(paddleBillingInvitationsPolicy); + +export function createInvitationsPolicyEvaluator() { + const evaluator = createPoliciesEvaluator(); + + return { + async hasPoliciesForStage(stage: 'preliminary' | 'submission') { + return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage); + }, + + async canInvite(context, stage: 'preliminary' | 'submission') { + return evaluator.evaluate(invitationPolicyRegistry, context, 'ALL', stage); + }, + }; +} +``` + +## Customer Extension Pattern + +Customers can extend policies by creating their own registries, adding to existing registries, or composing policy evaluators. + +### Method 1: Own Registry + +```typescript +// customer-invitation-policies.ts +import { createPolicyRegistry, definePolicy } from '@kit/policies'; + +const customerInvitationRegistry = createPolicyRegistry(); + +customerInvitationRegistry.registerPolicy( + definePolicy({ + id: 'custom-domain-check', + stages: ['preliminary'], + evaluate: async (context) => { + const allowedDomains = ['company.com', 'partner.com']; + + for (const invitation of context.invitations) { + const domain = invitation.email?.split('@')[1]; + if (!allowedDomains.includes(domain)) { + return deny({ + code: 'DOMAIN_NOT_ALLOWED', + message: `Email domain ${domain} is not allowed`, + remediation: 'Use an email from an approved domain', + }); + } + } + + return allow(); + }, + }), +); + +export function createCustomInvitationPolicyEvaluator() { + const evaluator = createPoliciesEvaluator(); + + return { + async validateCustomRules(context, stage) { + return evaluator.evaluate(customerInvitationRegistry, context, 'ALL', stage); + }, + }; +} +``` + +### Method 2: Compose Policy Evaluators + +```typescript +// Use both built-in and custom policies +import { createInvitationsPolicyEvaluator } from '@kit/team-accounts/policies'; +import { createCustomInvitationPolicyEvaluator } from './customer-policies'; + +async function validateInvitations(context, stage) { + const builtinEvaluator = createInvitationsPolicyEvaluator(); + const customEvaluator = createCustomInvitationPolicyEvaluator(); + + // Run built-in policies + const builtinResult = await builtinEvaluator.canInvite(context, stage); + + if (!builtinResult.allowed) { + throw new Error(builtinResult.reasons.join(', ')); + } + + // Run custom policies + const customResult = await customEvaluator.validateCustomRules(context, stage); + + if (!customResult.allowed) { + throw new Error(customResult.reasons.join(', ')); + } +} +``` + +## Complex Group Evaluation + +For advanced scenarios requiring complex business logic with multiple decision paths: + +### Example: Multi-Stage Enterprise Validation + +```typescript +// Complex scenario: (Authentication AND Email) AND (Subscription OR Trial) AND Final Validation +async function validateEnterpriseFeatureAccess(context: FeatureContext) { + const evaluator = createPoliciesEvaluator(); + + // Stage 1: Authentication Requirements (ALL must pass) + const authenticationGroup = { + operator: 'ALL' as const, + policies: [ + createPolicy(async (ctx) => + ctx.userId ? allow({ step: 'authenticated' }) : deny('Authentication required') + ), + createPolicy(async (ctx) => + ctx.email?.includes('@') ? allow({ step: 'email-valid' }) : deny('Valid email required') + ), + createPolicy(async (ctx) => + ctx.permissions.includes('enterprise-features') + ? allow({ step: 'permissions' }) + : deny('Enterprise permissions required') + ), + ], + }; + + // Stage 2: Billing Validation (ANY sufficient - flexible payment options) + const billingGroup = { + operator: 'ANY' as const, + policies: [ + createPolicy(async (ctx) => + ctx.subscription?.plan === 'enterprise' && ctx.subscription.active + ? allow({ billing: 'enterprise-subscription' }) + : deny('Enterprise subscription required') + ), + createPolicy(async (ctx) => + ctx.trial?.type === 'enterprise' && ctx.trial.daysRemaining > 0 + ? allow({ billing: 'enterprise-trial', daysLeft: ctx.trial.daysRemaining }) + : deny('Active enterprise trial required') + ), + createPolicy(async (ctx) => + ctx.adminOverride?.enabled && ctx.user.role === 'super-admin' + ? allow({ billing: 'admin-override' }) + : deny('Admin override not available') + ), + ], + }; + + // Stage 3: Final Constraints (ALL must pass) + const constraintsGroup = { + operator: 'ALL' as const, + policies: [ + createPolicy(async (ctx) => + ctx.team.memberCount <= ctx.maxMembers + ? allow({ constraint: 'team-size-valid' }) + : deny('Team size exceeds plan limits') + ), + createPolicy(async (ctx) => + ctx.organization.complianceStatus === 'approved' + ? allow({ constraint: 'compliance-approved' }) + : deny('Organization compliance approval required') + ), + ], + }; + + // Execute all groups sequentially - ALL groups must pass + const result = await evaluator.evaluateGroups([ + authenticationGroup, + billingGroup, + constraintsGroup + ], context); + + return { + allowed: result.allowed, + reasons: result.reasons, + metadata: { + authenticationPassed: result.results.some(r => r.metadata?.step === 'authenticated'), + billingMethod: result.results.find(r => r.metadata?.billing)?.metadata?.billing, + constraintsValidated: result.results.some(r => r.metadata?.constraint), + } + }; +} +``` + +### Group Evaluation Flow + +1. **Sequential Group Processing**: Groups are evaluated in order +2. **All Groups Must Pass**: If any group fails, entire evaluation fails +3. **Short-Circuiting**: Stops on first group failure for performance +4. **Metadata Preservation**: All policy results and metadata are collected + +### Group Operators + +- **`ALL` (AND logic)**: All policies in the group must pass + - Short-circuits on first failure for performance + - Use for mandatory requirements where every condition must be met + +- **`ANY` (OR logic)**: At least one policy in the group must pass + - Short-circuits on first success for performance + - Use for flexible requirements where multiple options are acceptable + +### Performance Considerations + +- **Order groups by criticality**: Put fast, critical checks first +- **Group by evaluation cost**: Separate expensive operations +- **Monitor evaluation time**: Track performance for optimization + +## API Reference + +### Core Functions + +- `createPolicyRegistry()` — Create a feature-specific registry +- `definePolicy(config)` — Define a policy with metadata and configuration +- `createPoliciesEvaluator()` — Create a policy evaluator instance +- `allow(metadata?)` — Return a success result with optional metadata +- `deny(reason | error)` — Return a failure result (supports strings and structured errors) + +### Policy Evaluator Methods + +- `evaluator.evaluate(registry, context, operator, stage?)` — Evaluate registry policies +- `evaluator.evaluateGroups(groups, context)` — Evaluate complex group logic +- `evaluator.hasPoliciesForStage(registry, stage?)` — Check if policies exist for a stage + +### Types + +- `PolicyContext` — Base context interface +- `PolicyResult` — Policy evaluation result +- `PolicyStage` — Execution stage (`'preliminary' | 'submission' | string`) +- `EvaluationResult` — Contains `allowed`, `reasons`, and `results` arrays +- `PolicyGroup` — Group configuration with `operator` and `policies` + +## Related documentation + +- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team management operations +- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Payment provider setup +- [Row Level Security](/docs/next-supabase-turbo/security/row-level-security) - Database-level security +- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing with seat limits diff --git a/docs/api/registry-api.mdoc b/docs/api/registry-api.mdoc new file mode 100644 index 000000000..febec58cd --- /dev/null +++ b/docs/api/registry-api.mdoc @@ -0,0 +1,415 @@ +--- +status: "published" +label: "Registry API" +title: "Registry API for Interchangeable Services | Next.js Supabase SaaS Kit" +description: "Build pluggable infrastructure with MakerKit's Registry API. Swap billing providers, mailers, monitoring services, and CMS clients without changing application code." +order: 6 +--- + +The Registry API provides a type-safe pattern for registering and resolving interchangeable service implementations. Use it to swap between billing providers (Stripe, Lemon Squeezy, Paddle), mailers (Resend, Mailgun), monitoring (Sentry, SignOz), and any other pluggable infrastructure based on environment variables. + +{% sequence title="Registry API Reference" description="Build pluggable infrastructure with the Registry API" %} + +[Why use a registry](#why-use-a-registry) + +[Core API](#core-api) + +[Creating a registry](#creating-a-registry) + +[Registering implementations](#registering-implementations) + +[Resolving implementations](#resolving-implementations) + +[Setup hooks](#setup-hooks) + +[Real-world examples](#real-world-examples) + +{% /sequence %} + +## Why use a registry + +MakerKit uses registries to decouple your application code from specific service implementations: + +| Problem | Registry Solution | +|---------|------------------| +| Billing provider lock-in | Switch from Stripe to Paddle via env var | +| Testing with different backends | Register mock implementations for tests | +| Multi-tenant configurations | Different providers per tenant | +| Lazy initialization | Services only load when first accessed | +| Type safety | Full TypeScript support for implementations | + +### How MakerKit uses registries + +``` +Environment Variable Registry Your Code +───────────────────── ──────── ───────── +BILLING_PROVIDER=stripe → billingRegistry → getBillingGateway() +MAILER_PROVIDER=resend → mailerRegistry → getMailer() +CMS_PROVIDER=keystatic → cmsRegistry → getCmsClient() +``` + +Your application code calls `getBillingGateway()` and receives the configured implementation without knowing which provider is active. + +--- + +## Core API + +The registry helper at `@kit/shared/registry` provides four methods: + +| Method | Description | +|--------|-------------| +| `register(name, factory)` | Store an async factory for an implementation | +| `get(...names)` | Resolve one or more implementations | +| `addSetup(group, callback)` | Queue initialization tasks | +| `setup(group?)` | Execute setup tasks (once per group) | + +--- + +## Creating a registry + +Use `createRegistry<T, N>()` to create a typed registry: + +```tsx +import { createRegistry } from '@kit/shared/registry'; + +// Define the interface implementations must follow +interface EmailService { + send(to: string, subject: string, body: string): Promise<void>; +} + +// Define allowed provider names +type EmailProvider = 'resend' | 'mailgun' | 'sendgrid'; + +// Create the registry +const emailRegistry = createRegistry<EmailService, EmailProvider>(); +``` + +The generic parameters ensure: + +- All registered implementations match `EmailService` +- Only valid provider names can be used +- `get()` returns correctly typed implementations + +--- + +## Registering implementations + +Use `register()` to add implementations. Factories can be sync or async: + +```tsx +// Async factory with dynamic import (recommended for code splitting) +emailRegistry.register('resend', async () => { + const { createResendMailer } = await import('./mailers/resend'); + return createResendMailer(); +}); + +// Sync factory +emailRegistry.register('mailgun', () => { + return new MailgunService(process.env.MAILGUN_API_KEY!); +}); + +// Chaining +emailRegistry + .register('resend', async () => createResendMailer()) + .register('mailgun', async () => createMailgunMailer()) + .register('sendgrid', async () => createSendgridMailer()); +``` + +{% callout title="Lazy loading" %} +Factories only execute when `get()` is called. This keeps your bundle small since unused providers aren't imported. +{% /callout %} + +--- + +## Resolving implementations + +Use `get()` to resolve implementations. Always await the result: + +```tsx +// Single implementation +const mailer = await emailRegistry.get('resend'); +await mailer.send('user@example.com', 'Welcome', 'Hello!'); + +// Multiple implementations (returns tuple) +const [primary, fallback] = await emailRegistry.get('resend', 'mailgun'); + +// Dynamic resolution from environment +const provider = process.env.EMAIL_PROVIDER as EmailProvider; +const mailer = await emailRegistry.get(provider); +``` + +### Creating a helper function + +Wrap the registry in a helper for cleaner usage: + +```tsx +export async function getEmailService(): Promise<EmailService> { + const provider = (process.env.EMAIL_PROVIDER ?? 'resend') as EmailProvider; + return emailRegistry.get(provider); +} + +// Usage +const mailer = await getEmailService(); +await mailer.send('user@example.com', 'Welcome', 'Hello!'); +``` + +--- + +## Setup hooks + +Use `addSetup()` and `setup()` for initialization tasks that should run once: + +```tsx +// Add setup tasks +emailRegistry.addSetup('initialize', async () => { + console.log('Initializing email service...'); + // Verify API keys, warm up connections, etc. +}); + +emailRegistry.addSetup('initialize', async () => { + console.log('Loading email templates...'); +}); + +// Run all setup tasks (idempotent) +await emailRegistry.setup('initialize'); +await emailRegistry.setup('initialize'); // No-op, already ran +``` + +### Setup groups + +Use different groups to control when initialization happens: + +```tsx +emailRegistry.addSetup('verify-credentials', async () => { + // Quick check at startup +}); + +emailRegistry.addSetup('warm-cache', async () => { + // Expensive operation, run later +}); + +// At startup +await emailRegistry.setup('verify-credentials'); + +// Before first email +await emailRegistry.setup('warm-cache'); +``` + +--- + +## Real-world examples + +### Billing provider registry + +```tsx +// lib/billing/registry.ts +import { createRegistry } from '@kit/shared/registry'; + +interface BillingGateway { + createCheckoutSession(params: CheckoutParams): Promise<{ url: string }>; + createBillingPortalSession(customerId: string): Promise<{ url: string }>; + cancelSubscription(subscriptionId: string): Promise<void>; +} + +type BillingProvider = 'stripe' | 'lemon-squeezy' | 'paddle'; + +const billingRegistry = createRegistry<BillingGateway, BillingProvider>(); + +billingRegistry + .register('stripe', async () => { + const { createStripeGateway } = await import('./gateways/stripe'); + return createStripeGateway(); + }) + .register('lemon-squeezy', async () => { + const { createLemonSqueezyGateway } = await import('./gateways/lemon-squeezy'); + return createLemonSqueezyGateway(); + }) + .register('paddle', async () => { + const { createPaddleGateway } = await import('./gateways/paddle'); + return createPaddleGateway(); + }); + +export async function getBillingGateway(): Promise<BillingGateway> { + const provider = (process.env.BILLING_PROVIDER ?? 'stripe') as BillingProvider; + return billingRegistry.get(provider); +} +``` + +**Usage:** + +```tsx +import { getBillingGateway } from '@/lib/billing/registry'; + +export async function createCheckout(priceId: string, userId: string) { + const billing = await getBillingGateway(); + + const session = await billing.createCheckoutSession({ + priceId, + userId, + successUrl: '/checkout/success', + cancelUrl: '/pricing', + }); + + return session.url; +} +``` + +### CMS client registry + +```tsx +// lib/cms/registry.ts +import { createRegistry } from '@kit/shared/registry'; + +interface CmsClient { + getPosts(options?: { limit?: number }): Promise<Post[]>; + getPost(slug: string): Promise<Post | null>; + getPages(): Promise<Page[]>; +} + +type CmsProvider = 'keystatic' | 'wordpress' | 'supabase'; + +const cmsRegistry = createRegistry<CmsClient, CmsProvider>(); + +cmsRegistry + .register('keystatic', async () => { + const { createKeystaticClient } = await import('./clients/keystatic'); + return createKeystaticClient(); + }) + .register('wordpress', async () => { + const { createWordPressClient } = await import('./clients/wordpress'); + return createWordPressClient(process.env.WORDPRESS_URL!); + }) + .register('supabase', async () => { + const { createSupabaseCmsClient } = await import('./clients/supabase'); + return createSupabaseCmsClient(); + }); + +export async function getCmsClient(): Promise<CmsClient> { + const provider = (process.env.CMS_PROVIDER ?? 'keystatic') as CmsProvider; + return cmsRegistry.get(provider); +} +``` + +### Logger registry + +```tsx +// lib/logger/registry.ts +import { createRegistry } from '@kit/shared/registry'; + +interface Logger { + info(context: object, message: string): void; + error(context: object, message: string): void; + warn(context: object, message: string): void; + debug(context: object, message: string): void; +} + +type LoggerProvider = 'pino' | 'console'; + +const loggerRegistry = createRegistry<Logger, LoggerProvider>(); + +loggerRegistry + .register('pino', async () => { + const pino = await import('pino'); + return pino.default({ + level: process.env.LOG_LEVEL ?? 'info', + }); + }) + .register('console', () => ({ + info: (ctx, msg) => console.log('[INFO]', msg, ctx), + error: (ctx, msg) => console.error('[ERROR]', msg, ctx), + warn: (ctx, msg) => console.warn('[WARN]', msg, ctx), + debug: (ctx, msg) => console.debug('[DEBUG]', msg, ctx), + })); + +export async function getLogger(): Promise<Logger> { + const provider = (process.env.LOGGER ?? 'pino') as LoggerProvider; + return loggerRegistry.get(provider); +} +``` + +### Testing with mock implementations + +```tsx +// __tests__/billing.test.ts +import { createRegistry } from '@kit/shared/registry'; + +const mockBillingRegistry = createRegistry<BillingGateway, 'mock'>(); + +mockBillingRegistry.register('mock', () => ({ + createCheckoutSession: jest.fn().mockResolvedValue({ url: 'https://mock.checkout' }), + createBillingPortalSession: jest.fn().mockResolvedValue({ url: 'https://mock.portal' }), + cancelSubscription: jest.fn().mockResolvedValue(undefined), +})); + +test('checkout creates session', async () => { + const billing = await mockBillingRegistry.get('mock'); + + const result = await billing.createCheckoutSession({ + priceId: 'price_123', + userId: 'user_456', + }); + + expect(result.url).toBe('https://mock.checkout'); +}); +``` + +--- + +## Best practices + +### 1. Use environment variables for provider selection + +```tsx +// Good: Configuration-driven +const provider = process.env.BILLING_PROVIDER as BillingProvider; +const billing = await registry.get(provider); + +// Avoid: Hardcoded providers +const billing = await registry.get('stripe'); +``` + +### 2. Create helper functions for common access + +```tsx +// Good: Encapsulated helper +export async function getBillingGateway() { + const provider = process.env.BILLING_PROVIDER ?? 'stripe'; + return billingRegistry.get(provider as BillingProvider); +} + +// Usage is clean +const billing = await getBillingGateway(); +``` + +### 3. Use dynamic imports for code splitting + +```tsx +// Good: Lazy loaded +registry.register('stripe', async () => { + const { createStripeGateway } = await import('./stripe'); + return createStripeGateway(); +}); + +// Avoid: Eager imports +import { createStripeGateway } from './stripe'; +registry.register('stripe', () => createStripeGateway()); +``` + +### 4. Define strict interfaces + +```tsx +// Good: Well-defined interface +interface BillingGateway { + createCheckoutSession(params: CheckoutParams): Promise<CheckoutResult>; + createBillingPortalSession(customerId: string): Promise<PortalResult>; +} + +// Avoid: Loose typing +type BillingGateway = Record<string, (...args: any[]) => any>; +``` + +## Related documentation + +- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Payment provider setup +- [Monitoring Configuration](/docs/next-supabase-turbo/monitoring/overview) - Logger and APM setup +- [CMS Configuration](/docs/next-supabase-turbo/content) - Content management setup diff --git a/docs/api/team-account-api.mdoc b/docs/api/team-account-api.mdoc new file mode 100644 index 000000000..fb6f46e8e --- /dev/null +++ b/docs/api/team-account-api.mdoc @@ -0,0 +1,650 @@ +--- +status: "published" +label: "Team Account API" +order: 1 +title: "Team Account API | Next.js Supabase SaaS Kit" +description: "Complete reference for the Team Account API in MakerKit. Manage teams, members, permissions, invitations, subscriptions, and workspace data with type-safe methods." +--- + +The Team Account API manages team accounts, members, permissions, and invitations. Use it to check user permissions, manage team subscriptions, and handle team invitations in your multi-tenant SaaS application. + +{% sequence title="Team Account API Reference" description="Learn how to use the Team Account API in MakerKit" %} + +[Setup and initialization](#setup-and-initialization) + +[getTeamAccountById](#getteamaccountbyid) + +[getAccountWorkspace](#getaccountworkspace) + +[getSubscription](#getsubscription) + +[getOrder](#getorder) + +[hasPermission](#haspermission) + +[getMembersCount](#getmemberscount) + +[getCustomerId](#getcustomerid) + +[getInvitation](#getinvitation) + +[Real-world examples](#real-world-examples) + +{% /sequence %} + +## Setup and initialization + +Import `createTeamAccountsApi` from `@kit/team-accounts/api` and pass a Supabase server client. + +```tsx +import { createTeamAccountsApi } from '@kit/team-accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function ServerComponent() { + const client = getSupabaseServerClient(); + const api = createTeamAccountsApi(client); + + // Use API methods +} +``` + +In Server Actions: + +```tsx +'use server'; + +import { createTeamAccountsApi } from '@kit/team-accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function myServerAction() { + const client = getSupabaseServerClient(); + const api = createTeamAccountsApi(client); + + // Use API methods +} +``` + +{% callout title="Request-scoped clients" %} +Always create the Supabase client and API instance inside your request handler, not at module scope. The client is tied to the current user's session and RLS policies. +{% /callout %} + +## API Methods + +### getTeamAccountById + +Retrieves a team account by its UUID. Also verifies the current user has access to the team. + +```tsx +const account = await api.getTeamAccountById(accountId); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The team account UUID | + +**Returns:** + +```tsx +{ + id: string; + name: string; + slug: string; + picture_url: string | null; + public_data: Json | null; + primary_owner_user_id: string; + created_at: string; + updated_at: string; +} | null +``` + +**Usage notes:** + +- Returns `null` if the account doesn't exist or user lacks access +- RLS policies ensure users only see teams they belong to +- Use this to verify team membership before operations + +--- + +### getAccountWorkspace + +Returns the team workspace data for a given team slug. This is the primary method for loading team context in layouts. + +```tsx +const workspace = await api.getAccountWorkspace(slug); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `slug` | `string` | The team URL slug | + +**Returns:** + +```tsx +{ + account: { + id: string; + name: string; + slug: string; + picture_url: string | null; + role: string; + role_hierarchy_level: number; + primary_owner_user_id: string; + subscription_status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused' | null; + permissions: string[]; + }; + accounts: Array<{ + id: string; + name: string; + slug: string; + picture_url: string | null; + role: string; + }>; +} +``` + +**Usage notes:** + +- Called automatically in the `/home/[account]` layout +- The `permissions` array contains all permissions for the current user in this team +- Use `role_hierarchy_level` for role-based comparisons (lower = more permissions) + +--- + +### getSubscription + +Returns the subscription data for a team account, including all line items. + +```tsx +const subscription = await api.getSubscription(accountId); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The team account UUID | + +**Returns:** + +```tsx +{ + id: string; + account_id: string; + billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle'; + status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused'; + currency: string; + cancel_at_period_end: boolean; + period_starts_at: string; + period_ends_at: string; + trial_starts_at: string | null; + trial_ends_at: string | null; + items: Array<{ + id: string; + subscription_id: string; + product_id: string; + variant_id: string; + type: 'flat' | 'per_seat' | 'metered'; + quantity: number; + price_amount: number; + interval: 'month' | 'year'; + interval_count: number; + }>; +} | null +``` + +**Example: Check per-seat limits** + +```tsx +import { createTeamAccountsApi } from '@kit/team-accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function canAddTeamMember(accountId: string) { + const client = getSupabaseServerClient(); + const api = createTeamAccountsApi(client); + + const [subscription, membersCount] = await Promise.all([ + api.getSubscription(accountId), + api.getMembersCount(accountId), + ]); + + if (!subscription) { + // Free tier: allow up to 3 members + return membersCount < 3; + } + + const perSeatItem = subscription.items.find((item) => item.type === 'per_seat'); + + if (perSeatItem) { + return membersCount < perSeatItem.quantity; + } + + // Flat-rate plan: no seat limit + return true; +} +``` + +--- + +### getOrder + +Returns one-time purchase order data for team accounts using lifetime deals. + +```tsx +const order = await api.getOrder(accountId); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The team account UUID | + +**Returns:** + +```tsx +{ + id: string; + account_id: string; + billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle'; + status: 'pending' | 'completed' | 'refunded'; + currency: string; + total_amount: number; + items: Array<{ + product_id: string; + variant_id: string; + quantity: number; + price_amount: number; + }>; +} | null +``` + +--- + +### hasPermission + +Checks if a user has a specific permission within a team account. Use this for fine-grained authorization checks. + +```tsx +const canManage = await api.hasPermission({ + accountId: 'team-uuid', + userId: 'user-uuid', + permission: 'billing.manage', +}); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The team account UUID | +| `userId` | `string` | The user UUID to check | +| `permission` | `string` | The permission identifier | + +**Returns:** `boolean` + +**Built-in permissions:** + +| Permission | Description | +|------------|-------------| +| `billing.manage` | Manage subscription and payment methods | +| `members.invite` | Invite new team members | +| `members.remove` | Remove team members | +| `members.manage` | Update member roles | +| `settings.manage` | Update team settings | + +**Example: Permission-gated Server Action** + +```tsx +'use server'; + +import * as z from 'zod'; +import { authActionClient } from '@kit/next/safe-action'; +import { createTeamAccountsApi } from '@kit/team-accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const UpdateTeamSchema = z.object({ + accountId: z.string().uuid(), + name: z.string().min(2).max(50), +}); + +export const updateTeamSettings = authActionClient + .inputSchema(UpdateTeamSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const client = getSupabaseServerClient(); + const api = createTeamAccountsApi(client); + + const canManage = await api.hasPermission({ + accountId: data.accountId, + userId: user.id, + permission: 'settings.manage', + }); + + if (!canManage) { + return { + success: false, + error: 'You do not have permission to update team settings', + }; + } + + // Update team... + return { success: true }; + }); +``` + +--- + +### getMembersCount + +Returns the total number of members in a team account. + +```tsx +const count = await api.getMembersCount(accountId); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The team account UUID | + +**Returns:** `number | null` + +**Example: Display team size** + +```tsx +import { createTeamAccountsApi } from '@kit/team-accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function TeamStats({ accountId }: { accountId: string }) { + const client = getSupabaseServerClient(); + const api = createTeamAccountsApi(client); + const membersCount = await api.getMembersCount(accountId); + + return ( + <div> + <span className="font-medium">{membersCount}</span> + <span className="text-muted-foreground"> team members</span> + </div> + ); +} +``` + +--- + +### getCustomerId + +Returns the billing provider customer ID for a team account. + +```tsx +const customerId = await api.getCustomerId(accountId); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountId` | `string` | The team account UUID | + +**Returns:** `string | null` + +--- + +### getInvitation + +Retrieves invitation data from an invite token. Requires an admin client to bypass RLS for pending invitations. + +```tsx +const invitation = await api.getInvitation(adminClient, token); +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `adminClient` | `SupabaseClient` | Admin client (bypasses RLS) | +| `token` | `string` | The invitation token | + +**Returns:** + +```tsx +{ + id: number; + email: string; + account: { + id: string; + name: string; + slug: string; + }; + role: string; + expires_at: string; +} | null +``` + +**Example: Accept invitation flow** + +```tsx +import { createTeamAccountsApi } from '@kit/team-accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +async function getInvitationDetails(token: string) { + const client = getSupabaseServerClient(); + const adminClient = getSupabaseServerAdminClient(); + const api = createTeamAccountsApi(client); + + const invitation = await api.getInvitation(adminClient, token); + + if (!invitation) { + return { error: 'Invalid or expired invitation' }; + } + + const now = new Date(); + const expiresAt = new Date(invitation.expires_at); + + if (now > expiresAt) { + return { error: 'This invitation has expired' }; + } + + return { + teamName: invitation.account.name, + role: invitation.role, + email: invitation.email, + }; +} +``` + +{% callout type="warning" title="Admin client security" %} +The admin client bypasses Row Level Security. Only use it for operations that require elevated privileges, and always validate authorization separately. +{% /callout %} + +--- + +## Real-world examples + +### Complete team management Server Actions + +```tsx +// lib/server/team-actions.ts +'use server'; + +import * as z from 'zod'; +import { revalidatePath } from 'next/cache'; +import { authActionClient } from '@kit/next/safe-action'; +import { createTeamAccountsApi } from '@kit/team-accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const InviteMemberSchema = z.object({ + accountId: z.string().uuid(), + email: z.string().email(), + role: z.enum(['admin', 'member']), +}); + +const RemoveMemberSchema = z.object({ + accountId: z.string().uuid(), + userId: z.string().uuid(), +}); + +export const inviteMember = authActionClient + .inputSchema(InviteMemberSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const client = getSupabaseServerClient(); + const api = createTeamAccountsApi(client); + + // Check permission + const canInvite = await api.hasPermission({ + accountId: data.accountId, + userId: user.id, + permission: 'members.invite', + }); + + if (!canInvite) { + return { success: false, error: 'Permission denied' }; + } + + // Check seat limits + const [subscription, membersCount] = await Promise.all([ + api.getSubscription(data.accountId), + api.getMembersCount(data.accountId), + ]); + + if (subscription) { + const perSeatItem = subscription.items.find((i) => i.type === 'per_seat'); + if (perSeatItem && membersCount >= perSeatItem.quantity) { + return { + success: false, + error: 'Team has reached maximum seats. Please upgrade your plan.', + }; + } + } + + // Create invitation... + const { error } = await client.from('invitations').insert({ + account_id: data.accountId, + email: data.email, + role: data.role, + invited_by: user.id, + }); + + if (error) { + return { success: false, error: 'Failed to send invitation' }; + } + + revalidatePath(`/home/[account]/settings/members`, 'page'); + return { success: true }; + }); + +export const removeMember = authActionClient + .inputSchema(RemoveMemberSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const client = getSupabaseServerClient(); + const api = createTeamAccountsApi(client); + + // Cannot remove yourself + if (data.userId === user.id) { + return { success: false, error: 'You cannot remove yourself' }; + } + + // Check permission + const canRemove = await api.hasPermission({ + accountId: data.accountId, + userId: user.id, + permission: 'members.remove', + }); + + if (!canRemove) { + return { success: false, error: 'Permission denied' }; + } + + // Check if target is owner + const account = await api.getTeamAccountById(data.accountId); + if (account?.primary_owner_user_id === data.userId) { + return { success: false, error: 'Cannot remove the team owner' }; + } + + // Remove member... + const { error } = await client + .from('accounts_memberships') + .delete() + .eq('account_id', data.accountId) + .eq('user_id', data.userId); + + if (error) { + return { success: false, error: 'Failed to remove member' }; + } + + revalidatePath(`/home/[account]/settings/members`, 'page'); + return { success: true }; + }); +``` + +### Permission-based UI rendering + +```tsx +import { createTeamAccountsApi } from '@kit/team-accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { requireUser } from '@kit/supabase/require-user'; +import { redirect } from 'next/navigation'; + +async function TeamSettingsPage({ params }: { params: { account: string } }) { + const client = getSupabaseServerClient(); + const auth = await requireUser(client); + + if (auth.error) { + redirect(auth.redirectTo); + } + + const api = createTeamAccountsApi(client); + const workspace = await api.getAccountWorkspace(params.account); + + const permissions = { + canManageSettings: workspace.account.permissions.includes('settings.manage'), + canManageBilling: workspace.account.permissions.includes('billing.manage'), + canInviteMembers: workspace.account.permissions.includes('members.invite'), + canRemoveMembers: workspace.account.permissions.includes('members.remove'), + }; + + return ( + <div> + <h1>Team Settings</h1> + + {permissions.canManageSettings && ( + <section> + <h2>General Settings</h2> + {/* Settings form */} + </section> + )} + + {permissions.canManageBilling && ( + <section> + <h2>Billing</h2> + {/* Billing management */} + </section> + )} + + {permissions.canInviteMembers && ( + <section> + <h2>Invite Members</h2> + {/* Invitation form */} + </section> + )} + + {!permissions.canManageSettings && + !permissions.canManageBilling && + !permissions.canInviteMembers && ( + <p>You don't have permission to manage this team.</p> + )} + </div> + ); +} +``` + +## Related documentation + +- [Account API](/docs/next-supabase-turbo/api/account-api) - Personal account management +- [Team Workspace API](/docs/next-supabase-turbo/api/account-workspace-api) - Workspace context for layouts +- [Policies API](/docs/next-supabase-turbo/api/policies-api) - Business rule validation +- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing diff --git a/docs/api/user-workspace-api.mdoc b/docs/api/user-workspace-api.mdoc new file mode 100644 index 000000000..62fc0b8c0 --- /dev/null +++ b/docs/api/user-workspace-api.mdoc @@ -0,0 +1,358 @@ +--- +status: "published" +label: "User Workspace API" +order: 3 +title: "User Workspace API | Next.js Supabase SaaS Kit" +description: "Access personal workspace data in MakerKit layouts. Load user account information, subscription status, and account switcher data with the User Workspace API." +--- + +The User Workspace API provides personal account context for pages under `/home/(user)`. It loads user data, subscription status, and all accounts the user belongs to, making this information available to both server and client components. + +{% sequence title="User Workspace API Reference" description="Access personal workspace data in layouts and components" %} + +[loadUserWorkspace (Server)](#loaduserworkspace-server) + +[useUserWorkspace (Client)](#useuserworkspace-client) + +[Data structure](#data-structure) + +[Usage patterns](#usage-patterns) + +{% /sequence %} + +## loadUserWorkspace (Server) + +Loads the personal workspace data for the authenticated user. Use this in Server Components within the `/home/(user)` route group. + +```tsx +import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace'; + +export default async function PersonalDashboard() { + const data = await loadUserWorkspace(); + + return ( + <div> + <h1>Welcome, {data.user.email}</h1> + <p>Account: {data.workspace.name}</p> + </div> + ); +} +``` + +### Function signature + +```tsx +async function loadUserWorkspace(): Promise<UserWorkspaceData> +``` + +### Caching behavior + +The function uses React's `cache()` to deduplicate calls within a single request. You can call it multiple times in nested components without additional database queries. + +```tsx +// Both calls use the same cached data +const layout = await loadUserWorkspace(); // First call: hits database +const page = await loadUserWorkspace(); // Second call: returns cached data +``` + +{% callout title="Performance consideration" %} +While calls are deduplicated within a request, the data is fetched on every navigation. If you only need a subset of the data (like subscription status), consider making a more targeted query. +{% /callout %} + +--- + +## useUserWorkspace (Client) + +Access the workspace data in client components using the `useUserWorkspace` hook. The data is provided through React Context from the layout. + +```tsx +'use client'; + +import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace'; + +export function ProfileCard() { + const { workspace, user, accounts } = useUserWorkspace(); + + return ( + <div className="rounded-lg border p-4"> + <div className="flex items-center gap-3"> + {workspace.picture_url && ( + <img + src={workspace.picture_url} + alt={workspace.name ?? 'Profile'} + className="h-10 w-10 rounded-full" + /> + )} + <div> + <p className="font-medium">{workspace.name}</p> + <p className="text-sm text-muted-foreground">{user.email}</p> + </div> + </div> + + {workspace.subscription_status && ( + <div className="mt-3"> + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Plan: {workspace.subscription_status} + </span> + </div> + )} + </div> + ); +} +``` + +{% callout type="warning" title="Context requirement" %} +The `useUserWorkspace` hook only works within the `/home/(user)` route group where the context provider is set up. Using it outside this layout will throw an error. +{% /callout %} + +--- + +## Data structure + +### UserWorkspaceData + +```tsx +import type { User } from '@supabase/supabase-js'; + +interface UserWorkspaceData { + workspace: { + id: string | null; + name: string | null; + picture_url: string | null; + public_data: Json | null; + subscription_status: SubscriptionStatus | null; + }; + + user: User; + + accounts: Array<{ + id: string | null; + name: string | null; + picture_url: string | null; + role: string | null; + slug: string | null; + }>; +} +``` + +### subscription_status values + +| Status | Description | +|--------|-------------| +| `active` | Active subscription | +| `trialing` | In trial period | +| `past_due` | Payment failed, grace period | +| `canceled` | Subscription canceled | +| `unpaid` | Payment required | +| `incomplete` | Setup incomplete | +| `incomplete_expired` | Setup expired | +| `paused` | Subscription paused | + +### accounts array + +The `accounts` array contains all accounts the user belongs to, including: + +- Their personal account +- Team accounts where they're a member +- The user's role in each account + +This data powers the account switcher component. + +--- + +## Usage patterns + +### Personal dashboard page + +```tsx +import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace'; + +export default async function DashboardPage() { + const { workspace, user, accounts } = await loadUserWorkspace(); + + const hasActiveSubscription = + workspace.subscription_status === 'active' || + workspace.subscription_status === 'trialing'; + + return ( + <div className="space-y-6"> + <header> + <h1 className="text-2xl font-bold">Dashboard</h1> + <p className="text-muted-foreground"> + Welcome back, {user.user_metadata.full_name || user.email} + </p> + </header> + + {!hasActiveSubscription && ( + <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4"> + <p>Upgrade to unlock premium features</p> + <a href="/pricing" className="text-primary underline"> + View plans + </a> + </div> + )} + + <section> + <h2 className="text-lg font-medium">Your teams</h2> + <ul className="mt-2 space-y-2"> + {accounts + .filter((a) => a.slug !== null) + .map((account) => ( + <li key={account.id}> + <a + href={`/home/${account.slug}`} + className="flex items-center gap-2 rounded-lg p-2 hover:bg-muted" + > + {account.picture_url && ( + <img + src={account.picture_url} + alt="" + className="h-8 w-8 rounded" + /> + )} + <span>{account.name}</span> + <span className="ml-auto text-xs text-muted-foreground"> + {account.role} + </span> + </a> + </li> + ))} + </ul> + </section> + </div> + ); +} +``` + +### Account switcher component + +```tsx +'use client'; + +import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace'; +import { useRouter } from 'next/navigation'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@kit/ui/select'; + +export function AccountSwitcher() { + const { workspace, accounts } = useUserWorkspace(); + const router = useRouter(); + + const handleChange = (value: string) => { + if (value === 'personal') { + router.push('/home'); + } else { + router.push(`/home/${value}`); + } + }; + + return ( + <Select + defaultValue={workspace.id ?? 'personal'} + onValueChange={handleChange} + > + <SelectTrigger className="w-[200px]"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="personal"> + Personal Account + </SelectItem> + {accounts + .filter((a) => a.slug) + .map((account) => ( + <SelectItem key={account.id} value={account.slug!}> + {account.name} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} +``` + +### Feature gating with subscription status + +```tsx +'use client'; + +import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace'; + +interface FeatureGateProps { + children: React.ReactNode; + fallback?: React.ReactNode; + requiredStatus?: string[]; +} + +export function FeatureGate({ + children, + fallback, + requiredStatus = ['active', 'trialing'], +}: FeatureGateProps) { + const { workspace } = useUserWorkspace(); + + const hasAccess = requiredStatus.includes( + workspace.subscription_status ?? '' + ); + + if (!hasAccess) { + return fallback ?? null; + } + + return <>{children}</>; +} + +// Usage +function PremiumFeature() { + return ( + <FeatureGate + fallback={ + <div className="text-center p-4"> + <p>This feature requires a paid plan</p> + <a href="/pricing">Upgrade now</a> + </div> + } + > + <ExpensiveComponent /> + </FeatureGate> + ); +} +``` + +### Combining with server data + +```tsx +import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export default async function TasksPage() { + const { workspace, user } = await loadUserWorkspace(); + const client = getSupabaseServerClient(); + + // Fetch additional data using the workspace context + const { data: tasks } = await client + .from('tasks') + .select('*') + .eq('account_id', workspace.id) + .eq('created_by', user.id) + .order('created_at', { ascending: false }); + + return ( + <div> + <h1>My Tasks</h1> + <TaskList tasks={tasks ?? []} /> + </div> + ); +} +``` + +## Related documentation + +- [Team Workspace API](/docs/next-supabase-turbo/api/account-workspace-api) - Team account context +- [Account API](/docs/next-supabase-turbo/api/account-api) - Account operations +- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication diff --git a/docs/billing/billing-api.mdoc b/docs/billing/billing-api.mdoc new file mode 100644 index 000000000..034297878 --- /dev/null +++ b/docs/billing/billing-api.mdoc @@ -0,0 +1,461 @@ +--- +status: "published" +label: "Billing API" +title: "Billing API Reference for Next.js Supabase SaaS Kit" +order: 10 +description: "Complete API reference for Makerkit's billing service. Create checkouts, manage subscriptions, report usage, and handle billing operations programmatically." +--- + +The Billing Gateway Service provides a unified API for all billing operations, regardless of which payment provider you use (Stripe, Lemon Squeezy, or Paddle). This abstraction lets you switch providers without changing your application code. + +## Getting the Billing Service + +```tsx +import { createBillingGatewayService } from '@kit/billing-gateway'; + +// Get service for the configured provider +const service = createBillingGatewayService( + process.env.NEXT_PUBLIC_BILLING_PROVIDER +); + +// Or specify a provider explicitly +const stripeService = createBillingGatewayService('stripe'); +``` + +For most operations, get the provider from the user's subscription record: + +```tsx +import { createAccountsApi } from '@kit/accounts/api'; + +const accountsApi = createAccountsApi(supabaseClient); +const subscription = await accountsApi.getSubscription(accountId); +const provider = subscription?.billing_provider ?? 'stripe'; + +const service = createBillingGatewayService(provider); +``` + +## Create Checkout Session + +Start a new subscription or one-off purchase. + +```tsx +const { checkoutToken } = await service.createCheckoutSession({ + accountId: 'uuid-of-account', + plan: billingConfig.products[0].plans[0], // From billing.config.ts + returnUrl: 'https://yourapp.com/billing/return', + customerEmail: 'user@example.com', // Optional + customerId: 'cus_xxx', // Optional, if customer already exists + enableDiscountField: true, // Optional, show coupon input + variantQuantities: [ // Optional, for per-seat billing + { variantId: 'price_xxx', quantity: 5 } + ], +}); +``` + +**Parameters:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `accountId` | `string` | Yes | UUID of the account making the purchase | +| `plan` | `Plan` | Yes | Plan object from your billing config | +| `returnUrl` | `string` | Yes | URL to redirect after checkout | +| `customerEmail` | `string` | No | Pre-fill customer email | +| `customerId` | `string` | No | Existing customer ID (skips customer creation) | +| `enableDiscountField` | `boolean` | No | Show coupon/discount input | +| `variantQuantities` | `array` | No | Override quantities for line items | + +**Returns:** + +```tsx +{ + checkoutToken: string // Token to open checkout UI +} +``` + +**Example: Server Action** + +```tsx +'use server'; + +import { createBillingGatewayService } from '@kit/billing-gateway'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import billingConfig from '~/config/billing.config'; + +export async function createCheckout(planId: string, accountId: string) { + const supabase = getSupabaseServerClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + throw new Error('Not authenticated'); + } + + const plan = billingConfig.products + .flatMap(p => p.plans) + .find(p => p.id === planId); + + if (!plan) { + throw new Error('Plan not found'); + } + + const service = createBillingGatewayService(billingConfig.provider); + + const { checkoutToken } = await service.createCheckoutSession({ + accountId, + plan, + returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/billing/return`, + customerEmail: user.email, + }); + + return { checkoutToken }; +} +``` + +## Retrieve Checkout Session + +Check the status of a checkout session after redirect. + +```tsx +const session = await service.retrieveCheckoutSession({ + sessionId: 'cs_xxx', // From URL params after redirect +}); +``` + +**Returns:** + +```tsx +{ + checkoutToken: string | null, + status: 'complete' | 'expired' | 'open', + isSessionOpen: boolean, + customer: { + email: string | null + } +} +``` + +**Example: Return page handler** + +```tsx +// app/[locale]/home/[account]/billing/return/page.tsx +import { createBillingGatewayService } from '@kit/billing-gateway'; + +export default async function BillingReturnPage({ + searchParams, +}: { + searchParams: Promise<{ session_id?: string }> +}) { + const { session_id } = await searchParams; + + if (!session_id) { + return <div>Invalid session</div>; + } + + const service = createBillingGatewayService('stripe'); + const session = await service.retrieveCheckoutSession({ + sessionId: session_id, + }); + + if (session.status === 'complete') { + return <div>Payment successful!</div>; + } + + return <div>Payment pending or failed</div>; +} +``` + +## Create Billing Portal Session + +Open the customer portal for subscription management. + +```tsx +const { url } = await service.createBillingPortalSession({ + customerId: 'cus_xxx', // From billing_customers table + returnUrl: 'https://yourapp.com/billing', +}); + +// Redirect user to the portal URL +``` + +**Parameters:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `customerId` | `string` | Yes | Customer ID from billing provider | +| `returnUrl` | `string` | Yes | URL to redirect after portal session | + +**Example: Server Action** + +```tsx +'use server'; + +import { redirect } from 'next/navigation'; +import { createBillingGatewayService } from '@kit/billing-gateway'; +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function openBillingPortal(accountId: string) { + const supabase = getSupabaseServerClient(); + const api = createAccountsApi(supabase); + + const customerId = await api.getCustomerId(accountId); + + if (!customerId) { + throw new Error('No billing customer found'); + } + + const service = createBillingGatewayService('stripe'); + + const { url } = await service.createBillingPortalSession({ + customerId, + returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`, + }); + + redirect(url); +} +``` + +## Cancel Subscription + +Cancel a subscription immediately or at period end. + +```tsx +const { success } = await service.cancelSubscription({ + subscriptionId: 'sub_xxx', + invoiceNow: false, // Optional: charge immediately for usage +}); +``` + +**Parameters:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subscriptionId` | `string` | Yes | Subscription ID from provider | +| `invoiceNow` | `boolean` | No | Invoice outstanding usage immediately | + +**Example: Cancel at period end** + +```tsx +'use server'; + +import { createBillingGatewayService } from '@kit/billing-gateway'; +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function cancelSubscription(accountId: string) { + const supabase = getSupabaseServerClient(); + const api = createAccountsApi(supabase); + + const subscription = await api.getSubscription(accountId); + + if (!subscription) { + throw new Error('No subscription found'); + } + + const service = createBillingGatewayService(subscription.billing_provider); + + await service.cancelSubscription({ + subscriptionId: subscription.id, + }); + + return { success: true }; +} +``` + +## Report Usage (Metered Billing) + +Report usage for metered billing subscriptions. + +### Stripe + +Stripe uses customer ID and a meter event name: + +```tsx +await service.reportUsage({ + id: 'cus_xxx', // Customer ID + eventName: 'api_requests', // Meter name in Stripe + usage: { + quantity: 100, + }, +}); +``` + +### Lemon Squeezy + +Lemon Squeezy uses subscription item ID: + +```tsx +await service.reportUsage({ + id: 'sub_item_xxx', // Subscription item ID + usage: { + quantity: 100, + action: 'increment', // or 'set' + }, +}); +``` + +**Parameters:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | Yes | Customer ID (Stripe) or subscription item ID (LS) | +| `eventName` | `string` | Stripe only | Meter event name | +| `usage.quantity` | `number` | Yes | Usage amount | +| `usage.action` | `'increment' \| 'set'` | No | How to apply usage (LS only) | + +**Example: Track API usage** + +```tsx +import { createBillingGatewayService } from '@kit/billing-gateway'; +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function trackApiUsage(accountId: string, requestCount: number) { + const supabase = getSupabaseServerClient(); + const api = createAccountsApi(supabase); + + const subscription = await api.getSubscription(accountId); + + if (!subscription || subscription.status !== 'active') { + return; // No active subscription + } + + const service = createBillingGatewayService(subscription.billing_provider); + const customerId = await api.getCustomerId(accountId); + + if (subscription.billing_provider === 'stripe') { + await service.reportUsage({ + id: customerId!, + eventName: 'api_requests', + usage: { quantity: requestCount }, + }); + } else { + // Lemon Squeezy: need subscription item ID + const { data: item } = await supabase + .from('subscription_items') + .select('id') + .eq('subscription_id', subscription.id) + .eq('type', 'metered') + .single(); + + if (item) { + await service.reportUsage({ + id: item.id, + usage: { quantity: requestCount, action: 'increment' }, + }); + } + } +} +``` + +## Query Usage + +Retrieve usage data for a metered subscription. + +### Stripe + +```tsx +const usage = await service.queryUsage({ + id: 'meter_xxx', // Stripe Meter ID + customerId: 'cus_xxx', + filter: { + startTime: Math.floor(Date.now() / 1000) - 86400 * 30, // 30 days ago + endTime: Math.floor(Date.now() / 1000), + }, +}); +``` + +### Lemon Squeezy + +```tsx +const usage = await service.queryUsage({ + id: 'sub_item_xxx', // Subscription item ID + customerId: 'cus_xxx', + filter: { + page: 1, + size: 100, + }, +}); +``` + +**Returns:** + +```tsx +{ + value: number // Total usage in period +} +``` + +## Update Subscription Item + +Update the quantity of a subscription item (e.g., seat count). + +```tsx +const { success } = await service.updateSubscriptionItem({ + subscriptionId: 'sub_xxx', + subscriptionItemId: 'si_xxx', + quantity: 10, +}); +``` + +**Parameters:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subscriptionId` | `string` | Yes | Subscription ID | +| `subscriptionItemId` | `string` | Yes | Line item ID within subscription | +| `quantity` | `number` | Yes | New quantity (minimum 1) | + +{% alert type="default" title="Automatic seat updates" %} +For per-seat billing, Makerkit automatically updates seat counts when team members are added or removed. You typically don't need to call this directly. +{% /alert %} + +## Get Subscription Details + +Retrieve subscription details from the provider. + +```tsx +const subscription = await service.getSubscription('sub_xxx'); +``` + +**Returns:** Provider-specific subscription object. + +## Get Plan Details + +Retrieve plan/price details from the provider. + +```tsx +const plan = await service.getPlanById('price_xxx'); +``` + +**Returns:** Provider-specific plan/price object. + +## Error Handling + +All methods can throw errors. Wrap calls in try-catch: + +```tsx +try { + const { checkoutToken } = await service.createCheckoutSession({ + // ... + }); +} catch (error) { + if (error instanceof Error) { + console.error('Billing error:', error.message); + } + // Handle error appropriately +} +``` + +Common errors: +- Invalid API keys +- Invalid price/plan IDs +- Customer not found +- Subscription not found +- Network/provider errors + +## Related Documentation + +- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts +- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Handle billing events +- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based billing guide +- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing diff --git a/docs/billing/billing-schema.mdoc b/docs/billing/billing-schema.mdoc new file mode 100644 index 000000000..f5306d7f0 --- /dev/null +++ b/docs/billing/billing-schema.mdoc @@ -0,0 +1,632 @@ +--- +status: "published" +label: "Billing Schema" +title: "Configure SaaS Pricing Plans with the Billing Schema" +order: 1 +description: "Define your SaaS pricing with Makerkit's billing schema. Configure products, plans, flat subscriptions, per-seat pricing, metered usage, and one-off payments for Stripe, Lemon Squeezy, or Paddle." +--- + +The billing schema defines your products and pricing in a single configuration file. This schema drives the pricing table UI, checkout sessions, and subscription management across all supported providers (Stripe, Lemon Squeezy, Paddle). + +## Schema Structure + +The schema has three levels: + +``` +Products (what you sell) +└── Plans (pricing options: monthly, yearly) + └── Line Items (how you charge: flat, per-seat, metered) +``` + +**Example:** A "Pro" product might have "Pro Monthly" and "Pro Yearly" plans. Each plan has line items defining the actual charges. + +## Quick Start + +Create or edit `apps/web/config/billing.config.ts`: + +```tsx +import { createBillingSchema } from '@kit/billing'; + +const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe'; + +export default createBillingSchema({ + provider, + products: [ + { + id: 'pro', + name: 'Pro', + description: 'For growing teams', + currency: 'USD', + badge: 'Popular', + plans: [ + { + id: 'pro-monthly', + name: 'Pro Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_xxxxxxxxxxxxx', // Your Stripe Price ID + name: 'Pro Plan', + cost: 29, + type: 'flat', + }, + ], + }, + { + id: 'pro-yearly', + name: 'Pro Yearly', + paymentType: 'recurring', + interval: 'year', + lineItems: [ + { + id: 'price_yyyyyyyyyyyyy', // Your Stripe Price ID + name: 'Pro Plan', + cost: 290, + type: 'flat', + }, + ], + }, + ], + }, + ], +}); +``` + +{% alert type="warning" title="Match IDs exactly" %} +Line item `id` values **must match** the Price IDs in your billing provider (Stripe, Lemon Squeezy, or Paddle). The schema validates this format but cannot verify the IDs exist in your provider account. +{% /alert %} + +## Setting the Billing Provider + +Set the provider via environment variable: + +```bash +NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle +``` + +Also update the database configuration: + +```sql +UPDATE public.config SET billing_provider = 'stripe'; +``` + +The provider determines which API is called when creating checkouts, managing subscriptions, and processing webhooks. + +## Products + +Products represent what you're selling (e.g., "Starter", "Pro", "Enterprise"). Each product can have multiple plans with different billing intervals. + +```tsx +{ + id: 'pro', + name: 'Pro', + description: 'For growing teams', + currency: 'USD', + badge: 'Popular', + highlighted: true, + enableDiscountField: true, + features: [ + 'Unlimited projects', + 'Priority support', + 'Advanced analytics', + ], + plans: [/* ... */], +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique identifier (your choice, not the provider's ID) | +| `name` | Yes | Display name in pricing table | +| `description` | Yes | Short description shown to users | +| `currency` | Yes | ISO currency code (e.g., "USD", "EUR") | +| `plans` | Yes | Array of pricing plans | +| `badge` | No | Badge text (e.g., "Popular", "Best Value") | +| `highlighted` | No | Visually highlight this product | +| `enableDiscountField` | No | Show coupon/discount input at checkout | +| `features` | No | Feature list for pricing table | +| `hidden` | No | Hide from pricing table (for legacy plans) | + +The `id` is your internal identifier. It doesn't need to match anything in Stripe or your payment provider. + +## Plans + +Plans define pricing options within a product. Typically, you'll have monthly and yearly variants. + +```tsx +{ + id: 'pro-monthly', + name: 'Pro Monthly', + paymentType: 'recurring', + interval: 'month', + trialDays: 14, + lineItems: [/* ... */], +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique identifier (your choice) | +| `name` | Yes | Display name | +| `paymentType` | Yes | `'recurring'` or `'one-time'` | +| `interval` | Recurring only | `'month'` or `'year'` | +| `lineItems` | Yes | Array of line items (charges) | +| `trialDays` | No | Free trial period in days | +| `custom` | No | Mark as custom/enterprise plan (see below) | +| `href` | Custom only | Link for custom plans | +| `label` | Custom only | Button label for custom plans | +| `buttonLabel` | No | Custom checkout button text | + +**Plan ID validation:** The schema validates that plan IDs are unique across all products. + +## Line Items + +Line items define how you charge for a plan. Makerkit supports three types: + +| Type | Use Case | Example | +|------|----------|---------| +| `flat` | Fixed recurring price | $29/month | +| `per_seat` | Per-user pricing | $10/seat/month | +| `metered` | Usage-based pricing | $0.01 per API call | + +**Provider limitations:** + +- **Stripe:** Supports multiple line items per plan (mix flat + per-seat + metered) +- **Lemon Squeezy:** One line item per plan only +- **Paddle:** Flat and per-seat only (no metered billing) + +### Flat Subscriptions + +The most common pricing model. A fixed amount charged at each billing interval. + +```tsx +{ + id: 'pro-monthly', + name: 'Pro Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', // Stripe Price ID + name: 'Pro Plan', + cost: 29, + type: 'flat', + }, + ], +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | **Must match** your provider's Price ID | +| `name` | Yes | Display name | +| `cost` | Yes | Price (for UI display only) | +| `type` | Yes | `'flat'` | + +{% alert type="default" title="Cost is for display only" %} +The `cost` field is used for the pricing table UI. The actual charge comes from your billing provider. Make sure they match to avoid confusing users. +{% /alert %} + +### Metered Billing + +Charge based on usage (API calls, storage, tokens). You report usage through the billing API, and the provider calculates charges at the end of each billing period. + +```tsx +{ + id: 'api-monthly', + name: 'API Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + name: 'API Requests', + cost: 0, + type: 'metered', + unit: 'requests', + tiers: [ + { upTo: 1000, cost: 0 }, // First 1000 free + { upTo: 10000, cost: 0.001 }, // $0.001 per request + { upTo: 'unlimited', cost: 0.0005 }, // Volume discount + ], + }, + ], +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | **Must match** your provider's Price ID | +| `name` | Yes | Display name | +| `cost` | Yes | Base cost (usually 0 for metered) | +| `type` | Yes | `'metered'` | +| `unit` | Yes | Unit label (e.g., "requests", "GBs", "tokens") | +| `tiers` | Yes | Array of pricing tiers | + +**Tier structure:** + +```tsx +{ + upTo: number | 'unlimited', // Usage threshold + cost: number, // Cost per unit in this tier +} +``` + +The last tier should always have `upTo: 'unlimited'`. + +{% alert type="warning" title="Provider-specific metered billing" %} +Stripe and Lemon Squeezy handle metered billing differently. See the [metered usage guide](/docs/next-supabase-turbo/billing/metered-usage) for provider-specific implementation details. +{% /alert %} + +### Per-Seat Billing + +Charge based on team size. Makerkit automatically updates seat counts when members are added or removed from a team account. + +```tsx +{ + id: 'team-monthly', + name: 'Team Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + name: 'Team Seats', + cost: 0, + type: 'per_seat', + tiers: [ + { upTo: 3, cost: 0 }, // First 3 seats free + { upTo: 10, cost: 12 }, // $12/seat for 4-10 + { upTo: 'unlimited', cost: 10 }, // Volume discount + ], + }, + ], +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | **Must match** your provider's Price ID | +| `name` | Yes | Display name | +| `cost` | Yes | Base cost (usually 0 for tiered) | +| `type` | Yes | `'per_seat'` | +| `tiers` | Yes | Array of pricing tiers | + +**Common patterns:** + +```tsx +// Free tier + flat per-seat +tiers: [ + { upTo: 5, cost: 0 }, // 5 free seats + { upTo: 'unlimited', cost: 15 }, +] + +// Volume discounts +tiers: [ + { upTo: 10, cost: 20 }, // $20/seat for 1-10 + { upTo: 50, cost: 15 }, // $15/seat for 11-50 + { upTo: 'unlimited', cost: 10 }, +] + +// Flat price (no tiers) +tiers: [ + { upTo: 'unlimited', cost: 10 }, +] +``` + +Makerkit handles seat count updates automatically when: +- A new member joins the team +- A member is removed from the team +- A member invitation is accepted + +[Full per-seat billing guide →](/docs/next-supabase-turbo/billing/per-seat-billing) + +### One-Off Payments + +Single charges for lifetime access, add-ons, or credits. One-off payments are stored in the `orders` table instead of `subscriptions`. + +```tsx +{ + id: 'lifetime', + name: 'Lifetime Access', + paymentType: 'one-time', + // No interval for one-time payments + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + name: 'Lifetime Access', + cost: 299, + type: 'flat', + }, + ], +} +``` + +**Key differences from subscriptions:** + +- `paymentType` must be `'one-time'` +- No `interval` field +- Line items can only be `type: 'flat'` +- Data is stored in `orders` and `order_items` tables + +[Full one-off payments guide →](/docs/next-supabase-turbo/billing/one-off-payments) + +## Combining Line Items (Stripe Only) + +With Stripe, you can combine multiple line items in a single plan. This is useful for hybrid pricing models: + +```tsx +{ + id: 'growth-monthly', + name: 'Growth Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + // Base platform fee + { + id: 'price_base_fee', + name: 'Platform Fee', + cost: 49, + type: 'flat', + }, + // Per-seat charges + { + id: 'price_seats', + name: 'Team Seats', + cost: 0, + type: 'per_seat', + tiers: [ + { upTo: 5, cost: 0 }, + { upTo: 'unlimited', cost: 10 }, + ], + }, + // Usage-based charges + { + id: 'price_api', + name: 'API Calls', + cost: 0, + type: 'metered', + unit: 'calls', + tiers: [ + { upTo: 10000, cost: 0 }, + { upTo: 'unlimited', cost: 0.001 }, + ], + }, + ], +} +``` + +{% alert type="warning" title="Lemon Squeezy and Paddle limitations" %} +Lemon Squeezy and Paddle only support one line item per plan. The schema validation will fail if you add multiple line items with these providers. +{% /alert %} + +## Custom Plans (Enterprise/Contact Us) + +Display a plan in the pricing table without checkout functionality. Useful for enterprise tiers or "Contact Us" options. + +```tsx +{ + id: 'enterprise', + name: 'Enterprise', + paymentType: 'recurring', + interval: 'month', + custom: true, + label: '$5,000+', // or 'common.contactUs' for i18n + href: '/contact', + buttonLabel: 'Contact Sales', + lineItems: [], // Must be empty array +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `custom` | Yes | Set to `true` | +| `label` | Yes | Price label (e.g., "Custom pricing", "$5,000+") | +| `href` | Yes | Link destination (e.g., "/contact", "mailto:sales@...") | +| `buttonLabel` | No | Custom CTA text | +| `lineItems` | Yes | Must be empty array `[]` | + +Custom plans appear in the pricing table but clicking them navigates to `href` instead of opening checkout. + +## Legacy Plans + +When you discontinue a plan but have existing subscribers, use the `hidden` flag to keep the plan in your schema without showing it in the pricing table: + +```tsx +{ + id: 'old-pro', + name: 'Pro (Legacy)', + description: 'This plan is no longer available', + currency: 'USD', + hidden: true, // Won't appear in pricing table + plans: [ + { + id: 'old-pro-monthly', + name: 'Pro Monthly (Legacy)', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_legacy_xxx', + name: 'Pro Plan', + cost: 19, + type: 'flat', + }, + ], + }, + ], +} +``` + +Hidden plans: +- Don't appear in the pricing table +- Still display correctly in the user's billing section +- Allow existing subscribers to continue without issues + +**If you remove a plan entirely:** Makerkit will attempt to fetch plan details from the billing provider. This works for `flat` line items only. For complex plans, keep them in your schema with `hidden: true`. + +## Schema Validation + +The `createBillingSchema` function validates your configuration and throws errors for common mistakes: + +| Validation | Rule | +|------------|------| +| Unique Plan IDs | Plan IDs must be unique across all products | +| Unique Line Item IDs | Line item IDs must be unique across all plans | +| Provider constraints | Lemon Squeezy: max 1 line item per plan | +| Required tiers | Metered and per-seat items require `tiers` array | +| One-time payments | Must have `type: 'flat'` line items only | +| Recurring payments | Must specify `interval: 'month'` or `'year'` | + +## Complete Example + +Here's a full billing schema with multiple products and pricing models: + +```tsx +import { createBillingSchema } from '@kit/billing'; + +const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe'; + +export default createBillingSchema({ + provider, + products: [ + // Free tier (custom plan, no billing) + { + id: 'free', + name: 'Free', + description: 'Get started for free', + currency: 'USD', + plans: [ + { + id: 'free', + name: 'Free', + paymentType: 'recurring', + interval: 'month', + custom: true, + label: '$0', + href: '/auth/sign-up', + buttonLabel: 'Get Started', + lineItems: [], + }, + ], + }, + // Pro tier with monthly/yearly + { + id: 'pro', + name: 'Pro', + description: 'For professionals and small teams', + currency: 'USD', + badge: 'Popular', + highlighted: true, + features: [ + 'Unlimited projects', + 'Priority support', + 'Advanced analytics', + 'Custom integrations', + ], + plans: [ + { + id: 'pro-monthly', + name: 'Pro Monthly', + paymentType: 'recurring', + interval: 'month', + trialDays: 14, + lineItems: [ + { + id: 'price_pro_monthly', + name: 'Pro Plan', + cost: 29, + type: 'flat', + }, + ], + }, + { + id: 'pro-yearly', + name: 'Pro Yearly', + paymentType: 'recurring', + interval: 'year', + trialDays: 14, + lineItems: [ + { + id: 'price_pro_yearly', + name: 'Pro Plan', + cost: 290, + type: 'flat', + }, + ], + }, + ], + }, + // Team tier with per-seat pricing + { + id: 'team', + name: 'Team', + description: 'For growing teams', + currency: 'USD', + features: [ + 'Everything in Pro', + 'Team management', + 'SSO authentication', + 'Audit logs', + ], + plans: [ + { + id: 'team-monthly', + name: 'Team Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_team_monthly', + name: 'Team Seats', + cost: 0, + type: 'per_seat', + tiers: [ + { upTo: 5, cost: 0 }, + { upTo: 'unlimited', cost: 15 }, + ], + }, + ], + }, + ], + }, + // Enterprise tier + { + id: 'enterprise', + name: 'Enterprise', + description: 'For large organizations', + currency: 'USD', + features: [ + 'Everything in Team', + 'Dedicated support', + 'Custom contracts', + 'SLA guarantees', + ], + plans: [ + { + id: 'enterprise', + name: 'Enterprise', + paymentType: 'recurring', + interval: 'month', + custom: true, + label: 'Custom', + href: '/contact', + buttonLabel: 'Contact Sales', + lineItems: [], + }, + ], + }, + ], +}); +``` + +## Related Documentation + +- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and provider comparison +- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe billing +- [Lemon Squeezy Setup](/docs/next-supabase-turbo/billing/lemon-squeezy) - Configure Lemon Squeezy +- [Paddle Setup](/docs/next-supabase-turbo/billing/paddle) - Configure Paddle +- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing +- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based pricing +- [One-Off Payments](/docs/next-supabase-turbo/billing/one-off-payments) - Lifetime deals and add-ons \ No newline at end of file diff --git a/docs/billing/billing-webhooks.mdoc b/docs/billing/billing-webhooks.mdoc new file mode 100644 index 000000000..3c269ac47 --- /dev/null +++ b/docs/billing/billing-webhooks.mdoc @@ -0,0 +1,467 @@ +--- +status: "published" +label: "Handling Webhooks" +title: "Handle Billing Webhooks in Next.js Supabase SaaS Kit" +order: 9 +description: "Learn how to handle billing webhooks from Stripe, Lemon Squeezy, and Paddle. Extend the default webhook handler with custom logic for payment events, subscription changes, and more." +--- + +Webhooks let your billing provider notify your application about events like successful payments, subscription changes, and cancellations. Makerkit handles the core webhook processing, but you can extend it with custom logic. + +## Default Webhook Behavior + +Makerkit's webhook handler automatically: + +1. Verifies the webhook signature +2. Processes the event based on type +3. Updates the database (`subscriptions`, `subscription_items`, `orders`, `order_items`) +4. Returns appropriate HTTP responses + +The webhook endpoint is: `/api/billing/webhook` + +## Extending the Webhook Handler + +Add custom logic by providing callbacks to `handleWebhookEvent`: + +```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %} +import { getBillingEventHandlerService } from '@kit/billing-gateway'; +import { getPlanTypesMap } from '@kit/billing'; +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'; + +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'); + + const supabaseClientProvider = () => getSupabaseServerAdminClient(); + + const service = await getBillingEventHandlerService( + supabaseClientProvider, + provider, + getPlanTypesMap(billingConfig), + ); + + try { + await service.handleWebhookEvent(request, { + // Add your custom callbacks here + onCheckoutSessionCompleted: async (subscription, customerId) => { + logger.info({ customerId }, 'Checkout completed'); + // Send welcome email, provision resources, etc. + }, + + onSubscriptionUpdated: async (subscription) => { + logger.info({ subscriptionId: subscription.id }, 'Subscription updated'); + // Handle plan changes, sync with external systems + }, + + onSubscriptionDeleted: async (subscriptionId) => { + logger.info({ subscriptionId }, 'Subscription deleted'); + // Clean up resources, send cancellation email + }, + + onPaymentSucceeded: async (sessionId) => { + logger.info({ sessionId }, 'Payment succeeded'); + // Send receipt, update analytics + }, + + onPaymentFailed: async (sessionId) => { + logger.info({ sessionId }, 'Payment failed'); + // Send payment failure notification + }, + + onInvoicePaid: async (data) => { + logger.info({ accountId: data.target_account_id }, 'Invoice paid'); + // Recharge credits, send invoice email + }, + }); + + 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 webhook', { status: 500 }); + } + }, + { auth: false } // Webhooks don't require authentication +); +``` + +## Available Callbacks + +### onCheckoutSessionCompleted + +Called when a checkout is successfully completed (new subscription or order). + +```tsx +onCheckoutSessionCompleted: async (subscription, customerId) => { + // subscription: UpsertSubscriptionParams | UpsertOrderParams + // customerId: string + + const accountId = subscription.target_account_id; + + // Send welcome email + await sendEmail({ + to: subscription.target_customer_email, + template: 'welcome', + data: { planName: subscription.line_items[0]?.product_id }, + }); + + // Provision resources + await provisionResources(accountId); + + // Track analytics + await analytics.track('subscription_created', { + accountId, + plan: subscription.line_items[0]?.variant_id, + }); +} +``` + +### onSubscriptionUpdated + +Called when a subscription is updated (plan change, renewal, etc.). + +```tsx +onSubscriptionUpdated: async (subscription) => { + // subscription: UpsertSubscriptionParams + + const accountId = subscription.target_account_id; + const status = subscription.status; + + // Handle plan changes + if (subscription.line_items) { + await syncPlanFeatures(accountId, subscription.line_items); + } + + // Handle status changes + if (status === 'past_due') { + await sendPaymentReminder(accountId); + } + + if (status === 'canceled') { + await scheduleResourceCleanup(accountId); + } +} +``` + +### onSubscriptionDeleted + +Called when a subscription is fully deleted/expired. + +```tsx +onSubscriptionDeleted: async (subscriptionId) => { + // subscriptionId: string + + // Look up the subscription in your database + const { data: subscription } = await supabase + .from('subscriptions') + .select('account_id') + .eq('id', subscriptionId) + .single(); + + if (subscription) { + // Clean up resources + await cleanupResources(subscription.account_id); + + // Send cancellation email + await sendCancellationEmail(subscription.account_id); + + // Update analytics + await analytics.track('subscription_canceled', { + accountId: subscription.account_id, + }); + } +} +``` + +### onPaymentSucceeded + +Called when a payment succeeds (for async payment methods like bank transfers). + +```tsx +onPaymentSucceeded: async (sessionId) => { + // sessionId: string (checkout session ID) + + // Look up the session details + const session = await billingService.retrieveCheckoutSession({ sessionId }); + + // Send receipt + await sendReceipt(session.customer.email); +} +``` + +### onPaymentFailed + +Called when a payment fails. + +```tsx +onPaymentFailed: async (sessionId) => { + // sessionId: string + + // Notify the customer + await sendPaymentFailedEmail(sessionId); + + // Log for monitoring + logger.warn({ sessionId }, 'Payment failed'); +} +``` + +### onInvoicePaid + +Called when an invoice is paid (subscriptions only, useful for credit recharges). + +```tsx +onInvoicePaid: async (data) => { + // data: { + // target_account_id: string, + // target_customer_id: string, + // target_customer_email: string, + // line_items: SubscriptionLineItem[], + // } + + const accountId = data.target_account_id; + const variantId = data.line_items[0]?.variant_id; + + // Recharge credits based on plan + await rechargeCredits(accountId, variantId); + + // Send invoice email + await sendInvoiceEmail(data.target_customer_email); +} +``` + +### onEvent (Catch-All) + +Handle any event not covered by the specific callbacks. + +```tsx +onEvent: async (event) => { + // event: unknown (provider-specific event object) + + // Example: Handle Stripe-specific events + if (event.type === 'invoice.payment_succeeded') { + const invoice = event.data.object as Stripe.Invoice; + // Custom handling + } + + // Example: Handle Lemon Squeezy events + if (event.event_name === 'license_key_created') { + // Handle license key creation + } +} +``` + +## Provider-Specific Events + +### Stripe Events + +| Event | Callback | Description | +|-------|----------|-------------| +| `checkout.session.completed` | `onCheckoutSessionCompleted` | Checkout completed | +| `customer.subscription.created` | `onSubscriptionUpdated` | New subscription | +| `customer.subscription.updated` | `onSubscriptionUpdated` | Subscription changed | +| `customer.subscription.deleted` | `onSubscriptionDeleted` | Subscription ended | +| `checkout.session.async_payment_succeeded` | `onPaymentSucceeded` | Async payment succeeded | +| `checkout.session.async_payment_failed` | `onPaymentFailed` | Async payment failed | +| `invoice.paid` | `onInvoicePaid` | Invoice paid | + +### Lemon Squeezy Events + +| Event | Callback | Description | +|-------|----------|-------------| +| `order_created` | `onCheckoutSessionCompleted` | Order created | +| `subscription_created` | `onCheckoutSessionCompleted` | Subscription created | +| `subscription_updated` | `onSubscriptionUpdated` | Subscription updated | +| `subscription_expired` | `onSubscriptionDeleted` | Subscription expired | + +### Paddle Events + +| Event | Callback | Description | +|-------|----------|-------------| +| `transaction.completed` | `onCheckoutSessionCompleted` | Transaction completed | +| `subscription.activated` | `onSubscriptionUpdated` | Subscription activated | +| `subscription.updated` | `onSubscriptionUpdated` | Subscription updated | +| `subscription.canceled` | `onSubscriptionDeleted` | Subscription canceled | + +## Example: Credit Recharge System + +Here's a complete example of recharging credits when an invoice is paid: + +```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %} +import { getBillingEventHandlerService } from '@kit/billing-gateway'; +import { getPlanTypesMap } from '@kit/billing'; +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'; + +export const POST = enhanceRouteHandler( + async ({ request }) => { + const provider = billingConfig.provider; + const logger = await getLogger(); + const adminClient = getSupabaseServerAdminClient(); + + const service = await getBillingEventHandlerService( + () => adminClient, + provider, + getPlanTypesMap(billingConfig), + ); + + try { + await service.handleWebhookEvent(request, { + onInvoicePaid: async (data) => { + const accountId = data.target_account_id; + const variantId = data.line_items[0]?.variant_id; + + if (!variantId) { + logger.error({ accountId }, 'No variant ID in invoice'); + return; + } + + // Get credits for this plan from your plans table + const { data: plan } = await adminClient + .from('plans') + .select('tokens') + .eq('variant_id', variantId) + .single(); + + if (!plan) { + logger.error({ variantId }, 'Plan not found'); + return; + } + + // Reset credits for the account + const { error } = await adminClient + .from('credits') + .upsert({ + account_id: accountId, + tokens: plan.tokens, + }); + + if (error) { + logger.error({ accountId, error }, 'Failed to update credits'); + throw error; + } + + logger.info({ accountId, tokens: plan.tokens }, 'Credits recharged'); + }, + }); + + return new Response('OK', { status: 200 }); + } catch (error) { + logger.error({ error }, 'Webhook processing failed'); + return new Response('Failed', { status: 500 }); + } + }, + { auth: false } +); +``` + +## Webhook Security + +### Signature Verification + +Makerkit automatically verifies webhook signatures. Never disable this in production. + +The verification uses: +- **Stripe:** `STRIPE_WEBHOOK_SECRET` +- **Lemon Squeezy:** `LEMON_SQUEEZY_SIGNING_SECRET` +- **Paddle:** `PADDLE_WEBHOOK_SECRET_KEY` + +### Idempotency + +Webhooks can be delivered multiple times. Make your handlers idempotent: + +```tsx +onCheckoutSessionCompleted: async (subscription) => { + // Check if already processed + const { data: existing } = await supabase + .from('processed_webhooks') + .select('id') + .eq('subscription_id', subscription.id) + .single(); + + if (existing) { + logger.info({ id: subscription.id }, 'Already processed, skipping'); + return; + } + + // Process the webhook + await processSubscription(subscription); + + // Mark as processed + await supabase + .from('processed_webhooks') + .insert({ subscription_id: subscription.id }); +} +``` + +### Error Handling + +Return appropriate HTTP status codes: + +- **200:** Success (even if you skip processing) +- **500:** Temporary failure (provider will retry) +- **400:** Invalid request (provider won't retry) + +```tsx +try { + await service.handleWebhookEvent(request, callbacks); + return new Response('OK', { status: 200 }); +} catch (error) { + if (isTemporaryError(error)) { + // Provider will retry + return new Response('Temporary failure', { status: 500 }); + } + // Don't retry invalid requests + return new Response('Invalid request', { status: 400 }); +} +``` + +## Debugging Webhooks + +### Local Development + +Use the Stripe CLI or ngrok to test webhooks locally: + +```bash +# Stripe CLI +stripe listen --forward-to localhost:3000/api/billing/webhook + +# ngrok (for Lemon Squeezy/Paddle) +ngrok http 3000 +``` + +### Logging + +Add detailed logging to track webhook processing: + +```tsx +const logger = await getLogger(); + +logger.info({ eventType: event.type }, 'Processing webhook'); +logger.debug({ payload: event }, 'Webhook payload'); +logger.error({ error }, 'Webhook failed'); +``` + +### Webhook Logs in Provider Dashboards + +Check webhook delivery status: +- **Stripe:** Dashboard → Developers → Webhooks → Recent events +- **Lemon Squeezy:** Settings → Webhooks → View logs +- **Paddle:** Developer Tools → Notifications → View logs + +## Related Documentation + +- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts +- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe webhooks +- [Lemon Squeezy Setup](/docs/next-supabase-turbo/billing/lemon-squeezy) - Configure LS webhooks +- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Recharge credits on payment diff --git a/docs/billing/credit-based-billing.mdoc b/docs/billing/credit-based-billing.mdoc new file mode 100644 index 000000000..ab59b2a0c --- /dev/null +++ b/docs/billing/credit-based-billing.mdoc @@ -0,0 +1,487 @@ +--- +status: "published" +label: 'Credits Based Billing' +title: 'Implement Credit-Based Billing for AI SaaS Apps' +order: 7 +description: 'Build a credit/token system for your AI SaaS. Learn how to add credits tables, consumption tracking, and automatic recharge on subscription renewal in Makerkit.' +--- + +Credit-based billing charges users based on tokens or credits consumed rather than time. This model is common in AI SaaS applications where users pay for API calls, generated content, or compute time. + +Makerkit doesn't include credit-based billing out of the box, but you can implement it using subscriptions plus custom database tables. This guide shows you how. + +## Architecture Overview + +``` +User subscribes → Credits allocated → User consumes credits → Invoice paid → Credits recharged +``` + +Components: +1. **`plans` table**: Maps subscription variants to credit amounts +2. **`credits` table**: Tracks available credits per account +3. **Database functions**: Check and consume credits +4. **Webhook handler**: Recharge credits on subscription renewal + +## Step 1: Create the Plans Table + +Store the credit allocation for each plan variant: + +```sql +CREATE TABLE public.plans ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + variant_id TEXT NOT NULL UNIQUE, + tokens INTEGER NOT NULL +); + +ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY; + +-- Allow authenticated users to read plans +CREATE POLICY read_plans ON public.plans + FOR SELECT TO authenticated + USING (true); + +-- Insert your plans +INSERT INTO public.plans (name, variant_id, tokens) VALUES + ('Starter', 'price_starter_monthly', 1000), + ('Pro', 'price_pro_monthly', 10000), + ('Enterprise', 'price_enterprise_monthly', 100000); +``` + +The `variant_id` should match the line item ID in your billing schema (e.g., Stripe Price ID). + +## Step 2: Create the Credits Table + +Track available credits per account: + +```sql +CREATE TABLE public.credits ( + account_id UUID PRIMARY KEY REFERENCES public.accounts(id) ON DELETE CASCADE, + tokens INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE public.credits ENABLE ROW LEVEL SECURITY; + +-- Users can read their own credits +CREATE POLICY read_credits ON public.credits + FOR SELECT TO authenticated + USING (account_id = (SELECT auth.uid())); + +-- Only service role can modify credits +-- No INSERT/UPDATE/DELETE policies for authenticated users +``` + +{% alert type="warning" title="Security: Restrict credit modifications" %} +Users should only read their credits. All modifications should go through the service role (admin client) to prevent manipulation. +{% /alert %} + +## Step 3: Create Helper Functions + +### Check if account has enough credits + +```sql +CREATE OR REPLACE FUNCTION public.has_credits( + p_account_id UUID, + p_tokens INTEGER +) +RETURNS BOOLEAN +SET search_path = '' +AS $$ +BEGIN + RETURN ( + SELECT tokens >= p_tokens + FROM public.credits + WHERE account_id = p_account_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION public.has_credits TO authenticated, service_role; +``` + +### Consume credits + +```sql +CREATE OR REPLACE FUNCTION public.consume_credits( + p_account_id UUID, + p_tokens INTEGER +) +RETURNS BOOLEAN +SET search_path = '' +AS $$ +DECLARE + v_current_tokens INTEGER; +BEGIN + -- Get current balance with row lock + SELECT tokens INTO v_current_tokens + FROM public.credits + WHERE account_id = p_account_id + FOR UPDATE; + + -- Check if enough credits + IF v_current_tokens IS NULL OR v_current_tokens < p_tokens THEN + RETURN FALSE; + END IF; + + -- Deduct credits + UPDATE public.credits + SET tokens = tokens - p_tokens, + updated_at = NOW() + WHERE account_id = p_account_id; + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION public.consume_credits TO service_role; +``` + +### Add credits (for recharges) + +```sql +CREATE OR REPLACE FUNCTION public.add_credits( + p_account_id UUID, + p_tokens INTEGER +) +RETURNS VOID +SET search_path = '' +AS $$ +BEGIN + INSERT INTO public.credits (account_id, tokens) + VALUES (p_account_id, p_tokens) + ON CONFLICT (account_id) + DO UPDATE SET + tokens = public.credits.tokens + p_tokens, + updated_at = NOW(); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION public.add_credits TO service_role; +``` + +### Reset credits (for subscription renewal) + +```sql +CREATE OR REPLACE FUNCTION public.reset_credits( + p_account_id UUID, + p_tokens INTEGER +) +RETURNS VOID +SET search_path = '' +AS $$ +BEGIN + INSERT INTO public.credits (account_id, tokens) + VALUES (p_account_id, p_tokens) + ON CONFLICT (account_id) + DO UPDATE SET + tokens = p_tokens, + updated_at = NOW(); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION public.reset_credits TO service_role; +``` + +## Step 4: Consume Credits in Your Application + +When a user performs an action that costs credits: + +```tsx +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +export async function consumeApiCredits( + accountId: string, + tokensRequired: number +) { + const adminClient = getSupabaseServerAdminClient(); + + // Consume credits atomically + const { data: success, error } = await adminClient.rpc('consume_credits', { + p_account_id: accountId, + p_tokens: tokensRequired, + }); + + if (error) { + throw new Error(`Failed to consume credits: ${error.message}`); + } + + if (!success) { + throw new Error('Insufficient credits'); + } + + return true; +} +``` + +### Example: AI API Route + +```tsx +// app/api/ai/generate/route.ts +import { NextResponse } from 'next/server'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +const TOKENS_PER_REQUEST = 10; + +export async function POST(request: Request) { + const client = getSupabaseServerClient(); + const adminClient = getSupabaseServerAdminClient(); + + // Get current user's account + const { data: { user } } = await client.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check credits before processing + const { data: hasCredits } = await client.rpc('has_credits', { + p_account_id: user.id, + p_tokens: TOKENS_PER_REQUEST, + }); + + if (!hasCredits) { + return NextResponse.json( + { error: 'Insufficient credits', code: 'INSUFFICIENT_CREDITS' }, + { status: 402 } + ); + } + + try { + // Call AI API + const { prompt } = await request.json(); + const result = await callAIService(prompt); + + // Consume credits after successful response + await adminClient.rpc('consume_credits', { + p_account_id: user.id, + p_tokens: TOKENS_PER_REQUEST, + }); + + return NextResponse.json({ result }); + } catch (error) { + return NextResponse.json( + { error: 'Generation failed' }, + { status: 500 } + ); + } +} +``` + +## Step 5: Display Credits in UI + +Create a component to show remaining credits: + +```tsx +// components/credits-display.tsx +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +export function CreditsDisplay({ accountId }: { accountId: string }) { + const client = useSupabase(); + + const { data: credits, isLoading } = useQuery({ + queryKey: ['credits', accountId], + queryFn: async () => { + const { data, error } = await client + .from('credits') + .select('tokens') + .eq('account_id', accountId) + .single(); + + if (error) throw error; + return data?.tokens ?? 0; + }, + }); + + if (isLoading) return <span>Loading...</span>; + + return ( + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground">Credits:</span> + <span className="font-medium">{credits?.toLocaleString()}</span> + </div> + ); +} +``` + +## Step 6: Recharge Credits on Subscription Renewal + +Extend the webhook handler to recharge credits when an invoice is paid: + +```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %} +import { getBillingEventHandlerService } from '@kit/billing-gateway'; +import { getPlanTypesMap } from '@kit/billing'; +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'; + +export const POST = enhanceRouteHandler( + async ({ request }) => { + const provider = billingConfig.provider; + const logger = await getLogger(); + const adminClient = getSupabaseServerAdminClient(); + + const service = await getBillingEventHandlerService( + () => adminClient, + provider, + getPlanTypesMap(billingConfig), + ); + + try { + await service.handleWebhookEvent(request, { + onInvoicePaid: async (data) => { + const accountId = data.target_account_id; + const variantId = data.line_items[0]?.variant_id; + + if (!variantId) { + logger.warn({ accountId }, 'No variant ID in invoice'); + return; + } + + // Get token allocation for this plan + const { data: plan, error: planError } = await adminClient + .from('plans') + .select('tokens') + .eq('variant_id', variantId) + .single(); + + if (planError || !plan) { + logger.error({ variantId, planError }, 'Plan not found'); + return; + } + + // Reset credits to plan allocation + const { error: creditError } = await adminClient.rpc('reset_credits', { + p_account_id: accountId, + p_tokens: plan.tokens, + }); + + if (creditError) { + logger.error({ accountId, creditError }, 'Failed to reset credits'); + throw creditError; + } + + logger.info( + { accountId, tokens: plan.tokens }, + 'Credits recharged on invoice payment' + ); + }, + + onCheckoutSessionCompleted: async (subscription) => { + // Also allocate credits on initial subscription + const accountId = subscription.target_account_id; + const variantId = subscription.line_items[0]?.variant_id; + + if (!variantId) return; + + const { data: plan } = await adminClient + .from('plans') + .select('tokens') + .eq('variant_id', variantId) + .single(); + + if (plan) { + await adminClient.rpc('reset_credits', { + p_account_id: accountId, + p_tokens: plan.tokens, + }); + + logger.info( + { accountId, tokens: plan.tokens }, + 'Initial credits allocated' + ); + } + }, + }); + + return new Response('OK', { status: 200 }); + } catch (error) { + logger.error({ error }, 'Webhook failed'); + return new Response('Failed', { status: 500 }); + } + }, + { auth: false } +); +``` + +## Step 7: Use Credits in RLS Policies (Optional) + +Gate features based on credit balance: + +```sql +-- Only allow creating tasks if user has credits +CREATE POLICY tasks_insert_with_credits ON public.tasks + FOR INSERT TO authenticated + WITH CHECK ( + public.has_credits((SELECT auth.uid()), 1) + ); + +-- Only allow API calls if user has credits +CREATE POLICY api_calls_with_credits ON public.api_logs + FOR INSERT TO authenticated + WITH CHECK ( + public.has_credits(account_id, 1) + ); +``` + +## Testing + +1. Create a subscription in test mode +2. Verify initial credits are allocated +3. Consume some credits via your API +4. Trigger a subscription renewal (Stripe: `stripe trigger invoice.paid`) +5. Verify credits are recharged + +## Common Patterns + +### Rollover Credits + +To allow unused credits to roll over: + +```sql +-- In onInvoicePaid, add instead of reset: +await adminClient.rpc('add_credits', { + p_account_id: accountId, + p_tokens: plan.tokens, +}); +``` + +### Credit Expiration + +Add an expiration date to credits: + +```sql +ALTER TABLE public.credits ADD COLUMN expires_at TIMESTAMPTZ; + +-- Check expiration in has_credits function +CREATE OR REPLACE FUNCTION public.has_credits(...) +-- Add: AND (expires_at IS NULL OR expires_at > NOW()) +``` + +### Usage Tracking + +Track credit consumption for analytics: + +```sql +CREATE TABLE public.credit_transactions ( + id SERIAL PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + amount INTEGER NOT NULL, + type TEXT NOT NULL, -- 'consume', 'recharge', 'bonus' + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +## Related Documentation + +- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Billing architecture +- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Webhook event handling +- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Alternative usage-based billing +- [Database Functions](/docs/next-supabase-turbo/development/database-functions) - Creating Postgres functions diff --git a/docs/billing/custom-integration.mdoc b/docs/billing/custom-integration.mdoc new file mode 100644 index 000000000..e097f228d --- /dev/null +++ b/docs/billing/custom-integration.mdoc @@ -0,0 +1,638 @@ +--- +status: "published" +label: "Custom Integration" +title: "How to create a custom billing integration in Makerkit" +order: 11 +description: "Learn how to create a custom billing integration in Makerkit" +--- + +This guide explains how to create billing integration plugins for the Makerkit SaaS platform to allow you to use a custom billing provider. + +{% sequence title="How to create a custom billing integration in Makerkit" description="Learn how to create a custom billing integration in Makerkit" %} + +[Architecture Overview](#architecture-overview) + +[Package Structure](#package-structure) + +[Core Interface Implementation](#core-interface-implementation) + +[Environment Configuration](#environment-configuration) + +[Billing Strategy Service](#billing-strategy-service) + +[Webhook Handler Service](#webhook-handler-service) + +[Client-Side Components](#client-side-components) + +[Registration and Integration](#registration-and-integration) + +[Testing Strategy](#testing-strategy) + +[Security Best Practices](#security-best-practices) + +[Example Implementation](#example-implementation) +{% /sequence %} + +## Architecture Overview + +The Makerkit billing system uses a plugin-based architecture that allows multiple billing providers to coexist. The system consists of: + +### Core Components + +1. **Billing Strategy Provider Service** - Abstract interface for billing operations +2. **Billing Webhook Handler Service** - Abstract interface for webhook processing +3. **Registry System** - Dynamic loading and management of providers +4. **Schema Validation** - Type-safe configuration and data validation + +### Provider Structure + +Each billing provider is implemented as a separate package under `packages/{provider-name}/` with: + +- **Server-side services** - Billing operations and webhook handling +- **Client-side components** - Checkout flows and UI integration +- **Configuration schemas** - Environment variable validation +- **SDK abstractions** - Provider-specific API integrations + +### Data Flow + +``` +Client Request → Registry → Provider Service → External API → Webhook → Handler → Database +``` + +## Creating a package + +You can create a new package for your billing provider by running the following command: + +```bash +pnpm turbo gen package +``` + +This will create a new package in the packages directory, ready to use. You can move this anywhere in the `packages` directory, but we recommend keeping it in the `packages/billing` directory. + +## Package Structure + +Once we finalize the package structure, your structure should look like this: + +``` +packages/{provider-name}/ +├── package.json +├── tsconfig.json +├── index.ts +└── src/ + ├── index.ts + ├── components/ + │ ├── index.ts + │ └── {provider}-checkout.tsx + ├── constants/ + │ └── {provider}-events.ts + ├── schema/ + │ ├── {provider}-client-env.schema.ts + │ └── {provider}-server-env.schema.ts + └── services/ + ├── {provider}-billing-strategy.service.ts + ├── {provider}-webhook-handler.service.ts + ├── {provider}-sdk.ts + └── create-{provider}-billing-portal-session.ts +``` + +### package.json Template + +```json +{ + "name": "@kit/{provider-name}", + "private": true, + "version": "0.1.0", + "exports": { + ".": "./src/index.ts", + "./components": "./src/components/index.ts" + }, + "typesVersions": { + "*": { + "*": ["src/*"] + } + }, + "dependencies": { + "{provider-sdk}": "^x.x.x" + }, + "devDependencies": { + "@kit/billing": "workspace:*", + "@kit/eslint-config": "workspace:*", + "@kit/prettier-config": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:*", + "@types/react": "19.1.13", + "next": "16.0.0", + "react": "19.1.1", + "zod": "^3.25.74" + } +} +``` + +## Core Interface Implementation + +### BillingStrategyProviderService + +This abstract class defines the contract for all billing operations: + +```typescript {% title="packages/{provider}/src/services/{provider}-billing-strategy.service.ts" %} +import { BillingStrategyProviderService } from '@kit/billing'; + +export class YourProviderBillingStrategyService + implements BillingStrategyProviderService +{ + private readonly namespace = 'billing.{provider}'; + + async createCheckoutSession(params) { + // Implementation + } + + async createBillingPortalSession(params) { + // Implementation + } + + async cancelSubscription(params) { + // Implementation + } + + async retrieveCheckoutSession(params) { + // Implementation + } + + async reportUsage(params) { + // Implementation (if supported) + } + + async queryUsage(params) { + // Implementation (if supported) + } + + async updateSubscriptionItem(params) { + // Implementation + } + + async getPlanById(planId: string) { + // Implementation + } + + async getSubscription(subscriptionId: string) { + // Implementation + } +} +``` + +### BillingWebhookHandlerService + +This abstract class handles webhook events from the billing provider: + +```typescript {% title="packages/{provider}/src/services/{provider}-webhook-handler.service.ts" %} +import { BillingWebhookHandlerService } from '@kit/billing'; + +export class YourProviderWebhookHandlerService + implements BillingWebhookHandlerService +{ + private readonly provider = '{provider}' as const; + private readonly namespace = 'billing.{provider}'; + + async verifyWebhookSignature(request: Request) { + // Verify signature using provider's SDK + // Throw error if invalid + } + + async handleWebhookEvent(event: unknown, params) { + // Route events to appropriate handlers + switch (event.type) { + case 'subscription.created': + return this.handleSubscriptionCreated(event, params); + + case 'subscription.updated': + return this.handleSubscriptionUpdated(event, params); + // ... other events + } + } +} +``` + +## Environment Configuration + +### Server Environment Schema + +Create schemas for server-side configuration: + +```typescript {% title="packages/{provider}/src/schema/{provider}-server-env.schema.ts" %} +// src/schema/{provider}-server-env.schema.ts +import * as z from 'zod'; + +export const YourProviderServerEnvSchema = z.object({ + apiKey: z.string({ + description: '{Provider} API key for server-side operations', + required_error: '{PROVIDER}_API_KEY is required', + }), + webhooksSecret: z.string({ + description: '{Provider} webhook secret for verifying signatures', + required_error: '{PROVIDER}_WEBHOOK_SECRET is required', + }), +}); + +export type YourProviderServerEnv = z.infer<typeof YourProviderServerEnvSchema>; +``` + +### Client Environment Schema + +Create schemas for client-side configuration: + +```typescript {% title="packages/{provider}/src/schema/{provider}-client-env.schema.ts" %} +// src/schema/{provider}-client-env.schema.ts +import * as z from 'zod'; + +export const YourProviderClientEnvSchema = z.object({ + publicKey: z.string({ + description: '{Provider} public key for client-side operations', + required_error: 'NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY is required', + }), +}); + +export type YourProviderClientEnv = z.infer<typeof YourProviderClientEnvSchema>; +``` + +## Billing Strategy Service + +### Implementation Example + +{% alert type="warning" title="This is an abstract example" %} +The "client" class in the example below is not a real class, it's just an example of how to implement the BillingStrategyProviderService interface. You should refer to the SDK of your billing provider to implement the actual methods. +{% /alert %} + +Here's a detailed implementation pattern based on the Paddle service: + +```typescript +import 'server-only'; +import * as z from 'zod'; +import { BillingStrategyProviderService } from '@kit/billing'; +import { getLogger } from '@kit/shared/logger'; +import { createYourProviderClient } from './your-provider-sdk'; + +export class YourProviderBillingStrategyService + implements BillingStrategyProviderService +{ + private readonly namespace = 'billing.{provider}'; + + async createCheckoutSession( + params: z.infer<typeof CreateBillingCheckoutSchema>, + ) { + const logger = await getLogger(); + const client = await createYourProviderClient(); + + const ctx = { + name: this.namespace, + customerId: params.customerId, + accountId: params.accountId, + }; + + logger.info(ctx, 'Creating checkout session...'); + + try { + const response = await client.checkout.create({ + customer: { + id: params.customerId, + email: params.customerEmail, + }, + lineItems: params.plan.lineItems.map((item) => ({ + priceId: item.id, + quantity: 1, + })), + successUrl: params.returnUrl, + metadata: { + accountId: params.accountId, + }, + }); + + logger.info(ctx, 'Checkout session created successfully'); + + return { + checkoutToken: response.id, + }; + } catch (error) { + logger.error({ ...ctx, error }, 'Failed to create checkout session'); + throw new Error('Failed to create checkout session'); + } + } + + async cancelSubscription( + params: z.infer<typeof CancelSubscriptionParamsSchema>, + ) { + const logger = await getLogger(); + const client = await createYourProviderClient(); + + const ctx = { + name: this.namespace, + subscriptionId: params.subscriptionId, + }; + + logger.info(ctx, 'Cancelling subscription...'); + + try { + await client.subscriptions.cancel(params.subscriptionId, { + immediate: params.invoiceNow ?? true, + }); + + logger.info(ctx, 'Subscription cancelled successfully'); + + return { success: true }; + } catch (error) { + logger.error({ ...ctx, error }, 'Failed to cancel subscription'); + throw new Error('Failed to cancel subscription'); + } + } + + // Implement other required methods... +} +``` + +### SDK Client Wrapper + +Create a reusable SDK client: + +```typescript +// src/services/{provider}-sdk.ts +import 'server-only'; +import { YourProviderServerEnvSchema } from '../schema/{provider}-server-env.schema'; + +export async function createYourProviderClient() { + // parse the environment variables + const config = YourProviderServerEnvSchema.parse({ + apiKey: process.env.{PROVIDER}_API_KEY, + webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET, + }); + + return new YourProviderSDK({ + apiKey: config.apiKey, + }); +} +``` + +## Webhook Handler Service + +### Implementation Pattern + +```typescript +import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing'; +import { getLogger } from '@kit/shared/logger'; +import { createYourProviderClient } from './your-provider-sdk'; + +export class YourProviderWebhookHandlerService + implements BillingWebhookHandlerService +{ + constructor(private readonly planTypesMap: PlanTypeMap) {} + + private readonly provider = '{provider}' as const; + private readonly namespace = 'billing.{provider}'; + + async verifyWebhookSignature(request: Request) { + const body = await request.clone().text(); + const signature = request.headers.get('{provider}-signature'); + + if (!signature) { + throw new Error('Missing {provider} signature'); + } + + const { webhooksSecret } = YourProviderServerEnvSchema.parse({ + apiKey: process.env.{PROVIDER}_API_KEY, + webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET, + environment: process.env.{PROVIDER}_ENVIRONMENT || 'sandbox', + }); + + const client = await createYourProviderClient(); + + try { + const eventData = await client.webhooks.verify(body, signature, webhooksSecret); + + if (!eventData) { + throw new Error('Invalid signature'); + } + + return eventData; + } catch (error) { + throw new Error(`Webhook signature verification failed: ${error}`); + } + } + + async handleWebhookEvent(event: unknown, params) { + const logger = await getLogger(); + + switch (event.type) { + case 'checkout.session.completed': { + return this.handleCheckoutCompleted(event, params.onCheckoutSessionCompleted); + } + + case 'customer.subscription.created': + case 'customer.subscription.updated': { + return this.handleSubscriptionUpdated(event, params.onSubscriptionUpdated); + } + + case 'customer.subscription.deleted': { + return this.handleSubscriptionDeleted(event, params.onSubscriptionDeleted); + } + + default: { + logger.info( + { + name: this.namespace, + eventType: event.type, + }, + 'Unhandled webhook event type', + ); + + if (params.onEvent) { + await params.onEvent(event); + } + } + } + } + + private async handleCheckoutCompleted(event, onCheckoutSessionCompleted) { + // Extract subscription/order data from event + // Transform to standard format + // Call onCheckoutSessionCompleted with normalized data + } + + // Implement other event handlers... +} +``` + +## Client-Side Components + +### Checkout Component + +Create a React component for the checkout flow: + +```typescript +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { YourProviderClientEnvSchema } from '../schema/{provider}-client-env.schema'; + +interface YourProviderCheckoutProps { + onClose?: () => void; + checkoutToken: string; +} + +const config = YourProviderClientEnvSchema.parse({ + publicKey: process.env.NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY, + environment: process.env.NEXT_PUBLIC_{PROVIDER}_ENVIRONMENT || 'sandbox', +}); + +export function YourProviderCheckout({ + onClose, + checkoutToken, +}: YourProviderCheckoutProps) { + const router = useRouter(); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + async function initializeCheckout() { + try { + // Initialize provider's JavaScript SDK + const { YourProviderSDK } = await import('{provider}-js-sdk'); + + const sdk = new YourProviderSDK({ + publicKey: config.publicKey, + environment: config.environment, + }); + + // Open checkout + await sdk.redirectToCheckout({ + sessionId: checkoutToken, + successUrl: window.location.href, + cancelUrl: window.location.href, + }); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Checkout failed'; + setError(errorMessage); + onClose?.(); + } + } + + void initializeCheckout(); + }, [checkoutToken, onClose]); + + if (error) { + throw new Error(error); + } + + return null; // Provider handles the UI +} +``` + +## Registration and Integration + +### Register Billing Strategy + +Add your provider to the billing strategy registry: + +```typescript +// packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts + +// Register {Provider} billing strategy +billingStrategyRegistry.register('{provider}', async () => { + const { YourProviderBillingStrategyService } = await import('@kit/{provider}'); + return new YourProviderBillingStrategyService(); +}); +``` + +### Register Webhook Handler + +Add your provider to the webhook handler factory: + +```typescript +// packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts + +// Register {Provider} webhook handler +billingWebhookHandlerRegistry.register('{provider}', async () => { + const { YourProviderWebhookHandlerService } = await import('@kit/{provider}'); + return new YourProviderWebhookHandlerService(planTypesMap); +}); +``` + +### Update Package Exports + +Export your services from the main index file: + +```typescript +// packages/{provider}/src/index.ts +export { YourProviderBillingStrategyService } from './services/{provider}-billing-strategy.service'; +export { YourProviderWebhookHandlerService } from './services/{provider}-webhook-handler.service'; +export * from './components'; +export * from './constants/{provider}-events'; +export { + YourProviderClientEnvSchema, + type YourProviderClientEnv, +} from './schema/{provider}-client-env.schema'; +export { + YourProviderServerEnvSchema, + type YourProviderServerEnv, +} from './schema/{provider}-server-env.schema'; +``` + +## Security Best Practices + +### Environment Variables + +1. **Never expose secrets in client-side code** +2. **Use different credentials for sandbox and production** +3. **Validate all environment variables with Zod schemas** +4. **Store secrets securely (e.g., in environment variables or secret managers)** + +### Webhook Security + +1. **Always verify webhook signatures** +2. **Use HTTPS endpoints for webhooks** +3. **Log security events for monitoring** + +### Data Handling + +1. **Validate all incoming data with Zod schemas** +2. **Sanitize user inputs** +3. **Never log sensitive information (API keys, customer data)** +4. **Use structured logging with appropriate log levels** + +### Error Handling + +1. **Don't expose internal errors to users** +2. **Log errors with sufficient context for debugging** +3. **Implement proper error boundaries in React components** +4. **Handle rate limiting and API errors gracefully** + +## Example Implementation + +For a complete reference implementation, see the Stripe integration at `packages/billing/stripe/`. Key files to study: + +- `src/services/stripe-billing-strategy.service.ts` - Complete billing strategy implementation +- `src/services/stripe-webhook-handler.service.ts` - Webhook handling patterns +- `src/components/stripe-embedded-checkout.tsx` - Client-side checkout component +- `src/schema/` - Environment configuration schemas + +Also take a look at the Lemon Squeezy integration at `packages/billing/lemon-squeezy/` or the Paddle integration at `packages/plugins/paddle/` (in the plugins repository) + +## Conclusion + +**Important:** Different providers have different APIs, so the implementation will be different for each provider. + +Following this guide, you should be able to create a robust billing integration that: + +- Implements all required interfaces correctly +- Handles errors gracefully and securely +- Provides a good user experience +- Follows established patterns and best practices +- Integrates seamlessly with the existing billing system + +Remember to: + +1. Test thoroughly with the provider's sandbox environment +2. Follow security best practices throughout development +3. Document any provider-specific requirements or limitations +4. Consider edge cases and error scenarios +5. Validate your implementation against the existing test suite \ No newline at end of file diff --git a/docs/billing/lemon-squeezy.mdoc b/docs/billing/lemon-squeezy.mdoc new file mode 100644 index 000000000..e3572062d --- /dev/null +++ b/docs/billing/lemon-squeezy.mdoc @@ -0,0 +1,266 @@ +--- +status: "published" +label: "Lemon Squeezy" +title: "Configure Lemon Squeezy Billing for Your Next.js SaaS" +order: 3 +description: "Complete guide to setting up Lemon Squeezy payments in Makerkit. Lemon Squeezy is a Merchant of Record that handles global tax compliance, billing, and payments for your SaaS." +--- + +Lemon Squeezy is a Merchant of Record (MoR), meaning they handle all billing complexity for you: VAT, sales tax, invoicing, and compliance across 100+ countries. You receive payouts minus their fees. + +## Why Choose Lemon Squeezy? + +**Pros:** +- Automatic global tax compliance (VAT, GST, sales tax) +- No need to register for tax collection in different countries +- Simpler setup than Stripe for international sales +- Built-in license key generation (great for desktop apps) +- Lower complexity for solo founders + +**Cons:** +- One line item per plan (no mixing flat + metered + per-seat) +- Less flexibility than Stripe +- Higher fees than Stripe in some regions + +## Prerequisites + +1. Create a [Lemon Squeezy account](https://lemonsqueezy.com) +2. Create a Store in your Lemon Squeezy dashboard +3. Create Products and Variants for your pricing plans +4. Set up a webhook endpoint + +## Step 1: Environment Variables + +Add these variables to your `.env.local`: + +```bash +LEMON_SQUEEZY_SECRET_KEY=your_api_key_here +LEMON_SQUEEZY_SIGNING_SECRET=your_webhook_signing_secret +LEMON_SQUEEZY_STORE_ID=your_store_id +``` + +| Variable | Description | Where to Find | +|----------|-------------|---------------| +| `LEMON_SQUEEZY_SECRET_KEY` | API key for server-side calls | Settings → API | +| `LEMON_SQUEEZY_SIGNING_SECRET` | Webhook signature verification | Settings → Webhooks | +| `LEMON_SQUEEZY_STORE_ID` | Your store's numeric ID | Settings → Stores | + +{% alert type="error" title="Keep secrets secure" %} +Add these to `.env.local` only. Never commit them to your repository or add them to `.env`. +{% /alert %} + +## Step 2: Configure Billing Provider + +Set Lemon Squeezy as your billing provider in the environment: + +```bash +NEXT_PUBLIC_BILLING_PROVIDER=lemon-squeezy +``` + +And update the database: + +```sql +UPDATE public.config SET billing_provider = 'lemon-squeezy'; +``` + +## Step 3: Create Products in Lemon Squeezy + +1. Go to your Lemon Squeezy Dashboard → Products +2. Click **New Product** +3. Configure your product: + - **Name**: "Pro Plan", "Starter Plan", etc. + - **Pricing**: Choose subscription or one-time + - **Variant**: Create variants for different billing intervals + +4. Copy the **Variant ID** (not Product ID) for your billing schema + +The Variant ID looks like `123456` (numeric). This goes in your line item's `id` field. + +## Step 4: Update Billing Schema + +Lemon Squeezy has a key limitation: **one line item per plan**. You cannot mix flat, per-seat, and metered billing in a single plan. + +```tsx +import { createBillingSchema } from '@kit/billing'; + +export default createBillingSchema({ + provider: 'lemon-squeezy', + products: [ + { + id: 'pro', + name: 'Pro', + description: 'For professionals', + currency: 'USD', + plans: [ + { + id: 'pro-monthly', + name: 'Pro Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: '123456', // Lemon Squeezy Variant ID + name: 'Pro Plan', + cost: 29, + type: 'flat', + }, + // Cannot add more line items with Lemon Squeezy! + ], + }, + ], + }, + ], +}); +``` + +{% alert type="warning" title="Single line item only" %} +The schema validation will fail if you add multiple line items with Lemon Squeezy. This is a platform limitation. +{% /alert %} + +## Step 5: Configure Webhooks + +### Local Development + +Lemon Squeezy requires a public URL for webhooks. Use a tunneling service like ngrok: + +```bash +# Install ngrok +npm install -g ngrok + +# Expose your local server +ngrok http 3000 +``` + +Copy the ngrok URL (e.g., `https://abc123.ngrok.io`). + +### Create Webhook in Lemon Squeezy + +1. Go to Settings → Webhooks +2. Click **Add Webhook** +3. Configure: + - **URL**: `https://your-ngrok-url.ngrok.io/api/billing/webhook` (dev) or `https://yourdomain.com/api/billing/webhook` (prod) + - **Secret**: Generate a secure secret and save it as `LEMON_SQUEEZY_SIGNING_SECRET` + +4. Select these events: + - `order_created` + - `subscription_created` + - `subscription_updated` + - `subscription_expired` + +5. Click **Save** + +### Production Webhooks + +For production, replace the ngrok URL with your actual domain: + +``` +https://yourdomain.com/api/billing/webhook +``` + +## Metered Usage with Lemon Squeezy + +Lemon Squeezy handles metered billing differently than Stripe. Usage applies to the entire subscription, not individual line items. + +### Setup Fee + Metered Usage + +Use the `setupFee` property for a flat base charge plus usage-based pricing: + +```tsx +{ + id: 'api-monthly', + name: 'API Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: '123456', + name: 'API Access', + cost: 0, + type: 'metered', + unit: 'requests', + setupFee: 10, // $10 base fee + tiers: [ + { upTo: 1000, cost: 0 }, + { upTo: 'unlimited', cost: 0.001 }, + ], + }, + ], +} +``` + +The setup fee is charged once when the subscription is created. + +### Reporting Usage + +Report usage using the billing API: + +```tsx +import { createBillingGatewayService } from '@kit/billing-gateway'; + +async function reportUsage(subscriptionItemId: string, quantity: number) { + const service = createBillingGatewayService('lemon-squeezy'); + + return service.reportUsage({ + id: subscriptionItemId, // From subscription_items table + usage: { + quantity, + action: 'increment', + }, + }); +} +``` + +See the [metered usage guide](/docs/next-supabase-turbo/billing/metered-usage) for complete implementation details. + +## Testing + +### Test Mode + +Lemon Squeezy has a test mode. Enable it in your dashboard under Settings → Test Mode. + +Test mode uses separate products and variants, so create test versions of your products. + +### Test Cards + +In test mode, use these card numbers: +- **Success**: `4242 4242 4242 4242` +- **Decline**: `4000 0000 0000 0002` + +Any future expiry date and any 3-digit CVC will work. + +## Common Issues + +### Webhook signature verification failed + +1. Check that `LEMON_SQUEEZY_SIGNING_SECRET` matches the secret in your Lemon Squeezy webhook settings +2. Ensure the raw request body is used for verification (not parsed JSON) +3. Verify the webhook URL is correct + +### Subscription not created + +1. Check webhook logs in Lemon Squeezy dashboard +2. Verify the `order_created` event is enabled +3. Check your application logs for errors + +### Multiple line items error + +Lemon Squeezy only supports one line item per plan. Restructure your pricing to use a single line item, or use Stripe for more complex pricing models. + +## Testing Checklist + +Before going live: + +- [ ] Create test products in Lemon Squeezy test mode +- [ ] Test subscription checkout with test card +- [ ] Verify subscription appears in user's billing section +- [ ] Test subscription cancellation +- [ ] Verify webhook events are processed correctly +- [ ] Test with failing card to verify error handling +- [ ] Switch to production products and webhook URL + +## Related Documentation + +- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and provider comparison +- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure your pricing +- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Custom webhook handling +- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based billing implementation diff --git a/docs/billing/metered-usage.mdoc b/docs/billing/metered-usage.mdoc new file mode 100644 index 000000000..3b3ca23f4 --- /dev/null +++ b/docs/billing/metered-usage.mdoc @@ -0,0 +1,399 @@ +--- +status: "published" +label: "Metered Usage" +title: "Implement Metered Usage Billing for APIs and SaaS" +order: 5 +description: "Charge customers based on actual usage with metered billing. Learn how to configure usage-based pricing and report usage to Stripe or Lemon Squeezy in your Next.js SaaS." +--- + +Metered usage billing charges customers based on consumption (API calls, storage, compute time, etc.). You report usage throughout the billing period, and the provider calculates charges at invoice time. + +## How It Works + +1. Customer subscribes to a metered plan +2. Your application tracks usage and reports it to the billing provider +3. At the end of each billing period, the provider invoices based on total usage +4. Makerkit stores usage data in `subscription_items` for reference + +## Schema Configuration + +Define a metered line item in your billing schema: + +```tsx {% title="apps/web/config/billing.config.ts" %} +{ + id: 'api-plan', + name: 'API Plan', + description: 'Pay only for what you use', + currency: 'USD', + plans: [ + { + id: 'api-monthly', + name: 'API Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_api_requests', // Provider Price ID + name: 'API Requests', + cost: 0, + type: 'metered', + unit: 'requests', + tiers: [ + { upTo: 1000, cost: 0 }, // First 1000 free + { upTo: 10000, cost: 0.001 }, // $0.001/request + { upTo: 'unlimited', cost: 0.0005 }, // Volume discount + ], + }, + ], + }, + ], +} +``` + +The `tiers` define progressive pricing. The last tier should always have `upTo: 'unlimited'`. + +## Provider Differences + +Stripe and Lemon Squeezy handle metered billing differently: + +| Feature | Stripe | Lemon Squeezy | +|---------|--------|---------------| +| Report to | Customer ID + meter name | Subscription item ID | +| Usage action | Implicit increment | Explicit `increment` or `set` | +| Multiple meters | Yes (per customer) | No (per subscription) | +| Real-time usage | Yes (Billing Meter) | Limited | + +## Stripe Implementation + +Stripe uses [Billing Meters](https://docs.stripe.com/billing/subscriptions/usage-based/implementation-guide) for metered billing. + +### 1. Create a Meter in Stripe + +1. Go to Stripe Dashboard → Billing → Meters +2. Click **Create meter** +3. Configure: + - **Event name**: `api_requests` (you'll use this in your code) + - **Aggregation**: Sum (most common) + - **Value key**: `value` (default) + +### 2. Create a Metered Price + +1. Go to Products → Your Product +2. Add a price with **Usage-based** pricing +3. Select your meter +4. Configure tier pricing + +### 3. Report Usage + +```tsx +import { createBillingGatewayService } from '@kit/billing-gateway'; +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function reportApiUsage(accountId: string, requestCount: number) { + const supabase = getSupabaseServerClient(); + const api = createAccountsApi(supabase); + + // Get customer ID for this account + const customerId = await api.getCustomerId(accountId); + + if (!customerId) { + throw new Error('No billing customer found'); + } + + const service = createBillingGatewayService('stripe'); + + await service.reportUsage({ + id: customerId, + eventName: 'api_requests', // Matches your Stripe meter + usage: { + quantity: requestCount, + }, + }); +} +``` + +### 4. Integrate with Your API + +```tsx +// app/api/data/route.ts +import { NextResponse } from 'next/server'; +import { reportApiUsage } from '~/lib/billing'; + +export async function GET(request: Request) { + const accountId = getAccountIdFromRequest(request); + + // Process the request + const data = await fetchData(); + + // Report usage (fire and forget or await) + reportApiUsage(accountId, 1).catch(console.error); + + return NextResponse.json(data); +} +``` + +For high-volume APIs, batch usage reports: + +```tsx +// lib/usage-buffer.ts +const usageBuffer = new Map<string, number>(); + +export function bufferUsage(accountId: string, quantity: number) { + const current = usageBuffer.get(accountId) ?? 0; + usageBuffer.set(accountId, current + quantity); +} + +// Flush every minute +setInterval(async () => { + for (const [accountId, quantity] of usageBuffer.entries()) { + if (quantity > 0) { + await reportApiUsage(accountId, quantity); + usageBuffer.set(accountId, 0); + } + } +}, 60000); +``` + +## Lemon Squeezy Implementation + +Lemon Squeezy requires reporting to a subscription item ID. + +### 1. Create a Usage-Based Product + +1. Go to Products → New Product +2. Select **Usage-based** pricing +3. Configure your pricing tiers + +### 2. Get the Subscription Item ID + +After a customer subscribes, find their subscription item: + +```tsx +const { data: subscriptionItem } = await supabase + .from('subscription_items') + .select('id') + .eq('subscription_id', subscriptionId) + .eq('type', 'metered') + .single(); +``` + +### 3. Report Usage + +```tsx +import { createBillingGatewayService } from '@kit/billing-gateway'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function reportUsageLS( + accountId: string, + quantity: number +) { + const supabase = getSupabaseServerClient(); + + // Get subscription and item + const { data: subscription } = await supabase + .from('subscriptions') + .select('id') + .eq('account_id', accountId) + .eq('status', 'active') + .single(); + + if (!subscription) { + throw new Error('No active subscription'); + } + + const { data: item } = await supabase + .from('subscription_items') + .select('id') + .eq('subscription_id', subscription.id) + .eq('type', 'metered') + .single(); + + if (!item) { + throw new Error('No metered item found'); + } + + const service = createBillingGatewayService('lemon-squeezy'); + + await service.reportUsage({ + id: item.id, + usage: { + quantity, + action: 'increment', // or 'set' to replace + }, + }); +} +``` + +### Lemon Squeezy Usage Actions + +- **`increment`**: Add to existing usage (default) +- **`set`**: Replace the current usage value + +```tsx +// Increment by 100 +await service.reportUsage({ + id: itemId, + usage: { quantity: 100, action: 'increment' }, +}); + +// Set total to 500 (overwrites previous) +await service.reportUsage({ + id: itemId, + usage: { quantity: 500, action: 'set' }, +}); +``` + +## Querying Usage + +### Stripe + +```tsx +const usage = await service.queryUsage({ + id: 'meter_xxx', // Stripe Meter ID + customerId: 'cus_xxx', + filter: { + startTime: Math.floor(Date.now() / 1000) - 86400 * 30, + endTime: Math.floor(Date.now() / 1000), + }, +}); + +console.log(`Total usage: ${usage.value}`); +``` + +### Lemon Squeezy + +```tsx +const usage = await service.queryUsage({ + id: 'sub_item_xxx', + customerId: 'cus_xxx', + filter: { + page: 1, + size: 100, + }, +}); +``` + +## Combining Metered + Flat Pricing (Stripe Only) + +Charge a base fee plus usage: + +```tsx +lineItems: [ + { + id: 'price_base', + name: 'Platform Access', + cost: 29, + type: 'flat', + }, + { + id: 'price_api', + name: 'API Calls', + cost: 0, + type: 'metered', + unit: 'calls', + tiers: [ + { upTo: 10000, cost: 0 }, // Included in base + { upTo: 'unlimited', cost: 0.001 }, + ], + }, +] +``` + +## Setup Fee with Metered Usage (Lemon Squeezy) + +Lemon Squeezy supports a one-time setup fee: + +```tsx +{ + id: '123456', + name: 'API Access', + cost: 0, + type: 'metered', + unit: 'requests', + setupFee: 49, // One-time charge on subscription creation + tiers: [ + { upTo: 1000, cost: 0 }, + { upTo: 'unlimited', cost: 0.001 }, + ], +} +``` + +## Displaying Usage to Users + +Show customers their current usage: + +```tsx +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +export function UsageDisplay({ accountId }: { accountId: string }) { + const { data: usage, isLoading } = useQuery({ + queryKey: ['usage', accountId], + queryFn: () => fetch(`/api/usage/${accountId}`).then(r => r.json()), + refetchInterval: 60000, // Update every minute + }); + + if (isLoading) return <span>Loading usage...</span>; + + return ( + <div className="space-y-2"> + <div className="flex justify-between"> + <span>API Requests</span> + <span>{usage?.requests?.toLocaleString() ?? 0}</span> + </div> + <div className="h-2 bg-muted rounded"> + <div + className="h-full bg-primary rounded" + style={{ width: `${Math.min(100, (usage?.requests / 10000) * 100)}%` }} + /> + </div> + <p className="text-xs text-muted-foreground"> + {usage?.requests > 10000 + ? `${((usage.requests - 10000) * 0.001).toFixed(2)} overage` + : `${10000 - usage?.requests} free requests remaining`} + </p> + </div> + ); +} +``` + +## Testing Metered Billing + +1. **Create a metered subscription** +2. **Report some usage:** + ```bash + # Stripe CLI + stripe billing_meters create_event \ + --event-name api_requests \ + --payload customer=cus_xxx,value=100 + ``` +3. **Check usage in dashboard** +4. **Create an invoice to see charges:** + ```bash + stripe invoices create --customer cus_xxx + stripe invoices finalize inv_xxx + ``` + +## Common Issues + +### Usage not appearing + +1. Verify the meter event name matches +2. Check that customer ID is correct +3. Look for errors in your application logs +4. Check Stripe Dashboard → Billing → Meters → Events + +### Incorrect charges + +1. Verify your tier configuration in Stripe matches your schema +2. Check if using graduated vs. volume pricing +3. Review the invoice line items in Stripe Dashboard + +## Related Documentation + +- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure pricing +- [Billing API](/docs/next-supabase-turbo/billing/billing-api) - Full API reference +- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Alternative usage model +- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Provider configuration diff --git a/docs/billing/one-off-payments.mdoc b/docs/billing/one-off-payments.mdoc new file mode 100644 index 000000000..db9fc6804 --- /dev/null +++ b/docs/billing/one-off-payments.mdoc @@ -0,0 +1,387 @@ +--- +status: "published" +label: "One-Off Payments" +title: "Configure One-Off Payments for Lifetime Deals and Add-Ons" +order: 8 +description: "Implement one-time purchases in your SaaS for lifetime access, add-ons, or credits. Learn how to configure one-off payments with Stripe, Lemon Squeezy, or Paddle in Makerkit." +--- + +One-off payments are single charges for non-recurring products: lifetime access, add-ons, credit packs, or physical goods. Unlike subscriptions, one-off purchases are stored in the `orders` table. + +## Use Cases + +- **Lifetime access**: One-time purchase for perpetual access +- **Add-ons**: Additional features or capacity +- **Credit packs**: Buy credits/tokens in bulk +- **Digital products**: Templates, courses, ebooks +- **One-time services**: Setup fees, consulting + +## Schema Configuration + +Define a one-time payment plan: + +```tsx {% title="apps/web/config/billing.config.ts" %} +{ + id: 'lifetime', + name: 'Lifetime Access', + description: 'Pay once, access forever', + currency: 'USD', + badge: 'Best Value', + features: [ + 'All Pro features', + 'Lifetime updates', + 'Priority support', + ], + plans: [ + { + id: 'lifetime-deal', + name: 'Lifetime Access', + paymentType: 'one-time', // Not recurring + // No interval for one-time + lineItems: [ + { + id: 'price_lifetime_xxx', // Provider Price ID + name: 'Lifetime Access', + cost: 299, + type: 'flat', // Only flat is supported for one-time + }, + ], + }, + ], +} +``` + +**Key differences from subscriptions:** + +- `paymentType` is `'one-time'` instead of `'recurring'` +- No `interval` field +- Line items must be `type: 'flat'` (no metered or per-seat) + +## Provider Setup + +### Stripe + +1. Create a product in Stripe Dashboard +2. Add a **One-time** price +3. Copy the Price ID to your billing schema + +### Lemon Squeezy + +1. Create a product with **Single payment** pricing +2. Copy the Variant ID to your billing schema + +### Paddle + +1. Create a product with one-time pricing +2. Copy the Price ID to your billing schema + +## Database Storage + +One-off purchases are stored differently than subscriptions: + +| Entity | Table | Description | +|--------|-------|-------------| +| Subscriptions | `subscriptions`, `subscription_items` | Recurring payments | +| One-off | `orders`, `order_items` | Single payments | + +### Orders Table Schema + +```sql +orders +├── id (text) - Order ID from provider +├── account_id (uuid) - Purchasing account +├── billing_customer_id (int) - Customer reference +├── status (payment_status) - 'pending', 'succeeded', 'failed' +├── billing_provider (enum) - 'stripe', 'lemon-squeezy', 'paddle' +├── total_amount (numeric) - Total charge +├── currency (varchar) +└── created_at, updated_at + +order_items +├── id (text) - Item ID +├── order_id (text) - Reference to order +├── product_id (text) +├── variant_id (text) +├── price_amount (numeric) +└── quantity (integer) +``` + +## Checking Order Status + +Query orders to check if a user has purchased a product: + +```tsx +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function hasLifetimeAccess(accountId: string): Promise<boolean> { + const supabase = getSupabaseServerClient(); + + const { data: order } = await supabase + .from('orders') + .select('id, status') + .eq('account_id', accountId) + .eq('status', 'succeeded') + .single(); + + return !!order; +} + +// Check for specific product +export async function hasPurchasedProduct( + accountId: string, + productId: string +): Promise<boolean> { + const supabase = getSupabaseServerClient(); + + const { data: order } = await supabase + .from('orders') + .select(` + id, + order_items!inner(product_id) + `) + .eq('account_id', accountId) + .eq('status', 'succeeded') + .eq('order_items.product_id', productId) + .single(); + + return !!order; +} +``` + +## Gating Features + +Use order status to control access: + +```tsx +// Server Component +import { hasLifetimeAccess } from '~/lib/orders'; + +export default async function PremiumFeature({ + accountId, +}: { + accountId: string; +}) { + const hasAccess = await hasLifetimeAccess(accountId); + + if (!hasAccess) { + return <UpgradePrompt />; + } + + return <PremiumContent />; +} +``` + +### RLS Policy Example + +Gate database access based on orders: + +```sql +-- Function to check if account has a successful order +CREATE OR REPLACE FUNCTION public.has_lifetime_access(p_account_id UUID) +RETURNS BOOLEAN +SET search_path = '' +AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM public.orders + WHERE account_id = p_account_id + AND status = 'succeeded' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Example policy +CREATE POLICY premium_content_access ON public.premium_content + FOR SELECT TO authenticated + USING ( + public.has_lifetime_access(account_id) + ); +``` + +## Handling Webhooks + +One-off payment webhooks work similarly to subscriptions: + +```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %} +await service.handleWebhookEvent(request, { + onCheckoutSessionCompleted: async (orderOrSubscription, customerId) => { + // Check if this is an order (one-time) or subscription + if ('order_id' in orderOrSubscription) { + // One-time payment + logger.info({ orderId: orderOrSubscription.order_id }, 'Order completed'); + + // Provision access, send receipt, etc. + await provisionLifetimeAccess(orderOrSubscription.target_account_id); + await sendOrderReceipt(orderOrSubscription); + } else { + // Subscription + logger.info('Subscription created'); + } + }, + + onPaymentFailed: async (sessionId) => { + // Handle failed one-time payments + await notifyPaymentFailed(sessionId); + }, +}); +``` + +### Stripe-Specific Events + +For one-off payments, add these webhook events in Stripe: + +- `checkout.session.completed` +- `checkout.session.async_payment_failed` +- `checkout.session.async_payment_succeeded` + +{% alert type="default" title="Async payment methods" %} +Some payment methods (bank transfers, certain local methods) are asynchronous. Listen for `async_payment_succeeded` to confirm these payments. +{% /alert %} + +## Mixing Orders and Subscriptions + +You can offer both one-time and recurring products: + +```tsx +products: [ + // Subscription product + { + id: 'pro', + name: 'Pro', + plans: [ + { + id: 'pro-monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [{ id: 'price_monthly', cost: 29, type: 'flat' }], + }, + ], + }, + // One-time product + { + id: 'lifetime', + name: 'Lifetime', + plans: [ + { + id: 'lifetime-deal', + paymentType: 'one-time', + lineItems: [{ id: 'price_lifetime', cost: 299, type: 'flat' }], + }, + ], + }, +] +``` + +Check for either type of access: + +```tsx +export async function hasAccess(accountId: string): Promise<boolean> { + const supabase = getSupabaseServerClient(); + + // Check subscription + const { data: subscription } = await supabase + .from('subscriptions') + .select('id') + .eq('account_id', accountId) + .eq('status', 'active') + .single(); + + if (subscription) return true; + + // Check lifetime order + const { data: order } = await supabase + .from('orders') + .select('id') + .eq('account_id', accountId) + .eq('status', 'succeeded') + .single(); + + return !!order; +} +``` + +## Billing Mode Configuration + +By default, Makerkit checks subscriptions for billing status. To use orders as the primary billing mechanism (versions before 2.12.0): + +```bash +BILLING_MODE=one-time +``` + +When set, the billing section will display orders instead of subscriptions. + +{% alert type="default" title="Version 2.12.0+" %} +From version 2.12.0 onwards, orders and subscriptions can coexist. The `BILLING_MODE` setting is only needed if you want to exclusively use one-time payments. +{% /alert %} + +## Add-On Purchases + +Sell additional items to existing subscribers: + +```tsx +// Add-on product +{ + id: 'addon-storage', + name: 'Extra Storage', + plans: [ + { + id: 'storage-10gb', + name: '10GB Storage', + paymentType: 'one-time', + lineItems: [ + { id: 'price_storage_10gb', name: '10GB Storage', cost: 19, type: 'flat' }, + ], + }, + ], +} +``` + +Track purchased add-ons: + +```tsx +export async function getStorageLimit(accountId: string): Promise<number> { + const supabase = getSupabaseServerClient(); + + // Base storage from subscription + const baseStorage = 5; // GB + + // Additional storage from orders + const { data: orders } = await supabase + .from('orders') + .select('order_items(product_id)') + .eq('account_id', accountId) + .eq('status', 'succeeded'); + + const additionalStorage = orders?.reduce((total, order) => { + const hasStorage = order.order_items.some( + item => item.product_id === 'storage-10gb' + ); + return hasStorage ? total + 10 : total; + }, 0) ?? 0; + + return baseStorage + additionalStorage; +} +``` + +## Testing One-Off Payments + +1. **Test checkout:** + - Navigate to your pricing page + - Select the one-time product + - Complete checkout with test card `4242 4242 4242 4242` +2. **Verify database:** + ```sql + SELECT * FROM orders WHERE account_id = 'your-account-id'; + SELECT * FROM order_items WHERE order_id = 'order-id'; + ``` +3. **Test access gating:** + - Verify features are unlocked after purchase + - Test with accounts that haven't purchased + +## Related Documentation + +- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure pricing +- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Handle payment events +- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Provider configuration +- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Token/credit systems diff --git a/docs/billing/overview.mdoc b/docs/billing/overview.mdoc new file mode 100644 index 000000000..cfe44a636 --- /dev/null +++ b/docs/billing/overview.mdoc @@ -0,0 +1,268 @@ +--- +status: "published" +label: "How Billing Works" +title: "Billing in Next.js Supabase Turbo" +description: "Complete guide to implementing billing in your Next.js Supabase SaaS. Configure subscriptions, one-off payments, metered usage, and per-seat pricing with Stripe, Lemon Squeezy, or Paddle." +order: 0 +--- + +Makerkit's billing system lets you accept payments through Stripe, Lemon Squeezy, or Paddle with a unified API. You define your pricing once in a schema, and the gateway routes requests to your chosen provider. Switching providers requires changing one environment variable. + +## Quick Start + +Set your billing provider: + +```bash +NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle +``` + +Update the database configuration to match: + +```sql +UPDATE public.config SET billing_provider = 'stripe'; +``` + +Then [configure your billing schema](/docs/next-supabase-turbo/billing/billing-schema) with your products and pricing. + +## Choose Your Provider + +| Provider | Best For | Tax Handling | Multi-line Items | +|----------|----------|--------------|------------------| +| [Stripe](/docs/next-supabase-turbo/billing/stripe) | Maximum flexibility, global reach | You handle (or use Stripe Tax) | Yes | +| [Lemon Squeezy](/docs/next-supabase-turbo/billing/lemon-squeezy) | Simplicity, automatic tax compliance | Merchant of Record | No (1 per plan) | +| [Paddle](/docs/next-supabase-turbo/billing/paddle) | B2B SaaS, automatic tax compliance | Merchant of Record | No (flat + per-seat only) | + +**Merchant of Record** means Lemon Squeezy and Paddle handle VAT, sales tax, and compliance globally. With Stripe, you're responsible for tax collection (though Stripe Tax can help). + +## Supported Pricing Models + +Makerkit supports four billing models out of the box: + +### Flat Subscriptions + +Fixed monthly or annual pricing. The most common SaaS model. + +```tsx +{ + id: 'price_xxx', + name: 'Pro Plan', + cost: 29, + type: 'flat', +} +``` + +[Learn more about configuring flat subscriptions →](/docs/next-supabase-turbo/billing/billing-schema#flat-subscriptions) + +### Per-Seat Billing + +Charge based on team size. Makerkit automatically updates seat counts when members join or leave. + +```tsx +{ + id: 'price_xxx', + name: 'Team', + cost: 0, + type: 'per_seat', + tiers: [ + { upTo: 3, cost: 0 }, // First 3 seats free + { upTo: 10, cost: 12 }, // $12/seat up to 10 + { upTo: 'unlimited', cost: 10 }, + ] +} +``` + +[Configure per-seat billing →](/docs/next-supabase-turbo/billing/per-seat-billing) + +### Metered Usage + +Charge based on consumption (API calls, storage, tokens). Report usage through the billing API. + +```tsx +{ + id: 'price_xxx', + name: 'API Requests', + cost: 0, + type: 'metered', + unit: 'requests', + tiers: [ + { upTo: 1000, cost: 0 }, + { upTo: 'unlimited', cost: 0.001 }, + ] +} +``` + +[Set up metered billing →](/docs/next-supabase-turbo/billing/metered-usage) + +### One-Off Payments + +Lifetime deals, add-ons, or credits. Stored in the `orders` table instead of `subscriptions`. + +```tsx +{ + paymentType: 'one-time', + lineItems: [{ + id: 'price_xxx', + name: 'Lifetime Access', + cost: 299, + type: 'flat', + }] +} +``` + +[Configure one-off payments →](/docs/next-supabase-turbo/billing/one-off-payments) + +### Credit-Based Billing + +For AI SaaS and token-based systems. Combine subscriptions with a credits table for consumption tracking. + +[Implement credit-based billing →](/docs/next-supabase-turbo/billing/credit-based-billing) + +## Architecture Overview + +The billing system uses a provider-agnostic architecture: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Your App │────▶│ Gateway │────▶│ Provider │ +│ (billing.config) │ (routes requests) │ (Stripe/LS/Paddle) +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Database │◀────│ Webhook Handler│◀────│ Webhook │ +│ (subscriptions) │ (processes events) │ (payment events) +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +**Package structure:** + +- `@kit/billing` (core): Schema validation, interfaces, types +- `@kit/billing-gateway`: Provider routing, unified API +- `@kit/stripe`: Stripe-specific implementation +- `@kit/lemon-squeezy`: Lemon Squeezy-specific implementation +- `@kit/paddle`: Paddle-specific implementation (plugin) + +This abstraction means your application code stays the same regardless of provider. The billing schema defines what you sell, and each provider package handles the API specifics. + +## Database Schema + +Billing data is stored in four main tables: + +| Table | Purpose | +|-------|---------| +| `billing_customers` | Links accounts to provider customer IDs | +| `subscriptions` | Active and historical subscription records | +| `subscription_items` | Line items within subscriptions (for per-seat, metered) | +| `orders` | One-off payment records | +| `order_items` | Items within one-off orders | + +All tables have Row Level Security (RLS) enabled. Users can only read their own billing data. + +## Configuration Files + +### billing.config.ts + +Your pricing schema lives at `apps/web/config/billing.config.ts`: + +```tsx +import { createBillingSchema } from '@kit/billing'; + +export default createBillingSchema({ + provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER, + products: [ + { + id: 'starter', + name: 'Starter', + description: 'For individuals', + currency: 'USD', + plans: [/* ... */], + }, + ], +}); +``` + +[Full billing schema documentation →](/docs/next-supabase-turbo/billing/billing-schema) + +### Environment Variables + +Each provider requires specific environment variables: + +**Stripe:** +```bash +STRIPE_SECRET_KEY=sk_... +STRIPE_WEBHOOK_SECRET=whsec_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_... +``` + +**Lemon Squeezy:** +```bash +LEMON_SQUEEZY_SECRET_KEY=... +LEMON_SQUEEZY_SIGNING_SECRET=... +LEMON_SQUEEZY_STORE_ID=... +``` + +**Paddle:** +```bash +PADDLE_API_KEY=... +PADDLE_WEBHOOK_SECRET_KEY=... +NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=... +``` + +## Common Tasks + +### Check if an account has a subscription + +```tsx +import { createAccountsApi } from '@kit/accounts/api'; + +const api = createAccountsApi(supabaseClient); +const subscription = await api.getSubscription(accountId); + +if (subscription?.status === 'active') { + // User has active subscription +} +``` + +### Create a checkout session + +```tsx +import { createBillingGatewayService } from '@kit/billing-gateway'; + +const service = createBillingGatewayService(provider); +const { checkoutToken } = await service.createCheckoutSession({ + accountId, + plan, + returnUrl: `${origin}/billing/return`, + customerEmail: user.email, +}); +``` + +### Handle billing webhooks + +Webhooks are processed at `/api/billing/webhook`. Extend the handler for custom logic: + +```tsx +await service.handleWebhookEvent(request, { + onCheckoutSessionCompleted: async (subscription) => { + // Send welcome email, provision resources, etc. + }, + onSubscriptionDeleted: async (subscriptionId) => { + // Clean up, send cancellation email, etc. + }, +}); +``` + +[Full webhook documentation →](/docs/next-supabase-turbo/billing/billing-webhooks) + +## Next Steps + +1. **[Configure your billing schema](/docs/next-supabase-turbo/billing/billing-schema)** to define your products and pricing +2. **Set up your payment provider:** [Stripe](/docs/next-supabase-turbo/billing/stripe), [Lemon Squeezy](/docs/next-supabase-turbo/billing/lemon-squeezy), or [Paddle](/docs/next-supabase-turbo/billing/paddle) +3. **[Handle webhooks](/docs/next-supabase-turbo/billing/billing-webhooks)** for payment events +4. **[Use the billing API](/docs/next-supabase-turbo/billing/billing-api)** to manage subscriptions programmatically + +For advanced use cases: +- [Per-seat billing](/docs/next-supabase-turbo/billing/per-seat-billing) for team-based pricing +- [Metered usage](/docs/next-supabase-turbo/billing/metered-usage) for consumption-based billing +- [Credit-based billing](/docs/next-supabase-turbo/billing/credit-based-billing) for AI/token systems +- [Custom integrations](/docs/next-supabase-turbo/billing/custom-integration) for other payment providers \ No newline at end of file diff --git a/docs/billing/paddle.mdoc b/docs/billing/paddle.mdoc new file mode 100644 index 000000000..80bb9259b --- /dev/null +++ b/docs/billing/paddle.mdoc @@ -0,0 +1,475 @@ +--- +status: "published" +label: 'Paddle' +title: 'Configuring Paddle Billing | Next.js Supabase SaaS Kit Turbo' +order: 4 +description: 'Complete guide to integrating Paddle billing with your Next.js Supabase SaaS application. Learn how to set up payment processing, webhooks, and subscription management with Paddle as your Merchant of Record.' +--- + +Paddle is a comprehensive billing solution that acts as a Merchant of Record (MoR), handling all payment processing, tax calculations, compliance, and regulatory requirements for your SaaS business. + +This integration eliminates the complexity of managing global tax compliance, PCI requirements, and payment processing infrastructure. + +## Overview + +This guide will walk you through: +- Setting up Paddle for development and production +- Configuring webhooks for real-time billing events +- Creating and managing subscription products +- Testing the complete billing flow +- Deploying to production + +## Limitations + +Paddle currently supports flat and per-seat plans. Metered subscriptions are not supported with Paddle. + +## Prerequisites + +Before starting, ensure you have: +- A Paddle account (sandbox for development, live for production) +- Access to your application's environment configuration +- A method to expose your local development server (ngrok, LocalTunnel, Localcan, etc.) + +## Step 0: Fetch the Paddle package from the plugins repository + +The Paddle package is released as a plugin in the Plugins repository. You can fetch it by running the following command: + +```bash +npx @makerkit/cli@latest plugins install +``` + +Please choose the Paddle plugin from the list of available plugins. + +## Step 1: Registering Paddle + +Now we need to register the services from the Paddle plugin. + +### Install the Paddle package + +Run the following command to add the Paddle package to our billing package: + +```bash +pnpm --filter @kit/billing-gateway add "@kit/paddle@workspace:*" +``` + +### Registering the Checkout component + +Update the function `loadCheckoutComponent` to include the `paddle` block, +which will dynamically import the Paddle checkout component: + +```tsx {% title="packages/billing/gateway/src/components/embedded-checkout.tsx" %} +import { Suspense, lazy } from 'react'; + +import { Enums } from '@kit/supabase/database'; +import { LoadingOverlay } from '@kit/ui/loading-overlay'; + +type BillingProvider = Enums<'billing_provider'>; + +// Create lazy components at module level (not during render) +const StripeCheckoutLazy = lazy(async () => { + const { StripeCheckout } = await import('@kit/stripe/components'); + return { default: StripeCheckout }; +}); + +const LemonSqueezyCheckoutLazy = lazy(async () => { + const { LemonSqueezyEmbeddedCheckout } = + await import('@kit/lemon-squeezy/components'); + return { default: LemonSqueezyEmbeddedCheckout }; +}); + +const PaddleCheckoutLazy = lazy(async () => { + const { PaddleCheckout } = await import( + '@kit/paddle/components' + ); + return { default: PaddleCheckout }; +}); + + +type CheckoutProps = { + onClose: (() => unknown) | undefined; + checkoutToken: string; +}; + +export function EmbeddedCheckout( + props: React.PropsWithChildren<{ + checkoutToken: string; + provider: BillingProvider; + onClose?: () => void; + }>, +) { + return ( + <> + <Suspense fallback={<LoadingOverlay fullPage={false} />}> + <CheckoutSelector + provider={props.provider} + onClose={props.onClose} + checkoutToken={props.checkoutToken} + /> + </Suspense> + + <BlurryBackdrop /> + </> + ); +} + +function CheckoutSelector( + props: CheckoutProps & { provider: BillingProvider }, +) { + switch (props.provider) { + case 'stripe': + return ( + <StripeCheckoutLazy + onClose={props.onClose} + checkoutToken={props.checkoutToken} + /> + ); + + case 'lemon-squeezy': + return ( + <LemonSqueezyCheckoutLazy + onClose={props.onClose} + checkoutToken={props.checkoutToken} + /> + ); + + case 'paddle': + return ( + <PaddleCheckoutLazy + onClose={props.onClose} + checkoutToken={props.checkoutToken} + /> + ) + + default: + throw new Error(`Unsupported provider: ${props.provider as string}`); + } +} + +function BlurryBackdrop() { + return ( + <div + className={ + 'bg-background/30 fixed top-0 left-0 w-full backdrop-blur-sm' + + ' !m-0 h-full' + } + /> + ); +} +``` + +### Registering the Webhook handler + +At `packages/billing/gateway/src/server/services/billing-event-handler +/billing-event-handler-factory.service.ts`, add the snippet below at the +bottom of the file: + +```tsx {% title="packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts" %} + // Register Paddle webhook handler +billingWebhookHandlerRegistry.register('paddle', async () => { + const { PaddleWebhookHandlerService } = await import('@kit/paddle'); + + return new PaddleWebhookHandlerService(planTypesMap); +}); +``` + +### Registering the Billing service + +Finally, at `packages/billing/gateway/src/server/services/billing-event-handler +/billing-gateway-registry.ts`, add the snippet below at the +bottom of the file: + +```tsx {% title="packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts" %} +// Register Paddle billing strategy +billingStrategyRegistry.register('paddle', async () => { + const { PaddleBillingStrategyService } = await import('@kit/paddle'); + + return new PaddleBillingStrategyService(); +}); +``` + +## Step 2: Create Paddle Account + +### Development Account (Sandbox) +1. Visit [Paddle Developer Console](https://sandbox-vendors.paddle.com/signup) +2. Complete the registration process +3. Verify your email address +4. Navigate to your sandbox dashboard + +### Important Notes +- The sandbox environment allows unlimited testing without processing real payments +- All transactions in sandbox mode use test card numbers +- Webhooks and API calls work identically to production +- The Paddle payment provider currently only supports flat and per-seat plans (metered subscriptions are not supported) + +## Step 3: Configure Billing Provider + +### Database Configuration + +Set Paddle as your billing provider in the database: + +```sql +-- Update the billing provider in your configuration table +UPDATE public.config +set billing_provider = 'paddle'; +``` + +### Environment Configuration + +Add the following to your `.env.local` file: + +```bash +# Set Paddle as the active billing provider +NEXT_PUBLIC_BILLING_PROVIDER=paddle +``` + +This environment variable tells your application to use Paddle-specific components and API endpoints for billing operations. + +## Step 4: API Key Configuration + +Paddle requires two types of API keys for complete integration: + +### Server-Side API Key (Required) + +1. In your Paddle dashboard, navigate to **Developer Tools** → **Authentication** +2. Click **Generate New API Key** +3. Give it a descriptive name (e.g., "Production API Key" or "Development API Key") +4. **Configure the required permissions** for your API key: + - **Write Customer Portal Sessions** - For managing customer billing portals + - **Read Customers** - For retrieving customer information + - **Read Prices** - For displaying pricing information + - **Read Products** - For product catalog access + - **Read/Write Subscriptions** - For subscription management + - **Read Transactions** - For payment and transaction tracking +5. Copy the generated key immediately (it won't be shown again) +6. Add to your `.env.local`: + +```bash +PADDLE_API_KEY=your_server_api_key_here +``` + +**Security Note**: This key has access to the specified Paddle account permissions and should never be exposed to the client-side code. Only grant the minimum permissions required for your integration. + +### Client-Side Token (Required) + +1. In the same **Authentication** section, look for **Client-side tokens** +2. Click **New Client-Side Token** +3. Copy the client token +4. Add to your `.env.local`: + +```bash +NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_client_token_here +``` + +**Important**: This token is safe to expose in client-side code but should be restricted to your specific domains. + +## Step 5: Webhook Configuration + +Webhooks enable real-time synchronization between Paddle and your application for events like successful payments, subscription changes, and cancellations. + +### Set Up Local Development Tunnel + +First, expose your local development server to the internet: + +#### Using ngrok (Recommended) +```bash +# Install ngrok if not already installed +npm install -g ngrok + +# Expose port 3000 (default Next.js port) +ngrok http 3000 +``` + +#### Using LocalTunnel +```bash +# Install localtunnel +npm install -g localtunnel + +# Expose port 3000 +lt --port 3000 +``` + +### Configure Webhook Destination + +1. In Paddle dashboard, go to **Developer Tools** → **Notifications** +2. Click **New Destination** +3. Configure the destination: + - **Destination URL**: `https://your-tunnel-url.ngrok.io/api/billing/webhook` + - **Description**: "Local Development Webhook" + - **Active**: ✅ Checked + +### Select Webhook Events + +Enable these essential events for proper billing integration: + +### Retrieve Webhook Secret + +1. After creating the destination, click on it to view details +2. Copy the **Endpoint Secret** (used to verify webhook authenticity) +3. Add to your `.env.local`: + +```bash +PADDLE_WEBHOOK_SECRET_KEY=your_webhook_secret_here +``` + +### Test Webhook Connection + +You can test the webhook endpoint by making a GET request to verify it's accessible: + +```bash +curl https://your-tunnel-url.ngrok.io/api/billing/webhook +``` + +Expected response: `200 OK` with a message indicating the webhook endpoint is active. + +## Step 6: Product and Pricing Configuration + +### Create Products in Paddle + +1. Navigate to **Catalog** → **Products** in your Paddle dashboard +2. Click **Create Product** +3. Configure your product: + +**Basic Information:** +- **Product Name**: "Starter Plan", "Pro Plan", etc. +- **Description**: Detailed description of the plan features +- **Tax Category**: Select appropriate category (usually "Software") + +**Pricing Configuration:** +- **Billing Interval**: Monthly, Yearly, or Custom +- **Price**: Set in your primary currency +- **Trial Period**: Optional free trial duration + +### Configure Billing Settings + +Update your billing configuration file with the Paddle product IDs: + +```typescript +// apps/web/config/billing.config.ts +export const billingConfig = { + provider: 'paddle', + products: [ + { + id: 'starter', + name: 'Starter Plan', + description: 'Perfect for individuals and small teams', + badge: 'Most Popular', + features: [ + 'Up to 5 projects', + 'Basic support', + '1GB storage' + ], + plans: [ + { + name: 'Starter Monthly', + id: 'starter-monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'pri_starter_monthly_001', // Paddle Price ID + name: 'Starter', + cost: 9.99, + type: 'flat' as const, + }, + ], + } + ] + } + // Add more products... + ] +}; +``` + +## Step 7: Checkout Configuration + +### Default Payment Link Configuration + +1. Go to **Checkout** → **Checkout Settings** in Paddle dashboard +2. Configure **Default Payment Link**: use`http://localhost:3000` - but when deploying to production, you should use your production domain. +3. Save the configuration + +## Step 8: Testing the Integration + +### Development Testing Checklist + +**Environment Verification:** +- [ ] All environment variables are set correctly +- [ ] Webhook tunnel is active and accessible +- [ ] Destination was defined using the correct URL +- [ ] Database billing provider is set to 'paddle' in both DB and ENV + +**Subscription Flow Testing:** +1. Navigate to your billing/pricing page (`/home/billing` or equivalent) +2. Click on a subscription plan +3. Complete the checkout flow using Paddle test cards +4. Verify successful redirect to success page +5. Check that subscription appears in user dashboard +6. Verify webhook events are received in your application logs + +You can also test cancellation flows: +- cancel the subscription from the billing portal +- delete the account and verify the subscription is cancelled as well + +### Test Card Numbers + +[Follow this link to get the test card numbers](https://developer.paddle.com/concepts/payment-methods/credit-debit-card#test-payment-method) + +### Webhook Testing + +Monitor webhook delivery in your application logs: + +```bash +# Watch your development logs +pnpm dev + +# In another terminal, monitor webhook requests +tail -f logs/webhook.log +``` + +## Step 9: Production Deployment + +### Apply for Live Paddle Account + +1. In your Paddle dashboard, click **Go Live** +2. Complete the application process: + - Business information and verification + - Tax information and documentation + - Banking details for payouts + - Identity verification for key personnel + +**Timeline**: Live account approval typically takes 1-3 business days. + +### Production Environment Setup + +Create production-specific configuration: + +```bash +# Production environment variables +NEXT_PUBLIC_BILLING_PROVIDER=paddle +PADDLE_API_KEY=your_production_api_key +NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_production_client_token +PADDLE_WEBHOOK_SECRET_KEY=your_production_webhook_secret +``` + +### Production Webhook Configuration + +1. Create a new webhook destination for production +2. Set the destination URL to your production domain: + ``` + https://yourdomain.com/api/billing/webhook + ``` +3. Enable the same events as configured for development +4. Update your production environment with the new webhook secret + +### Production Products and Pricing + +1. Create production versions of your products in the live environment +2. Update your production billing configuration with live Price IDs +3. Test the complete flow on production with small-amount transactions + +### Support Resources + +Refer to the [Paddle Documentation](https://developer.paddle.com) for more information: + +- **Paddle Documentation**: [https://developer.paddle.com](https://developer.paddle.com) +- **Status Page**: [https://status.paddle.com](https://status.paddle.com) \ No newline at end of file diff --git a/docs/billing/per-seat-billing.mdoc b/docs/billing/per-seat-billing.mdoc new file mode 100644 index 000000000..f995371d0 --- /dev/null +++ b/docs/billing/per-seat-billing.mdoc @@ -0,0 +1,293 @@ +--- +status: "published" +label: "Per Seat Billing" +title: "Configure Per-Seat Billing for Team Subscriptions" +order: 6 +description: "Implement per-seat pricing for your SaaS. Makerkit automatically tracks team members and updates seat counts with your billing provider when members join or leave." +--- + +Per-seat billing charges customers based on the number of users (seats) in their team. Makerkit handles this automatically: when team members are added or removed, the subscription is updated with the new seat count. + +## How It Works + +1. You define a `per_seat` line item in your billing schema +2. When a team subscribes, Makerkit counts current members and sets the initial quantity +3. When members join or leave, Makerkit updates the subscription quantity +4. Your billing provider (Stripe, Lemon Squeezy, Paddle) handles proration + +No custom code required for basic per-seat billing. + +## Schema Configuration + +Define a per-seat line item in your billing schema: + +```tsx {% title="apps/web/config/billing.config.ts" %} +import { createBillingSchema } from '@kit/billing'; + +export default createBillingSchema({ + provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER, + products: [ + { + id: 'team', + name: 'Team', + description: 'For growing teams', + currency: 'USD', + features: [ + 'Unlimited projects', + 'Team collaboration', + 'Priority support', + ], + plans: [ + { + id: 'team-monthly', + name: 'Team Monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_team_monthly', // Your Stripe Price ID + name: 'Team Seats', + cost: 0, // Base cost (calculated from tiers) + type: 'per_seat', + tiers: [ + { upTo: 3, cost: 0 }, // First 3 seats free + { upTo: 10, cost: 15 }, // $15/seat for seats 4-10 + { upTo: 'unlimited', cost: 12 }, // Volume discount + ], + }, + ], + }, + ], + }, + ], +}); +``` + +## Pricing Tier Patterns + +### Free Tier + Per-Seat + +Include free seats for small teams: + +```tsx +tiers: [ + { upTo: 5, cost: 0 }, // 5 free seats + { upTo: 'unlimited', cost: 10 }, // $10/seat after +] +``` + +### Flat Per-Seat (No Tiers) + +Simple per-seat pricing: + +```tsx +tiers: [ + { upTo: 'unlimited', cost: 15 }, // $15/seat for all seats +] +``` + +### Volume Discounts + +Reward larger teams: + +```tsx +tiers: [ + { upTo: 10, cost: 20 }, // $20/seat for 1-10 + { upTo: 50, cost: 15 }, // $15/seat for 11-50 + { upTo: 100, cost: 12 }, // $12/seat for 51-100 + { upTo: 'unlimited', cost: 10 }, // $10/seat for 100+ +] +``` + +### Base Fee + Per-Seat (Stripe Only) + +Combine a flat fee with per-seat pricing: + +```tsx +lineItems: [ + { + id: 'price_base_fee', + name: 'Platform Fee', + cost: 49, + type: 'flat', + }, + { + id: 'price_seats', + name: 'Team Seats', + cost: 0, + type: 'per_seat', + tiers: [ + { upTo: 5, cost: 0 }, + { upTo: 'unlimited', cost: 10 }, + ], + }, +] +``` + +{% alert type="warning" title="Stripe only" %} +Multiple line items (flat + per-seat) only work with Stripe. Lemon Squeezy and Paddle support one line item per plan. +{% /alert %} + +## Provider Setup + +### Stripe + +1. Create a product in Stripe Dashboard +2. Add a price with **Graduated pricing** or **Volume pricing** +3. Set the pricing tiers to match your schema +4. Copy the Price ID (e.g., `price_xxx`) to your line item `id` + +**Stripe pricing types:** + +- **Graduated**: Each tier applies to that range only (e.g., seats 1-5 at $0, seats 6-10 at $15) +- **Volume**: The price for all units is determined by the total quantity + +### Lemon Squeezy + +1. Create a product with **Usage-based pricing** +2. Configure the pricing tiers +3. Copy the Variant ID to your line item `id` + +### Paddle + +1. Create a product with quantity-based pricing +2. Configure as needed (Paddle handles proration automatically) +3. Copy the Price ID to your line item `id` + +{% alert type="default" title="Paddle trial limitation" %} +Paddle doesn't support updating subscription quantities during a trial period. If using per-seat billing with Paddle trials, consider using Feature Policies to restrict invitations during trials. +{% /alert %} + +## Automatic Seat Updates + +Makerkit automatically updates seat counts when: + +| Action | Effect | +|--------|--------| +| Team member accepts invitation | Seat count increases | +| Team member is removed | Seat count decreases | +| Team member leaves | Seat count decreases | +| Account is deleted | Subscription is canceled | + +The billing provider handles proration based on your settings. + +## Testing Per-Seat Billing + +1. **Create a team subscription:** + - Sign up and create a team account + - Subscribe to a per-seat plan + - Verify the initial seat count matches team size +2. **Add a member:** + - Invite a new member to the team + - Have them accept the invitation + - Check Stripe/LS/Paddle: subscription quantity should increase +3. **Remove a member:** + - Remove a member from the team + - Check: subscription quantity should decrease +4. **Verify proration:** + - Check the upcoming invoice in your provider dashboard + - Confirm proration is calculated correctly + +## Manual Seat Updates (Advanced) + +In rare cases, you might need to manually update seat counts: + +```tsx +import { createBillingGatewayService } from '@kit/billing-gateway'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function updateSeatCount( + subscriptionId: string, + subscriptionItemId: string, + newQuantity: number +) { + const supabase = getSupabaseServerClient(); + + // Get subscription to find the provider + const { data: subscription } = await supabase + .from('subscriptions') + .select('billing_provider') + .eq('id', subscriptionId) + .single(); + + if (!subscription) { + throw new Error('Subscription not found'); + } + + const service = createBillingGatewayService( + subscription.billing_provider + ); + + return service.updateSubscriptionItem({ + subscriptionId, + subscriptionItemId, + quantity: newQuantity, + }); +} +``` + +## Checking Seat Limits + +To enforce seat limits in your application: + +```tsx +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function canAddMember(accountId: string): Promise<boolean> { + const supabase = getSupabaseServerClient(); + const api = createAccountsApi(supabase); + + // Get current subscription + const subscription = await api.getSubscription(accountId); + + if (!subscription) { + return false; // No subscription + } + + // Get per-seat item + const { data: seatItem } = await supabase + .from('subscription_items') + .select('quantity') + .eq('subscription_id', subscription.id) + .eq('type', 'per_seat') + .single(); + + // Get current member count + const { count: memberCount } = await supabase + .from('accounts_memberships') + .select('*', { count: 'exact', head: true }) + .eq('account_id', accountId); + + // Check if under limit (if you have a max seats limit) + const maxSeats = 100; // Your limit + return (memberCount ?? 0) < maxSeats; +} +``` + +## Common Issues + +### Seat count not updating + +1. Check that the line item has `type: 'per_seat'` +2. Verify the subscription is active +3. Check webhook logs for errors +4. Ensure the subscription item ID is correct in the database + +### Proration not working as expected + +Configure proration behavior in your billing provider: +- **Stripe:** Customer Portal settings or API parameters +- **Lemon Squeezy:** Product settings +- **Paddle:** Automatic proration + +### "Minimum quantity" errors + +Some plans require at least 1 seat. Ensure your tiers start at a valid minimum. + +## Related Documentation + +- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Define pricing plans +- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe +- [Billing API](/docs/next-supabase-turbo/billing/billing-api) - Manual subscription updates +- [Team Accounts](/docs/next-supabase-turbo/api/team-account-api) - Team management diff --git a/docs/billing/stripe.mdoc b/docs/billing/stripe.mdoc new file mode 100644 index 000000000..d13201fdc --- /dev/null +++ b/docs/billing/stripe.mdoc @@ -0,0 +1,292 @@ +--- +status: "published" +label: "Stripe" +title: "Configure Stripe Billing for Your Next.js SaaS" +description: "Complete guide to setting up Stripe payments in Makerkit. Configure subscriptions, one-off payments, webhooks, and the Customer Portal for your Next.js Supabase application." +order: 2 +--- + +Stripe is the default billing provider in Makerkit. It offers the most flexibility with support for multiple line items, metered billing, and advanced subscription management. + +## Prerequisites + +Before you start: +1. Create a [Stripe account](https://dashboard.stripe.com/register) +2. Have your Stripe API keys ready (Dashboard → Developers → API keys) +3. Install the Stripe CLI for local webhook testing + +## Step 1: Environment Variables + +Add these variables to your `.env.local` file: + +```bash +# Stripe API Keys +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +``` + +| Variable | Description | Where to Find | +|----------|-------------|---------------| +| `STRIPE_SECRET_KEY` | Server-side API key | Dashboard → Developers → API keys | +| `STRIPE_WEBHOOK_SECRET` | Webhook signature verification | Generated by Stripe CLI or Dashboard | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Client-side key (safe to expose) | Dashboard → Developers → API keys | + +{% alert type="error" title="Never commit secret keys" %} +Add `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` to `.env.local` only. Never add them to `.env` or commit them to your repository. +{% /alert %} + +## Step 2: Configure Billing Provider + +Ensure Stripe is set as your billing provider: + +```bash +NEXT_PUBLIC_BILLING_PROVIDER=stripe +``` + +And in the database: + +```sql +UPDATE public.config SET billing_provider = 'stripe'; +``` + +## Step 3: Create Products in Stripe + +1. Go to Stripe Dashboard → Products +2. Click **Add product** +3. Configure your product: + - **Name**: "Pro Plan", "Starter Plan", etc. + - **Pricing**: Add prices for monthly and yearly intervals + - **Price ID**: Copy the `price_xxx` ID for your billing schema + +**Important:** The Price ID (e.g., `price_1NNwYHI1i3VnbZTqI2UzaHIe`) must match the `id` field in your billing schema's line items. + +## Step 4: Set Up Local Webhooks with Stripe CLI + +The Stripe CLI forwards webhook events from Stripe to your local development server. + +### Using Docker (Recommended) + +First, log in to Stripe: + +```bash +docker run --rm -it --name=stripe \ + -v ~/.config/stripe:/root/.config/stripe \ + stripe/stripe-cli:latest login +``` + +This opens a browser window to authenticate. Complete the login process. + +Then start listening for webhooks: + +```bash +pnpm run stripe:listen +``` + +Or manually: + +```bash +docker run --rm -it --name=stripe \ + -v ~/.config/stripe:/root/.config/stripe \ + stripe/stripe-cli:latest listen \ + --forward-to http://host.docker.internal:3000/api/billing/webhook +``` + +### Using Stripe CLI Directly + +If you prefer installing Stripe CLI globally: + +```bash +# macOS +brew install stripe/stripe-cli/stripe + +# Login +stripe login + +# Listen for webhooks +stripe listen --forward-to localhost:3000/api/billing/webhook +``` + +### Copy the Webhook Secret + +When you start listening, the CLI displays a webhook signing secret: + +``` +> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx +``` + +Copy this value and add it to your `.env.local`: + +```bash +STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx +``` + +{% alert type="default" title="Re-run after restart" %} +The webhook secret changes each time you restart the Stripe CLI. Update your `.env.local` accordingly. +{% /alert %} + +### Linux Troubleshooting + +If webhooks aren't reaching your app on Linux, try adding `--network=host`: + +```bash +docker run --rm -it --name=stripe \ + -v ~/.config/stripe:/root/.config/stripe \ + stripe/stripe-cli:latest listen \ + --network=host \ + --forward-to http://localhost:3000/api/billing/webhook +``` + +## Step 5: Configure Customer Portal + +The Stripe Customer Portal lets users manage their subscriptions, payment methods, and invoices. + +1. Go to Stripe Dashboard → Settings → Billing → Customer portal +2. Configure these settings: + +**Payment methods:** +- Allow customers to update payment methods: ✅ + +**Subscriptions:** +- Allow customers to switch plans: ✅ +- Choose products customers can switch between +- Configure proration behavior + +**Cancellations:** +- Allow customers to cancel subscriptions: ✅ +- Configure cancellation behavior (immediate vs. end of period) + +**Invoices:** +- Allow customers to view invoice history: ✅ + +{% img src="/assets/images/docs/stripe-customer-portal.webp" width="2712" height="1870" /%} + +## Step 6: Production Webhooks + +When deploying to production, configure webhooks in the Stripe Dashboard: + +1. Go to Stripe Dashboard → Developers → Webhooks +2. Click **Add endpoint** +3. Enter your webhook URL: `https://yourdomain.com/api/billing/webhook` +4. Select events to listen for: + +**Required events:** +- `checkout.session.completed` +- `customer.subscription.created` +- `customer.subscription.updated` +- `customer.subscription.deleted` + +**For one-off payments (optional):** +- `checkout.session.async_payment_failed` +- `checkout.session.async_payment_succeeded` + +5. Click **Add endpoint** +6. Copy the signing secret and add it to your production environment variables + +{% alert type="warning" title="Use a public URL" %} +Webhook URLs must be publicly accessible. Vercel preview deployments with authentication enabled won't work. Test by visiting the URL in an incognito browser window. +{% /alert %} + +## Free Trials Without Credit Card + +Allow users to start a trial without entering payment information: + +```bash +STRIPE_ENABLE_TRIAL_WITHOUT_CC=true +``` + +When enabled, users can start a subscription with a trial period and won't be charged until the trial ends. They'll need to add a payment method before the trial expires. + +You must also set `trialDays` in your billing schema: + +```tsx +{ + id: 'pro-monthly', + name: 'Pro Monthly', + paymentType: 'recurring', + interval: 'month', + trialDays: 14, // 14-day free trial + lineItems: [/* ... */], +} +``` + +## Migrating Existing Subscriptions + +If you're migrating to Makerkit with existing Stripe subscriptions, you need to add metadata to each subscription. + +Makerkit expects this metadata on subscriptions: + +```json +{ + "accountId": "uuid-of-the-account" +} +``` + +**Option 1: Add metadata manually** + +Use the Stripe Dashboard or a migration script to add the `accountId` metadata to existing subscriptions. + +**Option 2: Modify the webhook handler** + +If you can't update metadata, modify the webhook handler to look up accounts by customer ID: + +```tsx {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %} +// Instead of: +const accountId = subscription.metadata.accountId as string; + +// Query your database: +const { data: customer } = await supabase + .from('billing_customers') + .select('account_id') + .eq('customer_id', subscription.customer) + .single(); + +const accountId = customer?.account_id; +``` + +## Common Issues + +### Webhooks not received + +1. **Check the CLI is running:** `pnpm run stripe:listen` should show "Ready!" +2. **Verify the secret:** Copy the new webhook secret after each CLI restart +3. **Check the account:** Ensure you're logged into the correct Stripe account +4. **Check the URL:** The webhook endpoint is `/api/billing/webhook` + +### "No such price" error + +The Price ID in your billing schema doesn't exist in Stripe. Verify: +1. You're using test mode keys with test mode prices (or live with live) +2. The Price ID is copied correctly from Stripe Dashboard + +### Subscription not appearing in database + +1. Check webhook logs in Stripe Dashboard → Developers → Webhooks +2. Look for errors in your application logs +3. Verify the `accountId` is correctly passed in checkout metadata + +### Customer Portal not loading + +1. Ensure the Customer Portal is configured in Stripe Dashboard +2. Check that the customer has a valid subscription +3. Verify the `customerId` is correct + +## Testing Checklist + +Before going live: + +- [ ] Test subscription checkout with test card `4242 4242 4242 4242` +- [ ] Verify subscription appears in user's billing section +- [ ] Test subscription upgrade/downgrade via Customer Portal +- [ ] Test subscription cancellation +- [ ] Verify webhook events are processed correctly +- [ ] Test with failing card `4000 0000 0000 0002` to verify error handling +- [ ] For trials: test trial expiration and conversion to paid + +## Related Documentation + +- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts +- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure your pricing +- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Custom webhook handling +- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Report usage to Stripe +- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing diff --git a/docs/components/app-breadcrumbs.mdoc b/docs/components/app-breadcrumbs.mdoc new file mode 100644 index 000000000..390c8b1f0 --- /dev/null +++ b/docs/components/app-breadcrumbs.mdoc @@ -0,0 +1,84 @@ +--- +status: "published" + +label: "App Breadcrumbs" +title: "App Breadcrumbs Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the App Breadcrumbs component in the Next.js Supabase SaaS kit" +order: 6 +--- + + +The `AppBreadcrumbs` component creates a dynamic breadcrumb navigation based on the current URL path. It's designed to work with Next.js and uses the `usePathname` hook from Next.js for routing information. + +## Features + +- Automatically generates breadcrumbs from the current URL path +- Supports custom labels for path segments +- Limits the number of displayed breadcrumbs with an ellipsis for long paths +- Internationalization support with the `Trans` component +- Responsive design with different text sizes for mobile and desktop + +## Usage + +```tsx +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; + +function MyPage() { + return ( + <AppBreadcrumbs + values={{ + "custom-slug": "Custom Label" + }} + maxDepth={4} + /> + ); +} +``` + +When you have IDs in your URL, you can use the `values` prop to provide custom labels for those segments. For example, if your URL is `/users/123`, you can set `values={{ "123": "User Profile" }}` to display "User Profile" instead of "123" in the breadcrumb. + +```tsx +<AppBreadcrumbs + values={{ + "123": "User" + }} +/> +``` + +This will display "User" instead of "123" in the breadcrumb. + +## Props + +The component accepts two optional props: + +1. `values`: An object where keys are URL segments and values are custom labels. +- Type: `Record<string, string>` +- Default: `{}` +2. `maxDepth`: The maximum number of breadcrumb items to display before using an ellipsis. +- Type: `number` +- Default: `6` + +## Functionality + +- The component splits the current path into segments and creates a breadcrumb item for each. +- If the number of segments exceeds `maxDepth`, it shows an ellipsis (...) to indicate hidden segments. +- The last breadcrumb item is not clickable and represents the current page. +- Custom labels can be provided through the `values` prop. +- For segments without custom labels, it attempts to use an i18n key (`common.routes.[unslugified-path]`). If no translation is found, it falls back to the unslugified path. + +## Styling + +- The component uses Tailwind CSS classes for styling. +- Breadcrumb items are capitalized. +- On larger screens (lg breakpoint), the text size is slightly smaller. + +## Dependencies + +This component relies on several other components and utilities: + +- Next.js `usePathname` hook +- Custom UI components (Breadcrumb, BreadcrumbItem, etc.) from Shadcn UI +- `If` component for conditional rendering +- `Trans` component for internationalization + +This component provides a flexible and easy-to-use solution for adding breadcrumb navigation to your Next.js application. It's particularly useful for sites with deep hierarchical structures or those requiring dynamic breadcrumb generation. \ No newline at end of file diff --git a/docs/components/bordered-navigation-menu.mdoc b/docs/components/bordered-navigation-menu.mdoc new file mode 100644 index 000000000..592608d43 --- /dev/null +++ b/docs/components/bordered-navigation-menu.mdoc @@ -0,0 +1,86 @@ +--- +status: "published" + +label: "Bordered Navigation Menu" +title: "Bordered Navigation Menu Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the Bordered Navigation Menu component in the Next.js Supabase SaaS kit" +order: 8 +--- + + +The BorderedNavigationMenu components provide a stylish and interactive navigation menu with a bordered, underline-style active state indicator. These components are built on top of the NavigationMenu from Shadcn UI and are designed to work seamlessly with Next.js routing. + +## BorderedNavigationMenu + +This component serves as a container for navigation menu items. + +### Usage + +```jsx +import { BorderedNavigationMenu, BorderedNavigationMenuItem } from '@kit/ui/bordered-navigation-menu'; + +function MyNavigation() { + return ( + <BorderedNavigationMenu> + <BorderedNavigationMenuItem path="/home" label="Home" /> + <BorderedNavigationMenuItem path="/about" label="About" /> + {/* Add more menu items as needed */} + </BorderedNavigationMenu> + ); +} +``` + +### Props + +- `children: React.ReactNode`: The navigation menu items to be rendered. + +## BorderedNavigationMenuItem + +This component represents an individual item in the navigation menu. + +### Props + +- `path: string` (required): The URL path for the navigation item. +- `label: React.ReactNode | string` (required): The text or content to display for the item. +- `end?: boolean | ((path: string) => boolean)`: Determines if the path should match exactly or use a custom function for active state. +- `active?: boolean`: Manually set the active state of the item. +- `className?: string`: Additional CSS classes for the menu item container. +- `buttonClassName?: string`: Additional CSS classes for the button element. + +### Features + +1. **Automatic Active State**: Uses Next.js's `usePathname` to automatically determine if the item is active based on the current route. +2. **Custom Active State Logic**: Allows for custom active state determination through the `end` prop. +3. **Internationalization**: Supports i18n through the `Trans` component for string labels. +4. **Styling**: Utilizes Tailwind CSS for styling, with active items featuring an underline animation. + +### Example + +```jsx +<BorderedNavigationMenuItem + path="/dashboard" + label="common.dashboardLabel" + end={true} + className="my-custom-class" + buttonClassName="px-4 py-2" +/> +``` + +## Styling + +The components use Tailwind CSS for styling. Key classes include: + +- Menu container: `relative h-full space-x-2` +- Menu item button: `relative active:shadow-sm` +- Active indicator: `absolute -bottom-2.5 left-0 h-0.5 w-full bg-primary animate-in fade-in zoom-in-90` + +You can further customize the appearance by passing additional classes through the `className` and `buttonClassName` props. + +## Best Practices + +1. Use consistent labeling and paths across your application. +2. Leverage the `Trans` component for internationalization of labels. +3. Consider the `end` prop for more precise control over the active state for nested routes. +4. Use the `active` prop sparingly, preferring the automatic active state detection when possible. + +These components provide a sleek, accessible way to create navigation menus in your Next.js application, with built-in support for styling active states and internationalization. \ No newline at end of file diff --git a/docs/components/card-button.mdoc b/docs/components/card-button.mdoc new file mode 100644 index 000000000..3af81b079 --- /dev/null +++ b/docs/components/card-button.mdoc @@ -0,0 +1,156 @@ +--- +status: "published" +label: "Card Button" +title: "Card Button Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the Card Button component in the Next.js Supabase SaaS kit" +order: 7 +--- + +The CardButton components provide a set of customizable, interactive card-like buttons for use in React applications. These components are built with flexibility in mind, allowing for easy composition and styling. + +{% component path="card-button" /%} + +## Components + +### CardButton + +The main wrapper component for creating a card-like button. + +#### Props + +- `asChild?: boolean`: If true, the component will render its children directly. +- `className?: string`: Additional CSS classes to apply to the button. +- `children: React.ReactNode`: The content of the button. +- `...props`: Any additional button props. + +#### Usage + +```jsx +<CardButton onClick={handleClick}> + {/* Card content */} +</CardButton> +``` + +### CardButtonTitle + +Component for rendering the title of the card button. + +#### Props + +- `className?: string`: Additional CSS classes for the title. +- `asChild?: boolean`: If true, renders children directly. +- `children: React.ReactNode`: The title content. + +#### Usage + +```jsx +<CardButtonTitle>My Card Title</CardButtonTitle> +``` + +### CardButtonHeader + +Component for the header section of the card button. + +#### Props + +- `className?: string`: Additional CSS classes for the header. +- `asChild?: boolean`: If true, renders children directly. +- `displayArrow?: boolean`: Whether to display the chevron icon (default: true). +- `children: React.ReactNode`: The header content. + +#### Usage + +```jsx +<CardButtonHeader displayArrow={false}> + <CardButtonTitle>Header Content</CardButtonTitle> +</CardButtonHeader> +``` + +### CardButtonContent + +Component for the main content area of the card button. + +#### Props + +- `className?: string`: Additional CSS classes for the content area. +- `asChild?: boolean`: If true, renders children directly. +- `children: React.ReactNode`: The main content. + +#### Usage + +```jsx +<CardButtonContent> + <p>Main card content goes here</p> +</CardButtonContent> +``` + +### CardButtonFooter + +Component for the footer section of the card button. + +#### Props + +- `className?: string`: Additional CSS classes for the footer. +- `asChild?: boolean`: If true, renders children directly. +- `children: React.ReactNode`: The footer content. + +#### Usage + +```jsx +<CardButtonFooter> + <span>Footer information</span> +</CardButtonFooter> +``` + +## Styling + +These components use Tailwind CSS for styling. Key features include: + +- Hover and active states for interactive feedback +- Responsive sizing and layout +- Dark mode support +- Customizable through additional class names + +## Example + +Here's a complete example of how to use these components together: + +```jsx +import { + CardButton, + CardButtonTitle, + CardButtonHeader, + CardButtonContent, + CardButtonFooter +} from '@kit/ui/card-button'; + +function MyCardButton() { + return ( + <CardButton onClick={() => console.log('Card clicked')}> + <CardButtonHeader> + <CardButtonTitle>Featured Item</CardButtonTitle> + </CardButtonHeader> + <CardButtonContent> + <p>This is a detailed description of the featured item.</p> + </CardButtonContent> + <CardButtonFooter> + <span>Click to learn more</span> + </CardButtonFooter> + </CardButton> + ); +} +``` + +## Accessibility + +- The components use semantic HTML elements when not using the `asChild` prop. +- Interactive elements are keyboard accessible. + +## Best Practices + +1. Use clear, concise titles in `CardButtonTitle`. +2. Provide meaningful content in `CardButtonContent` for user understanding. +3. Use `CardButtonFooter` for calls-to-action or additional information. +4. Leverage the `asChild` prop when you need to change the underlying element (e.g., for routing with Next.js `Link` component). + +These CardButton components provide a flexible and customizable way to create interactive card-like buttons in your React application, suitable for various use cases such as feature showcases, navigation elements, or clickable information cards. \ No newline at end of file diff --git a/docs/components/coming-soon.mdoc b/docs/components/coming-soon.mdoc new file mode 100644 index 000000000..696e9f7a8 --- /dev/null +++ b/docs/components/coming-soon.mdoc @@ -0,0 +1,116 @@ +--- +status: "published" +label: "Temporary Landing Page" +title: "A temporary minimal landing page for your SaaS" +description: "Looking to ship as quickly as possible? Use the Coming Soon component to showcase your product's progress." +order: 9 +--- + +If you're rushing to launch your SaaS, you can use the Coming Soon component to showcase a minimal landing page for your product and generate buzz before you launch. + +{% component path="coming-soon" /%} + +My suggestions is to replace the whole `(marketing)` layout using the Coming Soon component. + +This will save you a lot of time making sure the landing page and the links are filled with the right information. + +```tsx {% title="apps/web/app/(marketing)/layout.tsx" %} +import Link from 'next/link'; + +import { + ComingSoon, + ComingSoonButton, + ComingSoonHeading, + ComingSoonLogo, + ComingSoonText, +} from '@kit/ui/marketing'; + +import { AppLogo } from '~/components/app-logo'; +import appConfig from '~/config/app.config'; + +export default function SiteLayout() { + return ( + <ComingSoon> + <ComingSoonLogo> + <AppLogo /> + </ComingSoonLogo> + + <ComingSoonHeading>{appConfig.name} is coming soon</ComingSoonHeading> + + <ComingSoonText> + We're building something amazing. Our team is working hard to bring + you a product that will revolutionize how you work. + </ComingSoonText> + + <ComingSoonButton asChild> + <Link href="#">Follow Our Progress</Link> + </ComingSoonButton> + + {/* Additional custom content */} + <div className="mt-8 flex justify-center gap-4"> + {/* Social icons, etc */} + </div> + </ComingSoon> + ); +} +``` + +Even better, you can use an env variable to check if it's a production build or not and displaying the normal layout during development: + +```tsx {% title="apps/web/app/(marketing)/layout.tsx" %} +import Link from 'next/link'; + +import { + ComingSoon, + ComingSoonButton, + ComingSoonHeading, + ComingSoonLogo, + ComingSoonText, +} from '@kit/ui/marketing'; + +import { SiteFooter } from '~/(marketing)/_components/site-footer'; +import { SiteHeader } from '~/(marketing)/_components/site-header'; + +import { AppLogo } from '~/components/app-logo'; +import appConfig from '~/config/app.config'; + +function SiteLayout(props: React.PropsWithChildren) { + if (!appConfig.production) { + return ( + <div className={'flex min-h-[100vh] flex-col'}> + <SiteHeader /> + + {props.children} + + <SiteFooter /> + </div> + ); + } + + return ( + <ComingSoon> + <ComingSoonLogo> + <AppLogo /> + </ComingSoonLogo> + + <ComingSoonHeading>{appConfig.name} is coming soon</ComingSoonHeading> + + <ComingSoonText> + We're building something amazing. Our team is working hard to bring + you a product that will revolutionize how you work. + </ComingSoonText> + + <ComingSoonButton asChild> + <Link href="#">Follow Our Progress</Link> + </ComingSoonButton> + + {/* Additional custom content */} + <div className="mt-8 flex justify-center gap-4"> + {/* Social icons, etc */} + </div> + </ComingSoon> + ); +} + +export default SiteLayout; +``` \ No newline at end of file diff --git a/docs/components/cookie-banner.mdoc b/docs/components/cookie-banner.mdoc new file mode 100644 index 000000000..886becffc --- /dev/null +++ b/docs/components/cookie-banner.mdoc @@ -0,0 +1,130 @@ +--- +status: "published" +label: "Cookie Banner" +title: "Cookie Banner Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the Cookie Banner component in the Next.js Supabase SaaS kit" +order: 7 +--- + +This module provides a `CookieBanner` component and a `useCookieConsent` hook for managing cookie consent in React applications. + +{% component path="cookie-banner" /%} + +## CookieBanner Component + +The CookieBanner component displays a consent banner for cookies and tracking technologies. + +### Usage + +```jsx +import dynamic from 'next/dynamic'; + +const CookieBanner = dynamic(() => import('@kit/ui/cookie-banner').then(m => m.CookieBanner), { + ssr: false +}); + +function App() { + return ( + <div> + {/* Your app content */} + <CookieBanner /> + </div> + ); +} +``` + +### Features + +- Displays only when consent status is unknown +- Automatically hides after user interaction +- Responsive design (different layouts for mobile and desktop) +- Internationalization support via the `Trans` component +- Animated entrance using Tailwind CSS + +## useCookieConsent Hook + +This custom hook manages the cookie consent state and provides methods to update it. + +### Usage + +```jsx +import { useCookieConsent } from '@kit/ui/cookie-banner'; + +function MyComponent() { + const { status, accept, reject, clear } = useCookieConsent(); + + // Use these values and functions as needed +} +``` + +### API + +- `status: ConsentStatus`: Current consent status (Accepted, Rejected, or Unknown) +- `accept(): void`: Function to accept cookies +- `reject(): void`: Function to reject cookies +- `clear(): void`: Function to clear the current consent status + +## ConsentStatus Enum + +```typescript +enum ConsentStatus { + Accepted = 'accepted', + Rejected = 'rejected', + Unknown = 'unknown' +} +``` + +## Key Features + +1. **Persistent Storage**: Consent status is stored in localStorage for persistence across sessions. +2. **Server-Side Rendering Compatible**: Checks for browser environment before accessing localStorage. +3. **Customizable**: The `COOKIE_CONSENT_STATUS` key can be configured as needed. +4. **Reactive**: The banner automatically updates based on the consent status. + +## Styling + +The component uses Tailwind CSS for styling, with support for dark mode and responsive design. + +## Accessibility + +- Uses Base UI's Dialog primitive for improved accessibility +- Autofocus on the "Accept" button for keyboard navigation + +## Internationalization + +The component uses the `Trans` component for internationalization. Ensure you have the following keys in your i18n configuration: + +- `cookieBanner.title` +- `cookieBanner.description` +- `cookieBanner.reject` +- `cookieBanner.accept` + +## Best Practices + +1. Place the `CookieBanner` component at the root of your application to ensure it's always visible when needed. +2. Use the `useCookieConsent` hook to conditionally render content or initialize tracking scripts based on the user's consent. +3. Provide clear and concise information about your cookie usage in the banner description. +4. Ensure your privacy policy is up-to-date and accessible from the cookie banner or nearby. + +## Example: Conditional Script Loading + +```jsx +function App() { + const { status } = useCookieConsent(); + + useEffect(() => { + if (status === ConsentStatus.Accepted) { + // Initialize analytics or other cookie-dependent scripts + } + }, [status]); + + return ( + <div> + {/* Your app content */} + <CookieBanner /> + </div> + ); +} +``` + +This cookie consent management system provides a user-friendly way to comply with cookie laws and regulations while maintaining a good user experience. \ No newline at end of file diff --git a/docs/components/data-table.mdoc b/docs/components/data-table.mdoc new file mode 100644 index 000000000..0ad1c94b4 --- /dev/null +++ b/docs/components/data-table.mdoc @@ -0,0 +1,108 @@ +--- +status: "published" + +label: "Data Table" +title: "Data Table Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the Data Table component in the Next.js Supabase SaaS kit" +order: 2 +--- + + +The DataTable component is a powerful and flexible table component built on top of TanStack Table (React Table v8). It provides a range of features for displaying and interacting with tabular data, including pagination, sorting, and custom rendering. + +## Usage + +```tsx +import { DataTable } from '@kit/ui/enhanced-data-table'; + +function MyComponent() { + const columns = [ + // Define your columns here + ]; + + const data = [ + // Your data array + ]; + + return ( + <DataTable + columns={columns} + data={data} + pageSize={10} + pageIndex={0} + pageCount={5} + /> + ); +} +``` + +## Props + +- `data: T[]` (required): An array of objects representing the table data. +- `columns: ColumnDef<T>[]` (required): An array of column definitions. +- `pageIndex?: number`: The current page index (0-based). +- `pageSize?: number`: The number of rows per page. +- `pageCount?: number`: The total number of pages. +- `onPaginationChange?: (pagination: PaginationState) => void`: Callback function for pagination changes. +- `tableProps?: React.ComponentProps<typeof Table>`: Additional props to pass to the underlying Table component. + +## Pagination + +The DataTable component handles pagination internally but can also be controlled externally. It provides navigation buttons for first page, previous page, next page, and last page. + +## Sorting + +Sorting is handled internally by the component. Click on column headers to sort by that column. + +## Filtering + +The component supports column filtering, which can be implemented in the column definitions. + +## Example with ServerDataLoader + +Here's an example of how to use the DataTable component with ServerDataLoader: + +```jsx +import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs'; +import { DataTable } from '@kit/ui/enhanced-data-table'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +function AccountsPage({ searchParams }) { + const client = getSupabaseServerAdminClient(); + const page = searchParams.page ? parseInt(searchParams.page) : 1; + const filters = getFilters(searchParams); + + return ( + <ServerDataLoader + table={'accounts'} + client={client} + page={page} + where={filters} + > + {({ data, page, pageSize, pageCount }) => ( + <DataTable + columns={[ + // Define your columns here + ]} + data={data} + page={page} + pageSize={pageSize} + pageCount={pageCount} + /> + )} + </ServerDataLoader> + ); +} +``` + +This example demonstrates how to use ServerDataLoader to fetch data from a Supabase table and pass it to the DataTable component. The ServerDataLoader handles the data fetching and pagination, while the DataTable takes care of rendering and client-side interactions. + +## Customization + +The DataTable component is built with customization in mind. You can customize the appearance using Tailwind CSS classes and extend its functionality by passing custom props to the underlying Table component. + +## Internationalization + +The component uses the `Trans` component for internationalization. Ensure you have your i18n setup correctly to leverage this feature. + +The DataTable component provides a powerful and flexible solution for displaying tabular data in your React applications, with built-in support for common table features and easy integration with server-side data loading. \ No newline at end of file diff --git a/docs/components/empty-state.mdoc b/docs/components/empty-state.mdoc new file mode 100644 index 000000000..cd555ca5a --- /dev/null +++ b/docs/components/empty-state.mdoc @@ -0,0 +1,95 @@ +--- +status: "published" +label: "Empty State" +title: "Empty State Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the Empty State component in the Next.js Supabase SaaS kit" +order: 7 +--- + +The `EmptyState` component is a flexible and reusable UI element designed to display when there's no content to show. It's perfect for scenarios like empty lists, search results with no matches, or initial states of features. + +{% component path="empty-state" /%} + +## Components + +1. `EmptyState`: The main wrapper component +2. `EmptyStateHeading`: For the main heading +3. `EmptyStateText`: For descriptive text +4. `EmptyStateButton`: For a call-to-action button + +## Usage + +```jsx +import { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton } from '@kit/ui/empty-state'; + +function MyComponent() { + return ( + <EmptyState> + <EmptyStateHeading>No results found</EmptyStateHeading> + <EmptyStateText>Try adjusting your search or filter to find what you're looking for.</EmptyStateText> + <EmptyStateButton>Clear filters</EmptyStateButton> + </EmptyState> + ); +} +``` + +## Component Details + +### EmptyState + +The main container that wraps all other components. + +- **Props**: Accepts all standard `div` props +- **Styling**: +- Flex container with centered content +- Rounded corners with a dashed border +- Light shadow for depth + +### EmptyStateHeading + +Used for the main heading of the empty state. + +- **Props**: Accepts all standard `h3` props +- **Styling**: +- Large text (2xl) +- Bold font +- Tight letter spacing + +### EmptyStateText + +For descriptive text explaining the empty state or providing guidance. + +- **Props**: Accepts all standard `p` props +- **Styling**: +- Small text +- Muted color for less emphasis + +### EmptyStateButton + +A button component for primary actions. + +- **Props**: Accepts all props from the base `Button` component +- **Styling**: +- Margin top for spacing +- Inherits styles from the base `Button` component + +## Features + +1. **Flexible Structure**: Components can be used in any order, and additional custom elements can be added. +2. **Automatic Layout**: The component automatically arranges its children in a centered, vertical layout. +3. **Customizable**: Each subcomponent accepts className props for custom styling. +4. **Type-Safe**: Utilizes TypeScript for prop type checking. + +## Customization + +You can customize the appearance of each component by passing a `className` prop: + +```tsx +<EmptyState className="bg-gray-100"> + <EmptyStateHeading className="text-primary">Custom Heading</EmptyStateHeading> + <EmptyStateText className="text-lg">Larger descriptive text</EmptyStateText> + <EmptyStateButton className="bg-secondary">Custom Button</EmptyStateButton> +</EmptyState> +``` + +This `EmptyState` component provides a clean, consistent way to handle empty states in your application. Its modular design allows for easy customization while maintaining a cohesive look and feel across different use cases. \ No newline at end of file diff --git a/docs/components/if.mdoc b/docs/components/if.mdoc new file mode 100644 index 000000000..9cc092780 --- /dev/null +++ b/docs/components/if.mdoc @@ -0,0 +1,95 @@ +--- +status: "published" + +label: "Conditional Rendering" +title: "Dynamic Conditional Rendering in the Next.js Supabase SaaS kit" +description: "Learn how to use the If component in the Next.js Supabase SaaS kit" +order: 4 +--- + + +The `If` component is a utility component for conditional rendering in React applications. It provides a clean, declarative way to render content based on a condition, with support for fallback content. + +## Features + +- Conditional rendering based on various types of conditions +- Support for render props pattern +- Optional fallback content +- Memoized for performance optimization + +## Usage + +```jsx +import { If } from '@kit/ui/if'; + +function MyComponent({ isLoggedIn, user }) { + return ( + <If condition={isLoggedIn} fallback={<LoginPrompt />}> + {(value) => <WelcomeMessage user={user} />} + </If> + ); +} +``` + +## Props + +The `If` component accepts the following props: + +- `condition: Condition<Value>` (required): The condition to evaluate. Can be any value, where falsy values (`false`, `null`, `undefined`, `0`, `''`) are considered false. +- `children: React.ReactNode | ((value: Value) => React.ReactNode)` (required): The content to render when the condition is truthy. Can be a React node or a function (render prop). +- `fallback?: React.ReactNode` (optional): Content to render when the condition is falsy. + +## Types + +```typescript +type Condition<Value = unknown> = Value | false | null | undefined | 0 | ''; +``` + +## Examples + +### Basic usage + +```jsx +<If condition={isLoading}> + <LoadingSpinner /> +</If> +``` + +### With fallback + +```jsx +<If condition={hasData} fallback={<NoDataMessage />}> + <DataDisplay data={data} /> +</If> +``` + +### Using render props + +```jsx +<If condition={user}> + {(user) => <UserProfile username={user.name} />} +</If> +``` + +## Performance + +The `If` component uses `useMemo` to optimize performance by memoizing the rendered output. This means it will only re-render when the `condition`, `children`, or `fallback` props change. + +## Best Practices + +1. Use the `If` component for simple conditional rendering to improve readability. +2. Leverage the render props pattern when you need to use the condition's value in the rendered content. +3. Provide a fallback for better user experience when the condition is false. +4. Remember that the condition is re-evaluated on every render, so keep it simple to avoid unnecessary computations. + +## Typescript Support + +The `If` component is fully typed - This allows for type-safe usage of the render props pattern: + +```typescript +<If condition={user}> + {(user) => <UserProfile name={user.name} email={user.email} />} +</If> +``` + +The `If` component provides a clean and efficient way to handle conditional rendering in React applications, improving code readability and maintainability. \ No newline at end of file diff --git a/docs/components/loading-overlay.mdoc b/docs/components/loading-overlay.mdoc new file mode 100644 index 000000000..8201a385e --- /dev/null +++ b/docs/components/loading-overlay.mdoc @@ -0,0 +1,96 @@ +--- +status: "published" +label: "Loading Overlay" +title: "Loading Overlay Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the Loading Overlay component in the Next.js Supabase SaaS kit" +order: 3 +--- + +The LoadingOverlay component is a versatile UI element designed to display a loading state with a spinner and optional content. It's perfect for indicating background processes or page loads in your application. + +{% component path="overlays/loading-overlay" /%} + +## Features + +- Customizable appearance through CSS classes +- Option for full-page overlay or inline loading indicator +- Spinner animation with customizable styling +- Ability to include additional content or messages + +## Usage + +```jsx +import { LoadingOverlay } from '@kit/ui/loading-overlay'; + +function MyComponent() { + return ( + <LoadingOverlay> + Loading your content... + </LoadingOverlay> + ); +} +``` + +## Props + +The LoadingOverlay component accepts the following props: + +- `children?: React.ReactNode`: Optional content to display below the spinner. +- `className?: string`: Additional CSS classes to apply to the container. +- `spinnerClassName?: string`: CSS classes to apply to the spinner component. +- `fullPage?: boolean`: Whether to display as a full-page overlay. Defaults to `true`. + +## Examples + +### Full-page overlay + +```jsx +<LoadingOverlay> + Please wait while we load your dashboard... +</LoadingOverlay> +``` + +### Inline loading indicator + +```jsx +<LoadingOverlay fullPage={false} className="h-40"> + Fetching results... +</LoadingOverlay> +``` + +### Customized appearance + +```jsx +<LoadingOverlay + className="bg-gray-800 text-white" + spinnerClassName="text-blue-500" +> + Processing your request... +</LoadingOverlay> +``` + +## Styling + +The LoadingOverlay uses Tailwind CSS for styling. Key classes include: + +- Flex layout with centered content: `flex flex-col items-center justify-center` +- Space between spinner and content: `space-y-4` +- Full-page overlay (when `fullPage` is true): +``` +fixed left-0 top-0 z-[100] h-screen w-screen bg-background + ``` + +You can further customize the appearance by passing additional classes through the `className` and `spinnerClassName` props. + +## Accessibility + +When using the LoadingOverlay, consider adding appropriate ARIA attributes to improve accessibility, such as `aria-busy="true"` on the parent element that's in a loading state. + +## Best Practices + +1. Use full-page overlays sparingly to avoid disrupting user experience. +2. Provide clear, concise messages to inform users about what's loading. +3. Consider using inline loading indicators for smaller UI elements or partial page updates. +4. Ensure sufficient contrast between the overlay and the spinner for visibility. + +The LoadingOverlay component provides a simple yet effective way to indicate loading states in your application, enhancing user experience by providing visual feedback during asynchronous operations or content loads. diff --git a/docs/components/marketing-components.mdoc b/docs/components/marketing-components.mdoc new file mode 100644 index 000000000..a8bec4fe1 --- /dev/null +++ b/docs/components/marketing-components.mdoc @@ -0,0 +1,566 @@ +--- +status: "published" +label: "Marketing Components" +title: "Marketing Components in the Next.js Supabase SaaS kit" +description: "Learn how to use the Marketing components in the Next.js Supabase SaaS kit" +order: 8 +--- + +Marketing components are designed to help you create beautiful and engaging marketing pages for your SaaS application. These components are built on top of the Shadcn UI library and are designed to work seamlessly with Next.js routing. + +## Hero + +The Hero component is a versatile and customizable landing page hero section for React applications. + +{% component path="marketing/hero" /%} + +### Import + +```jsx +import { Hero } from '@kit/ui/marketing'; +``` + +### Usage + +```jsx +import { Hero, Pill, CtaButton } from '@kit/ui/marketing'; +import Image from 'next/image'; + +function LandingPage() { + return ( + <Hero + pill={<Pill>New Feature</Pill>} + title="Welcome to Our App" + subtitle="Discover the power of our innovative solution" + cta={<CtaButton>Get Started</CtaButton>} + image={ + <Image + src="/hero-image.jpg" + alt="Hero Image" + width={1200} + height={600} + /> + } + /> + ); +} +``` + +### Styling + +The Hero component uses Tailwind CSS for styling. You can customize its appearance by: + +1. Modifying the default classes in the component. +2. Passing additional classes via the `className` prop. +3. Overriding styles in your CSS using the appropriate selectors. + +### Animations + +By default, the Hero component applies entrance animations to its elements. You can disable these animations by setting the `animate` prop to `false`. + +### Accessibility + +The Hero component uses semantic HTML elements and follows accessibility best practices: + +- The main title uses an `<h1>` tag (via the `HeroTitle` component). +- The subtitle uses an `<h3>` tag for proper heading hierarchy. + +Ensure that any images passed via the `image` prop include appropriate `alt` text for screen readers. + +### Notes + +- The Hero component is designed to be flexible and can accommodate various content types through its props. +- For optimal performance, consider lazy-loading large images passed to the `image` prop. +- The component is responsive and adjusts its layout for different screen sizes. + +### A Larger example straight from the kit + +Below is a larger example of a Hero component with additional elements like a pill, CTA button, and image: + +```tsx +import { Hero, Pill, CtaButton, GradientSecondaryText } from '@kit/ui/marketing'; +import { Trans } from '@kit/ui/trans'; +import { LayoutDashboard } from 'lucide-react'; +import Image from 'next/image'; + +<Hero + pill={ + <Pill label={'New'}> + <span>The leading SaaS Starter Kit for ambitious developers</span> + </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={ + 'delay-250 rounded-2xl border border-gray-200 duration-1000 ease-out animate-in fade-in zoom-in-50 fill-mode-both dark:border-primary/10' + } + width={3558} + height={2222} + src={`/images/dashboard.webp`} + alt={`App Image`} + /> + } +/> + +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={ + 'h-4 animate-in fade-in slide-in-from-left-8' + + ' delay-1000 duration-1000 zoom-in fill-mode-both' + } + /> + </span> + </Link> + </CtaButton> + + <CtaButton variant={'link'}> + <Link href={'/contact'}> + <Trans i18nKey={'common.contactUs'} /> + </Link> + </CtaButton> + </div> + ); +} +``` + +## HeroTitle + +The `HeroTitle` component is a specialized heading component used within the Hero component to display the main title. + +{% component path="marketing/hero-title" /%} + +### Props + +The `HeroTitle` component accepts the following props: + +1. `asChild?: boolean`: Whether to render the component as a child of the `Slot` component. +2. `HTMLAttributes<HTMLHeadingElement>`: Additional attributes to apply to the heading element. + +### Usage + +```tsx +import { HeroTitle } from '@kit/ui/marketing'; + +function LandingPage() { + return ( + <HeroTitle asChild> + Welcome to Our App + </HeroTitle> + ); +} +``` + +## Pill + +The `Pill` component is a small, rounded content container often used for highlighting or categorizing information. + +{% component path="marketing/pill" /%} + +### Usage + +Use the `Pill` component to create a small, rounded content container with optional label text. + +```tsx +import { Pill } from '@kit/ui/marketing'; + +function LandingPage() { + return ( + <Pill label="New"> + Discover the power of our innovative + </Pill> + ); +} +``` + +## Features + +The `FeatureShowcase`, `FeatureShowcaseIconContainer`, `FeatureGrid`, and `FeatureCard` components are designed to showcase product features on marketing pages. + +### FeatureShowcase + +The `FeatureShowcase` component is a layout component that showcases a feature with an icon, heading, and description. + +### FeatureShowcaseIconContainer + +The `FeatureShowcaseIconContainer` component is a layout component that contains an icon for the `FeatureShowcase` component. + +### FeatureGrid + +The `FeatureGrid` component is a layout component that arranges `FeatureCard` components in a grid layout. + +### FeatureCard + +The `FeatureCard` component is a card component that displays a feature with a label, description, and optional image. + +### Usage + +Use the `FeatureShowcase` component to showcase a feature with an icon, heading, and description. + +```tsx + <div className={'container mx-auto'}> + <div + className={'flex flex-col space-y-16 xl:space-y-32 2xl:space-y-36'} + > + <FeatureShowcase + heading={ + <> + <b className="font-semibold dark:text-white"> + The ultimate SaaS Starter Kit + </b> + .{' '} + <GradientSecondaryText> + Unleash your creativity and build your SaaS faster than ever + with Makerkit. + </GradientSecondaryText> + </> + } + icon={ + <FeatureShowcaseIconContainer> + <LayoutDashboard className="h-5" /> + <span>All-in-one solution</span> + </FeatureShowcaseIconContainer> + } + > + <FeatureGrid> + <FeatureCard + className={ + 'relative col-span-2 overflow-hidden bg-violet-500 text-white lg:h-96' + } + label={'Beautiful Dashboard'} + description={`Makerkit provides a beautiful dashboard to manage your SaaS business.`} + > + <Image + className="absolute right-0 top-0 hidden h-full w-full rounded-tl-2xl border border-border lg:top-36 lg:flex lg:h-auto lg:w-10/12" + src={'/images/dashboard-header.webp'} + width={'2061'} + height={'800'} + alt={'Dashboard Header'} + /> + </FeatureCard> + + <FeatureCard + className={ + 'relative col-span-2 w-full overflow-hidden lg:col-span-1' + } + label={'Authentication'} + description={`Makerkit provides a variety of providers to allow your users to sign in.`} + > + <Image + className="absolute left-16 top-32 hidden h-auto w-8/12 rounded-l-2xl lg:flex" + src={'/images/sign-in.webp'} + width={'1760'} + height={'1680'} + alt={'Sign In'} + /> + </FeatureCard> + + <FeatureCard + className={ + 'relative col-span-2 overflow-hidden lg:col-span-1 lg:h-96' + } + label={'Multi Tenancy'} + description={`Multi tenant memberships for your SaaS business.`} + > + <Image + className="absolute right-0 top-0 hidden h-full w-full rounded-tl-2xl border lg:top-28 lg:flex lg:h-auto lg:w-8/12" + src={'/images/multi-tenancy.webp'} + width={'2061'} + height={'800'} + alt={'Multi Tenancy'} + /> + </FeatureCard> + + <FeatureCard + className={'relative col-span-2 overflow-hidden lg:h-96'} + label={'Billing'} + description={`Makerkit supports multiple payment gateways to charge your customers.`} + > + <Image + className="absolute right-0 top-0 hidden h-full w-full rounded-tl-2xl border border-border lg:top-36 lg:flex lg:h-auto lg:w-11/12" + src={'/images/billing.webp'} + width={'2061'} + height={'800'} + alt={'Billing'} + /> + </FeatureCard> + </FeatureGrid> + </FeatureShowcase> + </div> +</div> +``` + +## SecondaryHero + +The `SecondaryHero` component is a secondary hero section that can be used to highlight additional features or content on a landing page. + +```tsx +<SecondaryHero + pill={<Pill>Get started 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." +/> +``` + +{% component path="marketing/secondary-hero" /%} + +## Header + +The `Header` component is a navigation header that can be used to display links to different sections of a marketing page. + +```tsx +export function SiteHeader(props: { user?: User | null }) { + return ( + <Header + logo={<AppLogo />} + navigation={<SiteNavigation />} + actions={<SiteHeaderAccountSection user={props.user ?? null} />} + /> + ); +} +``` + +## Footer + +The `Footer` component is a footer section that can be used to display links, social media icons, and other information on a marketing page. + +```tsx +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" />, + }, + ], + }, + ]} + /> + ); +} +``` + +## CtaButton + +The `CtaButton` component is a call-to-action button that can be used to encourage users to take a specific action. + +{% component path="marketing/cta-button" /%} + +```tsx +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={ + 'h-4 animate-in fade-in slide-in-from-left-8' + + ' delay-1000 duration-1000 zoom-in fill-mode-both' + } + /> + </span> + </Link> + </CtaButton> + + <CtaButton variant={'link'}> + <Link href={'/contact'}> + <Trans i18nKey={'common.contactUs'} /> + </Link> + </CtaButton> + </div> + ); +} +``` + +## GradientSecondaryText + +The `GradientSecondaryText` component is a text component that applies a gradient color to the text. + +{% component path="marketing/gradient-secondary-text" /%} + +```tsx +function GradientSecondaryTextExample() { + return ( + <p> + <GradientSecondaryText> + Unleash your creativity and build your SaaS faster than ever with + Makerkit. + </GradientSecondaryText> + </p> + ); +} +``` + +## GradientText + +The `GradientText` component is a text component that applies a gradient color to the text. + +{% component path="marketing/gradient-text" /%} + +```tsx +function GradientTextExample() { + return ( + <p> + <GradientText className={'from-primary/60 to-primary'}> + Unleash your creativity and build your SaaS faster than ever with + Makerkit. + </GradientText> + </p> + ); +} +``` + +You can use the Tailwind CSS gradient utility classes to customize the gradient colors. + +```tsx +<GradientText className={'from-violet-500 to-purple-700'}> + Unleash your creativity and build your SaaS faster than ever with Makerkit. +</GradientText> +``` + +## NewsletterSignupContainer + +The `NewsletterSignupContainer` is a comprehensive component for handling newsletter signups in a marketing context. It manages the entire signup flow, including form display, loading state, and success/error messages. + +{% component path="marketing/newsletter-sign-up" /%} + +### Import + +```jsx +import { NewsletterSignupContainer } from '@kit/ui/marketing'; +``` + +### Props + +The `NewsletterSignupContainer` accepts the following props: +- `onSignup`: the callback function that will notify you of a submission +- `heading`: the heading of the component +- `description`: the description below the heading +- `successMessage`: the text to display on successful submissions +- `errorMessage`: the text to display on errors + +The component also accepts all standard HTML div attributes. + +### Usage + +```tsx +'use client'; + +import { NewsletterSignupContainer } from '@kit/ui/marketing'; + +function WrapperNewsletterComponent() { + const handleNewsletterSignup = async (email: string) => { + // Implement your signup logic here + await apiClient.subscribeToNewsletter(email); + }; + + return ( + <NewsletterSignupContainer + onSignup={handleNewsletterSignup} + heading="Join Our Community" + description="Be the first to know about new features and updates." + successMessage="You're all set! Check your inbox for a confirmation email." + errorMessage="Oops! Something went wrong. Please try again later." + className="max-w-md mx-auto" + /> + ); +} +``` + +Wrap the component into a parent `client` component as you'll need to pass the `onSignup` function to the component. + +The `onSignup` function should handle the signup process, such as making an API request to subscribe the user to the newsletter, whichever provider you're using. + +### Behavior + +1. Initially displays the newsletter signup form. +2. When the form is submitted, it shows a loading spinner. +3. On successful signup, displays a success message. +4. If an error occurs during signup, shows an error message. + +### Styling + +The component uses Tailwind CSS for styling. The container is flexbox-based and centers its content. You can customize the appearance by passing additional classes via the `className` prop. + +### Accessibility + +- Uses semantic HTML structure with appropriate headings. +- Provides clear feedback for form submission states. +- Error and success messages are displayed using the `Alert` component for consistent styling and accessibility. + +### Notes + +- It integrates with other Makerkit UI components like `Alert`, `Heading`, and `Spinner`. +- The actual signup logic is decoupled from the component, allowing for flexibility in implementation. diff --git a/docs/components/multi-step-forms.mdoc b/docs/components/multi-step-forms.mdoc new file mode 100644 index 000000000..25723db13 --- /dev/null +++ b/docs/components/multi-step-forms.mdoc @@ -0,0 +1,345 @@ +--- +status: "published" +label: "Multi Step Forms" +title: "Multi Step Forms in the Next.js Supabase SaaS kit" +description: "Building multi-step forms in the Next.js Supabase SaaS kit" +order: 0 +--- + +{% callout type="warning" title="Deprecated in v3" %} +`MultiStepForm` was removed in v3. Use `Form` with conditional step rendering instead. +{% /callout %} + +The Multi-Step Form Component is a powerful and flexible wrapper around React Hook Form, Zod, and Shadcn UI. It provides a simple API to create multi-step forms with ease, perfect for complex registration processes, surveys, or any scenario where you need to break down a long form into manageable steps. + +## Features + +- Easy integration with React Hook Form and Zod for form management and validation +- Built-in step management +- Customizable layout and styling +- Progress tracking with optional Stepper component +- TypeScript support for type-safe form schemas + +{% component path="multi-step-form" /%} + +## Usage + +Here's a basic example of how to use the Multi-Step Form Component: + +```tsx +import { MultiStepForm, MultiStepFormStep } from '@kit/ui/multi-step-form'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; + +const FormSchema = createStepSchema({ + step1: z.object({ /* ... */ }), + step2: z.object({ /* ... */ }), +}); + +export function MyForm() { + const form = useForm({ + resolver: zodResolver(FormSchema), + // ... + }); + + const onSubmit = (data) => { + // Handle form submission + }; + + return ( + <MultiStepForm schema={FormSchema} form={form} onSubmit={onSubmit}> + <MultiStepFormStep name="step1"> + {/* Step 1 fields */} + </MultiStepFormStep> + <MultiStepFormStep name="step2"> + {/* Step 2 fields */} + </MultiStepFormStep> + </MultiStepForm> + ); +} +``` + +## Key Components + +### MultiStepForm + +The main wrapper component that manages the form state and step progression. + +Props: +- `schema`: Zod schema for form validation +- `form`: React Hook Form's `useForm` instance +- `onSubmit`: Function to handle form submission +- `className`: Optional CSS classes + +### MultiStepFormStep + +Represents an individual step in the form. + +Props: +- `name`: Unique identifier for the step (should match a key in your schema) +- `children`: Step content + +### MultiStepFormHeader + +Optional component for adding a header to your form, often used with the Stepper component. + +### MultiStepFormContextProvider + +Provides access to form context within child components. + +### useMultiStepFormContext + +The hook returns an object with the following properties: + +- `form: UseFormReturn<z.infer<Schema>>` - The original form object. +- `currentStep: string` - The name of the current step. +- `currentStepIndex: number` - The index of the current step (0-based). +- `totalSteps: number` - The total number of steps in the form. +- `isFirstStep: boolean` - Whether the current step is the first step. +- `isLastStep: boolean` - Whether the current step is the last step. +- `nextStep: (e: React.SyntheticEvent) => void` - Function to move to the next step. +- `prevStep: (e: React.SyntheticEvent) => void` - Function to move to the previous step. +- `goToStep: (index: number) => void` - Function to jump to a specific step by index. +- `direction: 'forward' | 'backward' | undefined` - The direction of the last step change. +- `isStepValid: () => boolean` - Function to check if the current step is valid. +- `isValid: boolean` - Whether the entire form is valid. +- `errors: FieldErrors<z.infer<Schema>>` - Form errors from React Hook Form. +- `mutation: UseMutationResult` - A mutation object for handling form submission. + +## Example + +Here's a more complete example of a multi-step form with three steps: Account, Profile, and Review. The form uses Zod for schema validation and React Hook Form for form management. + +```tsx +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { Button } from '@kit/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@kit/ui/form'; +import { Input } from '@kit/ui/input'; +import { + MultiStepForm, + MultiStepFormContextProvider, + MultiStepFormHeader, + MultiStepFormStep, + createStepSchema, + useMultiStepFormContext, +} from '@kit/ui/multi-step-form'; +import { Stepper } from '@kit/ui/stepper'; + +const FormSchema = createStepSchema({ + account: z.object({ + username: z.string().min(3), + email: z.string().email(), + }), + profile: z.object({ + password: z.string().min(8), + age: z.coerce.number().min(18), + }), +}); + +type FormValues = z.infer<typeof FormSchema>; + +export function MultiStepFormDemo() { + const form = useForm<FormValues>({ + resolver: zodResolver(FormSchema), + defaultValues: { + account: { + username: '', + email: '', + }, + profile: { + password: '', + }, + }, + reValidateMode: 'onBlur', + mode: 'onBlur', + }); + + const onSubmit = (data: FormValues) => { + console.log('Form submitted:', data); + }; + + return ( + <MultiStepForm + className={'space-y-10 p-8 rounded-xl border'} + schema={FormSchema} + form={form} + onSubmit={onSubmit} + > + <MultiStepFormHeader + className={'flex w-full flex-col justify-center space-y-6'} + > + <h2 className={'text-xl font-bold'}>Create your account</h2> + + <MultiStepFormContextProvider> + {({ currentStepIndex }) => ( + <Stepper + variant={'numbers'} + steps={['Account', 'Profile', 'Review']} + currentStep={currentStepIndex} + /> + )} + </MultiStepFormContextProvider> + </MultiStepFormHeader> + + <MultiStepFormStep name="account"> + <AccountStep /> + </MultiStepFormStep> + + <MultiStepFormStep name="profile"> + <ProfileStep /> + </MultiStepFormStep> + + <MultiStepFormStep name="review"> + <ReviewStep /> + </MultiStepFormStep> + </MultiStepForm> + ); +} + +function AccountStep() { + const { form, nextStep, isStepValid } = useMultiStepFormContext(); + + return ( + <Form {...form}> + <div className={'flex flex-col gap-4'}> + <FormField + name="account.username" + render={({ field }) => ( + <FormItem> + <FormLabel>Username</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + name="account.email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input type="email" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="flex justify-end"> + <Button onClick={nextStep} disabled={!isStepValid()}> + Next + </Button> + </div> + </div> + </Form> + ); +} + +function ProfileStep() { + const { form, nextStep, prevStep } = useMultiStepFormContext(); + + return ( + <Form {...form}> + <div className={'flex flex-col gap-4'}> + <FormField + name="profile.password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input type="password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + name="profile.age" + render={({ field }) => ( + <FormItem> + <FormLabel>Age</FormLabel> + <FormControl> + <Input type="number" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="flex justify-end space-x-2"> + <Button type={'button'} variant={'outline'} onClick={prevStep}> + Previous + </Button> + + <Button onClick={nextStep}>Next</Button> + </div> + </div> + </Form> + ); +} + +function ReviewStep() { + const { prevStep, form } = useMultiStepFormContext<typeof FormSchema>(); + const values = form.getValues(); + + return ( + <div className={'flex flex-col space-y-4'}> + <div className={'flex flex-col space-y-4'}> + <div>Great! Please review the values.</div> + + <div className={'flex flex-col space-y-2 text-sm'}> + <div> + <span>Username</span>: <span>{values.account.username}</span> + </div> + <div> + <span>Email</span>: <span>{values.account.email}</span> + </div> + <div> + <span>Age</span>: <span>{values.profile.age}</span> + </div> + </div> + </div> + + <div className="flex justify-end space-x-2"> + <Button type={'button'} variant={'outline'} onClick={prevStep}> + Back + </Button> + + <Button type={'submit'}>Create Account</Button> + </div> + </div> + ); +} +``` + +The inner components `AccountStep`, `ProfileStep`, and `ReviewStep` represent the individual steps of the form. They use the `useMultiStepFormContext` hook to access form utilities like `nextStep`, `prevStep`, and `isStepValid`. + +These are built using ShadcnUI - so please [do refer to the ShadcnUI documentation](https://ui.shadcn.com/docs/components/form) for more information on how to use the components. + +## Tips + +1. Use the `createStepSchema` helper to easily create Zod schemas for your multi-step form. +2. Leverage the `useMultiStepFormContext` hook in your step components to access form utilities. +3. Combine with the Stepper component for visual progress indication. +4. Customize the look and feel using the provided `className` props and your own CSS. + +The Multi-Step Form Component simplifies the creation of complex, multi-step forms while providing a great user experience. It's flexible enough to handle a wide variety of use cases while keeping your code clean and maintainable. + + diff --git a/docs/components/page.mdoc b/docs/components/page.mdoc new file mode 100644 index 000000000..9454d1b94 --- /dev/null +++ b/docs/components/page.mdoc @@ -0,0 +1,136 @@ +--- +status: "published" + +label: "Page" +title: "Page Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the Page component in the Next.js Supabase SaaS kit" +order: 5 +--- + + +The Page component is a versatile layout component that provides different page structures based on the specified style. It's designed to create consistent layouts across your application with support for sidebar and header-based designs. + +## Usage + +```jsx +import { Page, PageNavigation, PageBody, PageHeader } from '@kit/ui/page'; + +function MyPage() { + return ( + <Page style="sidebar"> + <PageNavigation> + {/* Navigation content */} + </PageNavigation> + <PageHeader title="Dashboard" description="Welcome to your dashboard"> + {/* Optional header content */} + </PageHeader> + <PageBody> + {/* Main page content */} + </PageBody> + </Page> + ); +} +``` + +## Page Component Props + +- `style?: 'sidebar' | 'header' | 'custom'`: Determines the layout style (default: 'sidebar') +- `contentContainerClassName?: string`: Custom class for the content container +- `className?: string`: Additional classes for the main container +- `sticky?: boolean`: Whether to make the header sticky (for 'header' style) + +## Sub-components + +### PageNavigation + +Wraps the navigation content, typically used within the Page component. + +### PageMobileNavigation + +Wraps the mobile navigation content, displayed only on smaller screens. + +### PageBody + +Contains the main content of the page. + +Props: +- `className?: string`: Additional classes for the body container + +### PageHeader + +Displays the page title and description. + +Props: +- `title?: string | React.ReactNode`: The page title +- `description?: string | React.ReactNode`: The page description +- `className?: string`: Additional classes for the header container + +### PageTitle + +Renders the main title of the page. + +### PageDescription + +Renders the description text below the page title. + +## Layout Styles + +### Sidebar Layout + +The default layout, featuring a sidebar navigation and main content area. + +### Header Layout + +A layout with a top navigation bar and content below. + +### Custom Layout + +Allows for complete custom layouts by directly rendering children. + +## Examples + +### Sidebar Layout + +```jsx +<Page style="sidebar"> + <PageNavigation> + <SidebarContent /> + </PageNavigation> + <PageHeader title="Dashboard" description="Overview of your account"> + <UserMenu /> + </PageHeader> + <PageBody> + <DashboardContent /> + </PageBody> +</Page> +``` + +### Header Layout + +```jsx +<Page style="header" sticky={true}> + <PageNavigation> + <HeaderNavLinks /> + </PageNavigation> + <PageMobileNavigation> + <MobileMenu /> + </PageMobileNavigation> + <PageBody> + <PageHeader title="Profile" description="Manage your account settings" /> + <ProfileSettings /> + </PageBody> +</Page> +``` + +## Customization + +The Page component and its sub-components use Tailwind CSS classes for styling. You can further customize the appearance by passing additional classes through the `className` props or by modifying the default classes in the component implementation. + +## Best Practices + +1. Use consistent layout styles across similar pages for a cohesive user experience. +2. Leverage the PageHeader component to provide clear page titles and descriptions. +3. Utilize the PageNavigation and PageMobileNavigation components to create responsive navigation experiences. +4. When using the 'custom' style, ensure you handle responsive behavior manually. + +The Page component and its related components provide a flexible system for creating structured, responsive layouts in your React application, promoting consistency and ease of maintenance across your project. \ No newline at end of file diff --git a/docs/components/shadcn.mdoc b/docs/components/shadcn.mdoc new file mode 100644 index 000000000..2c3cfe4db --- /dev/null +++ b/docs/components/shadcn.mdoc @@ -0,0 +1,1621 @@ +--- +status: "published" +label: "Shadcn UI" +title: "Use the Shadcn UI components in the Next.js Supabase SaaS kit" +description: "Learn how to use the Shadcn UI components in the Next.js Supabase SaaS kit for building custom UIs" +order: -1 +--- + +Makerkit uses [Shadcn UI](https://ui.shadcn.com) for its UI components. You can use the components in your Next.js Supabase SaaS kit. + +In this page, we showcase the components available in Makerkit and how to use them in your Next.js Supabase SaaS kit. Most of these examples were taken from the Shadcn UI docs, however, we've updated the paths to reflect the path in the kit. + +The components are located at `packages/ui/src/shadcn`. They are exported as named exports, so you can import them directly from the package `@kit/ui`. + +## Accordion + +The `Accordion` component is a collapsible container that allows users to expand and collapse content. It is a great way to organize and present information in a clear and concise manner. + +{% component path="shadcn/accordion" /%} + +```tsx +import { Accordion, AccordionItem } from '@kit/ui/accordion'; + +function MyAccordion() { + return ( + <Accordion> + <AccordionItem title="Section 1"> + <p>Content for Section 1</p> + </AccordionItem> + <AccordionItem title="Section 2"> + <p>Content for Section 2</p> + </AccordionItem> + </Accordion> + ); +} +``` + +## Alert + +The `Alert` component is a visually distinctive and informative message that alerts users of important information or actions. + +### Default Alert + +The default alert component is a versatile and customizable way to display messages to users. It can be used for success, warning, error, or informational purposes. + +{% component path="shadcn/alert/default-alert" /%} + +```tsx +import { InfoIcon } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; + +export default function DefaultAlertDemo() { + return ( + <Alert> + <InfoIcon className="h-5 w-5" /> + + <AlertTitle> + <span>This is a default alert</span> + </AlertTitle> + + <AlertDescription>This is the description of the alert.</AlertDescription> + </Alert> + ); +} +``` + +### Info Alert + +For more informative alerts, you can use the `info` variant. + +{% component path="shadcn/alert/info-alert" /%} + +```tsx +import { InfoIcon } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; + +export default function InfoAlertDemo() { + return ( + <Alert variant={'info'}> + <InfoIcon className="h-5 w-5" /> + + <AlertTitle> + <span>This is an info alert</span> + </AlertTitle> + + <AlertDescription> + This is the description of the alert. + </AlertDescription> + </Alert> + ); +} +``` + +### Success Alert + +For success alerts, use the `success` variant. + +{% component path="shadcn/alert/success-alert" /%} + +```tsx +import { CheckCircleIcon } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; + +export default function SuccessAlertDemo() { + return ( + <Alert variant={'success'}> + <CheckCircleIcon className="h-5 w-5" /> + + <AlertTitle> + <span>This is a success alert</span> + </AlertTitle> + + <AlertDescription> + This is the description of the alert. + </AlertDescription> + </Alert> + ); +} +``` + +### Warning Alert + +For warning alerts, use the `warning` variant. + +{% component path="shadcn/alert/warning-alert" /%} + +```tsx +import { TriangleAlertIcon } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; + +export default function WarningAlertDemo() { + return ( + <Alert variant={'warning'}> + <TriangleAlertIcon className="h-5 w-5" /> + + <AlertTitle> + <span>This is a warning alert</span> + </AlertTitle> + + <AlertDescription>This is the description of the alert.</AlertDescription> + </Alert> + ); +} +``` + +### Destructive Alert + +For error alerts, use the `error` variant. + +{% component path="shadcn/alert/destructive-alert" /%} + +```tsx +import { XCircleIcon } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; + +export default function ErrorAlertDemo() { + return ( + <Alert variant={'destructive'}> + <XCircleIcon className="h-5 w-5" /> + + <AlertTitle> + <span>This is an error alert</span> + </AlertTitle> + + <AlertDescription> + This is the description of the alert. + </AlertDescription> + </Alert> + ); +} +``` + +## AlertDialog + +The `AlertDialog` component is a modal dialog that displays an alert message and provides an action to the user. It is a versatile and customizable way to display messages to users. + +{% component path="shadcn/alert-dialog" /%} + +```tsx +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@kit/ui/alert-dialog'; +import { Button } from '@kit/ui/button'; + +import WrapperComponent from '~/components/content/wrapper'; + +export default function AlertDialogDemo() { + return ( + <WrapperComponent> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="outline">Show Dialog</Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> + <AlertDialogDescription> + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction>Continue</AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </WrapperComponent> + ); +} +``` + +For more information on the `AlertDialog` component, please refer to the [AlertDialog](https://ui.shadcn.com/docs/components/alert-dialog) documentation. + +## Badge + +The `Badge` component is a small label or tag that is displayed inline with other content. It is used to highlight important information or to indicate the status of an item. + +{% component path="shadcn/badge" /%} + +```tsx +import { Badge } from '@kit/ui/badge'; + +function BadgeDemo() { + return ( + <> + <div className="flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <Badge variant={'outline'}>Profile</Badge> + <Badge variant={'outline'}>Settings</Badge> + <Badge variant={'outline'}>Messages</Badge> + </div> + + <div className="flex items-center gap-2"> + <Badge variant={'info'}> + <span>Info</span> + </Badge> + <Badge variant={'success'}> + <span>Success</span> + </Badge> + <Badge variant={'warning'}> + <span>Warning</span> + </Badge> + <Badge variant={'destructive'}> + <span>Error</span> + </Badge> + </div> + </div> + </> + ); +} +``` + +For more information on the `Badge` component, please refer to the [Badge](https://ui.shadcn.com/docs/components/badge) documentation. + +## Breadcrumbs + +The Breadcrumbs component is a navigational component that displays a list of links that represent the user's current location within a website or application. It is commonly used in multi-level navigation menus to provide a clear path for users to navigate back to previous pages. + +{% component path="shadcn/breadcrumbs" /%} + +```tsx +import { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@kit/ui/breadcrumb'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; + +export default function BreadcrumbDemo() { + return ( + <Breadcrumb> + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink href="/">Home</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <DropdownMenu> + <DropdownMenuTrigger className="flex items-center gap-1"> + <BreadcrumbEllipsis className="h-4 w-4" /> + <span className="sr-only">Toggle menu</span> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem>Documentation</DropdownMenuItem> + <DropdownMenuItem>Themes</DropdownMenuItem> + <DropdownMenuItem>GitHub</DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbLink href="/docs/components">Components</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbPage>Breadcrumb</BreadcrumbPage> + </BreadcrumbItem> + </BreadcrumbList> + </Breadcrumb> + ); +} +``` + +For more information on the `Breadcrumbs` component, please refer to the [Breadcrumbs](https://ui.shadcn.com/docs/components/breadcrumbs) documentation. + +## Button + +The `Button` component is a basic button component that can be used to trigger an action or event. + +```tsx +import { Button } from '@kit/ui/button'; + +export default function ButtonDemo() { + return ( + <Button>Button</Button> + ); +} +``` + +{% component path="shadcn/button" /%} + +### Button Variants + +The `Button` component comes with several variants that you can use to style your buttons. You can use the `variant` prop to change the appearance of the button. + +{% component path="shadcn/button-variants" /%} + +```tsx +import { Button } from '@kit/ui/button'; + +export default function ButtonDemo() { + return ( + <Button variant="outline">Outline</Button> + <Button variant="secondary">Secondary</Button> + <Button variant="destructive">Destructive</Button> + ); +} +``` + +For more information on the `Button` component, please refer to the [Button](https://ui.shadcn.com/docs/components/button) documentation. + +## Calendar + +The `Calendar` component is a calendar component that allows users to select a date. + +{% component path="shadcn/calendar" /%} + + +```tsx +import { Calendar } from '@kit/ui/calendar'; + +export default function CalendarDemo() { + return ( + <Calendar /> + ); +} +``` + +For more information on the `Calendar` component, please refer to the [Calendar](https://ui.shadcn.com/docs/components/calendar) documentation. + +## Card + +The `Card` component is a flexible and customizable card component that can be used to display content in a visually appealing way. + +{% component path="shadcn/card" /%} + +```tsx +import * as React from 'react'; + +import { Button } from '@kit/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { Input } from '@kit/ui/input'; +import { Label } from '@kit/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@kit/ui/select'; + +export default function CardWithForm() { + return ( + <Card className="w-[350px]"> + <CardHeader> + <CardTitle>Create project</CardTitle> + <CardDescription>Deploy your new project in one-click.</CardDescription> + </CardHeader> + + <CardContent> + <form> + <div className="grid w-full items-center gap-4"> + <div className="flex flex-col space-y-1.5"> + <Label htmlFor="name">Name</Label> + <Input id="name" placeholder="Name of your project" /> + </div> + <div className="flex flex-col space-y-1.5"> + <Label htmlFor="framework">Framework</Label> + <Select> + <SelectTrigger id="framework"> + <SelectValue placeholder="Select" /> + </SelectTrigger> + <SelectContent position="popper"> + <SelectItem value="next">Next.js</SelectItem> + <SelectItem value="sveltekit">SvelteKit</SelectItem> + <SelectItem value="astro">Astro</SelectItem> + <SelectItem value="nuxt">Nuxt.js</SelectItem> + </SelectContent> + </Select> + </div> + </div> + </form> + </CardContent> + + <CardFooter className="flex justify-between"> + <Button variant="outline">Cancel</Button> + <Button>Deploy</Button> + </CardFooter> + </Card> + ); +} +``` + +For more information on the `Card` component, please refer to the [Card](https://ui.shadcn.com/docs/components/card) documentation. + +## Chart + +Charts are built using Recharts, a powerful charting library that provides a wide range of customization options. + +{% component path="shadcn/chart" /%} + +For more information, please refer to the ShadcnUI documentation for the [Chart component](https://ui.shadcn.com/docs/components/chart). + +## Checkbox + +The `Checkbox` component is a simple and customizable checkbox component that allows users to select one or more options. + +{% component path="shadcn/checkbox" /%} + +```tsx +import { Checkbox } from '@kit/ui/checkbox'; + +export default function CheckboxDemo() { + return ( + <div className="flex items-center space-x-2"> + <Checkbox id="terms" /> + + <label + htmlFor="terms" + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + Accept terms and conditions + </label> + </div> + ); +} +``` + +For more information on the `Checkbox` component, please refer to the [Checkbox](https://ui.shadcn.com/docs/components/checkbox) documentation. + +## Command + +The `Command` component is a powerful and customizable command palette component that allows users to access a wide range of commands and actions. + +{% component path="shadcn/command" lazy="true" /%} + +```tsx +import { + Calculator, + Calendar, + CreditCard, + Settings, + Smile, + User, +} from 'lucide-react'; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from '@kit/ui/command'; + +import WrapperComponent from '~/components/content/wrapper'; + +export default function CommandDemo() { + return ( + <> + <Command className="rounded-lg border shadow-md md:min-w-[450px]"> + <CommandInput placeholder="Type a command or search..." /> + <CommandList> + <CommandEmpty>No results found.</CommandEmpty> + <CommandGroup heading="Suggestions"> + <CommandItem> + <Calendar /> + <span>Calendar</span> + </CommandItem> + <CommandItem> + <Smile /> + <span>Search Emoji</span> + </CommandItem> + <CommandItem disabled> + <Calculator /> + <span>Calculator</span> + </CommandItem> + </CommandGroup> + <CommandSeparator /> + <CommandGroup heading="Settings"> + <CommandItem> + <User /> + <span>Profile</span> + <CommandShortcut>⌘P</CommandShortcut> + </CommandItem> + <CommandItem> + <CreditCard /> + <span>Billing</span> + <CommandShortcut>⌘B</CommandShortcut> + </CommandItem> + <CommandItem> + <Settings /> + <span>Settings</span> + <CommandShortcut>⌘S</CommandShortcut> + </CommandItem> + </CommandGroup> + </CommandList> + </Command> + </> + ); +} +``` + +For more information on the `Command` component, please refer to the [Command](https://ui.shadcn.com/docs/components/command) documentation. + +## Collapsible + +The `Collapsible` component is a powerful and customizable collapsible component that allows users to expand and collapse content. + +{% component path="shadcn/collapsible" /%} + +```tsx +'use client'; + +import * as React from 'react'; + +import { ChevronsUpDown } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@kit/ui/collapsible'; + +export default function CollapsibleDemo() { + const [isOpen, setIsOpen] = React.useState(false); + + return ( + <> + <Collapsible + open={isOpen} + onOpenChange={setIsOpen} + className="w-[350px] space-y-2" + > + <div className="flex items-center justify-between space-x-4 px-4"> + <h4 className="text-sm font-semibold"> + @peduarte starred 3 repositories + </h4> + + <CollapsibleTrigger asChild> + <Button variant="ghost" size="sm"> + <ChevronsUpDown className="h-4 w-4" /> + <span className="sr-only">Toggle</span> + </Button> + </CollapsibleTrigger> + </div> + + <div className="rounded-md border px-4 py-2 font-mono text-sm shadow-sm"> + @base-ui/primitives + </div> + + <CollapsibleContent className="space-y-2"> + <div className="rounded-md border px-4 py-2 font-mono text-sm shadow-sm"> + @base-ui/colors + </div> + + <div className="rounded-md border px-4 py-2 font-mono text-sm shadow-sm"> + @stitches/react + </div> + </CollapsibleContent> + </Collapsible> + </> + ); +} +``` + +For more information on how to use the Collapsible component, refer to the [Collapsible documentation](https://ui.shadcn.com/docs/components/collapsible). + +## Data Table + +The Data Table component uses TanStack React Table to display a table of data. It provides a flexible and customizable way to display data in a tabular format. + +{% component path="shadcn/data-table" /%} + +```tsx +'use client'; + +import * as React from 'react'; + +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { ArrowUpDown, ChevronDown, MoreHorizontal } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; +import { Checkbox } from '@kit/ui/checkbox'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; +import { Input } from '@kit/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@kit/ui/table'; + +const data: Payment[] = [ + { + id: 'm5gr84i9', + amount: 316, + status: 'success', + email: 'ken99@yahoo.com', + }, + { + id: '3u1reuv4', + amount: 242, + status: 'success', + email: 'Abe45@gmail.com', + }, + { + id: 'derv1ws0', + amount: 837, + status: 'processing', + email: 'Monserrat44@gmail.com', + }, + { + id: '5kma53ae', + amount: 874, + status: 'success', + email: 'Silas22@gmail.com', + }, + { + id: 'bhqecj4p', + amount: 721, + status: 'failed', + email: 'carmella@hotmail.com', + }, +]; + +export type Payment = { + id: string; + amount: number; + status: 'pending' | 'processing' | 'success' | 'failed'; + email: string; +}; + +export const columns: ColumnDef<Payment>[] = [ + { + id: 'select', + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && 'indeterminate') + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => ( + <div className="capitalize">{row.getValue('status')}</div> + ), + }, + { + accessorKey: 'email', + header: ({ column }) => { + return ( + <Button + variant="ghost" + onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} + > + Email + <ArrowUpDown /> + </Button> + ); + }, + cell: ({ row }) => <div className="lowercase">{row.getValue('email')}</div>, + }, + { + accessorKey: 'amount', + header: () => <div className="text-right">Amount</div>, + cell: ({ row }) => { + const amount = parseFloat(row.getValue('amount')); + + // Format the amount as a dollar amount + const formatted = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); + + return <div className="text-right font-medium">{formatted}</div>; + }, + }, + { + id: 'actions', + enableHiding: false, + cell: ({ row }) => { + const payment = row.original; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">Open menu</span> + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>Actions</DropdownMenuLabel> + <DropdownMenuItem + onClick={() => navigator.clipboard.writeText(payment.id)} + > + Copy payment ID + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem>View customer</DropdownMenuItem> + <DropdownMenuItem>View payment details</DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + }, +]; + +export default function DataTableDemo() { + const [sorting, setSorting] = React.useState<SortingState>([]); + const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( + [], + ); + const [columnVisibility, setColumnVisibility] = + React.useState<VisibilityState>({}); + const [rowSelection, setRowSelection] = React.useState({}); + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + return ( + <> + <div className="w-full"> + <div className="flex items-center py-4"> + <Input + placeholder="Filter emails..." + value={(table.getColumn('email')?.getFilterValue() as string) ?? ''} + onChange={(event) => + table.getColumn('email')?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" className="ml-auto"> + Columns <ChevronDown /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + <DropdownMenuCheckboxItem + key={column.id} + className="capitalize" + checked={column.getIsVisible()} + onCheckedChange={(value) => + column.toggleVisibility(!!value) + } + > + {column.id} + </DropdownMenuCheckboxItem> + ); + })} + </DropdownMenuContent> + </DropdownMenu> + </div> + <div className="rounded-md border"> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => { + return ( + <TableHead key={header.id}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </TableHead> + ); + })} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && 'selected'} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="h-24 text-center" + > + No results. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + <div className="flex items-center justify-end space-x-2 py-4"> + <div className="flex-1 text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length} of{' '} + {table.getFilteredRowModel().rows.length} row(s) selected. + </div> + <div className="space-x-2"> + <Button + variant="outline" + size="sm" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + Previous + </Button> + <Button + variant="outline" + size="sm" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + Next + </Button> + </div> + </div> + </div> + </> + ); +} +``` + +For more information on how to use the Data Table component, refer to the [Data Table documentation](/docs/next-supabase-turbo/components/data-table). + +For more information about Tanstack Table, refer to the [Tanstack Table documentation](https://tanstack.com/table/v8/docs/api/core/table). + +## Dropdown Menu + +The Dropdown Menu component is a customizable dropdown menu that can be used to display a list of options when a user interacts with a dropdown button. It is a versatile component that can be used in various contexts, such as navigation menus, dropdown menus, and more. + +{% component path="shadcn/dropdown-menu" /%} + +```tsx +import { Button } from '@kit/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; + +export default function DropdownMenuDemo() { + return ( + <> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline">Open</Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-56"> + <DropdownMenuLabel>My Account</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuGroup> + <DropdownMenuItem> + Profile + <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut> + </DropdownMenuItem> + <DropdownMenuItem> + Billing + <DropdownMenuShortcut>⌘B</DropdownMenuShortcut> + </DropdownMenuItem> + <DropdownMenuItem> + Settings + <DropdownMenuShortcut>⌘S</DropdownMenuShortcut> + </DropdownMenuItem> + <DropdownMenuItem> + Keyboard shortcuts + <DropdownMenuShortcut>⌘K</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuGroup> + <DropdownMenuSeparator /> + <DropdownMenuGroup> + <DropdownMenuItem>Team</DropdownMenuItem> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Invite users</DropdownMenuSubTrigger> + <DropdownMenuPortal> + <DropdownMenuSubContent> + <DropdownMenuItem>Email</DropdownMenuItem> + <DropdownMenuItem>Message</DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem>More...</DropdownMenuItem> + </DropdownMenuSubContent> + </DropdownMenuPortal> + </DropdownMenuSub> + <DropdownMenuItem> + New Team + <DropdownMenuShortcut>⌘+T</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuGroup> + <DropdownMenuSeparator /> + <DropdownMenuItem>GitHub</DropdownMenuItem> + <DropdownMenuItem>Support</DropdownMenuItem> + <DropdownMenuItem disabled>API</DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem> + Log out + <DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </> + ); +} +``` + +For more information on how to use the Dropdown Menu component, refer to the [Dropdown Menu documentation](https://ui.shadcn.com/docs/components/dropdown-menu). + +## Form + +The `Form` component wraps `react-hook-form` and provides a simple and customizable form component. + +{% component path="shadcn/form" /%} + +```tsx +'use client'; + +import { useForm } from 'react-hook-form'; +import { Button } from '@kit/ui/button'; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@kit/ui/form'; +import { Input } from '@kit/ui/input'; +import { Textarea } from '@kit/ui/textarea'; + +export default function FormDemo() { + const form = useForm({ + defaultValues: { + name: 'John Doe', + email: 'john@doe.com', + message: 'Hello, world!', + }, + }); + + return ( + <> + <Form {...form}> + <form + className="flex flex-col space-y-4" + onSubmit={form.handleSubmit((data) => { + console.log(data); + })} + > + <FormField + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input {...field} type="text" placeholder="Enter your name" /> + </FormControl> + <FormDescription>Please enter your name.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + {...field} + type="email" + placeholder="Enter your email" + /> + </FormControl> + <FormDescription>Please enter your email.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + name="message" + render={({ field }) => ( + <FormItem> + <FormLabel>Message</FormLabel> + <FormControl> + <Textarea {...field} placeholder="Enter your message" /> + </FormControl> + <FormDescription>Please enter your message.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <div className="flex justify-end"> + <Button type="submit">Submit</Button> + </div> + </form> + </Form> + </> + ); +} +``` + +For more information on how to use the Form component, refer to the [Form documentation](https://ui.shadcn.com/docs/components/form). + +## Heading + +The `Heading` component is a reusable component for rendering headings with different levels. + +{% component path="shadcn/heading" /%} + +```tsx +import { Heading } from '@kit/ui/heading'; + +function HeadingDemo() { + return ( + <> + <Heading level={1}>Heading 1</Heading> + <Heading level={2}>Heading 2</Heading> + <Heading level={3}>Heading 3</Heading> + <Heading level={4}>Heading 4</Heading> + <Heading level={5}>Heading 5</Heading> + <Heading level={6}>Heading 6</Heading> + </> + ); +} +``` + +For more information on how to use the Heading component, refer to the [Heading documentation](https://ui.shadcn.com/docs/components/heading). + +## Input + +The `Input` component is a customizable input field component that allows users to enter text. + +{% component path="shadcn/input" /%} + +```tsx +import { Input } from '@kit/ui/input'; + +function InputDemo() { + return ( + <div className="flex flex-col space-y-4"> + <Input placeholder="Enter your name" /> + <Input type="email" placeholder="Enter your email" /> + <Input type="password" placeholder="Enter your password" /> + <Input type="number" placeholder="Enter your age" /> + <Input type="tel" placeholder="Enter your phone number" /> + <Input type="url" placeholder="Enter your website URL" /> + </div> + ); +} +``` + +For more information on how to use the Input component, refer to the [Input documentation](https://ui.shadcn.com/docs/components/input). + +## Input OTP + +The `InputOTP` component is a customizable input field component that allows users to enter one-time passwords. + +{% component path="shadcn/input-otp" /%} + +```tsx +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from '@kit/ui/input-otp'; + +export default function InputOTPDemo() { + return ( + <div> + <InputOTP maxLength={6}> + <InputOTPGroup> + <InputOTPSlot index={0} /> + <InputOTPSlot index={1} /> + <InputOTPSlot index={2} /> + </InputOTPGroup> + <InputOTPSeparator /> + <InputOTPGroup> + <InputOTPSlot index={3} /> + <InputOTPSlot index={4} /> + <InputOTPSlot index={5} /> + </InputOTPGroup> + </InputOTP> + </div> + ); +} +``` + +For more information on how to use the Input OTP component, refer to the [Input OTP documentation](https://ui.shadcn.com/docs/components/input-otp). + +## Label + +The `Label` component is a reusable component for rendering labels with different styles. + +{% component path="shadcn/label" /%} + +```tsx +import { Checkbox } from '@kit/ui/checkbox'; +import { Label } from '@kit/ui/label'; + +import WrapperComponent from '~/components/content/wrapper'; + +export default function LabelDemo() { + return ( + <div> + <div className="flex items-center space-x-2"> + <Checkbox id="terms" /> + <Label htmlFor="terms">Accept terms and conditions</Label> + </div> + </WrapperComponent> + ); +} +``` + +For more information on how to use the Label component, refer to the [Label documentation](https://ui.shadcn.com/docs/components/label). + +## Navigation Menu + +The navigation menu component is a wrapper around the [Base UI Navigation Menu](https://base-ui.com/react/components/navigation-menu) component. + +{% component path="shadcn/navigation-menu.tsx" /%} + +```tsx +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, +} from '@kit/ui/navigation-menu'; + +export default function NavigationMenuDemo() { + return ( + <div> + <NavigationMenu> + <NavigationMenuList> + <NavigationMenuItem> + <NavigationMenuTrigger>Item One</NavigationMenuTrigger> + + <NavigationMenuContent> + <NavigationMenuLink>Link</NavigationMenuLink> + </NavigationMenuContent> + </NavigationMenuItem> + </NavigationMenuList> + </NavigationMenu> + </div> + ); +} +``` + +For more information on how to use the Navigation Menu component, refer to the [Navigation Menu documentation](https://ui.shadcn.com/docs/components/navigation-menu). + +## Popover + +THe Popover component helps you create a floating element that floats around a reference element. + +{% component path="shadcn/popover.tsx" /%} + +```tsx +import { Button } from '@kit/ui/button'; +import { Input } from '@kit/ui/input'; +import { Label } from '@kit/ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover'; + +export default function PopoverDemo() { + return ( + <div> + <div className="flex flex-col gap-4 items-center justify-center"> + <Popover> + <PopoverTrigger asChild> + <Button variant="outline">Open popover</Button> + </PopoverTrigger> + + <PopoverContent className="w-80"> + <div className="grid gap-4"> + <div className="space-y-2"> + <h4 className="font-medium leading-none">Dimensions</h4> + <p className="text-sm text-muted-foreground"> + Set the dimensions for the layer. + </p> + </div> + <div className="grid gap-2"> + <div className="grid grid-cols-3 items-center gap-4"> + <Label htmlFor="width">Width</Label> + <Input + id="width" + defaultValue="100%" + className="col-span-2 h-8" + /> + </div> + <div className="grid grid-cols-3 items-center gap-4"> + <Label htmlFor="maxWidth">Max. width</Label> + <Input + id="maxWidth" + defaultValue="300px" + className="col-span-2 h-8" + /> + </div> + <div className="grid grid-cols-3 items-center gap-4"> + <Label htmlFor="height">Height</Label> + <Input + id="height" + defaultValue="25px" + className="col-span-2 h-8" + /> + </div> + <div className="grid grid-cols-3 items-center gap-4"> + <Label htmlFor="maxHeight">Max. height</Label> + <Input + id="maxHeight" + defaultValue="none" + className="col-span-2 h-8" + /> + </div> + </div> + </div> + </PopoverContent> + </Popover> + </div> + </div> + ); +} +``` + +For more information on how to use the Popover component, refer to the [Popover documentation](https://ui.shadcn.com/docs/components/popover). + +## Radio Group + +The Radio Group component is a wrapper around the `Radio` component. It provides a more convenient way to work with radio buttons. + +{% component path="shadcn/radio-group" /%} + +```tsx +import { Label } from '@kit/ui/label'; +import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group'; + +export default function RadioGroupDemo() { + return ( + <RadioGroup defaultValue="comfortable"> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="default" id="r1" /> + <Label htmlFor="r1">Default</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="comfortable" id="r2" /> + <Label htmlFor="r2">Comfortable</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="compact" id="r3" /> + <Label htmlFor="r3">Compact</Label> + </div> + </RadioGroup> + ); +} +``` + +For more information on how to use the Radio Group component, refer to the [Radio Group documentation](https://ui.shadcn.com/docs/components/radio-group). + +## Select + +The `Select` component is a customizable select component that allows users to select an option from a list of options. + +{% component path="shadcn/select" /%} + +```tsx +import * as React from 'react'; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@kit/ui/select'; + +export default function SelectDemo() { + return ( + <div> + <Select> + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Select a fruit" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectLabel>Fruits</SelectLabel> + <SelectItem value="apple">Apple</SelectItem> + <SelectItem value="banana">Banana</SelectItem> + <SelectItem value="blueberry">Blueberry</SelectItem> + <SelectItem value="grapes">Grapes</SelectItem> + <SelectItem value="pineapple">Pineapple</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </div> + ); +} +``` + +For more information on the `Select` component, please refer to the [Select](https://ui.shadcn.com/docs/components/select) documentation. + +## Skeleton + +The `Skeleton` component is used to display a loading state while content is being fetched or rendered. It can be used to create a placeholder state for your components. + +{% component path="shadcn/skeleton" /%} + +```tsx +import { Skeleton } from '@kit/ui/skeleton'; + +export default function SkeletonDemo() { + return ( + <div className="flex flex-col gap-4"> + <Skeleton className="h-6 w-full" /> + ); +} +``` + +For more information on the `Skeleton` component, please refer to the [Skeleton](https://ui.shadcn.com/docs/components/skeleton) documentation. + +## Switch + +The `Switch` component is a customizable switch component that allows users to toggle between on and off states. + +{% component path="shadcn/switch" /%} + +```tsx +import { Label } from '@kit/ui/label'; +import { Switch } from '@kit/ui/switch'; + +export default function SwitchDemo() { + return ( + <div> + <div className="flex items-center space-x-2"> + <Switch id="airplane-mode" /> + <Label htmlFor="airplane-mode">Airplane Mode</Label> + </div> + </div> + ); +} +``` + +For more information on the `Switch` component, please refer to the [Switch](https://ui.shadcn.com/docs/components/switch) documentation. + +## Tabs + +The `Tabs` component is a customizable tabs component that allows users to switch between different views or sections. + +{% component path="shadcn/tabs" /%} + +```tsx +import { Button } from '@kit/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { Input } from '@kit/ui/input'; +import { Label } from '@kit/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs'; + +export default function TabsDemo() { + return ( + <Tabs defaultValue="account" className="w-[400px]"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="account">Account</TabsTrigger> + <TabsTrigger value="password">Password</TabsTrigger> + </TabsList> + <TabsContent value="account"> + <Card> + <CardHeader> + <CardTitle>Account</CardTitle> + <CardDescription> + Make changes to your account here. Click save when you're done. + </CardDescription> + </CardHeader> + <CardContent className="space-y-2"> + <div className="space-y-1"> + <Label htmlFor="name">Name</Label> + <Input id="name" defaultValue="Pedro Duarte" /> + </div> + <div className="space-y-1"> + <Label htmlFor="username">Username</Label> + <Input id="username" defaultValue="@peduarte" /> + </div> + </CardContent> + <CardFooter> + <Button>Save changes</Button> + </CardFooter> + </Card> + </TabsContent> + <TabsContent value="password"> + <Card> + <CardHeader> + <CardTitle>Password</CardTitle> + <CardDescription> + Change your password here. After saving, you'll be logged out. + </CardDescription> + </CardHeader> + <CardContent className="space-y-2"> + <div className="space-y-1"> + <Label htmlFor="current">Current password</Label> + <Input id="current" type="password" /> + </div> + <div className="space-y-1"> + <Label htmlFor="new">New password</Label> + <Input id="new" type="password" /> + </div> + </CardContent> + <CardFooter> + <Button>Save password</Button> + </CardFooter> + </Card> + </TabsContent> + </Tabs> + ); +} + +``` + +For more information on the `Tabs` component, please refer to the [Tabs](https://ui.shadcn.com/docs/components/tabs) documentation. + +## Textarea + +The `Textarea` component is a customizable textarea component that allows users to enter multiple lines of text. + +{% component path="shadcn/textarea" /%} + +```tsx +import { Textarea } from '@kit/ui/textarea'; + +function TextareaDemo() { + return ( + <div className="flex flex-col space-y-4"> + <Textarea placeholder="Enter your message" /> + </div> + ); +} +``` + +For more information on the `Textarea` component, please refer to the [Textarea](https://ui.shadcn.com/docs/components/textarea) documentation. + +## Tooltip + +The `Tooltip` component is a customizable tooltip component that displays additional information when hovering over an element. + +{% component path="shadcn/tooltip" /%} + +```tsx +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kit/ui/tooltip'; +import { Button } from '@kit/ui/button'; + +export default function TooltipDemo() { + return ( + <div> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="outline">Hover over me</Button> + </TooltipTrigger> + + <TooltipContent> + This is a tooltip with some content. + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); +} +``` + +For more information on the `Tooltip` component, please refer to the [Tooltip](https://ui.shadcn.com/docs/components/tooltip) documentation. \ No newline at end of file diff --git a/docs/components/stepper.mdoc b/docs/components/stepper.mdoc new file mode 100644 index 000000000..89a4442b0 --- /dev/null +++ b/docs/components/stepper.mdoc @@ -0,0 +1,111 @@ +--- +status: "published" +label: "Stepper" +title: "Stepper Component in the Next.js Supabase SaaS kit" +description: "Learn how to use the Stepper component in the Next.js Supabase SaaS kit" +order: 1 +--- + +The Stepper component is a versatile UI element designed to display a series of steps in a process or form. It provides visual feedback on the current step and supports different visual styles. + +{% component path="steppers/stepper" /%} + +## Usage + +```jsx +import { Stepper } from '@kit/ui/stepper'; + +function MyComponent() { + return ( + <Stepper + steps={['Step 1', 'Step 2', 'Step 3']} + currentStep={1} + variant="default" + /> + ); +} +``` + +## Props + +The Stepper component accepts the following props: + +- `steps: string[]` (required): An array of strings representing the labels for each step. +- `currentStep: number` (required): The index of the currently active step (0-based). +- `variant?: 'numbers' | 'default'` (optional): The visual style of the stepper. Defaults to 'default'. + +## Variants + +The Stepper component supports two visual variants: + +1. `default`: Displays steps as a horizontal line with labels underneath. +2. `numbers`: Displays steps as numbered circles with labels between them. + +{% component path="steppers/stepper-numbers" /%} + +## Features + +- Responsive design that adapts to different screen sizes +- Dark mode support +- Customizable appearance through CSS classes and variants +- Accessibility support with proper ARIA attributes + +## Component Breakdown + +### Main Stepper Component + +The main `Stepper` function renders the overall structure of the component. It: + +- Handles prop validation and default values +- Renders nothing if there are fewer than two steps +- Uses a callback to render individual steps +- Applies different CSS classes based on the chosen variant + +### Steps Rendering + +Steps are rendered using a combination of divs and spans, with different styling applied based on: + +- Whether the step is currently selected +- The chosen variant (default or numbers) + +### StepDivider Component + +For the 'numbers' variant, a `StepDivider` component is used to render the labels between numbered steps. It includes: + +- Styling for selected and non-selected states +- A divider line between steps (except for the last step) + +## Styling + +The component uses a combination of: + +- Tailwind CSS classes for basic styling +- `cva` (Class Variance Authority) for managing variant-based styling +- `classNames` function for conditional class application + +## Accessibility + +- The component uses `aria-selected` to indicate the current step +- Labels are associated with their respective steps for screen readers + +## Customization + +You can further customize the appearance of the Stepper by: + +1. Modifying the `classNameBuilder` function to add or change CSS classes +2. Adjusting the Tailwind CSS classes in the component JSX +3. Creating new variants in the `cva` configuration + +## Example + +```jsx +<Stepper + steps={['Account', 'Personal Info', 'Review']} + currentStep={1} + variant="numbers" +/> +``` + +This will render a numbered stepper with three steps, where "Personal Info" is the current (selected) step. + +The Stepper component provides a flexible and visually appealing way to guide users through multi-step processes in your application. Its support for different variants and easy customization makes it adaptable to various design requirements. \ No newline at end of file diff --git a/docs/configuration/application-configuration.mdoc b/docs/configuration/application-configuration.mdoc new file mode 100644 index 000000000..d31e86fd3 --- /dev/null +++ b/docs/configuration/application-configuration.mdoc @@ -0,0 +1,133 @@ +--- +status: "published" +label: "Application Configuration" +title: "Application Configuration in the Next.js Supabase SaaS Kit" +description: "Configure your app name, URL, theme colors, and locale settings in the Next.js Supabase SaaS Kit using environment variables." +order: 1 +--- + +The application configuration at `apps/web/config/app.config.ts` defines your SaaS application's core settings: name, URL, theme, and locale. Configure these using environment variables rather than editing the file directly. + +{% alert type="default" title="Configuration Approach" %} +All configuration is driven by environment variables and validated with Zod at build time. Invalid configuration fails the build immediately, preventing deployment of broken settings. +{% /alert %} + +## Configuration Options + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_PRODUCT_NAME` | Yes | Your product name (e.g., "Acme SaaS") | +| `NEXT_PUBLIC_SITE_TITLE` | Yes | Browser title tag and SEO title | +| `NEXT_PUBLIC_SITE_DESCRIPTION` | Yes | Meta description for SEO | +| `NEXT_PUBLIC_SITE_URL` | Yes | Full URL with protocol (e.g., `https://myapp.com`) | +| `NEXT_PUBLIC_DEFAULT_LOCALE` | No | Default language code (default: `en`) | +| `NEXT_PUBLIC_DEFAULT_THEME_MODE` | No | Theme: `light`, `dark`, or `system` | +| `NEXT_PUBLIC_THEME_COLOR` | Yes | Light theme color (hex, e.g., `#ffffff`) | +| `NEXT_PUBLIC_THEME_COLOR_DARK` | Yes | Dark theme color (hex, e.g., `#0a0a0a`) | + +## Basic Setup + +Add these to your `.env` file: + +```bash +NEXT_PUBLIC_SITE_URL=https://myapp.com +NEXT_PUBLIC_PRODUCT_NAME="My SaaS App" +NEXT_PUBLIC_SITE_TITLE="My SaaS App - Build Faster" +NEXT_PUBLIC_SITE_DESCRIPTION="The easiest way to build your SaaS application" +NEXT_PUBLIC_DEFAULT_LOCALE=en +NEXT_PUBLIC_DEFAULT_THEME_MODE=light +NEXT_PUBLIC_THEME_COLOR="#ffffff" +NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a" +``` + +## How It Works + +The configuration file parses environment variables through a Zod schema: + +```typescript +const appConfig = AppConfigSchema.parse({ + name: process.env.NEXT_PUBLIC_PRODUCT_NAME, + title: process.env.NEXT_PUBLIC_SITE_TITLE, + description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION, + url: process.env.NEXT_PUBLIC_SITE_URL, + locale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE, + theme: process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE, + themeColor: process.env.NEXT_PUBLIC_THEME_COLOR, + themeColorDark: process.env.NEXT_PUBLIC_THEME_COLOR_DARK, + production, +}); +``` + +## Environment-Specific Configuration + +Structure your environment files for different deployment stages: + +| File | Purpose | +|------|---------| +| `.env` | Shared settings across all environments | +| `.env.development` | Local development overrides | +| `.env.production` | Production-specific settings | +| `.env.local` | Local secrets (git-ignored) | + +**Example for development:** + +```bash +# .env.development +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +``` + +**Example for production:** + +```bash +# .env.production (or CI/CD environment) +NEXT_PUBLIC_SITE_URL=https://myapp.com +``` + +## Common Configuration Scenarios + +### B2C SaaS Application + +```bash +NEXT_PUBLIC_PRODUCT_NAME="PhotoEdit Pro" +NEXT_PUBLIC_SITE_TITLE="PhotoEdit Pro - Edit Photos Online" +NEXT_PUBLIC_SITE_DESCRIPTION="Professional photo editing in your browser. No download required." +NEXT_PUBLIC_SITE_URL=https://photoedit.pro +NEXT_PUBLIC_DEFAULT_THEME_MODE=system +``` + +### B2B SaaS Application + +```bash +NEXT_PUBLIC_PRODUCT_NAME="TeamFlow" +NEXT_PUBLIC_SITE_TITLE="TeamFlow - Project Management for Teams" +NEXT_PUBLIC_SITE_DESCRIPTION="Streamline your team's workflow with powerful project management tools." +NEXT_PUBLIC_SITE_URL=https://teamflow.io +NEXT_PUBLIC_DEFAULT_THEME_MODE=light +``` + +## Common Pitfalls + +1. **HTTP in production**: The build fails if `NEXT_PUBLIC_SITE_URL` uses `http://` in production. Always use `https://` for production deployments. +2. **Same theme colors**: The build fails if `NEXT_PUBLIC_THEME_COLOR` equals `NEXT_PUBLIC_THEME_COLOR_DARK`. They must be different values. +3. **Missing trailing slash**: Don't include a trailing slash in `NEXT_PUBLIC_SITE_URL`. Use `https://myapp.com` not `https://myapp.com/`. +4. **Forgetting to rebuild**: Environment variable changes require a rebuild. Run `pnpm build` after changing production values. + +## Accessing Configuration in Code + +Import the configuration anywhere in your application: + +```typescript +import appConfig from '~/config/app.config'; + +// Access values +console.log(appConfig.name); // "My SaaS App" +console.log(appConfig.url); // "https://myapp.com" +console.log(appConfig.theme); // "light" +console.log(appConfig.production); // true/false +``` + +## Related Topics + +- [Environment Variables](/docs/next-supabase-turbo/configuration/environment-variables) - Complete environment variable reference +- [Feature Flags](/docs/next-supabase-turbo/configuration/feature-flags-configuration) - Enable or disable features +- [Going to Production](/docs/next-supabase-turbo/going-to-production/checklist) - Production deployment checklist diff --git a/docs/configuration/authentication-configuration.mdoc b/docs/configuration/authentication-configuration.mdoc new file mode 100644 index 000000000..4fd305de9 --- /dev/null +++ b/docs/configuration/authentication-configuration.mdoc @@ -0,0 +1,206 @@ +--- +status: "published" +label: "Authentication Configuration" +title: "Authentication Configuration: Password, Magic Link, OAuth, MFA" +description: "Configure email/password, magic link, OTP, and OAuth authentication in the Next.js Supabase SaaS Kit. Set up password requirements, identity linking, and CAPTCHA protection." +order: 2 +--- + +The authentication configuration at `apps/web/config/auth.config.ts` controls which sign-in methods are available and how they behave. Configure using environment variables to enable password, magic link, OTP, or OAuth authentication. + +{% alert type="default" title="Quick Setup" %} +Password authentication is enabled by default. To switch to magic link or add OAuth providers, set the corresponding environment variables and configure the providers in your Supabase Dashboard. +{% /alert %} + +## Authentication Methods + +| Method | Environment Variable | Default | Description | +|--------|---------------------|---------|-------------| +| Password | `NEXT_PUBLIC_AUTH_PASSWORD` | `true` | Traditional email/password | +| Magic Link | `NEXT_PUBLIC_AUTH_MAGIC_LINK` | `false` | Passwordless email links | +| OTP | `NEXT_PUBLIC_AUTH_OTP` | `false` | One-time password codes | +| OAuth | Configure in code | `['google']` | Third-party providers | + +## Basic Configuration + +```bash +# Enable password authentication (default) +NEXT_PUBLIC_AUTH_PASSWORD=true +NEXT_PUBLIC_AUTH_MAGIC_LINK=false +NEXT_PUBLIC_AUTH_OTP=false +``` + +## Switching to Magic Link + +```bash +NEXT_PUBLIC_AUTH_PASSWORD=false +NEXT_PUBLIC_AUTH_MAGIC_LINK=true +``` + +## Switching to OTP + +```bash +NEXT_PUBLIC_AUTH_PASSWORD=false +NEXT_PUBLIC_AUTH_OTP=true +``` + +When using OTP, update your Supabase email templates in `apps/web/supabase/config.toml`: + +```toml +[auth.email.template.confirmation] +subject = "Confirm your email" +content_path = "./supabase/templates/otp.html" + +[auth.email.template.magic_link] +subject = "Sign in to Makerkit" +content_path = "./supabase/templates/otp.html" +``` + +Also update the templates in your Supabase Dashboard under **Authentication > Templates** for production. + +## OAuth Providers + +### Supported Providers + +The kit supports all Supabase OAuth providers: + +| Provider | ID | Provider | ID | +|----------|-----|----------|-----| +| Apple | `apple` | Kakao | `kakao` | +| Azure | `azure` | Keycloak | `keycloak` | +| Bitbucket | `bitbucket` | LinkedIn | `linkedin` | +| Discord | `discord` | LinkedIn OIDC | `linkedin_oidc` | +| Facebook | `facebook` | Notion | `notion` | +| Figma | `figma` | Slack | `slack` | +| GitHub | `github` | Spotify | `spotify` | +| GitLab | `gitlab` | Twitch | `twitch` | +| Google | `google` | Twitter | `twitter` | +| Fly | `fly` | WorkOS | `workos` | +| | | Zoom | `zoom` | + +### Configuring OAuth Providers + +OAuth providers are configured in two places: + +1. **Supabase Dashboard**: Enable and configure credentials (Client ID, Client Secret) +2. **Code**: Display in the sign-in UI + +Edit `apps/web/config/auth.config.ts` to change which providers appear: + +```typescript +providers: { + password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true', + magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true', + otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true', + oAuth: ['google', 'github'], // Add providers here +} +``` + +{% alert type="warning" title="Provider Configuration" %} +Adding a provider to the array only displays it in the UI. You must also configure the provider in your Supabase Dashboard with valid credentials. See [Supabase's OAuth documentation](https://supabase.com/docs/guides/auth/social-login). +{% /alert %} + +### OAuth Scopes + +Some providers require specific scopes. Configure them in `packages/features/auth/src/components/oauth-providers.tsx`: + +```tsx +const OAUTH_SCOPES: Partial<Record<Provider, string>> = { + azure: 'email', + keycloak: 'openid', + // add your OAuth providers here +}; +``` + +The kit ships with Azure and Keycloak scopes configured. Add additional providers as needed based on their OAuth requirements. + +### Local Development OAuth + +For local OAuth testing, configure your providers in `apps/web/supabase/config.toml`. See [Supabase's local development OAuth guide](https://supabase.com/docs/guides/local-development/managing-config). + +## Identity Linking + +Allow users to link multiple authentication methods (e.g., link Google to an existing email account): + +```bash +NEXT_PUBLIC_AUTH_IDENTITY_LINKING=true +``` + +This must also be enabled in your Supabase Dashboard under **Authentication > Settings**. + +## Password Requirements + +Enforce password strength rules: + +```bash +NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE=true +NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS=true +NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS=true +``` + +These rules validate: +1. At least one uppercase letter +2. At least one number +3. At least one special character + +## CAPTCHA Protection + +Protect authentication forms with Cloudflare Turnstile: + +```bash +NEXT_PUBLIC_CAPTCHA_SITE_KEY=your-site-key +CAPTCHA_SECRET_TOKEN=your-secret-token +``` + +Get your keys from the [Cloudflare Turnstile dashboard](https://dash.cloudflare.com/?to=/:account/turnstile). + +## Terms and Conditions + +Display a terms checkbox during sign-up: + +```bash +NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX=true +``` + +## MFA (Multi-Factor Authentication) + +MFA is built into Supabase Auth. To enforce MFA for specific operations: + +1. Enable MFA in your Supabase Dashboard +2. Customize RLS policies per [Supabase's MFA documentation](https://supabase.com/blog/mfa-auth-via-rls) + +The super admin dashboard already requires MFA for access. + +## How It Works + +The configuration file parses environment variables through a Zod schema: + +```typescript +const authConfig = AuthConfigSchema.parse({ + captchaTokenSiteKey: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY, + displayTermsCheckbox: + process.env.NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX === 'true', + enableIdentityLinking: + process.env.NEXT_PUBLIC_AUTH_IDENTITY_LINKING === 'true', + providers: { + password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true', + magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true', + otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true', + oAuth: ['google'], + }, +}); +``` + +## Common Pitfalls + +1. **OAuth not working**: Ensure the provider is configured in both the code and Supabase Dashboard with matching credentials. +2. **Magic link emails not arriving**: Check your email configuration and Supabase email templates. For local development, emails go to Mailpit at `localhost:54324`. +3. **OTP using wrong template**: Both OTP and magic link use the same Supabase email template type. Use `otp.html` for OTP or `magic-link.html` for magic links, but not both simultaneously. +4. **Identity linking fails**: Must be enabled in both environment variables and Supabase Dashboard. + +## Related Topics + +- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - Check user authentication status in code +- [Production Authentication](/docs/next-supabase-turbo/going-to-production/authentication) - Configure authentication for production +- [Authentication Emails](/docs/next-supabase-turbo/emails/authentication-emails) - Customize email templates +- [Environment Variables](/docs/next-supabase-turbo/configuration/environment-variables) - Complete variable reference diff --git a/docs/configuration/environment-variables.mdoc b/docs/configuration/environment-variables.mdoc new file mode 100644 index 000000000..544cf12e8 --- /dev/null +++ b/docs/configuration/environment-variables.mdoc @@ -0,0 +1,305 @@ +--- +status: "published" +title: "Environment Variables Reference for the Next.js Supabase SaaS Kit" +label: "Environment Variables" +order: 0 +description: "Complete reference for all environment variables in the Next.js Supabase SaaS Kit, including Supabase, Stripe, email, and feature flag configuration." +--- + +This page documents all environment variables used by the Next.js Supabase SaaS Kit. Variables are organized by category and include their purpose, required status, and default values. + +## Environment File Structure + +| File | Purpose | Git Status | +|------|---------|------------| +| `.env` | Shared settings across all environments | Committed | +| `.env.development` | Development-specific overrides | Committed | +| `.env.production` | Production-specific settings | Committed | +| `.env.local` | Local secrets and overrides | Git-ignored | + +**Priority order**: `.env.local` > `.env.development`/`.env.production` > `.env` + +## Required Variables + +These variables must be set for the application to start: + +```bash +# Supabase (required) +NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co +NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=your-public-key +SUPABASE_SECRET_KEY=your-service-role-key + +# App identity (required) +NEXT_PUBLIC_SITE_URL=https://yourapp.com +NEXT_PUBLIC_PRODUCT_NAME=Your Product +NEXT_PUBLIC_SITE_TITLE="Your Product - Tagline" +NEXT_PUBLIC_SITE_DESCRIPTION="Your product description" +``` + +## Core Configuration + +### Site Identity + +```bash +NEXT_PUBLIC_SITE_URL=https://example.com +NEXT_PUBLIC_PRODUCT_NAME=Makerkit +NEXT_PUBLIC_SITE_TITLE="Makerkit - Build SaaS Faster" +NEXT_PUBLIC_SITE_DESCRIPTION="Production-ready SaaS starter kit" +NEXT_PUBLIC_DEFAULT_LOCALE=en +``` + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_SITE_URL` | Yes | Full URL with protocol | +| `NEXT_PUBLIC_PRODUCT_NAME` | Yes | Product name shown in UI | +| `NEXT_PUBLIC_SITE_TITLE` | Yes | Browser title and SEO | +| `NEXT_PUBLIC_SITE_DESCRIPTION` | Yes | Meta description | +| `NEXT_PUBLIC_DEFAULT_LOCALE` | No | Default language (default: `en`) | + +### Theme + +```bash +NEXT_PUBLIC_DEFAULT_THEME_MODE=light +NEXT_PUBLIC_THEME_COLOR="#ffffff" +NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a" +NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true +``` + +| Variable | Options | Default | Description | +|----------|---------|---------|-------------| +| `NEXT_PUBLIC_DEFAULT_THEME_MODE` | `light`, `dark`, `system` | `light` | Initial theme | +| `NEXT_PUBLIC_THEME_COLOR` | Hex color | Required | Light theme color | +| `NEXT_PUBLIC_THEME_COLOR_DARK` | Hex color | Required | Dark theme color | +| `NEXT_PUBLIC_ENABLE_THEME_TOGGLE` | `true`, `false` | `true` | Allow theme switching | + +## Supabase Configuration + +```bash +NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co +NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=your-public-key +SUPABASE_SECRET_KEY=your-service-role-key +SUPABASE_DB_WEBHOOK_SECRET=your-webhook-secret +``` + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL | +| `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Yes | Public anon key | +| `SUPABASE_SECRET_KEY` | Yes | Service role key (keep secret) | +| `SUPABASE_DB_WEBHOOK_SECRET` | No | Webhook verification secret | + +{% alert type="warning" title="Legacy Key Names" %} +If you're using a version prior to 2.12.0, use `NEXT_PUBLIC_SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` instead. +{% /alert %} + +## Authentication + +```bash +NEXT_PUBLIC_AUTH_PASSWORD=true +NEXT_PUBLIC_AUTH_MAGIC_LINK=false +NEXT_PUBLIC_AUTH_OTP=false +NEXT_PUBLIC_AUTH_IDENTITY_LINKING=false +NEXT_PUBLIC_CAPTCHA_SITE_KEY= +CAPTCHA_SECRET_TOKEN= +NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX=false +``` + +| Variable | Default | Description | +|----------|---------|-------------| +| `NEXT_PUBLIC_AUTH_PASSWORD` | `true` | Enable password auth | +| `NEXT_PUBLIC_AUTH_MAGIC_LINK` | `false` | Enable magic link auth | +| `NEXT_PUBLIC_AUTH_OTP` | `false` | Enable OTP auth | +| `NEXT_PUBLIC_AUTH_IDENTITY_LINKING` | `false` | Allow identity linking | +| `NEXT_PUBLIC_CAPTCHA_SITE_KEY` | - | Cloudflare Turnstile site key | +| `CAPTCHA_SECRET_TOKEN` | - | Cloudflare Turnstile secret | +| `NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX` | `false` | Show terms checkbox | + +### Password Requirements + +```bash +NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE=false +NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS=false +NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS=false +``` + +## Navigation and Layout + +```bash +NEXT_PUBLIC_USER_NAVIGATION_STYLE=sidebar +NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED=false +NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar +NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED=false +NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=icon +NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER=true +``` + +| Variable | Options | Default | Description | +|----------|---------|---------|-------------| +| `NEXT_PUBLIC_USER_NAVIGATION_STYLE` | `sidebar`, `header` | `sidebar` | Personal nav layout | +| `NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED` | `true`, `false` | `false` | Start collapsed | +| `NEXT_PUBLIC_TEAM_NAVIGATION_STYLE` | `sidebar`, `header` | `sidebar` | Team nav layout | +| `NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED` | `true`, `false` | `false` | Start collapsed | +| `NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE` | `offcanvas`, `icon`, `none` | `icon` | Collapse behavior | +| `NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER` | `true`, `false` | `true` | Show collapse button | + +## Feature Flags + +```bash +NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=false +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=false +NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true +NEXT_PUBLIC_REALTIME_NOTIFICATIONS=false +NEXT_PUBLIC_ENABLE_VERSION_UPDATER=false +NEXT_PUBLIC_LANGUAGE_PRIORITY=application +``` + +| Variable | Default | Description | +|----------|---------|-------------| +| `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION` | `false` | Users can delete accounts | +| `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING` | `false` | Personal subscription billing | +| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS` | `true` | Enable team features | +| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION` | `true` | Users can create teams | +| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION` | `false` | Users can delete teams | +| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING` | `false` | Team subscription billing | +| `NEXT_PUBLIC_ENABLE_NOTIFICATIONS` | `true` | In-app notifications | +| `NEXT_PUBLIC_REALTIME_NOTIFICATIONS` | `false` | Live notification updates | +| `NEXT_PUBLIC_ENABLE_VERSION_UPDATER` | `false` | Check for updates | +| `NEXT_PUBLIC_LANGUAGE_PRIORITY` | `application` | `user` or `application` | + +## Billing Configuration + +### Provider Selection + +```bash +NEXT_PUBLIC_BILLING_PROVIDER=stripe +``` + +Options: `stripe` or `lemon-squeezy` + +### Stripe + +```bash +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +``` + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Yes (Stripe) | Publishable key | +| `STRIPE_SECRET_KEY` | Yes (Stripe) | Secret key | +| `STRIPE_WEBHOOK_SECRET` | Yes (Stripe) | Webhook signing secret | + +### Lemon Squeezy + +```bash +LEMON_SQUEEZY_SECRET_KEY=your-secret-key +LEMON_SQUEEZY_STORE_ID=your-store-id +LEMON_SQUEEZY_SIGNING_SECRET=your-signing-secret +``` + +| Variable | Required | Description | +|----------|----------|-------------| +| `LEMON_SQUEEZY_SECRET_KEY` | Yes (LS) | API secret key | +| `LEMON_SQUEEZY_STORE_ID` | Yes (LS) | Store identifier | +| `LEMON_SQUEEZY_SIGNING_SECRET` | Yes (LS) | Webhook signing secret | + +## Email Configuration + +### Provider Selection + +```bash +MAILER_PROVIDER=nodemailer +``` + +Options: `nodemailer` or `resend` + +### Common Settings + +```bash +EMAIL_SENDER="Your App <noreply@yourapp.com>" +CONTACT_EMAIL=contact@yourapp.com +``` + +### Resend + +```bash +RESEND_API_KEY=re_... +``` + +### Nodemailer (SMTP) + +```bash +EMAIL_HOST=smtp.provider.com +EMAIL_PORT=587 +EMAIL_USER=your-username +EMAIL_PASSWORD=your-password +EMAIL_TLS=true +``` + +## CMS Configuration + +### Provider Selection + +```bash +CMS_CLIENT=keystatic +``` + +Options: `keystatic` or `wordpress` + +### Keystatic + +```bash +NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=local +NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content +KEYSTATIC_PATH_PREFIX=apps/web +``` + +For GitHub storage: + +```bash +NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github +NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO=owner/repo +KEYSTATIC_GITHUB_TOKEN=github_pat_... +``` + +| Variable | Options | Description | +|----------|---------|-------------| +| `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND` | `local`, `cloud`, `github` | Storage backend | +| `NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH` | Path | Content directory | +| `KEYSTATIC_PATH_PREFIX` | Path | Monorepo prefix | +| `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` | `owner/repo` | GitHub repository | +| `KEYSTATIC_GITHUB_TOKEN` | Token | GitHub access token | + +### WordPress + +```bash +WORDPRESS_API_URL=https://your-site.com/wp-json +``` + +## Security Best Practices + +1. **Never commit secrets**: Use `.env.local` for sensitive values +2. **Use CI/CD variables**: Store production secrets in your deployment platform +3. **Rotate keys regularly**: Especially after team member changes +4. **Validate in production**: The kit validates configuration at build time + +## Common Pitfalls + +1. **HTTP in production**: `NEXT_PUBLIC_SITE_URL` must use `https://` in production builds. +2. **Same theme colors**: `NEXT_PUBLIC_THEME_COLOR` and `NEXT_PUBLIC_THEME_COLOR_DARK` must be different. +3. **Missing Supabase keys**: The app won't start without valid Supabase credentials. +4. **Forgetting to restart**: After changing environment variables, you may need to restart the development server. +5. **Wrong file for secrets**: Put secrets in `.env.local` (git-ignored), not `.env` (committed). + +## Related Topics + +- [Application Configuration](/docs/next-supabase-turbo/configuration/application-configuration) - Core app settings +- [Authentication Configuration](/docs/next-supabase-turbo/configuration/authentication-configuration) - Auth setup +- [Feature Flags](/docs/next-supabase-turbo/configuration/feature-flags-configuration) - Toggle features +- [Going to Production](/docs/next-supabase-turbo/going-to-production/checklist) - Deployment checklist diff --git a/docs/configuration/feature-flags-configuration.mdoc b/docs/configuration/feature-flags-configuration.mdoc new file mode 100644 index 000000000..20acec833 --- /dev/null +++ b/docs/configuration/feature-flags-configuration.mdoc @@ -0,0 +1,260 @@ +--- +status: "published" +label: "Feature Flags" +title: "Feature Flags Configuration in the Next.js Supabase SaaS Kit" +description: "Enable or disable team accounts, billing, notifications, and theme toggling in the Next.js Supabase SaaS Kit using feature flags." +order: 4 +--- + +The feature flags configuration at `apps/web/config/feature-flags.config.ts` controls which features are enabled in your application. Toggle team accounts, billing, notifications, and more using environment variables. + +{% alert type="default" title="Feature Flags vs Configuration" %} +Feature flags control whether functionality is available to users. Use them to ship different product tiers, run A/B tests, or disable features during maintenance. Unlike configuration, feature flags are meant to change at runtime or between deployments. +{% /alert %} + +{% alert type="warning" title="Defaults Note" %} +The "Default" column shows what the code uses if the environment variable is not set. The kit's `.env` file ships with different values to demonstrate features. Check your `.env` file for the actual starting values. +{% /alert %} + +## Available Feature Flags + +| Flag | Environment Variable | Default | Description | +|------|---------------------|---------|-------------| +| Theme Toggle | `NEXT_PUBLIC_ENABLE_THEME_TOGGLE` | `true` | Allow users to switch themes | +| Account Deletion | `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION` | `false` | Users can delete their accounts | +| Team Accounts | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS` | `true` | Enable team/organization features | +| Team Creation | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION` | `true` | Users can create new teams | +| Team Deletion | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION` | `false` | Users can delete their teams | +| Personal Billing | `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING` | `false` | Billing for personal accounts | +| Team Billing | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING` | `false` | Billing for team accounts | +| Notifications | `NEXT_PUBLIC_ENABLE_NOTIFICATIONS` | `true` | In-app notification system | +| Realtime Notifications | `NEXT_PUBLIC_REALTIME_NOTIFICATIONS` | `false` | Live notification updates | +| Version Updater | `NEXT_PUBLIC_ENABLE_VERSION_UPDATER` | `false` | Check for app updates | +| Teams Only | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY` | `false` | Skip personal accounts, use teams only | +| Language Priority | `NEXT_PUBLIC_LANGUAGE_PRIORITY` | `application` | User vs app language preference | + +## Common Configurations + +### B2C SaaS (Personal Accounts Only) + +For consumer applications where each user has their own account and subscription: + +```bash +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=false +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true +``` + +### B2B SaaS (Team Accounts Only) + +For business applications where organizations subscribe and manage team members. Enable `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY` to skip personal accounts entirely: + +```bash +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false +``` + +When `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true`: + +- Users are automatically redirected away from personal account routes to their team workspace +- The personal account section in the sidebar/workspace switcher is hidden +- After sign-in, users land on their team dashboard instead of a personal home page +- The last selected team is remembered in a cookie so returning users go straight to their team + +This is the recommended approach for B2B apps. It removes the personal account layer entirely so users only interact with team workspaces. + +### Hybrid Model (Both Personal and Team) + +For applications supporting both individual users and teams (uncommon): + +```bash +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true +``` + +### Managed Onboarding (No Self-Service Team Creation) + +For applications where you create teams on behalf of customers: + +```bash +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=false +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true +``` + +## Decision Matrix + +Use this matrix to decide which flags to enable: + +| Use Case | Theme | Teams | Team Creation | Personal Billing | Team Billing | Deletion | +|----------|-------|-------|---------------|------------------|--------------|----------| +| B2C Consumer App | Yes | No | - | Yes | - | Yes | +| B2B Team SaaS | Optional | Yes | Yes | No | Yes | Optional | +| Enterprise SaaS | Optional | Yes | No | No | Yes | No | +| Freemium Personal | Yes | No | - | Yes | - | Yes | +| Marketplace | Optional | Yes | Yes | Yes | No | Yes | + +## How It Works + +The configuration file parses environment variables with sensible defaults: + +```typescript +const featuresFlagConfig = FeatureFlagsSchema.parse({ + enableThemeToggle: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_THEME_TOGGLE, + true, + ), + enableAccountDeletion: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION, + false, + ), + enableTeamDeletion: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION, + false, + ), + enableTeamAccounts: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS, + true, + ), + enableTeamCreation: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION, + true, + ), + enablePersonalAccountBilling: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING, + false, + ), + enableTeamAccountBilling: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING, + false, + ), + languagePriority: process.env.NEXT_PUBLIC_LANGUAGE_PRIORITY, + enableNotifications: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS, + true, + ), + realtimeNotifications: getBoolean( + process.env.NEXT_PUBLIC_REALTIME_NOTIFICATIONS, + false, + ), + enableVersionUpdater: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_VERSION_UPDATER, + false, + ), +}); +``` + +## Using Feature Flags in Code + +### In Server Components + +```typescript +import featureFlagsConfig from '~/config/feature-flags.config'; + +export default function SettingsPage() { + return ( + <div> + {featureFlagsConfig.enableTeamAccounts && ( + <TeamAccountsSection /> + )} + {featureFlagsConfig.enableAccountDeletion && ( + <DeleteAccountButton /> + )} + </div> + ); +} +``` + +### In Client Components + +```tsx +'use client'; + +import featureFlagsConfig from '~/config/feature-flags.config'; + +export function ThemeToggle() { + if (!featureFlagsConfig.enableThemeToggle) { + return null; + } + + return <ThemeSwitch />; +} +``` + +### Conditional Navigation + +The navigation configuration files already use feature flags: + +```typescript +// From personal-account-navigation.config.tsx +featureFlagsConfig.enablePersonalAccountBilling + ? { + label: 'common.routes.billing', + path: pathsConfig.app.personalAccountBilling, + Icon: <CreditCard className={iconClasses} />, + } + : undefined, +``` + +## Feature Flag Details + +### Theme Toggle + +Controls whether users can switch between light and dark themes. When disabled, the app uses `NEXT_PUBLIC_DEFAULT_THEME_MODE` exclusively. + +### Account Deletion + +Allows users to permanently delete their personal accounts. Disabled by default to prevent accidental data loss. Consider enabling for GDPR compliance. + +### Team Accounts + +Master switch for all team functionality. When disabled, the application operates in personal-account-only mode. Disabling this also hides team-related navigation and features. + +### Team Creation + +Controls whether users can create new teams. Set to `false` for enterprise scenarios where you provision teams manually. + +### Team Deletion + +Allows team owners to delete their teams. Disabled by default to prevent accidental data loss. + +### Personal vs Team Billing + +Choose one based on your business model: + +- **Personal billing**: Each user subscribes individually (B2C) +- **Team billing**: Organizations subscribe and add team members (B2B) + +Enabling both is possible but uncommon. Most SaaS applications use one model. + +### Notifications + +Enables the in-app notification system. When combined with `realtimeNotifications`, notifications appear instantly via Supabase Realtime. + +### Language Priority + +Controls language selection behavior: + +- `application`: Use the app's default locale +- `user`: Respect the user's browser language preference + +### Version Updater + +When enabled, the app checks for updates and notifies users. Useful for deployed applications that receive frequent updates. + +## Common Pitfalls + +1. **Enabling both billing modes**: While technically possible, enabling both personal and team billing may create confusing user experience (unless your business model is a hybrid of both). Choose one model. +2. **Disabling teams after launch**: If you've collected team data and then disable teams, users lose access. Plan your model before launch. +3. **Forgetting deletion flows**: If you enable deletion, ensure you also handle cascading data deletion and GDPR compliance. +4. **Realtime without base notifications**: `realtimeNotifications` requires `enableNotifications` to be true. The realtime flag adds live updates, not the notification system itself. + +## Related Topics + +- [Application Configuration](/docs/next-supabase-turbo/configuration/application-configuration) - Core app settings +- [Environment Variables](/docs/next-supabase-turbo/configuration/environment-variables) - Complete variable reference +- [Navigation Configuration](/docs/next-supabase-turbo/configuration/personal-account-sidebar-configuration) - Sidebar customization diff --git a/docs/configuration/paths-configuration.mdoc b/docs/configuration/paths-configuration.mdoc new file mode 100644 index 000000000..6a6de62bc --- /dev/null +++ b/docs/configuration/paths-configuration.mdoc @@ -0,0 +1,214 @@ +--- +status: "published" +label: "Paths Configuration" +title: "Paths Configuration in the Next.js Supabase SaaS Kit" +description: "Configure route paths for authentication, personal accounts, and team accounts in the Next.js Supabase SaaS Kit. Centralized path management for consistent navigation." +order: 3 +--- + +The paths configuration at `apps/web/config/paths.config.ts` centralizes all route definitions. Instead of scattering magic strings throughout your codebase, reference paths from this single configuration file. + +{% alert type="default" title="Why Centralize Paths" %} +Centralizing paths prevents typos, makes refactoring easier, and ensures consistency across navigation, redirects, and links throughout your application. +{% /alert %} + +## Default Paths + +### Authentication Paths + +| Path Key | Default Value | Description | +|----------|---------------|-------------| +| `auth.signIn` | `/auth/sign-in` | Sign in page | +| `auth.signUp` | `/auth/sign-up` | Sign up page | +| `auth.verifyMfa` | `/auth/verify` | MFA verification | +| `auth.callback` | `/auth/callback` | OAuth callback handler | +| `auth.passwordReset` | `/auth/password-reset` | Password reset request | +| `auth.passwordUpdate` | `/update-password` | Password update completion | + +### Personal Account Paths + +| Path Key | Default Value | Description | +|----------|---------------|-------------| +| `app.home` | `/home` | Personal dashboard | +| `app.personalAccountSettings` | `/home/settings` | Profile settings | +| `app.personalAccountBilling` | `/home/billing` | Billing management | +| `app.personalAccountBillingReturn` | `/home/billing/return` | Billing portal return | + +### Team Account Paths + +| Path Key | Default Value | Description | +|----------|---------------|-------------| +| `app.accountHome` | `/home/[account]` | Team dashboard | +| `app.accountSettings` | `/home/[account]/settings` | Team settings | +| `app.accountBilling` | `/home/[account]/billing` | Team billing | +| `app.accountMembers` | `/home/[account]/members` | Team members | +| `app.accountBillingReturn` | `/home/[account]/billing/return` | Team billing return | +| `app.joinTeam` | `/join` | Team invitation acceptance | + +## Configuration File + +```typescript +import * as z from 'zod'; + +const PathsSchema = z.object({ + auth: z.object({ + signIn: z.string().min(1), + signUp: z.string().min(1), + verifyMfa: z.string().min(1), + callback: z.string().min(1), + passwordReset: z.string().min(1), + passwordUpdate: z.string().min(1), + }), + app: z.object({ + home: z.string().min(1), + personalAccountSettings: z.string().min(1), + personalAccountBilling: z.string().min(1), + personalAccountBillingReturn: z.string().min(1), + accountHome: z.string().min(1), + accountSettings: z.string().min(1), + accountBilling: z.string().min(1), + accountMembers: z.string().min(1), + accountBillingReturn: z.string().min(1), + joinTeam: z.string().min(1), + }), +}); + +const pathsConfig = PathsSchema.parse({ + auth: { + signIn: '/auth/sign-in', + signUp: '/auth/sign-up', + verifyMfa: '/auth/verify', + callback: '/auth/callback', + passwordReset: '/auth/password-reset', + passwordUpdate: '/update-password', + }, + app: { + home: '/home', + personalAccountSettings: '/home/settings', + personalAccountBilling: '/home/billing', + personalAccountBillingReturn: '/home/billing/return', + accountHome: '/home/[account]', + accountSettings: `/home/[account]/settings`, + accountBilling: `/home/[account]/billing`, + accountMembers: `/home/[account]/members`, + accountBillingReturn: `/home/[account]/billing/return`, + joinTeam: '/join', + }, +}); + +export default pathsConfig; +``` + +## Using Paths in Code + +### In Server Components and Actions + +```typescript +import pathsConfig from '~/config/paths.config'; +import { redirect } from 'next/navigation'; + +// Redirect to sign in +redirect(pathsConfig.auth.signIn); + +// Redirect to team dashboard +const teamSlug = 'acme-corp'; +redirect(pathsConfig.app.accountHome.replace('[account]', teamSlug)); +``` + +### In Client Components + +```tsx +import pathsConfig from '~/config/paths.config'; +import Link from 'next/link'; + +function Navigation() { + return ( + <nav> + <Link href={pathsConfig.app.home}>Dashboard</Link> + <Link href={pathsConfig.app.personalAccountSettings}>Settings</Link> + </nav> + ); +} +``` + +### Dynamic Team Paths + +Team account paths contain `[account]` as a placeholder. Replace it with the actual team slug: + +```typescript +import pathsConfig from '~/config/paths.config'; + +function getTeamPaths(teamSlug: string) { + return { + dashboard: pathsConfig.app.accountHome.replace('[account]', teamSlug), + settings: pathsConfig.app.accountSettings.replace('[account]', teamSlug), + billing: pathsConfig.app.accountBilling.replace('[account]', teamSlug), + members: pathsConfig.app.accountMembers.replace('[account]', teamSlug), + }; +} + +// Usage +const paths = getTeamPaths('acme-corp'); +// paths.dashboard = '/home/acme-corp' +// paths.settings = '/home/acme-corp/settings' +``` + +## Adding Custom Paths + +Extend the schema when adding new routes to your application: + +```typescript +const PathsSchema = z.object({ + auth: z.object({ + // ... existing auth paths + }), + app: z.object({ + // ... existing app paths + // Add your custom paths + projects: z.string().min(1), + projectDetail: z.string().min(1), + }), +}); + +const pathsConfig = PathsSchema.parse({ + auth: { + // ... existing values + }, + app: { + // ... existing values + projects: '/home/[account]/projects', + projectDetail: '/home/[account]/projects/[projectId]', + }, +}); +``` + +Then use the new paths: + +```typescript +const projectsPath = pathsConfig.app.projects + .replace('[account]', teamSlug); + +const projectDetailPath = pathsConfig.app.projectDetail + .replace('[account]', teamSlug) + .replace('[projectId]', projectId); +``` + +## Path Conventions + +Follow these conventions when adding paths: + +1. **Use lowercase with hyphens**: `/home/my-projects` not `/home/myProjects` +2. **Use brackets for dynamic segments**: `[account]`, `[projectId]` +3. **Keep paths shallow**: Avoid deeply nested routes when possible +4. **Group related paths**: Put team-related paths under `app.account*` + +## Common Pitfalls + +1. **Forgetting to replace dynamic segments**: Always replace `[account]` with the actual slug before using team paths in redirects or links. +2. **Hardcoding paths**: Don't use string literals like `'/home/settings'`. Always import from `pathsConfig` for consistency. +3. **Missing trailing slash consistency**: The kit doesn't use trailing slashes. Keep this consistent in your custom paths. + +## Related Topics + +- [Navigation Configuration](/docs/next-supabase-turbo/configuration/personal-account-sidebar-configuration) - Configure sidebar navigation +- [App Router Structure](/docs/next-supabase-turbo/installation/navigating-codebase) - Understand the route organization diff --git a/docs/configuration/personal-account-sidebar-configuration.mdoc b/docs/configuration/personal-account-sidebar-configuration.mdoc new file mode 100644 index 000000000..016c1759c --- /dev/null +++ b/docs/configuration/personal-account-sidebar-configuration.mdoc @@ -0,0 +1,291 @@ +--- +status: "published" +label: "Personal Account Navigation" +title: "Personal Account Navigation in Next.js Supabase" +description: "Configure the personal account sidebar navigation, layout style, and menu structure in the Next.js Supabase SaaS Kit." +order: 5 +--- + +The personal account navigation at `apps/web/config/personal-account-navigation.config.tsx` defines the sidebar menu for personal workspaces. Add your own routes here to extend the dashboard navigation. + +{% alert type="default" title="Where to Add Routes" %} +This is the file you'll edit most often when building your product. Add dashboard pages, settings sections, and feature-specific navigation items here. +{% /alert %} + +## Layout Options + +| Variable | Options | Default | Description | +|----------|---------|---------|-------------| +| `NEXT_PUBLIC_USER_NAVIGATION_STYLE` | `sidebar`, `header` | `sidebar` | Navigation layout style | +| `NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED` | `true`, `false` | `false` | Start with collapsed sidebar | +| `NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE` | `offcanvas`, `icon`, `none` | `icon` | How sidebar collapses | + +### Sidebar Style (Default) + +```bash +NEXT_PUBLIC_USER_NAVIGATION_STYLE=sidebar +``` + +Shows a vertical sidebar on the left with expandable sections. + +### Header Style + +```bash +NEXT_PUBLIC_USER_NAVIGATION_STYLE=header +``` + +Shows navigation in a horizontal header bar. + +### Collapse Behavior + +Control how the sidebar behaves when collapsed: + +```bash +# Icon mode: Shows icons only when collapsed +NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=icon + +# Offcanvas mode: Slides in/out as an overlay +NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=offcanvas + +# None: Sidebar cannot be collapsed +NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=none +``` + +## Default Configuration + +The kit ships with these routes: + +```tsx +import { CreditCard, Home, User } from 'lucide-react'; +import * as z from 'zod'; + +import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; + +import featureFlagsConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; + +const iconClasses = 'w-4'; + +const routes = [ + { + label: 'common.routes.application', + children: [ + { + label: 'common.routes.home', + path: pathsConfig.app.home, + Icon: <Home className={iconClasses} />, + highlightMatch: `${pathsConfig.app.home}$`, + }, + ], + }, + { + label: 'common.routes.settings', + children: [ + { + label: 'common.routes.profile', + path: pathsConfig.app.personalAccountSettings, + Icon: <User className={iconClasses} />, + }, + featureFlagsConfig.enablePersonalAccountBilling + ? { + label: 'common.routes.billing', + path: pathsConfig.app.personalAccountBilling, + Icon: <CreditCard className={iconClasses} />, + } + : undefined, + ].filter((route) => !!route), + }, +] satisfies z.output<typeof NavigationConfigSchema>['routes']; + +export const personalAccountNavigationConfig = NavigationConfigSchema.parse({ + routes, + style: process.env.NEXT_PUBLIC_USER_NAVIGATION_STYLE, + sidebarCollapsed: process.env.NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED, + sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE, +}); +``` + +## Adding Custom Routes + +### Simple Route + +Add a route to an existing section: + +```tsx +const routes = [ + { + label: 'common.routes.application', + children: [ + { + label: 'common.routes.home', + path: pathsConfig.app.home, + Icon: <Home className={iconClasses} />, + highlightMatch: `${pathsConfig.app.home}$`, + }, + { + label: 'Projects', + path: '/home/projects', + Icon: <Folder className={iconClasses} />, + }, + ], + }, + // ... rest of routes +]; +``` + +### New Section + +Add a new collapsible section: + +```tsx +const routes = [ + // ... existing sections + { + label: 'Your Store', + children: [ + { + label: 'Products', + path: '/home/products', + Icon: <Package className={iconClasses} />, + }, + { + label: 'Orders', + path: '/home/orders', + Icon: <ShoppingCart className={iconClasses} />, + }, + { + label: 'Analytics', + path: '/home/analytics', + Icon: <BarChart className={iconClasses} />, + }, + ], + }, +]; +``` + +### Nested Routes + +Create sub-menus within a section: + +```tsx +const routes = [ + { + label: 'Dashboard', + children: [ + { + label: 'Overview', + path: '/home', + Icon: <LayoutDashboard className={iconClasses} />, + highlightMatch: '^/home$', + }, + { + label: 'Projects', + path: '/home/projects', + Icon: <Folder className={iconClasses} />, + children: [ + { + label: 'Active', + path: '/home/projects/active', + Icon: <Play className={iconClasses} />, + }, + { + label: 'Archived', + path: '/home/projects/archived', + Icon: <Archive className={iconClasses} />, + }, + ], + }, + ], + }, +]; +``` + +### Conditional Routes + +Show routes based on feature flags or user state: + +```tsx +const routes = [ + { + label: 'common.routes.settings', + children: [ + { + label: 'common.routes.profile', + path: pathsConfig.app.personalAccountSettings, + Icon: <User className={iconClasses} />, + }, + // Only show billing if enabled + featureFlagsConfig.enablePersonalAccountBilling + ? { + label: 'common.routes.billing', + path: pathsConfig.app.personalAccountBilling, + Icon: <CreditCard className={iconClasses} />, + } + : undefined, + ].filter((route) => !!route), + }, +]; +``` + +## Route Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `label` | `string` | Yes | Display text (supports i18n keys) | +| `path` | `string` | Yes | Route path | +| `Icon` | `ReactNode` | No | Lucide icon component | +| `highlightMatch` | `string` | No | Regex pattern for active route highlighting | +| `children` | `Route[]` | No | Nested routes for sub-menus | + +## Internationalization + +Route labels support i18n keys: + +```tsx +{ + label: 'common.routes.dashboard', // Uses translation + path: '/home', + Icon: <Home className={iconClasses} />, +} +``` + +Translation files are in `apps/web/i18n/messages/[locale]/common.json`: + +```json +{ + "routes": { + "dashboard": "Dashboard", + "settings": "Settings", + "profile": "Profile" + } +} +``` + +For quick prototyping, use plain strings: + +```tsx +{ + label: 'My Projects', // Plain string + path: '/home/projects', + Icon: <Folder className={iconClasses} />, +} +``` + +## Best Practices + +1. **Use `pathsConfig`**: Import paths from the configuration instead of hardcoding strings. +2. **Group logically**: Put related routes in the same section. +3. **Use feature flags**: Conditionally show routes based on billing plans or user permissions. +4. **Consistent icons**: Use icons from `lucide-react` for visual consistency. + +## Common Pitfalls + +1. **Missing `highlightMatch` property**: The home route needs `highlightMatch` with a regex pattern to prevent it from matching all paths starting with `/home`. +2. **Forgetting to filter undefined**: When using conditional routes, always filter out `undefined` values with `.filter((route) => !!route)`. +3. **Wrong icon size**: Use `w-4` class for icons to match the navigation style. + +## Related Topics + +- [Team Account Navigation](/docs/next-supabase-turbo/configuration/team-account-sidebar-configuration) - Configure team navigation +- [Paths Configuration](/docs/next-supabase-turbo/configuration/paths-configuration) - Centralized path management +- [Adding Pages](/docs/next-supabase-turbo/development/marketing-pages) - Create new dashboard pages diff --git a/docs/configuration/team-account-sidebar-configuration.mdoc b/docs/configuration/team-account-sidebar-configuration.mdoc new file mode 100644 index 000000000..db8114d23 --- /dev/null +++ b/docs/configuration/team-account-sidebar-configuration.mdoc @@ -0,0 +1,265 @@ +--- +status: "published" +label: "Team Account Navigation" +title: "Team Account Navigation Configuration in the Next.js Supabase SaaS Kit" +description: "Configure the team account sidebar navigation, layout style, and menu structure in the Next.js Supabase SaaS Kit for B2B team workspaces." +order: 6 +--- + +The team account navigation at `apps/web/config/team-account-navigation.config.tsx` defines the sidebar menu for team workspaces. This configuration differs from personal navigation because routes include the team slug as a dynamic segment. + +{% alert type="default" title="Team Context" %} +Team navigation routes use `[account]` as a placeholder that gets replaced with the actual team slug at runtime (e.g., `/home/acme-corp/settings`). +{% /alert %} + +## Layout Options + +| Variable | Options | Default | Description | +|----------|---------|---------|-------------| +| `NEXT_PUBLIC_TEAM_NAVIGATION_STYLE` | `sidebar`, `header` | `sidebar` | Navigation layout style | +| `NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED` | `true`, `false` | `false` | Start with collapsed sidebar | +| `NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE` | `offcanvas`, `icon`, `none` | `icon` | How sidebar collapses | + +### Sidebar Style (Default) + +```bash +NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar +``` + +### Header Style + +```bash +NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=header +``` + +## Default Configuration + +The kit ships with these team routes: + +```tsx +import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react'; + +import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; + +import featureFlagsConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; + +const iconClasses = 'w-4'; + +const getRoutes = (account: string) => [ + { + label: 'common.routes.application', + children: [ + { + label: 'common.routes.dashboard', + path: pathsConfig.app.accountHome.replace('[account]', account), + Icon: <LayoutDashboard className={iconClasses} />, + highlightMatch: `${pathsConfig.app.accountHome.replace('[account]', account)}$`, + }, + ], + }, + { + label: 'common.routes.settings', + collapsible: false, + children: [ + { + label: 'common.routes.settings', + path: createPath(pathsConfig.app.accountSettings, account), + Icon: <Settings className={iconClasses} />, + }, + { + label: 'common.routes.members', + path: createPath(pathsConfig.app.accountMembers, account), + Icon: <Users className={iconClasses} />, + }, + featureFlagsConfig.enableTeamAccountBilling + ? { + label: 'common.routes.billing', + path: createPath(pathsConfig.app.accountBilling, account), + Icon: <CreditCard className={iconClasses} />, + } + : undefined, + ].filter(Boolean), + }, +]; + +export function getTeamAccountSidebarConfig(account: string) { + return NavigationConfigSchema.parse({ + routes: getRoutes(account), + style: process.env.NEXT_PUBLIC_TEAM_NAVIGATION_STYLE, + sidebarCollapsed: process.env.NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED, + sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE, + }); +} + +function createPath(path: string, account: string) { + return path.replace('[account]', account); +} +``` + +## Key Differences from Personal Navigation + +| Aspect | Personal Navigation | Team Navigation | +|--------|--------------------|-----------------:| +| Export | `personalAccountNavigationConfig` (object) | `getTeamAccountSidebarConfig(account)` (function) | +| Paths | Static (e.g., `/home/settings`) | Dynamic (e.g., `/home/acme-corp/settings`) | +| Context | Single user workspace | Team-specific workspace | + +## Adding Custom Routes + +### Simple Route + +Add a route to an existing section. Use the `createPath` helper to inject the team slug: + +```tsx +const getRoutes = (account: string) => [ + { + label: 'common.routes.application', + children: [ + { + label: 'common.routes.dashboard', + path: createPath(pathsConfig.app.accountHome, account), + Icon: <LayoutDashboard className={iconClasses} />, + highlightMatch: `${createPath(pathsConfig.app.accountHome, account)}$`, + }, + { + label: 'Projects', + path: createPath('/home/[account]/projects', account), + Icon: <Folder className={iconClasses} />, + }, + ], + }, + // ... rest of routes +]; +``` + +### New Section + +Add a new section for team-specific features: + +```tsx +const getRoutes = (account: string) => [ + // ... existing sections + { + label: 'Workspace', + children: [ + { + label: 'Projects', + path: createPath('/home/[account]/projects', account), + Icon: <Folder className={iconClasses} />, + }, + { + label: 'Documents', + path: createPath('/home/[account]/documents', account), + Icon: <FileText className={iconClasses} />, + }, + { + label: 'Integrations', + path: createPath('/home/[account]/integrations', account), + Icon: <Plug className={iconClasses} />, + }, + ], + }, +]; +``` + +### Conditional Routes + +Show routes based on feature flags or team permissions: + +```tsx +const getRoutes = (account: string) => [ + { + label: 'common.routes.settings', + collapsible: false, + children: [ + { + label: 'common.routes.settings', + path: createPath(pathsConfig.app.accountSettings, account), + Icon: <Settings className={iconClasses} />, + }, + { + label: 'common.routes.members', + path: createPath(pathsConfig.app.accountMembers, account), + Icon: <Users className={iconClasses} />, + }, + // Only show billing if enabled + featureFlagsConfig.enableTeamAccountBilling + ? { + label: 'common.routes.billing', + path: createPath(pathsConfig.app.accountBilling, account), + Icon: <CreditCard className={iconClasses} />, + } + : undefined, + ].filter(Boolean), + }, +]; +``` + +## Route Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `label` | `string` | Yes | Display text (supports i18n keys) | +| `path` | `string` | Yes | Route path with team slug | +| `Icon` | `ReactNode` | No | Lucide icon component | +| `highlightMatch` | `string` | No | Regex pattern for active route highlighting | +| `children` | `Route[]` | No | Nested routes for sub-menus | +| `collapsible` | `boolean` | No | Whether section can collapse | + +## Using the createPath Helper + +Always use the `createPath` helper to replace `[account]` with the team slug: + +```tsx +function createPath(path: string, account: string) { + return path.replace('[account]', account); +} + +// Usage +const settingsPath = createPath('/home/[account]/settings', 'acme-corp'); +// Result: '/home/acme-corp/settings' +``` + +For paths defined in `pathsConfig`, use the same pattern: + +```tsx +createPath(pathsConfig.app.accountSettings, account) +// Converts '/home/[account]/settings' to '/home/acme-corp/settings' +``` + +## Non-Collapsible Sections + +Set `collapsible: false` to keep a section always expanded: + +```tsx +{ + label: 'common.routes.settings', + collapsible: false, // Always expanded + children: [ + // ... routes + ], +} +``` + +## Best Practices + +1. **Always use `createPath`**: Never hardcode team slugs. Always use the helper function. +2. **Keep paths in `pathsConfig`**: When adding new routes, add them to `paths.config.ts` first. +3. **Filter undefined routes**: When using conditional routes, always add `.filter(Boolean)`. +4. **Use the `highlightMatch` property**: Add `highlightMatch` with a regex pattern to index routes to prevent matching nested paths. +5. **Consider mobile**: Test navigation on mobile devices. Complex nested menus can be hard to navigate. + +## Common Pitfalls + +1. **Forgetting to replace `[account]`**: If paths show `[account]` literally in the URL, you forgot to use `createPath`. +2. **Not exporting as function**: Team navigation must be a function that accepts the account slug, not a static object. +3. **Mixing personal and team routes**: Team routes should use `/home/[account]/...`, personal routes use `/home/...`. +4. **Hardcoding team slugs**: Never hardcode a specific team slug. Always use the `account` parameter. + +## Related Topics + +- [Personal Account Navigation](/docs/next-supabase-turbo/configuration/personal-account-sidebar-configuration) - Configure personal navigation +- [Paths Configuration](/docs/next-supabase-turbo/configuration/paths-configuration) - Centralized path management +- [Team Accounts](/docs/next-supabase-turbo/api/team-account-api) - Understanding team workspaces +- [Feature Flags](/docs/next-supabase-turbo/configuration/feature-flags-configuration) - Toggle team features diff --git a/docs/content/cms-api.mdoc b/docs/content/cms-api.mdoc new file mode 100644 index 000000000..513fa176a --- /dev/null +++ b/docs/content/cms-api.mdoc @@ -0,0 +1,467 @@ +--- +status: "published" +label: "CMS API" +title: "CMS API Reference for the Next.js Supabase SaaS Kit" +description: "Complete API reference for fetching, filtering, and rendering content from any CMS provider in Makerkit." +order: 1 +--- + +The CMS API provides a unified interface for fetching content regardless of your storage backend. The same code works with Keystatic, WordPress, Supabase, or any custom CMS client you create. + +## Creating a CMS Client + +The `createCmsClient` function returns a client configured for your chosen provider: + +```tsx +import { createCmsClient } from '@kit/cms'; + +const client = await createCmsClient(); +``` + +The provider is determined by the `CMS_CLIENT` environment variable: + +```bash +CMS_CLIENT=keystatic # Default +CMS_CLIENT=wordpress +CMS_CLIENT=supabase # Requires plugin +``` + +You can also override the provider at runtime: + +```tsx +import { createCmsClient } from '@kit/cms'; + +// Force WordPress regardless of env var +const wpClient = await createCmsClient('wordpress'); +``` + +## Fetching Multiple Content Items + +Use `getContentItems()` to retrieve lists of content with filtering and pagination: + +```tsx +import { createCmsClient } from '@kit/cms'; + +const client = await createCmsClient(); + +const { items, total } = await client.getContentItems({ + collection: 'posts', + limit: 10, + offset: 0, + sortBy: 'publishedAt', + sortDirection: 'desc', + status: 'published', +}); +``` + +### Options Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `collection` | `string` | Required | The collection to query (`posts`, `documentation`, `changelog`) | +| `limit` | `number` | `10` | Maximum items to return | +| `offset` | `number` | `0` | Number of items to skip (for pagination) | +| `sortBy` | `'publishedAt' \| 'order' \| 'title'` | `'publishedAt'` | Field to sort by | +| `sortDirection` | `'asc' \| 'desc'` | `'asc'` | Sort direction | +| `status` | `'published' \| 'draft' \| 'review' \| 'pending'` | `'published'` | Filter by content status | +| `categories` | `string[]` | - | Filter by category slugs | +| `tags` | `string[]` | - | Filter by tag slugs | +| `language` | `string` | - | Filter by language code | +| `content` | `boolean` | `true` | Whether to fetch full content (set `false` for list views) | +| `parentIds` | `string[]` | - | Filter by parent content IDs (for hierarchical content) | + +### Pagination Example + +```tsx +import { createCmsClient } from '@kit/cms'; +import { cache } from 'react'; + +const getPostsPage = cache(async (page: number, perPage = 10) => { + const client = await createCmsClient(); + + return client.getContentItems({ + collection: 'posts', + limit: perPage, + offset: (page - 1) * perPage, + sortBy: 'publishedAt', + sortDirection: 'desc', + }); +}); + +// Usage in a Server Component +async function BlogList({ page }: { page: number }) { + const { items, total } = await getPostsPage(page); + const totalPages = Math.ceil(total / 10); + + return ( + <div> + {items.map((post) => ( + <article key={post.id}> + <h2>{post.title}</h2> + <p>{post.description}</p> + </article> + ))} + + <nav> + Page {page} of {totalPages} + </nav> + </div> + ); +} +``` + +### Filtering by Category + +```tsx +const { items } = await client.getContentItems({ + collection: 'posts', + categories: ['tutorials', 'guides'], + limit: 5, +}); +``` + +### List View Optimization + +For list views where you only need titles and descriptions, skip content fetching: + +```tsx +const { items } = await client.getContentItems({ + collection: 'posts', + content: false, // Don't fetch full content + limit: 20, +}); +``` + +## Fetching a Single Content Item + +Use `getContentItemBySlug()` to retrieve a specific piece of content: + +```tsx +import { createCmsClient } from '@kit/cms'; + +const client = await createCmsClient(); + +const post = await client.getContentItemBySlug({ + slug: 'getting-started', + collection: 'posts', +}); + +if (!post) { + // Handle not found +} +``` + +### Options Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `slug` | `string` | Required | The URL slug of the content item | +| `collection` | `string` | Required | The collection to search | +| `status` | `'published' \| 'draft' \| 'review' \| 'pending'` | `'published'` | Required status for the item | + +### Draft Preview + +To preview unpublished content (e.g., for admin users): + +```tsx +const draft = await client.getContentItemBySlug({ + slug: 'upcoming-feature', + collection: 'posts', + status: 'draft', +}); +``` + +## Content Item Shape + +All CMS providers return items matching this TypeScript interface: + +```tsx +interface ContentItem { + id: string; + title: string; + label: string | undefined; + slug: string; + url: string; + description: string | undefined; + content: unknown; // Provider-specific format + publishedAt: string; // ISO date string + image: string | undefined; + status: 'draft' | 'published' | 'review' | 'pending'; + categories: Category[]; + tags: Tag[]; + order: number; + parentId: string | undefined; + children: ContentItem[]; + collapsible?: boolean; + collapsed?: boolean; +} + +interface Category { + id: string; + name: string; + slug: string; +} + +interface Tag { + id: string; + name: string; + slug: string; +} +``` + +## Rendering Content + +Content format varies by provider (Markdoc nodes, HTML, React nodes). Use the `ContentRenderer` component for provider-agnostic rendering: + +```tsx +import { createCmsClient, ContentRenderer } from '@kit/cms'; +import { notFound } from 'next/navigation'; + +async function ArticlePage({ slug }: { slug: string }) { + const client = await createCmsClient(); + + const article = await client.getContentItemBySlug({ + slug, + collection: 'posts', + }); + + if (!article) { + notFound(); + } + + return ( + <article> + <header> + <h1>{article.title}</h1> + {article.description && <p>{article.description}</p>} + <time dateTime={article.publishedAt}> + {new Date(article.publishedAt).toLocaleDateString()} + </time> + </header> + + <ContentRenderer content={article.content} /> + + <footer> + {article.categories.map((cat) => ( + <span key={cat.id}>{cat.name}</span> + ))} + </footer> + </article> + ); +} +``` + +## Working with Categories and Tags + +### Fetch All Categories + +```tsx +const categories = await client.getCategories({ + limit: 50, + offset: 0, +}); +``` + +### Fetch a Category by Slug + +```tsx +const category = await client.getCategoryBySlug('tutorials'); + +if (category) { + // Fetch posts in this category + const { items } = await client.getContentItems({ + collection: 'posts', + categories: [category.slug], + }); +} +``` + +### Fetch All Tags + +```tsx +const tags = await client.getTags({ + limit: 100, +}); +``` + +### Fetch a Tag by Slug + +```tsx +const tag = await client.getTagBySlug('react'); +``` + +## Building Dynamic Pages + +### Blog Post Page + +```tsx {% title="app/[locale]/(marketing)/blog/[slug]/page.tsx" %} +import { createCmsClient, ContentRenderer } from '@kit/cms'; +import { notFound } from 'next/navigation'; + +interface Props { + params: Promise<{ slug: string }>; +} + +export async function generateStaticParams() { + const client = await createCmsClient(); + const { items } = await client.getContentItems({ + collection: 'posts', + content: false, + limit: 1000, + }); + + return items.map((post) => ({ + slug: post.slug, + })); +} + +export async function generateMetadata({ params }: Props) { + const { slug } = await params; + const client = await createCmsClient(); + + const post = await client.getContentItemBySlug({ + slug, + collection: 'posts', + }); + + if (!post) { + return {}; + } + + return { + title: post.title, + description: post.description, + openGraph: { + images: post.image ? [post.image] : [], + }, + }; +} + +export default async function BlogPostPage({ params }: Props) { + const { slug } = await params; + const client = await createCmsClient(); + + const post = await client.getContentItemBySlug({ + slug, + collection: 'posts', + }); + + if (!post) { + notFound(); + } + + return ( + <article> + <h1>{post.title}</h1> + <ContentRenderer content={post.content} /> + </article> + ); +} +``` + +### CMS-Powered Static Pages + +Store pages like Terms of Service or Privacy Policy in your CMS: + +```tsx {% title="app/[slug]/page.tsx" %} +import { createCmsClient, ContentRenderer } from '@kit/cms'; +import { notFound } from 'next/navigation'; + +interface Props { + params: Promise<{ slug: string }>; +} + +export default async function StaticPage({ params }: Props) { + const { slug } = await params; + const client = await createCmsClient(); + + const page = await client.getContentItemBySlug({ + slug, + collection: 'pages', // Create this collection in your CMS + }); + + if (!page) { + notFound(); + } + + return ( + <div> + <h1>{page.title}</h1> + <ContentRenderer content={page.content} /> + </div> + ); +} +``` + +{% alert type="default" title="Create the pages collection" %} +This example assumes you've added a `pages` collection to your CMS configuration. By default, Makerkit includes `posts`, `documentation`, and `changelog` collections. +{% /alert %} + +## Caching Strategies + +### React Cache + +Wrap CMS calls with React's `cache()` for request deduplication: + +```tsx +import { createCmsClient } from '@kit/cms'; +import { cache } from 'react'; + +export const getPost = cache(async (slug: string) => { + const client = await createCmsClient(); + + return client.getContentItemBySlug({ + slug, + collection: 'posts', + }); +}); +``` + +### Next.js Data Cache + +The CMS client respects Next.js caching. For static content, pages are cached at build time with `generateStaticParams()`. + +For dynamic content that should revalidate: + +```tsx +import { unstable_cache } from 'next/cache'; +import { createCmsClient } from '@kit/cms'; + +const getCachedPosts = unstable_cache( + async () => { + const client = await createCmsClient(); + return client.getContentItems({ collection: 'posts', limit: 10 }); + }, + ['posts-list'], + { revalidate: 3600 } // Revalidate every hour +); +``` + +## Provider-Specific Notes + +### Keystatic + +- Collections: `posts`, `documentation`, `changelog` (configurable in `keystatic.config.ts`) +- Categories and tags are stored as arrays of strings +- Content is Markdoc, rendered via `@kit/keystatic/renderer` + +### WordPress + +- Collections map to WordPress content types: use `posts` for posts, `pages` for pages +- Categories and tags use WordPress's native taxonomy system +- Language filtering uses tags (add `en`, `de`, etc. tags to posts) +- Content is HTML, rendered via `@kit/wordpress/renderer` + +### Supabase + +- Uses the `content_items`, `categories`, and `tags` tables +- Requires the Supabase CMS plugin installation +- Content can be HTML or any format you store +- Works with [Supamode](/supabase-cms) for admin UI + +## Next Steps + +- [Keystatic Setup](/docs/next-supabase-turbo/content/keystatic): Configure local or GitHub storage +- [WordPress Setup](/docs/next-supabase-turbo/content/wordpress): Connect to WordPress REST API +- [Supabase CMS Plugin](/docs/next-supabase-turbo/content/supabase): Store content in your database +- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build integrations for Sanity, Contentful, etc. diff --git a/docs/content/cms.mdoc b/docs/content/cms.mdoc new file mode 100644 index 000000000..c7a51c6f0 --- /dev/null +++ b/docs/content/cms.mdoc @@ -0,0 +1,128 @@ +--- +status: "published" +title: "CMS Integration in the Next.js Supabase SaaS Kit" +label: "CMS" +description: "Makerkit's CMS interface abstracts content storage, letting you swap between Keystatic, WordPress, or Supabase without changing your application code." +order: 0 +--- + +Makerkit provides a unified CMS interface that decouples your application from the underlying content storage. Write your content queries once, then swap between Keystatic, WordPress, or Supabase without touching your React components. + +This abstraction means you can start with local Markdown files during development, then switch to WordPress for a content team, or Supabase for a database-driven approach, all without rewriting your data fetching logic. + +## Supported CMS Providers + +Makerkit ships with two built-in CMS implementations and one plugin: + +| Provider | Storage | Best For | Edge Compatible | +|----------|---------|----------|-----------------| +| [Keystatic](/docs/next-supabase-turbo/content/keystatic) | Local files or GitHub | Solo developers, Git-based workflows | GitHub mode only | +| [WordPress](/docs/next-supabase-turbo/content/wordpress) | WordPress REST API | Content teams, existing WordPress sites | Yes | +| [Supabase](/docs/next-supabase-turbo/content/supabase) | PostgreSQL via Supabase | Database-driven content, custom admin | Yes | + +You can also [create your own CMS client](/docs/next-supabase-turbo/content/creating-your-own-cms-client) for providers like Sanity, Contentful, or Strapi. + +## How It Works + +The CMS interface consists of three layers: + +1. **CMS Client**: An abstract class that defines methods like `getContentItems()` and `getContentItemBySlug()`. Each provider implements this interface. +2. **Content Renderer**: A React component that knows how to render content from each provider (Markdoc for Keystatic, HTML for WordPress, etc.). +3. **Registry**: A dynamic import system that loads the correct client based on the `CMS_CLIENT` environment variable. + +```tsx +// This code works with any CMS provider +import { createCmsClient } from '@kit/cms'; + +const client = await createCmsClient(); +const { items } = await client.getContentItems({ + collection: 'posts', + limit: 10, + sortBy: 'publishedAt', + sortDirection: 'desc', +}); +``` + +The `CMS_CLIENT` environment variable determines which implementation gets loaded: + +```bash +CMS_CLIENT=keystatic # Default - file-based content +CMS_CLIENT=wordpress # WordPress REST API +CMS_CLIENT=supabase # Supabase database (requires plugin) +``` + +## Default Collections + +Keystatic ships with three pre-configured collections: + +- **posts**: Blog posts with title, description, categories, tags, and Markdoc content +- **documentation**: Hierarchical docs with parent-child relationships and ordering +- **changelog**: Release notes and updates + +WordPress maps to its native content types (posts and pages). Supabase uses the `content_items` table with flexible metadata. + +## Choosing a Provider + +**Choose Keystatic if:** +- You're a solo developer or small team +- You want version-controlled content in your repo +- You prefer Markdown/Markdoc for writing +- You don't need real-time collaborative editing + +**Choose WordPress if:** +- You have an existing WordPress site +- Your content team knows WordPress +- You need its plugin ecosystem (SEO, forms, etc.) +- You want a battle-tested admin interface + +**Choose Supabase if:** +- You want content in your existing database +- You need row-level security on content +- You're building a user-generated content feature +- You want to use [Supamode](/supabase-cms) as your admin + +## Quick Start + +By default, Makerkit uses Keystatic with local storage. No configuration needed. + +To switch providers, set the environment variable and follow the provider-specific setup: + +```bash +# .env +CMS_CLIENT=keystatic +``` + +Then use the [CMS API](/docs/next-supabase-turbo/content/cms-api) to fetch content in your components: + +```tsx +import { createCmsClient, ContentRenderer } from '@kit/cms'; +import { notFound } from 'next/navigation'; + +async function BlogPost({ slug }: { slug: string }) { + const client = await createCmsClient(); + + const post = await client.getContentItemBySlug({ + slug, + collection: 'posts', + }); + + if (!post) { + notFound(); + } + + return ( + <article> + <h1>{post.title}</h1> + <ContentRenderer content={post.content} /> + </article> + ); +} +``` + +## Next Steps + +- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation for fetching and filtering content +- [Keystatic Setup](/docs/next-supabase-turbo/content/keystatic): Configure local or GitHub storage +- [WordPress Setup](/docs/next-supabase-turbo/content/wordpress): Connect to WordPress REST API +- [Supabase CMS Plugin](/docs/next-supabase-turbo/content/supabase): Store content in your database +- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build your own integration diff --git a/docs/content/creating-your-own-cms-client.mdoc b/docs/content/creating-your-own-cms-client.mdoc new file mode 100644 index 000000000..a2ed84178 --- /dev/null +++ b/docs/content/creating-your-own-cms-client.mdoc @@ -0,0 +1,629 @@ +--- +status: "published" +label: "Custom CMS" +title: "Building a Custom CMS Client for the Next.js Supabase SaaS Kit" +description: "Implement the CMS interface to integrate Sanity, Contentful, Strapi, Payload, or any headless CMS with Makerkit." +order: 5 +--- + +Makerkit's CMS interface is designed to be extensible. If you're using a CMS that isn't supported out of the box, you can create your own client by implementing the `CmsClient` abstract class. + +This guide walks through building a custom CMS client using a fictional HTTP API as an example. The same pattern works for Sanity, Contentful, Strapi, Payload, or any headless CMS. + +## The CMS Interface + +Your client must implement these methods: + +```tsx +import { Cms, CmsClient } from '@kit/cms-types'; + +export abstract class CmsClient { + // Fetch multiple content items with filtering and pagination + abstract getContentItems( + options?: Cms.GetContentItemsOptions + ): Promise<{ + total: number; + items: Cms.ContentItem[]; + }>; + + // Fetch a single content item by slug + abstract getContentItemBySlug(params: { + slug: string; + collection: string; + status?: Cms.ContentItemStatus; + }): Promise<Cms.ContentItem | undefined>; + + // Fetch categories + abstract getCategories( + options?: Cms.GetCategoriesOptions + ): Promise<Cms.Category[]>; + + // Fetch a single category by slug + abstract getCategoryBySlug( + slug: string + ): Promise<Cms.Category | undefined>; + + // Fetch tags + abstract getTags( + options?: Cms.GetTagsOptions + ): Promise<Cms.Tag[]>; + + // Fetch a single tag by slug + abstract getTagBySlug( + slug: string + ): Promise<Cms.Tag | undefined>; +} +``` + +## Type Definitions + +The CMS types are defined in `packages/cms/types/src/cms-client.ts`: + +```tsx +export namespace Cms { + export interface ContentItem { + id: string; + title: string; + label: string | undefined; + url: string; + description: string | undefined; + content: unknown; + publishedAt: string; + image: string | undefined; + status: ContentItemStatus; + slug: string; + categories: Category[]; + tags: Tag[]; + order: number; + children: ContentItem[]; + parentId: string | undefined; + collapsible?: boolean; + collapsed?: boolean; + } + + export type ContentItemStatus = 'draft' | 'published' | 'review' | 'pending'; + + export interface Category { + id: string; + name: string; + slug: string; + } + + export interface Tag { + id: string; + name: string; + slug: string; + } + + export interface GetContentItemsOptions { + collection: string; + limit?: number; + offset?: number; + categories?: string[]; + tags?: string[]; + content?: boolean; + parentIds?: string[]; + language?: string | undefined; + sortDirection?: 'asc' | 'desc'; + sortBy?: 'publishedAt' | 'order' | 'title'; + status?: ContentItemStatus; + } + + export interface GetCategoriesOptions { + slugs?: string[]; + limit?: number; + offset?: number; + } + + export interface GetTagsOptions { + slugs?: string[]; + limit?: number; + offset?: number; + } +} +``` + +## Example Implementation + +Here's a complete example for a fictional HTTP API: + +```tsx {% title="packages/cms/my-cms/src/my-cms-client.ts" %} +import { Cms, CmsClient } from '@kit/cms-types'; + +const API_URL = process.env.MY_CMS_API_URL; +const API_KEY = process.env.MY_CMS_API_KEY; + +export function createMyCmsClient() { + return new MyCmsClient(); +} + +class MyCmsClient extends CmsClient { + private async fetch<T>(endpoint: string, options?: RequestInit): Promise<T> { + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}`, + ...options?.headers, + }, + }); + + if (!response.ok) { + throw new Error(`CMS API error: ${response.status}`); + } + + return response.json(); + } + + async getContentItems( + options: Cms.GetContentItemsOptions + ): Promise<{ total: number; items: Cms.ContentItem[] }> { + const params = new URLSearchParams(); + + params.set('collection', options.collection); + + if (options.limit) { + params.set('limit', options.limit.toString()); + } + + if (options.offset) { + params.set('offset', options.offset.toString()); + } + + if (options.status) { + params.set('status', options.status); + } + + if (options.sortBy) { + params.set('sort_by', options.sortBy); + } + + if (options.sortDirection) { + params.set('sort_direction', options.sortDirection); + } + + if (options.categories?.length) { + params.set('categories', options.categories.join(',')); + } + + if (options.tags?.length) { + params.set('tags', options.tags.join(',')); + } + + if (options.language) { + params.set('language', options.language); + } + + const data = await this.fetch<{ + total: number; + items: ApiContentItem[]; + }>(`/content?${params.toString()}`); + + return { + total: data.total, + items: data.items.map(this.mapContentItem), + }; + } + + async getContentItemBySlug(params: { + slug: string; + collection: string; + status?: Cms.ContentItemStatus; + }): Promise<Cms.ContentItem | undefined> { + try { + const queryParams = new URLSearchParams({ + collection: params.collection, + }); + + if (params.status) { + queryParams.set('status', params.status); + } + + const data = await this.fetch<ApiContentItem>( + `/content/${params.slug}?${queryParams.toString()}` + ); + + return this.mapContentItem(data); + } catch (error) { + // Return undefined for 404s + return undefined; + } + } + + async getCategories( + options?: Cms.GetCategoriesOptions + ): Promise<Cms.Category[]> { + const params = new URLSearchParams(); + + if (options?.limit) { + params.set('limit', options.limit.toString()); + } + + if (options?.offset) { + params.set('offset', options.offset.toString()); + } + + if (options?.slugs?.length) { + params.set('slugs', options.slugs.join(',')); + } + + const data = await this.fetch<ApiCategory[]>( + `/categories?${params.toString()}` + ); + + return data.map(this.mapCategory); + } + + async getCategoryBySlug(slug: string): Promise<Cms.Category | undefined> { + try { + const data = await this.fetch<ApiCategory>(`/categories/${slug}`); + return this.mapCategory(data); + } catch { + return undefined; + } + } + + async getTags(options?: Cms.GetTagsOptions): Promise<Cms.Tag[]> { + const params = new URLSearchParams(); + + if (options?.limit) { + params.set('limit', options.limit.toString()); + } + + if (options?.offset) { + params.set('offset', options.offset.toString()); + } + + if (options?.slugs?.length) { + params.set('slugs', options.slugs.join(',')); + } + + const data = await this.fetch<ApiTag[]>(`/tags?${params.toString()}`); + + return data.map(this.mapTag); + } + + async getTagBySlug(slug: string): Promise<Cms.Tag | undefined> { + try { + const data = await this.fetch<ApiTag>(`/tags/${slug}`); + return this.mapTag(data); + } catch { + return undefined; + } + } + + // Map API response to Makerkit's ContentItem interface + private mapContentItem(item: ApiContentItem): Cms.ContentItem { + return { + id: item.id, + title: item.title, + label: item.label ?? undefined, + slug: item.slug, + url: `/${item.collection}/${item.slug}`, + description: item.excerpt ?? undefined, + content: item.body, + publishedAt: item.published_at, + image: item.featured_image ?? undefined, + status: this.mapStatus(item.status), + categories: (item.categories ?? []).map(this.mapCategory), + tags: (item.tags ?? []).map(this.mapTag), + order: item.sort_order ?? 0, + parentId: item.parent_id ?? undefined, + children: [], + collapsible: item.collapsible ?? false, + collapsed: item.collapsed ?? false, + }; + } + + private mapCategory(cat: ApiCategory): Cms.Category { + return { + id: cat.id, + name: cat.name, + slug: cat.slug, + }; + } + + private mapTag(tag: ApiTag): Cms.Tag { + return { + id: tag.id, + name: tag.name, + slug: tag.slug, + }; + } + + private mapStatus(status: string): Cms.ContentItemStatus { + switch (status) { + case 'live': + case 'active': + return 'published'; + case 'draft': + return 'draft'; + case 'pending': + case 'scheduled': + return 'pending'; + case 'review': + return 'review'; + default: + return 'draft'; + } + } +} + +// API response types (adjust to match your CMS) +interface ApiContentItem { + id: string; + title: string; + label?: string; + slug: string; + collection: string; + excerpt?: string; + body: unknown; + published_at: string; + featured_image?: string; + status: string; + categories?: ApiCategory[]; + tags?: ApiTag[]; + sort_order?: number; + parent_id?: string; + collapsible?: boolean; + collapsed?: boolean; +} + +interface ApiCategory { + id: string; + name: string; + slug: string; +} + +interface ApiTag { + id: string; + name: string; + slug: string; +} +``` + +## Registering Your Client + +### 1. Add the CMS Type + +Update the type definition: + +```tsx {% title="packages/cms/types/src/cms.type.ts" %} +export type CmsType = 'wordpress' | 'keystatic' | 'my-cms'; +``` + +### 2. Register the Client + +Add your client to the registry: + +```tsx {% title="packages/cms/core/src/create-cms-client.ts" %} +import { CmsClient, CmsType } from '@kit/cms-types'; +import { createRegistry } from '@kit/shared/registry'; + +const CMS_CLIENT = process.env.CMS_CLIENT as CmsType; +const cmsRegistry = createRegistry<CmsClient, CmsType>(); + +// Existing registrations... +cmsRegistry.register('wordpress', async () => { + const { createWordpressClient } = await import('@kit/wordpress'); + return createWordpressClient(); +}); + +cmsRegistry.register('keystatic', async () => { + const { createKeystaticClient } = await import('@kit/keystatic'); + return createKeystaticClient(); +}); + +// Register your client +cmsRegistry.register('my-cms', async () => { + const { createMyCmsClient } = await import('@kit/my-cms'); + return createMyCmsClient(); +}); + +export async function createCmsClient(type: CmsType = CMS_CLIENT) { + return cmsRegistry.get(type); +} +``` + +### 3. Create a Content Renderer (Optional) + +If your CMS returns content in a specific format, create a renderer: + +```tsx {% title="packages/cms/core/src/content-renderer.tsx" %} +cmsContentRendererRegistry.register('my-cms', async () => { + const { MyCmsContentRenderer } = await import('@kit/my-cms/renderer'); + return MyCmsContentRenderer; +}); +``` + +Example renderer for HTML content: + +```tsx {% title="packages/cms/my-cms/src/renderer.tsx" %} +interface Props { + content: unknown; +} + +export function MyCmsContentRenderer({ content }: Props) { + if (typeof content !== 'string') { + return null; + } + + return ( + <div + className="prose prose-lg" + dangerouslySetInnerHTML={{ __html: content }} + /> + ); +} +``` + +For Markdown content: + +```tsx {% title="packages/cms/my-cms/src/renderer.tsx" %} +import { marked } from 'marked'; + +interface Props { + content: unknown; +} + +export function MyCmsContentRenderer({ content }: Props) { + if (typeof content !== 'string') { + return null; + } + + const html = marked(content); + + return ( + <div + className="prose prose-lg" + dangerouslySetInnerHTML={{ __html: html }} + /> + ); +} +``` + +### 4. Set the Environment Variable + +```bash +# .env +CMS_CLIENT=my-cms +MY_CMS_API_URL=https://api.my-cms.com +MY_CMS_API_KEY=your-api-key +``` + +## Real-World Examples + +### Sanity + +```tsx +import { createClient } from '@sanity/client'; +import { Cms, CmsClient } from '@kit/cms-types'; + +const client = createClient({ + projectId: process.env.SANITY_PROJECT_ID, + dataset: process.env.SANITY_DATASET, + useCdn: true, + apiVersion: '2024-01-01', +}); + +class SanityClient extends CmsClient { + async getContentItems(options: Cms.GetContentItemsOptions) { + const query = `*[_type == $collection && status == $status] | order(publishedAt desc) [$start...$end] { + _id, + title, + slug, + excerpt, + body, + publishedAt, + mainImage, + categories[]->{ _id, title, slug }, + tags[]->{ _id, title, slug } + }`; + + const params = { + collection: options.collection, + status: options.status ?? 'published', + start: options.offset ?? 0, + end: (options.offset ?? 0) + (options.limit ?? 10), + }; + + const items = await client.fetch(query, params); + const total = await client.fetch( + `count(*[_type == $collection && status == $status])`, + params + ); + + return { + total, + items: items.map(this.mapContentItem), + }; + } + + // ... implement other methods +} +``` + +### Contentful + +```tsx +import { createClient } from 'contentful'; +import { Cms, CmsClient } from '@kit/cms-types'; + +const client = createClient({ + space: process.env.CONTENTFUL_SPACE_ID!, + accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!, +}); + +class ContentfulClient extends CmsClient { + async getContentItems(options: Cms.GetContentItemsOptions) { + const response = await client.getEntries({ + content_type: options.collection, + limit: options.limit ?? 10, + skip: options.offset ?? 0, + order: ['-fields.publishedAt'], + }); + + return { + total: response.total, + items: response.items.map(this.mapContentItem), + }; + } + + // ... implement other methods +} +``` + +## Testing Your Client + +Create tests to verify your implementation: + +```tsx {% title="packages/cms/my-cms/src/__tests__/my-cms-client.test.ts" %} +import { describe, it, expect, beforeAll } from 'vitest'; +import { createMyCmsClient } from '../my-cms-client'; + +describe('MyCmsClient', () => { + const client = createMyCmsClient(); + + it('fetches content items', async () => { + const { items, total } = await client.getContentItems({ + collection: 'posts', + limit: 5, + }); + + expect(items).toBeInstanceOf(Array); + expect(typeof total).toBe('number'); + + if (items.length > 0) { + expect(items[0]).toHaveProperty('id'); + expect(items[0]).toHaveProperty('title'); + expect(items[0]).toHaveProperty('slug'); + } + }); + + it('fetches a single item by slug', async () => { + const item = await client.getContentItemBySlug({ + slug: 'test-post', + collection: 'posts', + }); + + if (item) { + expect(item.slug).toBe('test-post'); + } + }); + + it('returns undefined for non-existent slugs', async () => { + const item = await client.getContentItemBySlug({ + slug: 'non-existent-slug-12345', + collection: 'posts', + }); + + expect(item).toBeUndefined(); + }); +}); +``` + +## Next Steps + +- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation +- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers +- Check the [Keystatic implementation](https://github.com/makerkit/next-supabase-saas-kit-turbo/tree/main/packages/cms/keystatic) for a complete example diff --git a/docs/content/keystatic.mdoc b/docs/content/keystatic.mdoc new file mode 100644 index 000000000..c0db04594 --- /dev/null +++ b/docs/content/keystatic.mdoc @@ -0,0 +1,321 @@ +--- +status: "published" +title: "Keystatic CMS Setup for the Next.js Supabase SaaS Kit" +label: "Keystatic" +description: "Configure Keystatic as your CMS with local file storage for development or GitHub integration for production and team collaboration." +order: 2 +--- + +Keystatic is a file-based CMS that stores content as Markdown/Markdoc files. It's the default CMS in Makerkit because it requires zero setup for local development and integrates with Git for version-controlled content. + +## Storage Modes + +Keystatic supports three storage modes: + +| Mode | Storage | Best For | Edge Compatible | +|------|---------|----------|-----------------| +| `local` | Local filesystem | Development, solo projects | No | +| `github` | GitHub repository | Production, team collaboration | Yes | +| `cloud` | Keystatic Cloud | Managed hosting | Yes | + +Local mode reads files directly from disk. GitHub mode fetches content via the GitHub API, making it compatible with edge runtimes like Cloudflare Workers. + +## Local Storage (Default) + +Local mode works out of the box. Content lives in your repository's `content/` directory: + +```bash +# .env (optional - these are the defaults) +CMS_CLIENT=keystatic +NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=local +KEYSTATIC_PATH_PREFIX=apps/web +NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content +``` + +Content structure: + +``` +apps/web/content/ +├── posts/ # Blog posts +├── documentation/ # Docs (supports nesting) +└── changelog/ # Release notes +``` + +**Limitations**: Local mode doesn't work with edge runtimes (Cloudflare Workers, Vercel Edge) because it requires filesystem access. Use GitHub mode for edge deployments. + +## GitHub Storage + +GitHub mode fetches content from your repository via the GitHub API. This enables edge deployment and team collaboration through Git. + +### 1. Set Environment Variables + +```bash +# .env +CMS_CLIENT=keystatic +NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github +NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO=your-org/your-repo +KEYSTATIC_GITHUB_TOKEN=github_pat_xxxxxxxxxxxx +KEYSTATIC_PATH_PREFIX=apps/web +NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content +``` + +### 2. Create a GitHub Token + +1. Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens +2. Create a new token with: + - **Repository access**: Select your content repository + - **Permissions**: Contents (Read-only for production, Read and write for admin UI) +3. Copy the token to `KEYSTATIC_GITHUB_TOKEN` + +For read-only access (recommended for production): + +```bash +KEYSTATIC_GITHUB_TOKEN=github_pat_xxxxxxxxxxxx +``` + +### 3. Configure Path Prefix + +If your content isn't at the repository root, set the path prefix: + +```bash +# For monorepos where content is in apps/web/content/ +KEYSTATIC_PATH_PREFIX=apps/web +NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content +``` + +## Keystatic Cloud + +Keystatic Cloud is a managed service that handles GitHub authentication and provides a hosted admin UI. + +```bash +# .env +CMS_CLIENT=keystatic +NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=cloud +KEYSTATIC_STORAGE_PROJECT=your-project-id +``` + +Get your project ID from the [Keystatic Cloud dashboard](https://keystatic.cloud). + +## Adding the Admin UI + +Keystatic includes a visual editor for managing content. To add it: + +```bash +turbo gen keystatic +``` + +This creates a route at `/keystatic` where you can create and edit content. + +{% alert type="warning" title="Protect the admin in production" %} +By default, the Keystatic admin is only available in development. For production, add authentication: + +```tsx {% title="app/keystatic/layout.tsx" %} +import { redirect } from 'next/navigation'; +import { isSuperAdmin } from '@kit/admin'; + +export default async function KeystaticLayout({ + children, +}: { + children: React.ReactNode; +}) { + const isAdmin = await isSuperAdmin(); + + if (!isAdmin) { + redirect('/'); + } + + return children; +} +``` +{% /alert %} + +### GitHub Mode Admin Setup + +GitHub mode requires a GitHub App for the admin UI to authenticate and commit changes. + +1. Install the Keystatic GitHub App on your repository +2. Follow the [Keystatic GitHub mode documentation](https://keystatic.com/docs/github-mode) for setup + +The admin UI commits content changes directly to your repository, triggering your CI/CD pipeline. + +## Default Collections + +Makerkit configures three collections in `packages/cms/keystatic/src/keystatic.config.ts`: + +### Posts + +Blog posts with frontmatter: + +```yaml +--- +title: "Getting Started with Makerkit" +description: "A guide to building your SaaS" +publishedAt: 2025-01-15 +status: published +categories: + - tutorials +tags: + - getting-started +image: /images/posts/getting-started.webp +--- + +Content here... +``` + +### Documentation + +Hierarchical docs with ordering and collapsible sections: + +```yaml +--- +title: "Authentication" +label: "Auth" # Short label for navigation +description: "How authentication works" +order: 1 +status: published +collapsible: true +collapsed: false +--- + +Content here... +``` + +Documentation supports nested directories. A file at `documentation/auth/sessions/sessions.mdoc` automatically becomes a child of `documentation/auth/auth.mdoc`. + +### Changelog + +Release notes: + +```yaml +--- +title: "v2.0.0 Release" +description: "Major update with new features" +publishedAt: 2025-01-10 +status: published +--- + +Content here... +``` + +## Adding Custom Collections + +Edit `packages/cms/keystatic/src/keystatic.config.ts` to add collections: + +```tsx {% title="packages/cms/keystatic/src/keystatic.config.ts" %} +// In getKeystaticCollections() +return { + // ... existing collections + + pages: collection({ + label: 'Pages', + slugField: 'title', + path: `${path}pages/*`, + format: { contentField: 'content' }, + schema: { + title: fields.slug({ name: { label: 'Title' } }), + description: fields.text({ label: 'Description' }), + content: getContentField(), + status: fields.select({ + defaultValue: 'draft', + label: 'Status', + options: statusOptions, + }), + }, + }), +}; +``` + +## Content Format + +Keystatic uses [Markdoc](https://markdoc.dev), a Markdown superset with custom components. + +### Basic Markdown + +Standard Markdown syntax works: + +```markdown +# Heading + +Paragraph with **bold** and *italic*. + +- List item +- Another item + +```code +Code block +``` +``` + +### Images + +Images are stored in `public/site/images/` and referenced with the public path: + +```markdown +![Alt text](/site/images/screenshot.webp) +``` + +### Custom Components + +Makerkit extends Markdoc with custom nodes. Check `packages/cms/keystatic/src/markdoc-nodes.ts` for available components. + +## Cloudflare Workers Compatibility + +Cloudflare Workers don't send the `User-Agent` header, which the GitHub API requires. Add this workaround to `packages/cms/keystatic/src/keystatic-client.ts`: + +```tsx {% title="packages/cms/keystatic/src/keystatic-client.ts" %} +// Add at the top of the file +const self = global || globalThis || this; +const originalFetch = self.fetch; + +self.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const requestInit: RequestInit = { + ...(init ?? {}), + headers: { + ...(init?.headers ?? {}), + 'User-Agent': 'Cloudflare-Workers', + } + }; + + return originalFetch(input, requestInit); +}; +``` + +## Environment Variables Reference + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CMS_CLIENT` | No | `keystatic` | CMS provider | +| `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND` | No | `local` | Storage mode: `local`, `github`, `cloud` | +| `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` | GitHub only | - | Repository in `owner/repo` format | +| `KEYSTATIC_GITHUB_TOKEN` | GitHub only | - | GitHub personal access token | +| `KEYSTATIC_STORAGE_PROJECT` | Cloud only | - | Keystatic Cloud project ID | +| `KEYSTATIC_PATH_PREFIX` | No | - | Path to content in monorepos | +| `NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH` | No | `./content` | Content directory path | +| `KEYSTATIC_STORAGE_BRANCH_PREFIX` | No | - | Branch prefix for GitHub mode | + +## Troubleshooting + +### Content not loading in production + +Verify GitHub mode is configured: +- `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github` +- `KEYSTATIC_GITHUB_TOKEN` has read access to the repository +- `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` matches your repository + +### Admin UI shows authentication error + +For GitHub mode, ensure: +- The Keystatic GitHub App is installed on your repository +- Your GitHub token has write permissions (for the admin) + +### Edge runtime errors + +Local mode doesn't work on edge. Switch to GitHub or Cloud mode: +- Set `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github` +- Configure GitHub token with read access + +## Next Steps + +- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Learn the full API for fetching content +- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers +- [Keystatic Documentation](https://keystatic.com/docs): Official Keystatic docs diff --git a/docs/content/supabase.mdoc b/docs/content/supabase.mdoc new file mode 100644 index 000000000..269cf70f8 --- /dev/null +++ b/docs/content/supabase.mdoc @@ -0,0 +1,423 @@ +--- +status: "published" +title: "Supabase CMS Plugin for the Next.js Supabase SaaS Kit" +label: "Supabase" +description: "Store content in your Supabase database with optional Supamode integration for a visual admin interface." +order: 4 +--- + +The Supabase CMS plugin stores content directly in your Supabase database. This gives you full control over your content schema, row-level security policies, and the ability to query content alongside your application data. + +This approach works well when you want content in the same database as your app, need RLS policies on content, or want to use [Supamode](/supabase-cms) as your admin interface. + +## Installation + +### 1. Install the Plugin + +Run the Makerkit CLI from your app directory: + +```bash +npx @makerkit/cli plugins install +``` + +Select **Supabase CMS** when prompted. + +### 2. Add the Package Dependency + +Add the plugin to your CMS package: + +```bash +pnpm --filter "@kit/cms" add "@kit/supabase-cms@workspace:*" +``` + +### 3. Register the CMS Type + +Update the CMS type definition to include Supabase: + +```tsx {% title="packages/cms/types/src/cms.type.ts" %} +export type CmsType = 'wordpress' | 'keystatic' | 'supabase'; +``` + +### 4. Register the Client + +Add the Supabase client to the CMS registry: + +```tsx {% title="packages/cms/core/src/create-cms-client.ts" %} +import { CmsClient, CmsType } from '@kit/cms-types'; +import { createRegistry } from '@kit/shared/registry'; + +const CMS_CLIENT = process.env.CMS_CLIENT as CmsType; +const cmsRegistry = createRegistry<CmsClient, CmsType>(); + +// Existing registrations... +cmsRegistry.register('wordpress', async () => { + const { createWordpressClient } = await import('@kit/wordpress'); + return createWordpressClient(); +}); + +cmsRegistry.register('keystatic', async () => { + const { createKeystaticClient } = await import('@kit/keystatic'); + return createKeystaticClient(); +}); + +// Add Supabase registration +cmsRegistry.register('supabase', async () => { + const { createSupabaseCmsClient } = await import('@kit/supabase-cms'); + return createSupabaseCmsClient(); +}); + +export async function createCmsClient(type: CmsType = CMS_CLIENT) { + return cmsRegistry.get(type); +} +``` + +### 5. Register the Content Renderer + +Add the Supabase content renderer: + +```tsx {% title="packages/cms/core/src/content-renderer.tsx" %} +cmsContentRendererRegistry.register('supabase', async () => { + return function SupabaseContentRenderer({ content }: { content: unknown }) { + return content as React.ReactNode; + }; +}); +``` + +The default renderer returns content as-is. If you store HTML, it renders as HTML. For Markdown, add a Markdown renderer. + +### 6. Run the Migration + +Create a new migration file: + +```bash +pnpm --filter web supabase migration new cms +``` + +Copy the contents of `packages/plugins/supabase-cms/migration.sql` to the new migration file, then apply it: + +```bash +pnpm --filter web supabase migration up +``` + +### 7. Generate Types + +Regenerate TypeScript types to include the new tables: + +```bash +pnpm run supabase:web:typegen +``` + +### 8. Set the Environment Variable + +Switch to the Supabase CMS: + +```bash +# apps/web/.env +CMS_CLIENT=supabase +``` + +## Database Schema + +The plugin creates three tables: + +### content_items + +Stores all content (posts, pages, docs): + +```sql +create table public.content_items ( + id uuid primary key default gen_random_uuid(), + title text not null, + slug text not null unique, + description text, + content text, + image text, + status text not null default 'draft', + collection text not null default 'posts', + published_at timestamp with time zone, + created_at timestamp with time zone default now(), + updated_at timestamp with time zone default now(), + parent_id uuid references public.content_items(id), + "order" integer default 0, + language text, + metadata jsonb default '{}'::jsonb +); +``` + +### categories + +Content categories: + +```sql +create table public.categories ( + id uuid primary key default gen_random_uuid(), + name text not null, + slug text not null unique, + created_at timestamp with time zone default now() +); +``` + +### tags + +Content tags: + +```sql +create table public.tags ( + id uuid primary key default gen_random_uuid(), + name text not null, + slug text not null unique, + created_at timestamp with time zone default now() +); +``` + +### Junction Tables + +Many-to-many relationships: + +```sql +create table public.content_items_categories ( + content_item_id uuid references public.content_items(id) on delete cascade, + category_id uuid references public.categories(id) on delete cascade, + primary key (content_item_id, category_id) +); + +create table public.content_items_tags ( + content_item_id uuid references public.content_items(id) on delete cascade, + tag_id uuid references public.tags(id) on delete cascade, + primary key (content_item_id, tag_id) +); +``` + +## Using Supamode as Admin + +{% img src="/assets/images/supamode-cms-plugin-posts.webp" width="2970" height="2028" alt="Supamode CMS Posts Interface" /%} + +[Supamode](/supabase-cms) provides a visual interface for managing content in Supabase tables. It's built specifically for Supabase and integrates with RLS policies. + +{% alert type="default" title="Supamode is optional" %} +Supamode is a separate product. You can use any Postgres admin tool, build your own admin, or manage content via SQL. +{% /alert %} + +### Setting Up Supamode + +1. Install Supamode following the [installation guide](/docs/supamode/installation) +2. Sync the CMS tables to Supamode: + - Run the following SQL commands in Supabase Studio's SQL Editor: + ```sql + -- Run in Supabase Studio's SQL Editor + select supamode.sync_managed_tables('public', 'content_items'); + select supamode.sync_managed_tables('public', 'categories'); + select supamode.sync_managed_tables('public', 'tags'); + ``` +3. Configure table views in the Supamode UI under **Resources** + +### Content Editing + +With Supamode, you can: +- Create and edit content with a form-based UI +- Upload images to Supabase Storage +- Manage categories and tags +- Preview content before publishing +- Filter and search content + +## Querying Content + +The Supabase CMS client implements the standard CMS interface: + +```tsx +import { createCmsClient } from '@kit/cms'; + +const client = await createCmsClient(); + +// Get all published posts +const { items, total } = await client.getContentItems({ + collection: 'posts', + status: 'published', + limit: 10, + sortBy: 'publishedAt', + sortDirection: 'desc', +}); + +// Get a specific post +const post = await client.getContentItemBySlug({ + slug: 'getting-started', + collection: 'posts', +}); +``` + +### Direct Supabase Queries + +For complex queries, use the Supabase client directly: + +```tsx +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function getPostsWithCustomQuery() { + const client = getSupabaseServerClient(); + + const { data, error } = await client + .from('content_items') + .select(` + *, + categories:content_items_categories( + category:categories(*) + ), + tags:content_items_tags( + tag:tags(*) + ) + `) + .eq('collection', 'posts') + .eq('status', 'published') + .order('published_at', { ascending: false }) + .limit(10); + + return data; +} +``` + +## Row-Level Security + +Add RLS policies to control content access: + +```sql +-- Allow public read access to published content +create policy "Public can read published content" + on public.content_items + for select + using (status = 'published'); + +-- Allow authenticated users to read all content +create policy "Authenticated users can read all content" + on public.content_items + for select + to authenticated + using (true); + +-- Allow admins to manage content +create policy "Admins can manage content" + on public.content_items + for all + to authenticated + using ( + exists ( + select 1 from public.accounts + where accounts.id = auth.uid() + and accounts.is_admin = true + ) + ); +``` + +## Content Format + +The `content` field stores text. Common formats: + +### HTML + +Store rendered HTML directly: + +```tsx +const post = { + title: 'Hello World', + content: '<p>This is <strong>HTML</strong> content.</p>', +}; +``` + +Render with `dangerouslySetInnerHTML` or a sanitizing library. + +### Markdown + +Store Markdown and render at runtime: + +```tsx +import { marked } from 'marked'; +import type { Cms } from '@kit/cms-types'; + +function renderContent(markdown: string) { + return { __html: marked(markdown) }; +} + +function Post({ post }: { post: Cms.ContentItem }) { + return ( + <article> + <h1>{post.title}</h1> + <div dangerouslySetInnerHTML={renderContent(post.content as string)} /> + </article> + ); +} +``` + +### JSON + +Store structured content as JSON in the `metadata` field: + +```tsx +const post = { + title: 'Product Comparison', + content: '', // Optional summary + metadata: { + products: [ + { name: 'Basic', price: 9 }, + { name: 'Pro', price: 29 }, + ], + }, +}; +``` + +## Customizing the Schema + +Extend the schema by modifying the migration: + +```sql +-- Add custom fields +alter table public.content_items + add column author_id uuid references auth.users(id), + add column reading_time integer, + add column featured boolean default false; + +-- Add indexes +create index content_items_featured_idx + on public.content_items(featured) + where status = 'published'; +``` + +Update the Supabase client to handle custom fields. + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `CMS_CLIENT` | Yes | Set to `supabase` | + +The plugin uses your existing Supabase connection (no additional configuration needed). + +## Troubleshooting + +### Migration fails + +Check that you have the latest Supabase CLI and your local database is running: + +```bash +pnpm --filter web supabase start +pnpm --filter web supabase migration up +``` + +### TypeScript errors after migration + +Regenerate types: + +```bash +pnpm run supabase:web:typegen +``` + +### Content not appearing + +Verify: +- The `status` field is set to `published` +- The `collection` field matches your query +- RLS policies allow access + +## Next Steps + +- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation +- [Supamode Documentation](/docs/supamode/installation): Set up the admin interface +- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers diff --git a/docs/content/wordpress.mdoc b/docs/content/wordpress.mdoc new file mode 100644 index 000000000..1df42dc99 --- /dev/null +++ b/docs/content/wordpress.mdoc @@ -0,0 +1,293 @@ +--- +status: "published" +title: "WordPress CMS Integration for the Next.js Supabase SaaS Kit" +label: "WordPress" +description: "Connect your WordPress site to Makerkit using the REST API for blog posts, documentation, and dynamic pages." +order: 3 +--- + +WordPress integration lets you use an existing WordPress site as your content backend. Makerkit fetches content through the WordPress REST API, so you get the familiar WordPress admin while serving content from your Next.js app. + +This approach works well when you have a content team that knows WordPress, or you want to leverage WordPress plugins for SEO, forms, or other features. + +## Quick Setup + +### 1. Set Environment Variables + +```bash +# .env +CMS_CLIENT=wordpress +WORDPRESS_API_URL=https://your-wordpress-site.com +``` + +### 2. Configure WordPress Permalinks + +WordPress REST API requires pretty permalinks. In your WordPress admin: + +1. Go to Settings → Permalinks +2. Select "Post name" (`/%postname%/`) or any option except "Plain" +3. Save changes + +Without this, the REST API won't resolve slugs correctly. + +## Content Mapping + +WordPress content types map to Makerkit collections: + +| WordPress Type | Makerkit Collection | Notes | +|----------------|---------------------|-------| +| Posts | `posts` | Standard WordPress posts | +| Pages | `pages` | WordPress pages | + +### Blog Posts + +Create posts in WordPress with: +- **Category**: Add a category named `blog` for blog posts +- **Tags**: Use tags for filtering (including language codes like `en`, `de`) +- **Featured Image**: Automatically used as the post image + +```tsx +const { items } = await client.getContentItems({ + collection: 'posts', + categories: ['blog'], + limit: 10, +}); +``` + +### Documentation Pages + +WordPress doesn't natively support hierarchical documentation. To build docs: + +1. Create pages (not posts) for documentation +2. Enable categories for pages (see below) +3. Add a category named `documentation` + +#### Enabling Categories for Pages + +Add this to your theme's `functions.php`: + +```php {% title="wp-content/themes/your-theme/functions.php" %} +function add_categories_to_pages() { + register_taxonomy_for_object_type('category', 'page'); +} +add_action('init', 'add_categories_to_pages'); +``` + +Then fetch documentation: + +```tsx +const { items } = await client.getContentItems({ + collection: 'pages', + categories: ['documentation'], +}); +``` + +## Multi-Language Content + +WordPress doesn't have built-in multi-language support. Makerkit uses tags for language filtering: + +1. Create tags for each language: `en`, `de`, `fr`, etc. +2. Add the appropriate language tag to each post +3. Filter by language in your queries: + +```tsx +const { items } = await client.getContentItems({ + collection: 'posts', + language: 'en', // Filters by tag +}); +``` + +For full multi-language support, consider plugins like WPML or Polylang, then adapt the Makerkit WordPress client to use their APIs. + +## Local Development + +Makerkit includes a Docker Compose setup for local WordPress development: + +```bash +# From packages/cms/wordpress/ +docker-compose up +``` + +Or from the root: + +```bash +pnpm --filter @kit/wordpress run start +``` + +This starts WordPress at `http://localhost:8080`. + +### Default Credentials + +``` +Database Host: db +Database Name: wordpress +Database User: wordpress +Database Password: wordpress +``` + +On first visit, WordPress prompts you to complete the installation. + +## Production Configuration + +### WordPress Hosting + +Host WordPress anywhere that exposes the REST API: +- WordPress.com (Business plan or higher) +- Self-hosted WordPress +- Managed WordPress hosting (WP Engine, Kinsta, etc.) + +### CORS Configuration + +If your Next.js app and WordPress are on different domains, configure CORS in WordPress. + +Add to `wp-config.php`: + +```php {% title="wp-config.php" %} +header("Access-Control-Allow-Origin: https://your-nextjs-app.com"); +header("Access-Control-Allow-Methods: GET, OPTIONS"); +header("Access-Control-Allow-Headers: Content-Type"); +``` + +Or use a plugin like "WP CORS" for more control. + +### Caching + +The WordPress REST API can be slow. Consider: + +1. **WordPress caching plugins**: WP Super Cache, W3 Total Cache +2. **CDN for the API**: Cloudflare, Fastly +3. **Next.js caching**: Use `unstable_cache` or ISR + +```tsx +import { unstable_cache } from 'next/cache'; +import { createCmsClient } from '@kit/cms'; + +const getCachedPosts = unstable_cache( + async () => { + const client = await createCmsClient(); + return client.getContentItems({ collection: 'posts', limit: 10 }); + }, + ['wordpress-posts'], + { revalidate: 300 } // 5 minutes +); +``` + +## Content Structure + +### Post Fields + +WordPress posts return these fields through the Makerkit CMS interface: + +| Field | Source | Notes | +|-------|--------|-------| +| `title` | `title.rendered` | HTML-decoded | +| `content` | `content.rendered` | Full HTML content | +| `description` | `excerpt.rendered` | Post excerpt | +| `image` | Featured media | Full URL | +| `slug` | `slug` | URL slug | +| `publishedAt` | `date` | ISO 8601 format | +| `status` | `status` | Mapped to Makerkit statuses | +| `categories` | Category taxonomy | Array of category objects | +| `tags` | Tag taxonomy | Array of tag objects | +| `order` | `menu_order` | For page ordering | +| `parentId` | `parent` | For hierarchical pages | + +### Status Mapping + +| WordPress Status | Makerkit Status | +|------------------|-----------------| +| `publish` | `published` | +| `draft` | `draft` | +| `pending` | `pending` | +| Other | `draft` | + +## Rendering WordPress Content + +WordPress content is HTML. Use the `ContentRenderer` component: + +```tsx +import { createCmsClient, ContentRenderer } from '@kit/cms'; +import { notFound } from 'next/navigation'; + +async function BlogPost({ slug }: { slug: string }) { + const client = await createCmsClient(); + + const post = await client.getContentItemBySlug({ + slug, + collection: 'posts', + }); + + if (!post) { + notFound(); + } + + return ( + <article> + <h1>{post.title}</h1> + {/* ContentRenderer handles HTML safely */} + <ContentRenderer content={post.content} /> + </article> + ); +} +``` + +The WordPress renderer sanitizes HTML and applies appropriate styling. + +### Custom Styling + +WordPress content includes CSS classes. Add styles to your global CSS: + +```css {% title="apps/web/styles/globals.css" %} +/* WordPress block styles */ +.wp-block-image { + margin: 2rem 0; +} + +.wp-block-quote { + border-left: 4px solid var(--primary); + padding-left: 1rem; + font-style: italic; +} + +/* Gutenberg alignment */ +.alignwide { + max-width: 100vw; + margin-left: calc(-50vw + 50%); + margin-right: calc(-50vw + 50%); +} +``` + +## Environment Variables Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `CMS_CLIENT` | Yes | Set to `wordpress` | +| `WORDPRESS_API_URL` | Yes | WordPress site URL (no trailing slash) | + +## Troubleshooting + +### REST API returns 404 + +- Verify permalinks are set to something other than "Plain" +- Check that the REST API is accessible: `curl https://your-site.com/wp-json/wp/v2/posts` +- Some security plugins disable the REST API; check your plugins + +### Categories not working for pages + +Ensure you've added the `add_categories_to_pages()` function to your theme's `functions.php`. + +### Images not loading + +- Check that `WORDPRESS_API_URL` matches the site URL in WordPress settings +- Verify featured images are set on posts +- Check for mixed content issues (HTTP vs HTTPS) + +### CORS errors + +Add CORS headers to WordPress (see Production Configuration above) or use a proxy. + +## Next Steps + +- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation +- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers +- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build custom integrations diff --git a/docs/customization/fonts.mdoc b/docs/customization/fonts.mdoc new file mode 100644 index 000000000..ee6b6b573 --- /dev/null +++ b/docs/customization/fonts.mdoc @@ -0,0 +1,278 @@ +--- +status: "published" +label: "Updating Fonts" +title: "Customize Application Fonts | Next.js Supabase SaaS Kit" +order: 3 +description: "Configure custom fonts using Google Fonts, local fonts, or system fonts in your Makerkit application with Next.js font optimization." +--- + +Customize your application's typography by editing `apps/web/lib/fonts.ts`. This file defines the font families used throughout your app, with Next.js automatically handling font optimization, subsetting, and self-hosting for privacy and performance. + +By default, Makerkit uses Apple's system font on Apple devices (San Francisco) and falls back to Inter on other platforms. + +## Quick Font Change + +Replace the default Inter font with any Google Font: + +```tsx title="apps/web/lib/fonts.ts" +import { Poppins as SansFont } from 'next/font/google'; +import { cn } from '@kit/ui/utils'; + +const sans = SansFont({ + subsets: ['latin'], + variable: '--font-sans-fallback', + fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial'], + preload: true, + weight: ['300', '400', '500', '600', '700'], +}); + +const heading = sans; + +export { sans, heading }; + +export function getFontsClassName(theme?: string) { + const dark = theme === 'dark'; + const light = !dark; + + const font = [sans.variable, heading.variable].reduce<string[]>( + (acc, curr) => { + if (acc.includes(curr)) return acc; + return [...acc, curr]; + }, + [], + ); + + return cn(...font, { dark, light }); +} +``` + +## Using Different Fonts for Headings and Body + +Create visual hierarchy by using different fonts for headings and body text: + +```tsx title="apps/web/lib/fonts.ts" +import { Inter as SansFont, Playfair_Display as HeadingFont } from 'next/font/google'; +import { cn } from '@kit/ui/utils'; + +const sans = SansFont({ + subsets: ['latin'], + variable: '--font-sans-fallback', + fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial'], + preload: true, + weight: ['300', '400', '500', '600', '700'], +}); + +const heading = HeadingFont({ + subsets: ['latin'], + variable: '--font-heading', + fallback: ['Georgia', 'Times New Roman', 'serif'], + preload: true, + weight: ['400', '500', '600', '700'], +}); + +export { sans, heading }; + +export function getFontsClassName(theme?: string) { + const dark = theme === 'dark'; + const light = !dark; + + const font = [sans.variable, heading.variable].reduce<string[]>( + (acc, curr) => { + if (acc.includes(curr)) return acc; + return [...acc, curr]; + }, + [], + ); + + return cn(...font, { dark, light }); +} +``` + +Then update `apps/web/styles/shadcn-ui.css` to use the heading font in the `@theme inline` block: + +```css title="apps/web/styles/shadcn-ui.css" +@theme inline { + --font-sans: -apple-system, BlinkMacSystemFont, var(--font-sans-fallback); + --font-heading: var(--font-heading), Georgia, serif; +} +``` + +## Using Local Fonts + +For fonts not available on Google Fonts, or for complete control over font files: + +```tsx title="apps/web/lib/fonts.ts" +import localFont from 'next/font/local'; +import { cn } from '@kit/ui/utils'; + +const sans = localFont({ + src: [ + { + path: '../fonts/CustomFont-Regular.woff2', + weight: '400', + style: 'normal', + }, + { + path: '../fonts/CustomFont-Medium.woff2', + weight: '500', + style: 'normal', + }, + { + path: '../fonts/CustomFont-Bold.woff2', + weight: '700', + style: 'normal', + }, + ], + variable: '--font-sans-fallback', + fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial'], + preload: true, +}); + +const heading = sans; + +export { sans, heading }; +``` + +Place font files in `apps/web/fonts/` directory. Supported formats: `.woff2` (recommended), `.woff`, `.ttf`, `.otf`. + +## Removing Apple System Font Default + +By default, Makerkit prioritizes Apple's system font on macOS and iOS for a native feel. To use your chosen font consistently across all platforms: + +Edit the `@theme inline` block in `apps/web/styles/shadcn-ui.css`: + +```css title="apps/web/styles/shadcn-ui.css" +@theme inline { + /* Remove -apple-system and BlinkMacSystemFont to use your font everywhere */ + --font-sans: var(--font-sans-fallback); + --font-heading: var(--font-sans); +} +``` + +This ensures your Google Font or local font displays on Apple devices instead of San Francisco. + +## Popular Font Combinations + +### Modern SaaS (Clean and Professional) +```tsx +import { Inter as SansFont } from 'next/font/google'; +// Headings and body: Inter +``` + +### Editorial (Content-Heavy Apps) +```tsx +import { Source_Sans_3 as SansFont, Source_Serif_4 as HeadingFont } from 'next/font/google'; +// Body: Source Sans 3 +// Headings: Source Serif 4 +``` + +### Startup (Friendly and Approachable) +```tsx +import { DM_Sans as SansFont } from 'next/font/google'; +// Headings and body: DM Sans +``` + +### Technical (Developer Tools) +```tsx +import { IBM_Plex_Sans as SansFont, IBM_Plex_Mono as MonoFont } from 'next/font/google'; +// Body: IBM Plex Sans +// Code: IBM Plex Mono +``` + +### Premium (Luxury/Finance) +```tsx +import { Outfit as SansFont } from 'next/font/google'; +// Headings and body: Outfit +``` + +## Font Variable Reference + +The font system uses CSS variables defined in two places: + +| Variable | Defined In | Purpose | +|----------|------------|---------| +| `--font-sans-fallback` | `fonts.ts` | Next.js optimized font | +| `--font-heading` | `fonts.ts` | Heading font (if different) | +| `--font-sans` | `shadcn-ui.css` | Final font stack with system fallbacks | + +Tailwind uses these through `theme.css`: + +```css title="apps/web/styles/shadcn-ui.css" +@theme inline { + --font-sans: -apple-system, BlinkMacSystemFont, var(--font-sans-fallback); + --font-heading: var(--font-sans); +} +``` + +## Optimizing Font Loading + +### Preload Critical Fonts +```tsx +const sans = SansFont({ + // ... + preload: true, // Preload for faster initial render + display: 'swap', // Show fallback immediately, swap when loaded +}); +``` + +### Subset for Faster Loading +```tsx +const sans = SansFont({ + // ... + subsets: ['latin'], // Only load Latin characters + // Or load multiple subsets if needed: + // subsets: ['latin', 'latin-ext', 'cyrillic'], +}); +``` + +### Specify Only Needed Weights +```tsx +const sans = SansFont({ + // ... + weight: ['400', '500', '700'], // Only weights you actually use + // Avoid: weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'] +}); +``` + +## Common Mistakes + +**Loading too many font weights**: Each weight adds to bundle size. Only include weights you actually use (typically 400, 500, 600, 700). + +**Forgetting to update CSS variables**: After changing fonts in `fonts.ts`, you may need to update `shadcn-ui.css` if you want to remove the Apple system font priority or configure the heading font. + +**Using display: 'block'**: This causes invisible text until fonts load (FOIT). Use `display: 'swap'` for better perceived performance. + +**Not testing on Windows**: Apple system fonts don't exist on Windows. Always test your fallback fonts on non-Apple devices. + +## Verification + +After updating fonts: + +1. Check the Network tab in DevTools for font files loading +2. Verify fonts render on both Mac and Windows +3. Test with slow network throttling to see fallback behavior +4. Run Lighthouse to check for font-related performance issues + +```bash +# Quick check for font loading +pnpm dev +# Open DevTools > Network > Filter: Font +# Verify your custom font files are loading +``` + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Why does my custom font not appear on Mac?", "answer": "By default, Makerkit prioritizes Apple system fonts. Edit shadcn-ui.css and remove -apple-system and BlinkMacSystemFont from the --font-sans variable to use your custom font on Apple devices."}, + {"question": "How do I add a monospace font for code blocks?", "answer": "Import a monospace font in fonts.ts, export it, and add a --font-mono CSS variable. Then configure it in theme.css. Consider IBM Plex Mono, JetBrains Mono, or Fira Code."}, + {"question": "Can I use variable fonts?", "answer": "Yes. Next.js supports variable fonts. Specify weight as a range: weight: '100 900'. This loads a single file that supports all weights, often smaller than multiple static font files."}, + {"question": "How do I improve font loading performance?", "answer": "Limit font weights to those you use, enable preload: true, use display: 'swap', and only load needed subsets. Variable fonts can also reduce total download size."} + ] +/%} + +## Next Steps + +- Back to [Customization Overview](/docs/next-supabase-turbo/customization) +- Configure your [theme colors](/docs/next-supabase-turbo/customization/theme) to complement your typography +- Set up your [layout style](/docs/next-supabase-turbo/customization/layout-style) for navigation +- Update your [application logo](/docs/next-supabase-turbo/customization/logo) diff --git a/docs/customization/layout-style.mdoc b/docs/customization/layout-style.mdoc new file mode 100644 index 000000000..fc26adf7d --- /dev/null +++ b/docs/customization/layout-style.mdoc @@ -0,0 +1,285 @@ +--- +status: "published" +label: "Layout Style" +title: "Configure Navigation Layout | Next.js Supabase SaaS Kit" +order: 4 +description: "Choose between sidebar and header navigation layouts, configure collapsed states, and customize the navigation experience for your SaaS application." +--- + +Makerkit offers two navigation layouts: **sidebar** (default) and **header**. You can configure each workspace independently, with separate settings for personal accounts and team accounts. All layout options are controlled through environment variables. + +## Quick Configuration + +Set your preferred layout in `.env.local`: + +```bash title=".env.local" +# Personal account workspace layout +NEXT_PUBLIC_USER_NAVIGATION_STYLE=sidebar + +# Team account workspace layout +NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar +``` + +Available values: `sidebar`, `header`, or `custom`. + +## Layout Options Compared + +### Sidebar Layout (Default) + +The sidebar layout places navigation on the left side of the screen, providing a persistent, vertically-oriented menu. + +{% img src="/assets/images/docs/turbo-sidebar-layout.webp" width="2522" height="1910" /%} + +**Best for:** +- Apps with many navigation items (5+) +- Complex feature sets needing categorized menus +- Desktop-first applications +- Enterprise or admin-heavy dashboards + +**Configuration:** +```bash title=".env.local" +NEXT_PUBLIC_USER_NAVIGATION_STYLE=sidebar +NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar +``` + +### Header Layout + +The header layout places navigation horizontally at the top of the screen, with a more compact, traditional web app feel. + +{% img src="/assets/images/docs/turbo-header-layout.webp" width="3282" height="1918" /%} + +**Best for:** +- Apps with fewer navigation items (3-5) +- Consumer-facing products +- Mobile-first designs +- Simple, focused applications + +**Configuration:** +```bash title=".env.local" +NEXT_PUBLIC_USER_NAVIGATION_STYLE=header +NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=header +``` + +## Sidebar Behavior Options + +### Default Collapsed State + +Control whether the sidebar starts expanded or collapsed: + +```bash title=".env.local" +# Personal account sidebar (home workspace) +NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED=false + +# Team account sidebar +NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED=false +``` + +Set to `true` to start with a collapsed icon-only sidebar. Users can still expand it manually. + +### Collapsible Style + +Choose how the sidebar collapses and expands: + +```bash title=".env.local" +# Options: offcanvas, icon, none +NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=offcanvas +``` + +| Style | Behavior | +|-------|----------| +| `offcanvas` | Sidebar slides in/out as an overlay (mobile-friendly) | +| `icon` | Sidebar collapses to icons only, expanding on hover | +| `none` | Sidebar cannot be collapsed | + +### Sidebar Trigger Visibility + +Show or hide the sidebar toggle button: + +```bash title=".env.local" +NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER=true +``` + +Set to `false` if you want the sidebar to remain in its configured state without user control. + +## Complete Configuration Example + +Here's a full example for a team-focused SaaS with different layouts per workspace: + +```bash title=".env.local" +# Personal account: simple header navigation +NEXT_PUBLIC_USER_NAVIGATION_STYLE=header + +# Team workspace: full sidebar with collapsed default +NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar +NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED=true +NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=icon +NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER=true + +# Theme settings +NEXT_PUBLIC_DEFAULT_THEME_MODE=system +NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true +``` + +## Customizing Navigation Items + +Navigation items are defined in configuration files, not environment variables: + +| Workspace | Configuration File | +|-----------|-------------------| +| Personal Account | `apps/web/config/personal-account-navigation.config.tsx` | +| Team Account | `apps/web/config/team-account-navigation.config.tsx` | + +### Personal Account Navigation + +```tsx title="apps/web/config/personal-account-navigation.config.tsx" +import { CreditCard, Home, User, Settings } from 'lucide-react'; +import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; +import pathsConfig from '~/config/paths.config'; + +const iconClasses = 'w-4'; + +const routes = [ + { + label: 'common.routes.application', + children: [ + { + label: 'common.routes.home', + path: pathsConfig.app.home, + Icon: <Home className={iconClasses} />, + highlightMatch: `${pathsConfig.app.home}$`, + }, + ], + }, + { + label: 'common.routes.settings', + children: [ + { + label: 'common.routes.profile', + path: pathsConfig.app.personalAccountSettings, + Icon: <User className={iconClasses} />, + }, + { + label: 'common.routes.billing', + path: pathsConfig.app.personalAccountBilling, + Icon: <CreditCard className={iconClasses} />, + }, + ], + }, +]; + +export const personalAccountNavigationConfig = NavigationConfigSchema.parse({ + routes, + style: process.env.NEXT_PUBLIC_USER_NAVIGATION_STYLE, + sidebarCollapsed: process.env.NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED, + sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE, +}); +``` + +### Adding a New Navigation Item + +To add a custom page to the navigation: + +```tsx title="apps/web/config/personal-account-navigation.config.tsx" +import { BarChart3 } from 'lucide-react'; + +const routes = [ + { + label: 'common.routes.application', + children: [ + { + label: 'common.routes.home', + path: pathsConfig.app.home, + Icon: <Home className={iconClasses} />, + highlightMatch: `${pathsConfig.app.home}$`, + }, + // Add your custom navigation item + { + label: 'common.routes.analytics', + path: '/home/analytics', + Icon: <BarChart3 className={iconClasses} />, + }, + ], + }, + // ... rest of routes +]; +``` + +Remember to add the translation key to your locale files: + +```json title="apps/web/i18n/messages/en/common.json" +{ + "routes": { + "analytics": "Analytics" + } +} +``` + +## Navigation Schema Reference + +The `NavigationConfigSchema` supports these properties: + +```typescript +interface NavigationConfig { + routes: { + label: string; // Translation key for section header + collapsible?: boolean; // Allow section to collapse (default: false) + children: { + label: string; // Translation key for item + path: string; // Route path + Icon?: ReactNode; // Lucide icon component + highlightMatch?: string; // Regex pattern for active route highlighting + }[]; + }[]; + style?: 'sidebar' | 'header' | 'custom'; + sidebarCollapsed?: boolean | string; + sidebarCollapsedStyle?: 'offcanvas' | 'icon' | 'none'; +} +``` + +## Environment Variables Reference + +| Variable | Default | Options | Description | +|----------|---------|---------|-------------| +| `NEXT_PUBLIC_USER_NAVIGATION_STYLE` | `sidebar` | `sidebar`, `header`, `custom` | Personal account layout | +| `NEXT_PUBLIC_TEAM_NAVIGATION_STYLE` | `sidebar` | `sidebar`, `header`, `custom` | Team account layout | +| `NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED` | `false` | `true`, `false` | Personal sidebar default state | +| `NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED` | `false` | `true`, `false` | Team sidebar default state | +| `NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE` | `icon` | `offcanvas`, `icon`, `none` | Collapse behavior | +| `NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER` | `true` | `true`, `false` | Show collapse toggle | + +## Common Mistakes + +**Mixing layout styles inconsistently**: If personal accounts use header layout but teams use sidebar, the experience can feel disjointed. Consider the transition between workspaces. + +**Too many items in header layout**: Header navigation works best with 3-5 top-level items. More than that causes horizontal overflow or cramped spacing. Use sidebar layout for complex navigation. + +**Forgetting mobile behavior**: Sidebar layout automatically converts to a slide-out drawer on mobile. Test both layouts on narrow viewports. + +**Not updating translations**: Navigation labels use translation keys. Adding items without corresponding translations shows raw keys like `common.routes.analytics`. + +## Verification + +After changing layout configuration: + +1. Clear your browser's local storage (layout preferences are cached) +2. Restart the dev server for environment variable changes +3. Test both personal account and team account workspaces +4. Verify mobile responsiveness at 375px viewport width +5. Check that sidebar collapse/expand works correctly + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Can I use different layouts for personal and team accounts?", "answer": "Yes. Set NEXT_PUBLIC_USER_NAVIGATION_STYLE and NEXT_PUBLIC_TEAM_NAVIGATION_STYLE to different values. This is useful when team workspaces need more navigation complexity than personal accounts."}, + {"question": "How do I create a completely custom layout?", "answer": "Set the navigation style to 'custom' and implement your own layout component. You'll need to modify the layout files in apps/web/app/home/ to use your custom navigation component."}, + {"question": "Why isn't my sidebar staying collapsed?", "answer": "User preferences are stored in local storage and override environment defaults. Clear local storage or use the browser's incognito mode to test default behavior."}, + {"question": "How do I add icons to navigation items?", "answer": "Import icons from lucide-react and pass them as the Icon prop. Use className='w-4' to maintain consistent sizing with other navigation icons."} + ] +/%} + +## Next Steps + +- Back to [Customization Overview](/docs/next-supabase-turbo/customization) +- Configure your [theme colors](/docs/next-supabase-turbo/customization/theme) to match your navigation style +- Set up [custom fonts](/docs/next-supabase-turbo/customization/fonts) for navigation typography +- Update your [application logo](/docs/next-supabase-turbo/customization/logo) for the sidebar/header diff --git a/docs/customization/logo.mdoc b/docs/customization/logo.mdoc new file mode 100644 index 000000000..458219785 --- /dev/null +++ b/docs/customization/logo.mdoc @@ -0,0 +1,194 @@ +--- +status: "published" +label: "Updating the Logo" +title: "Customize Your Application Logo | Next.js Supabase SaaS Kit" +order: 1 +description: "Replace the default Makerkit logo with your own brand logo using SVG, image files, or custom React components." +--- + +Replace the default Makerkit logo by editing the `AppLogo` component at `apps/web/components/app-logo.tsx`. This single component controls the logo across your entire application: authentication pages, site header, footer, sidebar, and email templates. + +## Quick Start + +Open `apps/web/components/app-logo.tsx` and replace the existing SVG with your logo: + +```tsx title="apps/web/components/app-logo.tsx" +import Link from 'next/link'; +import { cn } from '@kit/ui/utils'; + +function LogoImage({ className }: { className?: string }) { + return ( + <img + src="/images/logo.svg" + alt="Your Company Name" + className={cn('w-[80px] lg:w-[95px]', className)} + /> + ); +} + +export function AppLogo({ + href, + label, + className, +}: { + href?: string | null; + className?: string; + label?: string; +}) { + if (href === null) { + return <LogoImage className={className} />; + } + + return ( + <Link aria-label={label ?? 'Home Page'} href={href ?? '/'}> + <LogoImage className={className} /> + </Link> + ); +} +``` + +Place your logo file in `apps/web/public/images/` and update the `src` path accordingly. + +## Logo Implementation Options + +### Option 1: SVG Component (Recommended) + +Inline SVGs provide the best performance and allow dynamic styling with Tailwind classes: + +```tsx title="apps/web/components/app-logo.tsx" +function LogoImage({ className }: { className?: string }) { + return ( + <svg + className={cn('w-[95px] h-auto', className)} + viewBox="0 0 100 32" + xmlns="http://www.w3.org/2000/svg" + > + <path + className="fill-primary dark:fill-white" + d="M10 5h80v22H10z" + /> + {/* Your SVG paths */} + </svg> + ); +} +``` + +**Benefits:** +- Supports `fill-primary` for automatic theme color adaptation +- Responds to dark mode with `dark:fill-white` +- Scales without quality loss +- No additional HTTP requests + +### Option 2: Next.js Image Component + +For PNG, JPG, or WebP logos, use `next/image` for automatic optimization: + +```tsx title="apps/web/components/app-logo.tsx" +import Image from 'next/image'; + +function LogoImage({ className }: { className?: string }) { + return ( + <Image + src="/images/logo.png" + alt="Your Company Name" + width={95} + height={32} + className={cn('w-[80px] lg:w-[95px] h-auto', className)} + priority + /> + ); +} +``` + +### Option 3: Dark Mode Variants + +When your logo needs different versions for light and dark modes: + +```tsx title="apps/web/components/app-logo.tsx" +import Image from 'next/image'; +import { cn } from '@kit/ui/utils'; + +function LogoImage({ className }: { className?: string }) { + return ( + <> + <Image + src="/images/logo-dark.svg" + alt="Your Company Name" + width={95} + height={32} + className={cn('hidden dark:block w-[80px] lg:w-[95px]', className)} + priority + /> + <Image + src="/images/logo-light.svg" + alt="Your Company Name" + width={95} + height={32} + className={cn('block dark:hidden w-[80px] lg:w-[95px]', className)} + priority + /> + </> + ); +} +``` + +## Where the Logo Appears + +The `AppLogo` component renders in these locations: + +| Location | File Path | Notes | +|----------|-----------|-------| +| Site Header | `packages/ui/src/makerkit/marketing/header.tsx` | Marketing pages | +| Site Footer | `packages/ui/src/makerkit/marketing/footer.tsx` | All pages | +| Auth Pages | `apps/web/app/[locale]/auth/layout.tsx` | Sign in, sign up | +| App Sidebar | `packages/ui/src/makerkit/sidebar-navigation.tsx` | Dashboard (when team accounts disabled) | +| Email Templates | `packages/email-templates/src/` | Transactional emails | + +## Favicon and Social Images + +Update these additional brand assets in `apps/web/app/`: + +``` +apps/web/app/ +├── favicon.ico # Browser tab icon (32x32) +├── icon.png # PWA icon (512x512) +├── apple-icon.png # iOS home screen (180x180) +└── opengraph-image.png # Social sharing (1200x630) +``` + +Generate these from your logo using tools like [RealFaviconGenerator](https://realfavicongenerator.net/) or [Favicon.io](https://favicon.io/). + +## Common Mistakes + +**Using low-resolution images**: Logos appear blurry on high-DPI displays. Always use SVG when possible, or provide 2x/3x image assets. + +**Forgetting alt text**: Screen readers need descriptive alt text. Use your company name, not "logo". + +**Hard-coded dimensions**: Use responsive classes like `w-[80px] lg:w-[95px]` instead of fixed pixel widths to ensure the logo scales appropriately on mobile. + +**Missing priority attribute**: Add `priority` to Next.js Image components for above-the-fold logos to prevent layout shift. + +## Verification + +After updating your logo: + +1. Check the marketing header at `http://localhost:3000` +2. Verify the auth pages at `http://localhost:3000/auth/sign-in` +3. Test dark mode toggle to confirm logo visibility +4. Inspect mobile viewport (375px width) for proper sizing + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "How do I make my SVG logo change color with the theme?", "answer": "Use Tailwind's fill classes on your SVG paths: fill-primary for the default theme color, or dark:fill-white to change in dark mode. Remove any hardcoded fill attributes from the SVG."}, + {"question": "What size should my logo be?", "answer": "Design for 95px width on desktop and 80px on mobile. SVGs scale automatically. For raster images, export at 2x resolution (190x64 pixels minimum) to support high-DPI displays."}, + {"question": "Can I use different logos in different parts of the app?", "answer": "Yes. You can modify the AppLogo component to accept a variant prop or create separate components. However, maintaining brand consistency is recommended."}, + {"question": "How do I update the logo in email templates?", "answer": "Email templates use the same AppLogo component where possible, but some email clients require inline images. Check packages/email-templates/src/components/ for email-specific logo handling."} + ] +/%} + +## Next Steps + +- Back to [Customization Overview](/docs/next-supabase-turbo/customization) +- Configure your [brand colors and theme](/docs/next-supabase-turbo/customization/theme) +- Customize your [application fonts](/docs/next-supabase-turbo/customization/fonts) diff --git a/docs/customization/tailwind-css.mdoc b/docs/customization/tailwind-css.mdoc new file mode 100644 index 000000000..8557294af --- /dev/null +++ b/docs/customization/tailwind-css.mdoc @@ -0,0 +1,236 @@ +--- +status: "published" +label: "Tailwind CSS" +title: "Tailwind CSS Configuration | Next.js Supabase SaaS Kit" +order: -1 +description: "Configure Tailwind CSS 4, extend the design system, and customize styles across your Makerkit monorepo application." +--- + +Makerkit uses Tailwind CSS 4 with Shadcn UI for styling. All style configuration lives in `apps/web/styles/`, with the main entry point at `globals.css`. This guide covers how to customize Tailwind, add new packages to the content paths, and extend the design system. + +## Style File Structure + +The styling system uses these files in `apps/web/styles/`: + +``` +apps/web/styles/ +├── globals.css # Main entry point, imports everything +├── theme.css # Theme color variables (light/dark mode, :root/.dark) +├── shadcn-ui.css # Maps CSS variables to Tailwind's @theme inline +├── makerkit.css # Makerkit-specific component styles +└── markdoc.css # Content/documentation styles +``` + +## Tailwind CSS 4 Configuration + +Tailwind CSS 4 uses CSS-based configuration instead of JavaScript. The `@theme inline` directive in `shadcn-ui.css` maps your CSS variables to Tailwind design tokens: + +```css title="apps/web/styles/shadcn-ui.css" +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + /* Border radius */ + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + + /* Font families */ + --font-sans: -apple-system, BlinkMacSystemFont, var(--font-sans-fallback); + --font-heading: var(--font-sans); +} +``` + +The actual color values are defined in `theme.css` using oklch format (see [Theme Colors](/docs/next-supabase-turbo/customization/theme)). + +These tokens become available as Tailwind utilities: `bg-primary`, `text-foreground`, `rounded-lg`, etc. + +## Adding Content Paths for New Packages + +When you create a new package in the monorepo, Tailwind needs to know where to scan for class names. Add a `@source` directive in `apps/web/styles/globals.css`: + +```css title="apps/web/styles/globals.css" +@import 'tailwindcss'; +@import 'tw-animate-css'; + +/* local styles */ +@import './theme.css'; +@import './shadcn-ui.css'; +@import './markdoc.css'; +@import './makerkit.css'; + +/* content sources - update the below if you add a new path */ +@source '../../../packages/*/src/**/*.{ts,tsx}'; +@source '../../../packages/features/*/src/**/*.{ts,tsx}'; +@source '../../../packages/billing/*/src/**/*.{ts,tsx}'; +@source '../../../packages/plugins/*/src/**/*.{ts,tsx}'; +@source '../../../packages/cms/*/src/**/*.{ts,tsx}'; +@source '../{app,components,config,lib}/**/*.{ts,tsx}'; + +/* Add your new package here */ +@source '../../../packages/your-package/src/**/*.{ts,tsx}'; +``` + +The `@source` directive is the Tailwind CSS 4 replacement for the `content` array in the old `tailwind.config.ts`. + +## Custom Utility Classes + +Add custom utilities using the `@utility` directive in `makerkit.css` or a new CSS file: + +```css title="apps/web/styles/makerkit.css" +@utility container { + @apply mx-auto px-4 lg:px-8 xl:max-w-[80rem]; +} +``` + +Or add utilities in a `@layer`: + +```css +@layer utilities { + .text-balance { + text-wrap: balance; + } + + .scrollbar-hidden { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .scrollbar-hidden::-webkit-scrollbar { + display: none; + } +} +``` + +## Extending the Theme + +Add custom design tokens in `shadcn-ui.css` inside the `@theme inline` block: + +```css title="apps/web/styles/shadcn-ui.css" +@theme inline { + /* ... existing tokens ... */ + + /* Custom colors */ + --color-brand: var(--brand); + --color-brand-light: var(--brand-light); +} +``` + +Then define the values in `theme.css`: + +```css title="apps/web/styles/theme.css" +:root { + --brand: oklch(65% 0.2 250); + --brand-light: oklch(85% 0.1 250); +} +``` + +Use these in your components: + +```tsx +<div className="bg-brand text-brand-light"> + Content +</div> +``` + +## Component-Level Styles + +For complex component styles, use `@layer components` in `makerkit.css`: + +```css title="apps/web/styles/makerkit.css" +@layer components { + .card-hover { + @apply transition-all duration-200; + @apply hover:shadow-lg hover:-translate-y-0.5; + } + + .btn-gradient { + @apply bg-gradient-to-r from-primary to-accent; + @apply text-primary-foreground; + @apply hover:opacity-90 transition-opacity; + } +} +``` + +## Shadcn UI Component Customization + +Override Shadcn component styles by targeting their classes: + +```css title="apps/web/styles/shadcn-ui.css" +@layer components { + /* Custom button variants */ + .btn-primary-gradient { + @apply bg-gradient-to-r from-blue-600 to-indigo-600; + @apply hover:from-blue-700 hover:to-indigo-700; + } +} +``` + +## Dark Mode Utilities + +Create dark-mode-aware utilities using the `dark:` variant: + +```css +@layer utilities { + .glass { + @apply bg-white/80 backdrop-blur-sm; + @apply dark:bg-neutral-900/80; + } + + .surface-elevated { + @apply bg-white shadow-sm; + @apply dark:bg-neutral-800 dark:shadow-none; + } +} +``` + +The dark variant is configured in `theme.css` as: + +```css +@custom-variant dark (&:is(.dark *)); +``` + +## Common Mistakes + +**Missing content paths**: New packages won't have their Tailwind classes compiled if you forget to add a `@source` directive. Styles will appear missing in production even if they work in development. + +**Using `@apply` excessively**: Reserve `@apply` for reusable component patterns. For one-off styles, use utility classes directly in JSX. Excessive `@apply` increases CSS bundle size. + +**Forgetting `@layer` directives**: Custom styles without `@layer` can have specificity issues. Always wrap custom styles in `@layer base`, `@layer components`, or `@layer utilities`. + +**Hardcoding colors**: Use theme variables (`bg-primary`, `text-foreground`) instead of hardcoded colors (`bg-blue-500`). This ensures consistency and makes theme changes easier. + +## Verification + +After modifying Tailwind configuration: + +1. Restart the dev server (Tailwind config changes require a restart) +2. Run `pnpm build` to verify all classes compile correctly +3. Check production build for missing styles: `pnpm build && pnpm start` +4. Verify dark mode works for any new utilities + +```bash +# Quick verification commands +pnpm dev # Development server +pnpm build # Production build (catches missing content paths) +pnpm typecheck # Type checking +``` + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Why are my Tailwind classes not working in production?", "answer": "The most common cause is missing @source directives in globals.css. Add your package path as a @source directive and rebuild. Classes must exist in scanned files to be included in the production CSS bundle."}, + {"question": "How do I add a completely custom color?", "answer": "Define the CSS variable in theme.css (:root block), e.g. --brand: oklch(65% 0.2 250). Then map it in shadcn-ui.css inside @theme inline: --color-brand: var(--brand). Use it as bg-brand or text-brand in your components."}, + {"question": "Should I use @apply or inline utilities?", "answer": "Prefer inline utilities for most cases. Use @apply only for frequently repeated patterns that need to stay in sync. Inline utilities are more explicit and easier to maintain."}, + {"question": "How do I override Shadcn component styles?", "answer": "Add overrides in shadcn-ui.css within @layer components. Target the specific component classes or create variant classes. You can also pass className props to override individual instances."} + ] +/%} + +## Next Steps + +- Back to [Customization Overview](/docs/next-supabase-turbo/customization) +- Configure your [theme colors](/docs/next-supabase-turbo/customization/theme) for brand consistency +- Set up [custom fonts](/docs/next-supabase-turbo/customization/fonts) for typography +- Choose your [layout style](/docs/next-supabase-turbo/customization/layout-style) for navigation \ No newline at end of file diff --git a/docs/customization/theme.mdoc b/docs/customization/theme.mdoc new file mode 100644 index 000000000..0359ef0d7 --- /dev/null +++ b/docs/customization/theme.mdoc @@ -0,0 +1,252 @@ +--- +status: "published" +label: "Updating the Theme" +title: "Customize Your Shadcn UI Theme Colors | Next.js Supabase SaaS Kit" +order: 0 +description: "Configure brand colors, dark mode, and Shadcn UI theme variables in your Makerkit application using Tailwind CSS 4." +--- + +Customize your application's color scheme by editing `apps/web/styles/theme.css`. This file defines all theme variables (`:root` and `.dark`) that Shadcn UI components use, giving you complete control over your brand colors in both light and dark modes. + +## Quick Theme Change + +The fastest way to update your theme is to use the [Shadcn UI Themes page](https://ui.shadcn.com/themes): + +1. Choose a color scheme on the Shadcn theme builder +2. Copy the generated CSS variables +3. Paste them into `apps/web/styles/theme.css` +4. Wrap color values with `hsl()` or `oklch()` functions (Tailwind CSS 4 requirement) + +## Theme File Structure + +Makerkit's theming uses three CSS files in `apps/web/styles/`: + +| File | Purpose | +|------|---------| +| `theme.css` | Your theme colors - `:root` and `.dark` variables (edit this file) | +| `shadcn-ui.css` | Maps CSS variables to Tailwind's `@theme inline` system | +| `globals.css` | Imports all styles and base Tailwind directives | + +## Core Theme Variables + +Edit `apps/web/styles/theme.css` to customize these color groups. Colors use oklch format: + +```css title="apps/web/styles/theme.css" +:root { + /* Background and text */ + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + + /* Primary brand color (buttons, links, focus rings) */ + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + + /* Secondary actions and elements */ + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + + /* Muted backgrounds and text */ + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + + /* Hover states and accents */ + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + + /* Destructive actions (delete, error) */ + --destructive: oklch(0.58 0.22 27); + + /* Cards and popovers */ + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + + /* Borders and inputs */ + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + + /* Border radius */ + --radius: 0.625rem; + + /* Sidebar-specific colors */ + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + /* Chart colors */ + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); +} +``` + +## Dark Mode Configuration + +Define dark mode colors in the `.dark` class within the same file: + +```css title="apps/web/styles/theme.css" +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + + --primary: oklch(0.87 0 0); + --primary-foreground: oklch(0.16 0 0); + + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + + --destructive: oklch(0.704 0.191 22.216); + + --card: oklch(0.16 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.16 0 0); + --popover-foreground: oklch(0.985 0 0); + + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + + --sidebar: oklch(0.16 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} +``` + +## Converting Shadcn Theme Colors to Tailwind CSS 4 + +Shadcn's theme builder outputs HSL values without the function wrapper. Tailwind CSS 4 requires explicit color functions. + +**Shadcn output:** +```css +--primary: 222.2 47.4% 11.2%; +``` + +**Tailwind CSS 4 format:** +```css +--primary: hsl(222.2 47.4% 11.2%); +``` + +You can also use `oklch()` for better color perception: +```css +--primary: oklch(21.03% 0.0318 264.65); +``` + +Use any AI tool or color converter to transform the values. The key is ensuring every color value is wrapped in a color function. + +## Using Tailwind Color Palette + +Reference Tailwind's built-in colors using CSS variables: + +```css +--primary: var(--color-blue-600); +--destructive: var(--color-red-500); +--accent: var(--color-indigo-100); +``` + +Available color scales: `slate`, `gray`, `zinc`, `neutral`, `stone`, `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`. + +Each scale includes shades from `50` (lightest) to `950` (darkest). + +## Theme Mode Configuration + +Control how theme switching works with these environment variables: + +```bash title=".env.local" +# Default theme: light, dark, or system +NEXT_PUBLIC_DEFAULT_THEME_MODE=system + +# Show/hide the theme toggle in the UI +NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true +``` + +## Custom Brand Color Example + +Here's a complete example using a custom indigo brand color: + +```css title="apps/web/styles/theme.css" +:root { + --primary: oklch(0.457 0.24 277.023); /* indigo-600 */ + --primary-foreground: oklch(1 0 0); /* white */ + + --secondary: oklch(0.943 0.029 282.832); /* indigo-100 */ + --secondary-foreground: oklch(0.272 0.174 282.572); /* indigo-900 */ + + --accent: oklch(0.969 0.014 282.832); /* indigo-50 */ + --accent-foreground: oklch(0.272 0.174 282.572); + + --ring: oklch(0.539 0.233 277.117); /* indigo-500 */ +} + +.dark { + --primary: oklch(0.673 0.208 277.568); /* indigo-400 */ + --primary-foreground: oklch(0.208 0.153 283.264); /* indigo-950 */ + + --secondary: oklch(0.272 0.174 282.572); /* indigo-900 */ + --secondary-foreground: oklch(0.943 0.029 282.832); + + --accent: oklch(0.351 0.209 281.288); /* indigo-800 */ + --accent-foreground: oklch(0.943 0.029 282.832); + + --ring: oklch(0.673 0.208 277.568); /* indigo-400 */ +} +``` + +## Common Mistakes + +**Forgetting color function wrappers**: Tailwind CSS 4 requires `hsl()`, `oklch()`, or `rgb()` around color values. Raw space-separated values like `222 47% 11%` won't work. + +**Low contrast ratios**: Ensure sufficient contrast between foreground and background colors. Use tools like [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) to verify WCAG compliance. + +**Inconsistent dark mode**: Always define dark mode variants for every color you customize. Missing dark mode variables cause jarring visual inconsistencies. + +**Not testing all components**: Theme changes affect every Shadcn component. After updating colors, click through your app to verify buttons, inputs, cards, and dialogs all look correct. + +## Verification + +After updating your theme: + +1. Start the dev server: `pnpm dev` +2. Toggle between light and dark modes +3. Check these component types: + - Primary buttons (`--primary`) + - Form inputs (`--input`, `--border`, `--ring`) + - Cards and dialogs (`--card`, `--popover`) + - Destructive actions (`--destructive`) + - Sidebar navigation (`--sidebar-*` variables) + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "How do I use a custom color not in Tailwind's palette?", "answer": "Define your color using hsl() or oklch() functions directly. For example: --primary: hsl(250 60% 45%). You can use any valid CSS color value wrapped in a color function."}, + {"question": "Why do my colors look different than the Shadcn theme preview?", "answer": "Tailwind CSS 4 requires explicit color functions (hsl, oklch, rgb). Convert space-separated HSL values to hsl() function calls. Also ensure you're using the same color space."}, + {"question": "Can I have different themes for different pages?", "answer": "The theme applies globally. For page-specific styling, use CSS classes or component-level overrides rather than modifying theme variables. You could also implement a theme context for programmatic switching."}, + {"question": "How do I disable dark mode entirely?", "answer": "Set NEXT_PUBLIC_DEFAULT_THEME_MODE=light and NEXT_PUBLIC_ENABLE_THEME_TOGGLE=false in your environment variables. This forces light mode and hides the toggle."} + ] +/%} + +## Next Steps + +- Back to [Customization Overview](/docs/next-supabase-turbo/customization) +- Set up your [Tailwind CSS configuration](/docs/next-supabase-turbo/customization/tailwind-css) for additional customizations +- Configure [custom fonts](/docs/next-supabase-turbo/customization/fonts) for your brand typography +- Update your [application logo](/docs/next-supabase-turbo/customization/logo) to match your theme diff --git a/docs/data-fetching/captcha-protection.mdoc b/docs/data-fetching/captcha-protection.mdoc new file mode 100644 index 000000000..19a723416 --- /dev/null +++ b/docs/data-fetching/captcha-protection.mdoc @@ -0,0 +1,209 @@ +--- +status: "published" +description: "Learn how to set up captcha protection for your API routes." +title: "Captcha Protection for your API Routes" +label: "Captcha Protection" +order: 7 +--- + +For captcha protection, we use [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile). + +{% sequence title="How to set up captcha protection for your API routes" description="Learn how to set up captcha protection for your API routes" %} + +[Setting up the environment variables](#setting-up-the-environment-variables) + +[Enabling the captcha protection](#enabling-the-captcha-protection) + +[Using captcha in your components](#using-captcha-in-your-components) + +[Verifying the token](#verifying-the-token) + +{% /sequence %} + +## Setting up the environment variables + +To enable it, you need to set the following environment variables: + +```bash +CAPTCHA_SECRET_TOKEN= +NEXT_PUBLIC_CAPTCHA_SITE_KEY= +``` + +You can find the `CAPTCHA_SECRET_TOKEN` in the Turnstile configuration. The `NEXT_PUBLIC_CAPTCHA_SITE_KEY` is public and safe to share. Instead, the `CAPTCHA_SECRET_TOKEN` should be kept secret. + +This guide assumes you have correctly set up your Turnstile configuration. If you haven't, please refer to the https://developers.cloudflare.com/turnstile. + +## Enabling the captcha protection + +When you set the token in the environment variables, the kit will automatically protect your API routes with captcha. + +**Important:** You also need to set the token in the Supabase Dashboard! + +## Using Captcha in Your Components + +The kit provides two clean APIs for captcha integration depending on your use case. + +### Option 1: Using the useCaptcha Hook + +For auth containers and simple forms, use the useCaptcha hook for zero-boilerplate captcha integration: + +```tsx +import { useCaptcha } from '@kit/auth/captcha/client'; + +function MyComponent({ captchaSiteKey }) { + const captcha = useCaptcha({ siteKey: captchaSiteKey }); + + const handleSubmit = async (data) => { + try { + await myServerAction({ + ...data, + captchaToken: captcha.token, + }); + } finally { + // Always reset after submission + captcha.reset(); + } + }; + + return ( + <form onSubmit={handleSubmit}> + {captcha.field} + <button type="submit">Submit</button> + </form> + ); +} +``` + +The useCaptcha hook returns: +- `token` - The current captcha token +- `reset()` - Function to reset the captcha widget +- `field` - The captcha component to render + +### Option 2: React Hook Form Integration + +For forms using react-hook-form, use the CaptchaField component with automatic form integration: + +```tsx +import { useForm } from 'react-hook-form'; +import { CaptchaField } from '@kit/auth/captcha/client'; + +function MyForm() { + const form = useForm({ + defaultValues: { + message: '', + captchaToken: '', + }, + }); + + const handleSubmit = async (data) => { + try { + await myServerAction(data); + form.reset(); // Automatically resets captcha too + } catch (error) { + // Handle error + } + }; + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(handleSubmit)}> + {/* Your form fields */} + + <CaptchaField + siteKey={config.captchaSiteKey} + control={form.control} + name="captchaToken" + /> + + <button type="submit">Submit</button> + </form> + </Form> + ); +} +``` + +When using React Hook Form integration: +- The captcha token is automatically set in the form state +- Calling form.reset() automatically resets the captcha +- No manual state management needed + +## Using with Server Actions + +Define your server action schema to include the captchaToken: + +```tsx +import * as z from 'zod'; +import { captchaActionClient } from '@kit/next/safe-action'; + +const MySchema = z.object({ + message: z.string(), + captchaToken: z.string(), +}); + +export const myServerAction = captchaActionClient + .inputSchema(MySchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // Your action code - captcha is automatically verified + console.log(data.message); + }); +``` + +The `captchaActionClient` automatically: + +1. Extracts the `captchaToken` from the data +2. Verifies it with Cloudflare Turnstile +3. Throws an error if verification fails + +### Important Notes + +- **Token Validity**: A captcha token is valid for one request only +- **Always Reset:** Always call captcha.reset() (or form.reset() with RHF) after submission, whether successful or not +- **Automatic Renewal**: The library automatically renews tokens when needed, but you must reset after consumption + +## Verifying the Token Manually + +If you need to verify the captcha token manually server-side (e.g., in API routes), use: + +```tsx +import { verifyCaptchaToken } from '@kit/auth/captcha/server'; + +async function myApiHandler(request: Request) { + const token = request.headers.get('x-captcha-token'); + + // Throws an error if invalid + await verifyCaptchaToken(token); + + // Your API logic +} +``` + +Note: If you use `captchaActionClient` or `enhanceRouteHandler` with captcha: true, verification is automatic and you don't need to call verifyCaptchaToken manually. + +## Upgrading from v2 + +{% callout title="Differences with v2" %} +In v2, captcha-protected actions used `enhanceAction` with `{ captcha: true }`. In v3, use `captchaActionClient` from `@kit/next/safe-action` which handles captcha verification automatically. Zod imports also changed from `import { z } from 'zod'` to `import * as z from 'zod'`. + +For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration). +{% /callout %} + +## Migration from old API (prior to v2.18.3) + +If you're migrating from the old `useCaptchaToken` hook: + +Before: + +```tsx +import { useCaptchaToken } from '@kit/auth/captcha/client'; + +const { captchaToken, resetCaptchaToken } = useCaptchaToken(); +// Manual state management required +``` + +After: + +```tsx +import { useCaptcha } from '@kit/auth/captcha/client'; + +const captcha = useCaptcha({ siteKey: captchaSiteKey }); +``` \ No newline at end of file diff --git a/docs/data-fetching/csrf-protection.mdoc b/docs/data-fetching/csrf-protection.mdoc new file mode 100644 index 000000000..01ded2aa9 --- /dev/null +++ b/docs/data-fetching/csrf-protection.mdoc @@ -0,0 +1,32 @@ +--- +status: "published" +title: "CSRF Protection" +description: "How CSRF protection works in Makerkit." +label: "CSRF Protection" +order: 6 +--- + +## CSRF Protection + +CSRF protection is handled automatically by Next.js when using Server Actions. You do not need to manage CSRF tokens manually. + +### Server Actions + +Server Actions are inherently protected against CSRF attacks by Next.js. The framework validates the origin of all Server Action requests, ensuring they come from the same origin as your application. + +No additional configuration or token passing is needed. + +### API Route Handlers + +API Route Handlers under `/api/*` do not have CSRF protection, as they are typically used for webhooks, external services, and third-party integrations. If you need to protect an API route from unauthorized access, use authentication checks via `enhanceRouteHandler` with `auth: true`. + +### Recommendations + +- **Prefer Server Actions** for all mutations from client components. They provide built-in CSRF protection and type safety. +- **Use Route Handlers** only for webhooks, streaming responses, or integrations that require standard HTTP endpoints. + +--- + +## V2 Legacy + +In v2, Makerkit used `@edge-csrf/nextjs` middleware to protect non-API routes against CSRF attacks. A `useCsrfToken` hook from `@kit/shared/hooks` was used to retrieve the CSRF token and pass it as an `X-CSRF-Token` header on fetch requests. Both have been removed in v3 since Server Actions handle CSRF protection natively. \ No newline at end of file diff --git a/docs/data-fetching/react-query.mdoc b/docs/data-fetching/react-query.mdoc new file mode 100644 index 000000000..5d75a297a --- /dev/null +++ b/docs/data-fetching/react-query.mdoc @@ -0,0 +1,713 @@ +--- +status: "published" +title: "Client-Side Data Fetching with React Query" +label: "React Query" +description: "Use React Query (TanStack Query) for client-side data fetching in MakerKit. Covers queries, mutations, caching, optimistic updates, and combining with Server Components." +order: 5 +--- + +React Query (TanStack Query v5) manages client-side data fetching with automatic caching, background refetching, and optimistic updates. MakerKit includes it pre-configured. Use React Query when you need real-time dashboards, infinite scroll, optimistic UI updates, or data shared across multiple components. For initial page loads, prefer Server Components. Tested with TanStack Query v5 (uses `gcTime` instead of `cacheTime`). + +### When to use React Query and Server Components? + +**Use React Query** for real-time updates, optimistic mutations, pagination, and shared client-side state. + +**Use Server Components** for initial page loads and SEO content. Combine both: load data server-side, then hydrate React Query for client interactivity. + +## When to Use React Query + +**Use React Query for:** +- Real-time dashboards that need background refresh +- Infinite scroll and pagination +- Data that multiple components share +- Optimistic updates for instant feedback +- Client-side filtering and sorting with server data + +**Use Server Components instead for:** +- Initial page loads +- SEO-critical content +- Data that doesn't need real-time updates + +## Basic Query + +Fetch data with `useQuery`. The query automatically caches results and handles loading/error states: + +```tsx +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +export function TasksList({ accountId }: { accountId: string }) { + const supabase = useSupabase(); + + const { data: tasks, isLoading, error } = useQuery({ + queryKey: ['tasks', accountId], + queryFn: async () => { + const { data, error } = await supabase + .from('tasks') + .select('*') + .eq('account_id', accountId) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data; + }, + }); + + if (isLoading) { + return <div>Loading tasks...</div>; + } + + if (error) { + return <div>Failed to load tasks</div>; + } + + return ( + <ul> + {tasks?.map((task) => ( + <li key={task.id}>{task.title}</li> + ))} + </ul> + ); +} +``` + +## Query Keys + +Query keys identify cached data. Structure them hierarchically for easy invalidation: + +```tsx +// Specific task +queryKey: ['tasks', taskId] + +// All tasks for an account +queryKey: ['tasks', { accountId }] + +// All tasks for an account with filters +queryKey: ['tasks', { accountId, status: 'pending', page: 1 }] + +// Invalidate all task queries +queryClient.invalidateQueries({ queryKey: ['tasks'] }); + +// Invalidate tasks for specific account +queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] }); +``` + +### Query Key Factory + +For larger apps, create a query key factory: + +```tsx +// lib/query-keys.ts +export const queryKeys = { + tasks: { + all: ['tasks'] as const, + list: (accountId: string) => ['tasks', { accountId }] as const, + detail: (taskId: string) => ['tasks', taskId] as const, + filtered: (accountId: string, filters: TaskFilters) => + ['tasks', { accountId, ...filters }] as const, + }, + members: { + all: ['members'] as const, + list: (accountId: string) => ['members', { accountId }] as const, + }, +}; + +// Usage +const { data } = useQuery({ + queryKey: queryKeys.tasks.list(accountId), + queryFn: () => fetchTasks(accountId), +}); + +// Invalidate all tasks +queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all }); +``` + +## Mutations + +Use `useMutation` for create, update, and delete operations: + +```tsx +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +export function CreateTaskForm({ accountId }: { accountId: string }) { + const supabase = useSupabase(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async (newTask: { title: string }) => { + const { data, error } = await supabase + .from('tasks') + .insert({ + title: newTask.title, + account_id: accountId, + }) + .select() + .single(); + + if (error) throw error; + return data; + }, + onSuccess: () => { + // Invalidate and refetch tasks list + queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] }); + }, + }); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + mutation.mutate({ title: formData.get('title') as string }); + }; + + return ( + <form onSubmit={handleSubmit}> + <input name="title" placeholder="Task title" required /> + <button type="submit" disabled={mutation.isPending}> + {mutation.isPending ? 'Creating...' : 'Create Task'} + </button> + {mutation.error && ( + <p className="text-destructive">Failed to create task</p> + )} + </form> + ); +} +``` + +## Optimistic Updates + +Update the UI immediately before the server responds for a snappier feel: + +```tsx +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +export function useUpdateTask(accountId: string) { + const supabase = useSupabase(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (task: { id: string; completed: boolean }) => { + const { data, error } = await supabase + .from('tasks') + .update({ completed: task.completed }) + .eq('id', task.id) + .select() + .single(); + + if (error) throw error; + return data; + }, + + // Optimistically update the cache + onMutate: async (updatedTask) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ + queryKey: ['tasks', { accountId }], + }); + + // Snapshot previous value + const previousTasks = queryClient.getQueryData<Task[]>([ + 'tasks', + { accountId }, + ]); + + // Optimistically update + queryClient.setQueryData<Task[]>( + ['tasks', { accountId }], + (old) => + old?.map((task) => + task.id === updatedTask.id + ? { ...task, completed: updatedTask.completed } + : task + ) + ); + + // Return context with snapshot + return { previousTasks }; + }, + + // Rollback on error + onError: (err, updatedTask, context) => { + queryClient.setQueryData( + ['tasks', { accountId }], + context?.previousTasks + ); + }, + + // Always refetch after error or success + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ['tasks', { accountId }], + }); + }, + }); +} +``` + +Usage: + +```tsx +function TaskItem({ task, accountId }: { task: Task; accountId: string }) { + const updateTask = useUpdateTask(accountId); + + return ( + <label className="flex items-center gap-2"> + <input + type="checkbox" + checked={task.completed} + onChange={(e) => + updateTask.mutate({ id: task.id, completed: e.target.checked }) + } + /> + <span className={task.completed ? 'line-through' : ''}> + {task.title} + </span> + </label> + ); +} +``` + +## Combining with Server Components + +Load initial data in Server Components, then hydrate React Query for client-side updates: + +```tsx +// app/tasks/page.tsx (Server Component) +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { TasksManager } from './tasks-manager'; + +export default async function TasksPage({ + params, +}: { + params: Promise<{ account: string }>; +}) { + const { account } = await params; + const supabase = getSupabaseServerClient(); + + const { data: tasks } = await supabase + .from('tasks') + .select('*') + .eq('account_slug', account) + .order('created_at', { ascending: false }); + + return ( + <TasksManager + accountSlug={account} + initialTasks={tasks ?? []} + /> + ); +} +``` + +```tsx +// tasks-manager.tsx (Client Component) +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +interface Props { + accountSlug: string; + initialTasks: Task[]; +} + +export function TasksManager({ accountSlug, initialTasks }: Props) { + const supabase = useSupabase(); + + const { data: tasks } = useQuery({ + queryKey: ['tasks', { accountSlug }], + queryFn: async () => { + const { data, error } = await supabase + .from('tasks') + .select('*') + .eq('account_slug', accountSlug) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data; + }, + // Use server data as initial value + initialData: initialTasks, + // Consider fresh for 30 seconds (skip immediate refetch) + staleTime: 30_000, + }); + + return ( + <div> + {/* tasks is initialTasks on first render, then live data */} + {tasks.map((task) => ( + <TaskItem key={task.id} task={task} /> + ))} + </div> + ); +} +``` + +## Caching Configuration + +Control how long data stays fresh and when to refetch: + +```tsx +const { data } = useQuery({ + queryKey: ['tasks', accountId], + queryFn: fetchTasks, + + // Data considered fresh for 5 minutes + staleTime: 5 * 60 * 1000, + + // Keep unused data in cache for 30 minutes + gcTime: 30 * 60 * 1000, + + // Refetch when window regains focus + refetchOnWindowFocus: true, + + // Refetch every 60 seconds + refetchInterval: 60_000, + + // Only refetch interval when tab is visible + refetchIntervalInBackground: false, +}); +``` + +### Global Defaults + +Set defaults for all queries in your QueryClient: + +```tsx +// lib/query-client.ts +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, // 1 minute + gcTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: true, + retry: 1, + }, + mutations: { + retry: 0, + }, + }, +}); +``` + +## Pagination + +Implement paginated queries: + +```tsx +'use client'; + +import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +const PAGE_SIZE = 10; + +export function PaginatedTasks({ accountId }: { accountId: string }) { + const [page, setPage] = useState(0); + const supabase = useSupabase(); + + const { data, isLoading, isPlaceholderData } = useQuery({ + queryKey: ['tasks', { accountId, page }], + queryFn: async () => { + const from = page * PAGE_SIZE; + const to = from + PAGE_SIZE - 1; + + const { data, error, count } = await supabase + .from('tasks') + .select('*', { count: 'exact' }) + .eq('account_id', accountId) + .order('created_at', { ascending: false }) + .range(from, to); + + if (error) throw error; + return { tasks: data, total: count ?? 0 }; + }, + // Keep previous data while fetching next page + placeholderData: keepPreviousData, + }); + + const totalPages = Math.ceil((data?.total ?? 0) / PAGE_SIZE); + + return ( + <div> + <ul className={isPlaceholderData ? 'opacity-50' : ''}> + {data?.tasks.map((task) => ( + <li key={task.id}>{task.title}</li> + ))} + </ul> + + <div className="flex gap-2 mt-4"> + <button + onClick={() => setPage((p) => Math.max(0, p - 1))} + disabled={page === 0} + > + Previous + </button> + <span> + Page {page + 1} of {totalPages} + </span> + <button + onClick={() => setPage((p) => p + 1)} + disabled={page >= totalPages - 1} + > + Next + </button> + </div> + </div> + ); +} +``` + +## Infinite Scroll + +For infinite scrolling lists: + +```tsx +'use client'; + +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +const PAGE_SIZE = 20; + +export function InfiniteTasksList({ accountId }: { accountId: string }) { + const supabase = useSupabase(); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ['tasks', { accountId, infinite: true }], + queryFn: async ({ pageParam }) => { + const from = pageParam * PAGE_SIZE; + const to = from + PAGE_SIZE - 1; + + const { data, error } = await supabase + .from('tasks') + .select('*') + .eq('account_id', accountId) + .order('created_at', { ascending: false }) + .range(from, to); + + if (error) throw error; + return data; + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + // Return undefined when no more pages + return lastPage.length === PAGE_SIZE ? allPages.length : undefined; + }, + }); + + const tasks = data?.pages.flatMap((page) => page) ?? []; + + return ( + <div> + <ul> + {tasks.map((task) => ( + <li key={task.id}>{task.title}</li> + ))} + </ul> + + {hasNextPage && ( + <button + onClick={() => fetchNextPage()} + disabled={isFetchingNextPage} + > + {isFetchingNextPage ? 'Loading...' : 'Load More'} + </button> + )} + </div> + ); +} +``` + +## Real-Time with Supabase Subscriptions + +Combine React Query with Supabase real-time for live updates: + +```tsx +'use client'; + +import { useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +export function LiveTasks({ accountId }: { accountId: string }) { + const supabase = useSupabase(); + const queryClient = useQueryClient(); + + const { data: tasks } = useQuery({ + queryKey: ['tasks', { accountId }], + queryFn: async () => { + const { data, error } = await supabase + .from('tasks') + .select('*') + .eq('account_id', accountId); + + if (error) throw error; + return data; + }, + }); + + // Subscribe to real-time changes + useEffect(() => { + const channel = supabase + .channel('tasks-changes') + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'tasks', + filter: `account_id=eq.${accountId}`, + }, + () => { + // Invalidate and refetch on any change + queryClient.invalidateQueries({ + queryKey: ['tasks', { accountId }], + }); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [supabase, queryClient, accountId]); + + return ( + <ul> + {tasks?.map((task) => ( + <li key={task.id}>{task.title}</li> + ))} + </ul> + ); +} +``` + +## Using Server Actions with React Query + +Combine Server Actions with React Query mutations: + +```tsx +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createTask } from './actions'; // Server Action + +export function useCreateTask(accountId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createTask, // Server Action as mutation function + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['tasks', { accountId }], + }); + }, + }); +} + +// Usage +function CreateTaskForm({ accountId }: { accountId: string }) { + const createTask = useCreateTask(accountId); + + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + createTask.mutate({ + title: formData.get('title') as string, + accountId, + }); + }} + > + <input name="title" required /> + <button disabled={createTask.isPending}> + {createTask.isPending ? 'Creating...' : 'Create'} + </button> + </form> + ); +} +``` + +## Common Mistakes + +### Forgetting 'use client' + +```tsx +// WRONG: React Query hooks require client components +export function Tasks() { + const { data } = useQuery({ ... }); // Error: hooks can't run on server +} + +// RIGHT: Mark as client component +'use client'; +export function Tasks() { + const { data } = useQuery({ ... }); +} +``` + +### Unstable Query Keys + +```tsx +// WRONG: New object reference on every render causes infinite refetches +const { data } = useQuery({ + queryKey: ['tasks', { accountId, filters: { status: 'pending' } }], + queryFn: fetchTasks, +}); + +// RIGHT: Use stable references +const filters = useMemo(() => ({ status: 'pending' }), []); +const { data } = useQuery({ + queryKey: ['tasks', { accountId, ...filters }], + queryFn: fetchTasks, +}); + +// OR: Spread primitive values directly +const { data } = useQuery({ + queryKey: ['tasks', accountId, 'pending'], + queryFn: fetchTasks, +}); +``` + +### Not Handling Loading States + +```tsx +// WRONG: Assuming data exists +function Tasks() { + const { data } = useQuery({ ... }); + return <ul>{data.map(...)}</ul>; // data might be undefined +} + +// RIGHT: Handle all states +function Tasks() { + const { data, isLoading, error } = useQuery({ ... }); + + if (isLoading) return <Skeleton />; + if (error) return <Error />; + if (!data?.length) return <Empty />; + + return <ul>{data.map(...)}</ul>; +} +``` + +## Next Steps + +- [Server Components](server-components) - Initial data loading +- [Server Actions](server-actions) - Mutations with Server Actions +- [Supabase Clients](supabase-clients) - Browser vs server clients diff --git a/docs/data-fetching/route-handlers.mdoc b/docs/data-fetching/route-handlers.mdoc new file mode 100644 index 000000000..499446cf1 --- /dev/null +++ b/docs/data-fetching/route-handlers.mdoc @@ -0,0 +1,567 @@ +--- +status: "published" +title: "API Route Handlers in Next.js" +label: "Route Handlers" +description: "Build API endpoints with Next.js Route Handlers. Covers the enhanceRouteHandler utility, webhook handling, CSRF protection, and when to use Route Handlers vs Server Actions." +order: 2 +--- + +[Route Handlers](/blog/tutorials/server-actions-vs-route-handlers) create HTTP API endpoints in Next.js by exporting functions named GET, POST, PUT, or DELETE from a `route.ts` file. + +While Server Actions handle most mutations, Route Handlers are essential for webhooks (Stripe, Lemon Squeezy), external API access, streaming responses, and scenarios needing custom HTTP headers or status codes. + +MakerKit's `enhanceRouteHandler` adds authentication and validation. Tested with Next.js 16 (async headers/params). + +{% callout title="When to use Route Handlers" %} +**Use Route Handlers** for webhooks, external services calling your API, streaming responses, and public APIs. **Use Server Actions** for mutations from your own app (forms, button clicks). +{% /callout %} + +## When to Use Route Handlers + +**Use Route Handlers for:** +- Webhook endpoints (Stripe, Lemon Squeezy, GitHub, etc.) +- External services calling your API +- Public APIs for third-party consumption +- Streaming responses or Server-Sent Events +- Custom headers, status codes, or response formats + +**Use Server Actions instead for:** +- Form submissions from your own app +- Mutations triggered by user interactions +- Any operation that doesn't need HTTP details + +## Basic Route Handler + +Create a `route.ts` file in any route segment: + +```tsx +// app/api/health/route.ts +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + }); +} +``` + +This creates an endpoint at `/api/health` that responds to GET requests. + +### HTTP Methods + +Export functions named after HTTP methods: + +```tsx +// app/api/tasks/route.ts +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + // Handle GET /api/tasks +} + +export async function POST(request: Request) { + // Handle POST /api/tasks +} + +export async function PUT(request: Request) { + // Handle PUT /api/tasks +} + +export async function DELETE(request: Request) { + // Handle DELETE /api/tasks +} +``` + +## Using enhanceRouteHandler + +The `enhanceRouteHandler` utility adds authentication, validation, and captcha verification: + +```tsx +import { NextResponse } from 'next/server'; +import * as z from 'zod'; +import { enhanceRouteHandler } from '@kit/next/routes'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const CreateTaskSchema = z.object({ + title: z.string().min(1), + accountId: z.string().uuid(), +}); + +export const POST = enhanceRouteHandler( + async ({ body, user, request }) => { + // body is validated against the schema + // user is the authenticated user + // request is the original NextRequest + + const supabase = getSupabaseServerClient(); + + const { data, error } = await supabase + .from('tasks') + .insert({ + title: body.title, + account_id: body.accountId, + created_by: user.id, + }) + .select() + .single(); + + if (error) { + return NextResponse.json( + { error: 'Failed to create task' }, + { status: 500 } + ); + } + + return NextResponse.json({ task: data }, { status: 201 }); + }, + { + schema: CreateTaskSchema, + auth: true, // Require authentication (default) + } +); +``` + +### Configuration Options + +```tsx +enhanceRouteHandler(handler, { + // Zod schema for request body validation + schema: MySchema, + + // Require authentication (default: true) + auth: true, + + // Require captcha verification (default: false) + captcha: false, +}); +``` + +### Public Endpoints + +For public endpoints (no authentication required): + +```tsx +export const GET = enhanceRouteHandler( + async ({ request }) => { + // user will be undefined + const supabase = getSupabaseServerClient(); + + const { data } = await supabase + .from('public_content') + .select('*') + .limit(10); + + return NextResponse.json({ content: data }); + }, + { auth: false } +); +``` + +## Dynamic Route Parameters + +Access route parameters in Route Handlers: + +```tsx +// app/api/tasks/[id]/route.ts +import { NextResponse } from 'next/server'; +import { enhanceRouteHandler } from '@kit/next/routes'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export const GET = enhanceRouteHandler( + async ({ user, params }) => { + const supabase = getSupabaseServerClient(); + + const { data, error } = await supabase + .from('tasks') + .select('*') + .eq('id', params.id) + .single(); + + if (error || !data) { + return NextResponse.json( + { error: 'Task not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ task: data }); + }, + { auth: true } +); + +export const DELETE = enhanceRouteHandler( + async ({ user, params }) => { + const supabase = getSupabaseServerClient(); + + const { error } = await supabase + .from('tasks') + .delete() + .eq('id', params.id) + .eq('created_by', user.id); + + if (error) { + return NextResponse.json( + { error: 'Failed to delete task' }, + { status: 500 } + ); + } + + return new Response(null, { status: 204 }); + }, + { auth: true } +); +``` + +## Webhook Handling + +Webhooks require special handling since they come from external services without user authentication. + +### Stripe Webhook Example + +```tsx +// app/api/webhooks/stripe/route.ts +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; + +export async function POST(request: Request) { + const body = await request.text(); + const headersList = await headers(); + const signature = headersList.get('stripe-signature'); + + if (!signature) { + return NextResponse.json( + { error: 'Missing signature' }, + { status: 400 } + ); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(body, signature, webhookSecret); + } catch (err) { + console.error('Webhook signature verification failed:', err); + return NextResponse.json( + { error: 'Invalid signature' }, + { status: 400 } + ); + } + + // Use admin client since webhooks don't have user context + const supabase = getSupabaseServerAdminClient(); + + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + + await supabase + .from('subscriptions') + .update({ status: 'active' }) + .eq('stripe_customer_id', session.customer); + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + + await supabase + .from('subscriptions') + .update({ status: 'cancelled' }) + .eq('stripe_subscription_id', subscription.id); + break; + } + + default: + console.log(`Unhandled event type: ${event.type}`); + } + + return NextResponse.json({ received: true }); +} +``` + +### Generic Webhook Pattern + +```tsx +// app/api/webhooks/[provider]/route.ts +import { NextResponse } from 'next/server'; +import { headers } from 'next/headers'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +type WebhookHandler = { + verifySignature: (body: string, signature: string) => boolean; + handleEvent: (event: unknown) => Promise<void>; +}; + +const handlers: Record<string, WebhookHandler> = { + stripe: { + verifySignature: (body, sig) => { /* ... */ }, + handleEvent: async (event) => { /* ... */ }, + }, + github: { + verifySignature: (body, sig) => { /* ... */ }, + handleEvent: async (event) => { /* ... */ }, + }, +}; + +export async function POST( + request: Request, + { params }: { params: Promise<{ provider: string }> } +) { + const { provider } = await params; + const handler = handlers[provider]; + + if (!handler) { + return NextResponse.json( + { error: 'Unknown provider' }, + { status: 404 } + ); + } + + const body = await request.text(); + const headersList = await headers(); + const signature = headersList.get('x-signature') ?? ''; + + if (!handler.verifySignature(body, signature)) { + return NextResponse.json( + { error: 'Invalid signature' }, + { status: 401 } + ); + } + + try { + const event = JSON.parse(body); + await handler.handleEvent(event); + return NextResponse.json({ received: true }); + } catch (error) { + console.error(`Webhook error (${provider}):`, error); + return NextResponse.json( + { error: 'Processing failed' }, + { status: 500 } + ); + } +} +``` + +## CSRF Protection + +CSRF protection is handled natively by Next.js Server Actions. No manual CSRF token management is needed. + +Routes under `/api/*` are intended for external access (webhooks, third-party integrations) and do not have CSRF protection. Use authentication checks via `enhanceRouteHandler` with `auth: true` if needed. + +## Streaming Responses + +Route Handlers support streaming for real-time data: + +```tsx +// app/api/stream/route.ts +export async function GET() { + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + for (let i = 0; i < 10; i++) { + const data = JSON.stringify({ count: i, timestamp: Date.now() }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} +``` + +## File Uploads + +Handle file uploads with Route Handlers: + +```tsx +// app/api/upload/route.ts +import { NextResponse } from 'next/server'; +import { enhanceRouteHandler } from '@kit/next/routes'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export const POST = enhanceRouteHandler( + async ({ request, user }) => { + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json( + { error: 'No file provided' }, + { status: 400 } + ); + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + { error: 'Invalid file type' }, + { status: 400 } + ); + } + + // Validate file size (5MB max) + if (file.size > 5 * 1024 * 1024) { + return NextResponse.json( + { error: 'File too large' }, + { status: 400 } + ); + } + + const supabase = getSupabaseServerClient(); + const fileName = `${user.id}/${Date.now()}-${file.name}`; + + const { error } = await supabase.storage + .from('uploads') + .upload(fileName, file); + + if (error) { + return NextResponse.json( + { error: 'Upload failed' }, + { status: 500 } + ); + } + + const { data: urlData } = supabase.storage + .from('uploads') + .getPublicUrl(fileName); + + return NextResponse.json({ + url: urlData.publicUrl, + }); + }, + { auth: true } +); +``` + +## Error Handling + +### Consistent Error Responses + +Create a helper for consistent error responses: + +```tsx +// lib/api-errors.ts +import { NextResponse } from 'next/server'; + +export function apiError( + message: string, + status: number = 500, + details?: Record<string, unknown> +) { + return NextResponse.json( + { + error: message, + ...details, + }, + { status } + ); +} + +export function notFound(resource: string = 'Resource') { + return apiError(`${resource} not found`, 404); +} + +export function unauthorized(message: string = 'Unauthorized') { + return apiError(message, 401); +} + +export function badRequest(message: string, field?: string) { + return apiError(message, 400, field ? { field } : undefined); +} +``` + +Usage: + +```tsx +import { notFound, badRequest } from '@/lib/api-errors'; + +export const GET = enhanceRouteHandler( + async ({ params }) => { + const task = await getTask(params.id); + + if (!task) { + return notFound('Task'); + } + + return NextResponse.json({ task }); + }, + { auth: true } +); +``` + +## Route Handler vs Server Action + +| Scenario | Use | +|----------|-----| +| Form submission from your app | Server Action | +| Button click triggers mutation | Server Action | +| Webhook from Stripe/GitHub | Route Handler | +| External service needs your API | Route Handler | +| Need custom status codes | Route Handler | +| Need streaming response | Route Handler | +| Need to set specific headers | Route Handler | + +## Common Mistakes + +### Forgetting to Verify Webhook Signatures + +```tsx +// WRONG: Trusting webhook data without verification +export async function POST(request: Request) { + const event = await request.json(); + await processEvent(event); // Anyone can call this! +} + +// RIGHT: Verify signature before processing +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get('x-signature'); + + if (!verifySignature(body, signature)) { + return new Response('Invalid signature', { status: 401 }); + } + + const event = JSON.parse(body); + await processEvent(event); +} +``` + +### Using Wrong Client in Webhooks + +```tsx +// WRONG: Regular client in webhook (no user session) +export async function POST(request: Request) { + const supabase = getSupabaseServerClient(); + // This will fail - no user session for RLS + await supabase.from('subscriptions').update({ ... }); +} + +// RIGHT: Admin client for webhook operations +export async function POST(request: Request) { + // Verify signature first! + const supabase = getSupabaseServerAdminClient(); + await supabase.from('subscriptions').update({ ... }); +} +``` + +## Next Steps + +- [Server Actions](server-actions) - For mutations from your app +- [CSRF Protection](csrf-protection) - Secure your endpoints +- [Captcha Protection](captcha-protection) - Bot protection diff --git a/docs/data-fetching/server-actions.mdoc b/docs/data-fetching/server-actions.mdoc new file mode 100644 index 000000000..fbf316b40 --- /dev/null +++ b/docs/data-fetching/server-actions.mdoc @@ -0,0 +1,816 @@ +--- +status: "published" +title: "Server Actions for Data Mutations" +label: "Server Actions" +description: "Use Server Actions to handle form submissions and data mutations in MakerKit. Covers authActionClient, validation, authentication, revalidation, and captcha protection." +order: 1 +--- + +Server Actions are async functions marked with `'use server'` that run on the server but can be called directly from client components. They handle form submissions, data mutations, and any operation that modifies your database. MakerKit's `authActionClient` adds authentication and Zod validation with zero boilerplate, while `publicActionClient` and `captchaActionClient` handle public and captcha-protected actions respectively. Tested with Next.js 16 and React 19. + +{% callout title="When to use Server Actions" %} +**Use Server Actions** for any mutation: form submissions, button clicks that create/update/delete data, and operations needing server-side validation. Use Route Handlers only for webhooks and external API access. +{% /callout %} + +## Basic Server Action + +A Server Action is any async function in a file marked with `'use server'`: + +```tsx +'use server'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function createTask(formData: FormData) { + const supabase = getSupabaseServerClient(); + const title = formData.get('title') as string; + + const { error } = await supabase.from('tasks').insert({ title }); + + if (error) { + return { success: false, error: error.message }; + } + + return { success: true }; +} +``` + +This works, but lacks validation, authentication, and proper error handling. The action clients solve these problems. + +## Using authActionClient + +The `authActionClient` creates type-safe, validated server actions with built-in authentication: + +1. **Authentication** - Verifies the user is logged in +2. **Validation** - Validates input against a Zod schema +3. **Type Safety** - Full end-to-end type inference + +```tsx +'use server'; + +import * as z from 'zod'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const CreateTaskSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + description: z.string().optional(), + accountId: z.string().uuid(), +}); + +export const createTask = authActionClient + .inputSchema(CreateTaskSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // data is typed and validated + // user is the authenticated user + const supabase = getSupabaseServerClient(); + + const { error } = await supabase.from('tasks').insert({ + title: data.title, + description: data.description, + account_id: data.accountId, + created_by: user.id, + }); + + if (error) { + throw new Error('Failed to create task'); + } + + return { success: true }; + }); +``` + +### Available Action Clients + +| Client | Import | Use Case | +|--------|--------|----------| +| `authActionClient` | `@kit/next/safe-action` | Requires authenticated user (most common) | +| `publicActionClient` | `@kit/next/safe-action` | No auth required (contact forms, etc.) | +| `captchaActionClient` | `@kit/next/safe-action` | Requires CAPTCHA + auth | + +### Public Actions + +For public actions (like contact forms), use `publicActionClient`: + +```tsx +'use server'; + +import * as z from 'zod'; +import { publicActionClient } from '@kit/next/safe-action'; + +const ContactFormSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + message: z.string().min(10), +}); + +export const submitContactForm = publicActionClient + .inputSchema(ContactFormSchema) + .action(async ({ parsedInput: data }) => { + // No user context - this is a public action + await sendEmail(data); + return { success: true }; + }); +``` + +## Calling Server Actions from Components + +### With useAction (Recommended) + +The `useAction` hook from `next-safe-action/hooks` is the primary way to call server actions from client components: + +```tsx +'use client'; + +import { useAction } from 'next-safe-action/hooks'; +import { createTask } from './actions'; + +export function CreateTaskForm({ accountId }: { accountId: string }) { + const { execute, isPending } = useAction(createTask, { + onSuccess: ({ data }) => { + // Handle success + }, + onError: ({ error }) => { + // Handle error + }, + }); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + execute({ + title: formData.get('title') as string, + accountId, + }); + }; + + return ( + <form onSubmit={handleSubmit}> + <input type="text" name="title" placeholder="Task title" required /> + + <button type="submit" disabled={isPending}> + {isPending ? 'Creating...' : 'Create Task'} + </button> + </form> + ); +} +``` + +### With useActionState (React 19) + +`useActionState` works with plain Server Actions (not `next-safe-action` wrapped actions). Define a plain action for this pattern: + +```tsx +// actions.ts +'use server'; + +export async function createTaskFormAction(prevState: unknown, formData: FormData) { + const title = formData.get('title') as string; + const accountId = formData.get('accountId') as string; + + // validate and create task... + + return { success: true }; +} +``` + +```tsx +'use client'; + +import { useActionState } from 'react'; +import { createTaskFormAction } from './actions'; + +export function CreateTaskForm({ accountId }: { accountId: string }) { + const [state, formAction, isPending] = useActionState(createTaskFormAction, null); + + return ( + <form action={formAction}> + <input type="hidden" name="accountId" value={accountId} /> + <input type="text" name="title" placeholder="Task title" /> + + {state?.error && ( + <p className="text-destructive">{state.error}</p> + )} + + <button type="submit" disabled={isPending}> + {isPending ? 'Creating...' : 'Create Task'} + </button> + </form> + ); +} +``` + +{% alert type="info" %} +`useActionState` expects a plain server action with signature `(prevState, formData) => newState`. For `next-safe-action` wrapped actions, use the `useAction` hook from `next-safe-action/hooks` instead. +{% /alert %} + +### Direct Function Calls + +Call Server Actions directly for more complex scenarios: + +```tsx +'use client'; + +import { useState, useTransition } from 'react'; +import { createTask } from './actions'; + +export function CreateTaskButton({ accountId }: { accountId: string }) { + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + + const handleClick = () => { + startTransition(async () => { + try { + const result = await createTask({ + title: 'New Task', + accountId, + }); + + if (!result?.data?.success) { + setError('Failed to create task'); + } + } catch (e) { + setError('An unexpected error occurred'); + } + }); + }; + + return ( + <> + <button onClick={handleClick} disabled={isPending}> + {isPending ? 'Creating...' : 'Quick Add Task'} + </button> + {error && <p className="text-destructive">{error}</p>} + </> + ); +} +``` + +## Revalidating Data + +After mutations, revalidate cached data so the UI reflects changes: + +### Revalidate by Path + +```tsx +'use server'; + +import * as z from 'zod'; +import { revalidatePath } from 'next/cache'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const CreateTaskSchema = z.object({ + title: z.string().min(1), + accountId: z.string().uuid(), +}); + +export const createTask = authActionClient + .inputSchema(CreateTaskSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const supabase = getSupabaseServerClient(); + await supabase.from('tasks').insert({ /* ... */ }); + + // Revalidate the tasks page + revalidatePath('/tasks'); + + // Or revalidate with layout + revalidatePath('/tasks', 'layout'); + + return { success: true }; + }); +``` + +### Revalidate by Tag + +For more granular control, use cache tags: + +```tsx +'use server'; + +import * as z from 'zod'; +import { revalidateTag } from 'next/cache'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const UpdateTaskSchema = z.object({ + id: z.string().uuid(), + title: z.string().min(1), +}); + +export const updateTask = authActionClient + .inputSchema(UpdateTaskSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const supabase = getSupabaseServerClient(); + await supabase.from('tasks').update(data).eq('id', data.id); + + // Revalidate all queries tagged with 'tasks' + revalidateTag('tasks'); + + // Or revalidate specific task + revalidateTag(`task-${data.id}`); + + return { success: true }; + }); +``` + +### Redirecting After Mutation + +```tsx +'use server'; + +import * as z from 'zod'; +import { redirect } from 'next/navigation'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const CreateTaskSchema = z.object({ + title: z.string().min(1), + accountId: z.string().uuid(), +}); + +export const createTask = authActionClient + .inputSchema(CreateTaskSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const supabase = getSupabaseServerClient(); + const { data: task } = await supabase + .from('tasks') + .insert({ /* ... */ }) + .select('id') + .single(); + + // Redirect to the new task + redirect(`/tasks/${task.id}`); + }); +``` + +## Error Handling + +### Returning Errors + +Return structured errors for the client to handle: + +```tsx +'use server'; + +import * as z from 'zod'; +import { revalidatePath } from 'next/cache'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const CreateTaskSchema = z.object({ + title: z.string().min(1), + accountId: z.string().uuid(), +}); + +export const createTask = authActionClient + .inputSchema(CreateTaskSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const supabase = getSupabaseServerClient(); + + // Check for duplicate title + const { data: existing } = await supabase + .from('tasks') + .select('id') + .eq('title', data.title) + .eq('account_id', data.accountId) + .single(); + + if (existing) { + return { + success: false, + error: 'A task with this title already exists', + }; + } + + const { error } = await supabase.from('tasks').insert({ /* ... */ }); + + if (error) { + // Log for debugging, return user-friendly message + console.error('Failed to create task:', error); + return { + success: false, + error: 'Failed to create task. Please try again.', + }; + } + + revalidatePath('/tasks'); + return { success: true }; + }); +``` + +### Throwing Errors + +For unexpected errors, throw to trigger error boundaries: + +```tsx +'use server'; + +import * as z from 'zod'; +import { revalidatePath } from 'next/cache'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const DeleteTaskSchema = z.object({ + taskId: z.string().uuid(), +}); + +export const deleteTask = authActionClient + .inputSchema(DeleteTaskSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const supabase = getSupabaseServerClient(); + + const { error } = await supabase + .from('tasks') + .delete() + .eq('id', data.taskId) + .eq('created_by', user.id); // Ensure ownership + + if (error) { + // This will be caught by error boundaries + // and reported to your monitoring provider + throw new Error('Failed to delete task'); + } + + revalidatePath('/tasks'); + return { success: true }; + }); +``` + +## Captcha Protection + +For sensitive actions, add Cloudflare Turnstile captcha verification: + +### Server Action Setup + +```tsx +'use server'; + +import * as z from 'zod'; +import { captchaActionClient } from '@kit/next/safe-action'; + +const TransferFundsSchema = z.object({ + amount: z.number().positive(), + toAccountId: z.string().uuid(), + captchaToken: z.string(), +}); + +export const transferFunds = captchaActionClient + .inputSchema(TransferFundsSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // Captcha is verified before this runs + // ... transfer logic + }); +``` + +### Client Component with Captcha + +```tsx +'use client'; + +import { useAction } from 'next-safe-action/hooks'; +import { useCaptcha } from '@kit/auth/captcha/client'; +import { transferFunds } from './actions'; + +export function TransferForm({ captchaSiteKey }: { captchaSiteKey: string }) { + const captcha = useCaptcha({ siteKey: captchaSiteKey }); + + const { execute, isPending } = useAction(transferFunds, { + onSuccess: () => { + // Handle success + }, + onSettled: () => { + // Always reset captcha after submission + captcha.reset(); + }, + }); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + execute({ + amount: Number(formData.get('amount')), + toAccountId: formData.get('toAccountId') as string, + captchaToken: captcha.token, + }); + }; + + return ( + <form onSubmit={handleSubmit}> + <input type="number" name="amount" placeholder="Amount" /> + <input type="text" name="toAccountId" placeholder="Recipient" /> + + {/* Render captcha widget */} + {captcha.field} + + <button type="submit" disabled={isPending}> + {isPending ? 'Transferring...' : 'Transfer'} + </button> + </form> + ); +} +``` + +See [Captcha Protection](captcha-protection) for detailed setup instructions. + +## Real-World Example: Team Settings + +Here's a complete example of Server Actions for team management: + +```tsx +// lib/server/team-actions.ts +'use server'; + +import * as z from 'zod'; +import { revalidatePath } from 'next/cache'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { getLogger } from '@kit/shared/logger'; + +const UpdateTeamSchema = z.object({ + teamId: z.string().uuid(), + name: z.string().min(2).max(50), + slug: z.string().min(2).max(30).regex(/^[a-z0-9-]+$/), +}); + +const InviteMemberSchema = z.object({ + teamId: z.string().uuid(), + email: z.string().email(), + role: z.enum(['member', 'admin']), +}); + +const RemoveMemberSchema = z.object({ + teamId: z.string().uuid(), + userId: z.string().uuid(), +}); + +export const updateTeam = authActionClient + .inputSchema(UpdateTeamSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + + logger.info({ teamId: data.teamId, userId: user.id }, 'Updating team'); + + // Check if slug is taken + const { data: existing } = await supabase + .from('accounts') + .select('id') + .eq('slug', data.slug) + .neq('id', data.teamId) + .single(); + + if (existing) { + return { + success: false, + error: 'This URL is already taken', + field: 'slug', + }; + } + + const { error } = await supabase + .from('accounts') + .update({ name: data.name, slug: data.slug }) + .eq('id', data.teamId); + + if (error) { + logger.error({ error, teamId: data.teamId }, 'Failed to update team'); + return { success: false, error: 'Failed to update team' }; + } + + revalidatePath(`/home/${data.slug}/settings`); + return { success: true }; + }); + +export const inviteMember = authActionClient + .inputSchema(InviteMemberSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const supabase = getSupabaseServerClient(); + + // Check if already a member + const { data: existing } = await supabase + .from('account_members') + .select('id') + .eq('account_id', data.teamId) + .eq('user_email', data.email) + .single(); + + if (existing) { + return { success: false, error: 'User is already a member' }; + } + + // Create invitation + const { error } = await supabase.from('invitations').insert({ + account_id: data.teamId, + email: data.email, + role: data.role, + invited_by: user.id, + }); + + if (error) { + return { success: false, error: 'Failed to send invitation' }; + } + + revalidatePath(`/home/[account]/settings/members`, 'page'); + return { success: true }; + }); + +export const removeMember = authActionClient + .inputSchema(RemoveMemberSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const supabase = getSupabaseServerClient(); + + // Prevent removing yourself + if (data.userId === user.id) { + return { success: false, error: 'You cannot remove yourself' }; + } + + const { error } = await supabase + .from('account_members') + .delete() + .eq('account_id', data.teamId) + .eq('user_id', data.userId); + + if (error) { + return { success: false, error: 'Failed to remove member' }; + } + + revalidatePath(`/home/[account]/settings/members`, 'page'); + return { success: true }; + }); +``` + +## Common Mistakes + +### Forgetting to Revalidate + +```tsx +// WRONG: Data changes but UI doesn't update +export const updateTask = authActionClient + .inputSchema(UpdateTaskSchema) + .action(async ({ parsedInput: data }) => { + await supabase.from('tasks').update(data).eq('id', data.id); + return { success: true }; + }); + +// RIGHT: Revalidate after mutation +export const updateTask = authActionClient + .inputSchema(UpdateTaskSchema) + .action(async ({ parsedInput: data }) => { + await supabase.from('tasks').update(data).eq('id', data.id); + revalidatePath('/tasks'); + return { success: true }; + }); +``` + +### Using try/catch Incorrectly + +```tsx +// WRONG: Swallowing errors silently +export const createTask = authActionClient + .inputSchema(CreateTaskSchema) + .action(async ({ parsedInput: data }) => { + try { + await supabase.from('tasks').insert(data); + } catch (e) { + // Error is lost, user sees "success" + } + return { success: true }; + }); + +// RIGHT: Return or throw errors +export const createTask = authActionClient + .inputSchema(CreateTaskSchema) + .action(async ({ parsedInput: data }) => { + const { error } = await supabase.from('tasks').insert(data); + + if (error) { + return { success: false, error: 'Failed to create task' }; + } + + return { success: true }; + }); +``` + +### Not Validating Ownership + +```tsx +// WRONG: Any user can delete any task +export const deleteTask = authActionClient + .inputSchema(DeleteTaskSchema) + .action(async ({ parsedInput: data }) => { + await supabase.from('tasks').delete().eq('id', data.taskId); + }); + +// RIGHT: Verify ownership (or use RLS) +export const deleteTask = authActionClient + .inputSchema(DeleteTaskSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const { error } = await supabase + .from('tasks') + .delete() + .eq('id', data.taskId) + .eq('created_by', user.id); // User can only delete their own tasks + + if (error) { + return { success: false, error: 'Task not found or access denied' }; + } + + return { success: true }; + }); +``` + +## Using enhanceAction (Deprecated) + +{% callout title="Deprecated" %} +`enhanceAction` is still available but deprecated. Use `authActionClient`, `publicActionClient`, or `captchaActionClient` for new code. +{% /callout %} + +The `enhanceAction` utility from `@kit/next/actions` wraps a Server Action with authentication, Zod validation, and optional captcha verification: + +```tsx +'use server'; + +import * as z from 'zod'; +import { enhanceAction } from '@kit/next/actions'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const CreateTaskSchema = z.object({ + title: z.string().min(1).max(200), + accountId: z.string().uuid(), +}); + +// Authenticated action (default) +export const createTask = enhanceAction( + async (data, user) => { + const supabase = getSupabaseServerClient(); + + await supabase.from('tasks').insert({ + title: data.title, + account_id: data.accountId, + created_by: user.id, + }); + + return { success: true }; + }, + { schema: CreateTaskSchema } +); + +// Public action (no auth required) +export const submitContactForm = enhanceAction( + async (data) => { + await sendEmail(data); + return { success: true }; + }, + { schema: ContactFormSchema, auth: false } +); + +// With captcha verification +export const sensitiveAction = enhanceAction( + async (data, user) => { + // captcha verified before this runs + }, + { schema: MySchema, captcha: true } +); +``` + +### Configuration Options + +```tsx +enhanceAction(handler, { + schema: MySchema, // Zod schema for input validation + auth: true, // Require authentication (default: true) + captcha: false, // Require captcha verification (default: false) +}); +``` + +### Migrating to authActionClient + +```tsx +// Before (enhanceAction) +export const myAction = enhanceAction( + async (data, user) => { /* ... */ }, + { schema: MySchema } +); + +// After (authActionClient) +export const myAction = authActionClient + .inputSchema(MySchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + /* ... */ + }); +``` + +| enhanceAction option | v3 equivalent | +|---------------------|---------------| +| `{ auth: true }` (default) | `authActionClient` | +| `{ auth: false }` | `publicActionClient` | +| `{ captcha: true }` | `captchaActionClient` | + +## Next Steps + +- [Route Handlers](route-handlers) - For webhooks and external APIs +- [Captcha Protection](captcha-protection) - Protect sensitive actions +- [React Query](react-query) - Combine with optimistic updates diff --git a/docs/data-fetching/server-components.mdoc b/docs/data-fetching/server-components.mdoc new file mode 100644 index 000000000..b71db982c --- /dev/null +++ b/docs/data-fetching/server-components.mdoc @@ -0,0 +1,487 @@ +--- +status: "published" +title: "Data Fetching with Server Components" +label: "Server Components" +description: "Load data in Next.js Server Components with Supabase. Covers streaming, Suspense boundaries, parallel data loading, caching, and error handling patterns." +order: 3 +--- + +Server Components fetch data on the server during rendering, streaming HTML directly to the browser without adding to your JavaScript bundle. They're the default for all data loading in MakerKit because they're secure (queries never reach the client), SEO-friendly (content renders for search engines), and fast (no client-side fetching waterfalls). Tested with Next.js 16 and React 19. + +{% callout title="When to use Server Components" %} +**Use Server Components** (the default) for page loads, SEO content, and data that doesn't need real-time updates. Only switch to Client Components with React Query when you need optimistic updates, real-time subscriptions, or client-side filtering. +{% /callout %} + +## Why Server Components for Data Fetching + +Server Components provide significant advantages for data loading: + +- **No client bundle impact** - Database queries don't increase JavaScript bundle size +- **Direct database access** - Query Supabase directly without API round-trips +- **Streaming** - Users see content progressively as data loads +- **SEO-friendly** - Content is rendered on the server for search engines +- **Secure by default** - Queries never reach the browser + +## Basic Data Fetching + +Every component in Next.js is a Server Component by default. Add the `async` keyword to fetch data directly: + +```tsx +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export default async function TasksPage() { + const supabase = getSupabaseServerClient(); + + const { data: tasks, error } = await supabase + .from('tasks') + .select('id, title, completed, created_at') + .order('created_at', { ascending: false }); + + if (error) { + throw new Error('Failed to load tasks'); + } + + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}>{task.title}</li> + ))} + </ul> + ); +} +``` + +## Streaming with Suspense + +Suspense boundaries let you show loading states while data streams in. This prevents the entire page from waiting for slow queries. + +### Page-Level Loading States + +Create a `loading.tsx` file next to your page to show a loading UI while the page data loads: + +```tsx +// app/tasks/loading.tsx +export default function Loading() { + return ( + <div className="space-y-4"> + <div className="h-8 w-48 animate-pulse bg-muted rounded" /> + <div className="h-64 animate-pulse bg-muted rounded" /> + </div> + ); +} +``` + +### Component-Level Suspense + +For granular control, wrap individual components in Suspense boundaries: + +```tsx +import { Suspense } from 'react'; + +export default function DashboardPage() { + return ( + <div className="grid grid-cols-2 gap-4"> + {/* Stats load first */} + <Suspense fallback={<StatsSkeleton />}> + <DashboardStats /> + </Suspense> + + {/* Tasks can load independently */} + <Suspense fallback={<TasksSkeleton />}> + <RecentTasks /> + </Suspense> + + {/* Activity loads last */} + <Suspense fallback={<ActivitySkeleton />}> + <ActivityFeed /> + </Suspense> + </div> + ); +} + +// Each component fetches its own data +async function DashboardStats() { + const supabase = getSupabaseServerClient(); + const { data } = await supabase.rpc('get_dashboard_stats'); + return <StatsDisplay stats={data} />; +} + +async function RecentTasks() { + const supabase = getSupabaseServerClient(); + const { data } = await supabase.from('tasks').select('*').limit(5); + return <TasksList tasks={data} />; +} +``` + +## Parallel Data Loading + +Load multiple data sources simultaneously to minimize waterfall requests. Use `Promise.all` to fetch in parallel: + +```tsx +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export default async function AccountDashboard({ + params, +}: { + params: Promise<{ account: string }>; +}) { + const { account } = await params; + const supabase = getSupabaseServerClient(); + + // All queries run in parallel + const [tasksResult, membersResult, statsResult] = await Promise.all([ + supabase + .from('tasks') + .select('*') + .eq('account_slug', account) + .limit(10), + supabase + .from('account_members') + .select('*, user:users(name, avatar_url)') + .eq('account_slug', account), + supabase.rpc('get_account_stats', { account_slug: account }), + ]); + + return ( + <Dashboard + tasks={tasksResult.data} + members={membersResult.data} + stats={statsResult.data} + /> + ); +} +``` + +### Avoiding Waterfalls + +A waterfall occurs when queries depend on each other sequentially: + +```tsx +// BAD: Waterfall - each query waits for the previous +async function SlowDashboard() { + const supabase = getSupabaseServerClient(); + + const { data: account } = await supabase + .from('accounts') + .select('*') + .single(); + + // This waits for account to load first + const { data: tasks } = await supabase + .from('tasks') + .select('*') + .eq('account_id', account.id); + + // This waits for tasks to load + const { data: members } = await supabase + .from('members') + .select('*') + .eq('account_id', account.id); +} + +// GOOD: Parallel loading when data is independent +async function FastDashboard({ accountId }: { accountId: string }) { + const supabase = getSupabaseServerClient(); + + const [tasks, members] = await Promise.all([ + supabase.from('tasks').select('*').eq('account_id', accountId), + supabase.from('members').select('*').eq('account_id', accountId), + ]); +} +``` + +## Caching Strategies + +### Request Deduplication + +Next.js automatically deduplicates identical fetch requests within a single render. If multiple components need the same data, wrap your data fetching in React's `cache()`: + +```tsx +import { cache } from 'react'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +// This query runs once per request, even if called multiple times +export const getAccount = cache(async (slug: string) => { + const supabase = getSupabaseServerClient(); + + const { data, error } = await supabase + .from('accounts') + .select('*') + .eq('slug', slug) + .single(); + + if (error) throw error; + return data; +}); + +// Both components can call getAccount('acme') - only one query runs +async function AccountHeader({ slug }: { slug: string }) { + const account = await getAccount(slug); + return <h1>{account.name}</h1>; +} + +async function AccountSidebar({ slug }: { slug: string }) { + const account = await getAccount(slug); + return <nav>{/* uses account.settings */}</nav>; +} +``` + +### Using `unstable_cache` for Persistent Caching + +For data that doesn't change often, use Next.js's `unstable_cache` to cache across requests: + +```tsx +import { unstable_cache } from 'next/cache'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const getCachedPricingPlans = unstable_cache( + async () => { + const supabase = getSupabaseServerClient(); + const { data } = await supabase + .from('pricing_plans') + .select('*') + .eq('active', true); + return data; + }, + ['pricing-plans'], // Cache key + { + revalidate: 3600, // Revalidate every hour + tags: ['pricing'], // Tag for manual revalidation + } +); + +export default async function PricingPage() { + const plans = await getCachedPricingPlans(); + return <PricingTable plans={plans} />; +} +``` + +To invalidate the cache after updates: + +```tsx +'use server'; + +import { revalidateTag } from 'next/cache'; + +export async function updatePricingPlan(data: PlanUpdate) { + const supabase = getSupabaseServerClient(); + await supabase.from('pricing_plans').update(data).eq('id', data.id); + + // Invalidate the pricing cache + revalidateTag('pricing'); +} +``` + +## Error Handling + +### Error Boundaries + +Create an `error.tsx` file to catch errors in your route segment: + +```tsx +// app/tasks/error.tsx +'use client'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + <div className="p-4 border border-destructive rounded-lg"> + <h2 className="text-lg font-semibold">Something went wrong</h2> + <p className="text-muted-foreground">{error.message}</p> + <button + onClick={reset} + className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded" + > + Try again + </button> + </div> + ); +} +``` + +### Graceful Degradation + +For non-critical data, handle errors gracefully instead of throwing: + +```tsx +async function OptionalWidget() { + const supabase = getSupabaseServerClient(); + + const { data, error } = await supabase + .from('widgets') + .select('*') + .limit(5); + + // Don't crash the page if this fails + if (error || !data?.length) { + return null; // or return a fallback UI + } + + return <WidgetList widgets={data} />; +} +``` + +## Real-World Example: Team Dashboard + +Here's a complete example combining multiple patterns: + +```tsx +// app/home/[account]/page.tsx +import { Suspense } from 'react'; +import { cache } from 'react'; +import { notFound } from 'next/navigation'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +// Cached account loader - reusable across components +const getAccount = cache(async (slug: string) => { + const supabase = getSupabaseServerClient(); + const { data } = await supabase + .from('accounts') + .select('*') + .eq('slug', slug) + .single(); + return data; +}); + +export default async function TeamDashboard({ + params, +}: { + params: Promise<{ account: string }>; +}) { + const { account: slug } = await params; + const account = await getAccount(slug); + + if (!account) { + notFound(); + } + + return ( + <div className="space-y-6"> + <h1 className="text-2xl font-bold">{account.name}</h1> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + {/* Stats stream in first */} + <Suspense fallback={<StatsSkeleton />}> + <AccountStats accountId={account.id} /> + </Suspense> + + {/* Tasks load independently */} + <Suspense fallback={<TasksSkeleton />}> + <RecentTasks accountId={account.id} /> + </Suspense> + </div> + + {/* Activity feed can load last */} + <Suspense fallback={<ActivitySkeleton />}> + <ActivityFeed accountId={account.id} /> + </Suspense> + </div> + ); +} + +async function AccountStats({ accountId }: { accountId: string }) { + const supabase = getSupabaseServerClient(); + const { data } = await supabase.rpc('get_account_stats', { + p_account_id: accountId, + }); + + return ( + <div className="grid grid-cols-3 gap-4"> + <StatCard title="Tasks" value={data.total_tasks} /> + <StatCard title="Completed" value={data.completed_tasks} /> + <StatCard title="Members" value={data.member_count} /> + </div> + ); +} + +async function RecentTasks({ accountId }: { accountId: string }) { + const supabase = getSupabaseServerClient(); + const { data: tasks } = await supabase + .from('tasks') + .select('id, title, completed, assignee:users(name)') + .eq('account_id', accountId) + .order('created_at', { ascending: false }) + .limit(5); + + return ( + <div className="border rounded-lg p-4"> + <h2 className="font-semibold mb-4">Recent Tasks</h2> + <ul className="space-y-2"> + {tasks?.map((task) => ( + <li key={task.id} className="flex items-center gap-2"> + <span className={task.completed ? 'line-through' : ''}> + {task.title} + </span> + {task.assignee && ( + <span className="text-sm text-muted-foreground"> + - {task.assignee.name} + </span> + )} + </li> + ))} + </ul> + </div> + ); +} + +async function ActivityFeed({ accountId }: { accountId: string }) { + const supabase = getSupabaseServerClient(); + const { data: activities } = await supabase + .from('activity_log') + .select('*, user:users(name)') + .eq('account_id', accountId) + .order('created_at', { ascending: false }) + .limit(10); + + return ( + <div className="border rounded-lg p-4"> + <h2 className="font-semibold mb-4">Recent Activity</h2> + <ul className="space-y-2"> + {activities?.map((activity) => ( + <li key={activity.id} className="text-sm"> + <span className="font-medium">{activity.user.name}</span>{' '} + {activity.action} + </li> + ))} + </ul> + </div> + ); +} +``` + +## When to Use Client Components Instead + +Server Components are great for initial page loads, but some scenarios need client components: + +- **Real-time updates** - Use React Query with Supabase subscriptions +- **User interactions** - Sorting, filtering, pagination with instant feedback +- **Forms** - Complex forms with validation and state management +- **Optimistic updates** - Update UI before server confirms + +For these cases, load initial data in Server Components and pass to client components: + +```tsx +// Server Component - loads initial data +export default async function TasksPage() { + const tasks = await loadTasks(); + return <TasksManager initialTasks={tasks} />; +} + +// Client Component - handles interactivity +'use client'; +function TasksManager({ initialTasks }) { + const [tasks, setTasks] = useState(initialTasks); + // ... sorting, filtering, real-time updates +} +``` + +## Next Steps + +- [Server Actions](server-actions) - Mutate data from Server Components +- [React Query](react-query) - Client-side data management +- [Route Handlers](route-handlers) - Build API endpoints diff --git a/docs/data-fetching/supabase-clients.mdoc b/docs/data-fetching/supabase-clients.mdoc new file mode 100644 index 000000000..e433aa995 --- /dev/null +++ b/docs/data-fetching/supabase-clients.mdoc @@ -0,0 +1,354 @@ +--- +status: "published" +title: "Supabase Clients in Next.js" +label: "Supabase Clients" +description: "How to use Supabase clients in browser and server environments. Includes the standard client, server client, and admin client for bypassing RLS." +order: 0 +--- + +MakerKit provides three Supabase clients for different environments: `useSupabase()` for Client Components, `getSupabaseServerClient()` for Server Components and Server Actions, and `getSupabaseServerAdminClient()` for admin operations that bypass Row Level Security. Use the right client for your context to ensure security and proper RLS enforcement. As of Next.js 16 and React 19, these patterns are tested and recommended. + +{% callout title="Which client should I use?" %} +**In Client Components**: Use `useSupabase()` hook. **In Server Components or Server Actions**: Use `getSupabaseServerClient()`. **For webhooks or admin tasks**: Use `getSupabaseServerAdminClient()` (bypasses RLS). +{% /callout %} + +## Client Overview + +| Client | Environment | RLS | Use Case | +|--------|-------------|-----|----------| +| `useSupabase()` | Browser (React) | Yes | Client components, real-time subscriptions | +| `getSupabaseServerClient()` | Server | Yes | Server Components, Server Actions, Route Handlers | +| `getSupabaseServerAdminClient()` | Server | **Bypassed** | Admin operations, migrations, webhooks | + +## Browser Client + +Use the `useSupabase` hook in client components. This client runs in the browser and respects all RLS policies. + +```tsx +'use client'; + +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +export function TasksList() { + const supabase = useSupabase(); + + const handleComplete = async (taskId: string) => { + const { error } = await supabase + .from('tasks') + .update({ completed: true }) + .eq('id', taskId); + + if (error) { + console.error('Failed to complete task:', error.message); + } + }; + + return ( + <button onClick={() => handleComplete('task-123')}> + Complete Task + </button> + ); +} +``` + +### Real-time Subscriptions + +The browser client supports real-time subscriptions for live updates: + +```tsx +'use client'; + +import { useEffect, useState } from 'react'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +export function LiveTasksList({ accountId }: { accountId: string }) { + const supabase = useSupabase(); + const [tasks, setTasks] = useState<Task[]>([]); + + useEffect(() => { + // Subscribe to changes + const channel = supabase + .channel('tasks-changes') + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'tasks', + filter: `account_id=eq.${accountId}`, + }, + (payload) => { + if (payload.eventType === 'INSERT') { + setTasks((prev) => [...prev, payload.new as Task]); + } + if (payload.eventType === 'UPDATE') { + setTasks((prev) => + prev.map((t) => (t.id === payload.new.id ? payload.new as Task : t)) + ); + } + if (payload.eventType === 'DELETE') { + setTasks((prev) => prev.filter((t) => t.id !== payload.old.id)); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [supabase, accountId]); + + return <ul>{tasks.map((task) => <li key={task.id}>{task.title}</li>)}</ul>; +} +``` + +## Server Client + +Use `getSupabaseServerClient()` in all server environments: Server Components, Server Actions, and Route Handlers. This is a unified client that works across all server contexts. + +```tsx +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +// Server Component +export default async function TasksPage() { + const supabase = getSupabaseServerClient(); + + const { data: tasks, error } = await supabase + .from('tasks') + .select('*') + .order('created_at', { ascending: false }); + + if (error) { + throw new Error('Failed to load tasks'); + } + + return <TasksList tasks={tasks} />; +} +``` + +### Server Actions + +The same client works in Server Actions: + +```tsx +'use server'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { authActionClient } from '@kit/next/safe-action'; + +export const createTask = authActionClient + .inputSchema(CreateTaskSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const supabase = getSupabaseServerClient(); + + const { error } = await supabase.from('tasks').insert({ + title: data.title, + account_id: data.accountId, + created_by: user.id, + }); + + if (error) { + throw new Error('Failed to create task'); + } + + return { success: true }; + }); +``` + +### Route Handlers + +And in Route Handlers: + +```tsx +import { NextResponse } from 'next/server'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { enhanceRouteHandler } from '@kit/next/routes'; + +export const GET = enhanceRouteHandler( + async ({ user }) => { + const supabase = getSupabaseServerClient(); + + const { data, error } = await supabase + .from('tasks') + .select('*') + .eq('created_by', user.id); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ tasks: data }); + }, + { auth: true } +); +``` + +## Admin Client (Use with Caution) + +The admin client bypasses Row Level Security entirely. It uses the service role key and should only be used for: + +- Webhook handlers that need to write data without user context +- Admin operations in protected admin routes +- Database migrations or seed scripts +- Background jobs running outside user sessions + +```tsx +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +// Example: Webhook handler that needs to update user data +export async function POST(request: Request) { + const payload = await request.json(); + + // Verify webhook signature first! + if (!verifyWebhookSignature(request)) { + return new Response('Unauthorized', { status: 401 }); + } + + // Admin client bypasses RLS - use only when necessary + const supabase = getSupabaseServerAdminClient(); + + const { error } = await supabase + .from('subscriptions') + .update({ status: payload.status }) + .eq('stripe_customer_id', payload.customer); + + if (error) { + return new Response('Failed to update', { status: 500 }); + } + + return new Response('OK', { status: 200 }); +} +``` + +### Security Warning + +The admin client has unrestricted database access. Before using it: + +1. **Verify the request** - Always validate webhook signatures or admin tokens +2. **Validate all input** - Never trust incoming data without validation +3. **Audit access** - Log admin operations for security audits +4. **Minimize scope** - Only query/update what's necessary + +```tsx +// WRONG: Using admin client without verification +export async function dangerousEndpoint(request: Request) { + const supabase = getSupabaseServerAdminClient(); + const { userId } = await request.json(); + + // This deletes ANY user - extremely dangerous! + await supabase.from('users').delete().eq('id', userId); +} + +// RIGHT: Verify authorization before admin operations +export async function safeEndpoint(request: Request) { + // 1. Verify the request comes from a trusted source + if (!verifyAdminToken(request)) { + return new Response('Unauthorized', { status: 401 }); + } + + // 2. Validate input + const parsed = AdminActionSchema.safeParse(await request.json()); + if (!parsed.success) { + return new Response('Invalid input', { status: 400 }); + } + + // 3. Now safe to use admin client + const supabase = getSupabaseServerAdminClient(); + // ... perform operation +} +``` + +## TypeScript Integration + +All clients are fully typed with your database schema. Generate types from your Supabase project: + +```bash +pnpm supabase gen types typescript --project-id your-project-id > packages/supabase/src/database.types.ts +``` + +Then your queries get full autocomplete and type checking: + +```tsx +const supabase = getSupabaseServerClient(); + +// TypeScript knows the shape of 'tasks' table +const { data } = await supabase + .from('tasks') // autocomplete table names + .select('id, title, completed, created_at') // autocomplete columns + .eq('completed', false); // type-safe filter values + +// data is typed as Pick<Task, 'id' | 'title' | 'completed' | 'created_at'>[] +``` + +## Common Mistakes + +### Using Browser Client on Server + +```tsx +// WRONG: useSupabase is a React hook, can't use in Server Components +export default async function Page() { + const supabase = useSupabase(); // This will error +} + +// RIGHT: Use server client +export default async function Page() { + const supabase = getSupabaseServerClient(); +} +``` + +### Using Admin Client When Not Needed + +```tsx +// WRONG: Using admin client for regular user operations +export const getUserTasks = authActionClient + .action(async ({ ctx: { user } }) => { + const supabase = getSupabaseServerAdminClient(); // Unnecessary, bypasses RLS + return supabase.from('tasks').select('*').eq('user_id', user.id); + }); + +// RIGHT: Use regular server client, RLS handles authorization +export const getUserTasks = authActionClient + .action(async ({ ctx: { user } }) => { + const supabase = getSupabaseServerClient(); // RLS ensures user sees only their data + return supabase.from('tasks').select('*'); + }); +``` + +### Creating Multiple Client Instances + +```tsx +// WRONG: Creating new client on every call +async function getTasks() { + const supabase = getSupabaseServerClient(); + return supabase.from('tasks').select('*'); +} + +async function getUsers() { + const supabase = getSupabaseServerClient(); // Another instance + return supabase.from('users').select('*'); +} + +// This is actually fine - the client is lightweight and shares the same +// cookie/auth state. But if you're making multiple queries in one function, +// reuse the instance: + +// BETTER: Reuse client within a function +async function loadDashboard() { + const supabase = getSupabaseServerClient(); + + const [tasks, users] = await Promise.all([ + supabase.from('tasks').select('*'), + supabase.from('users').select('*'), + ]); + + return { tasks, users }; +} +``` + +## Next Steps + +Now that you understand the Supabase clients, learn how to use them in different contexts: + +- [Server Components](server-components) - Loading data for pages +- [Server Actions](server-actions) - Mutations and form handling +- [React Query](react-query) - Client-side data management diff --git a/docs/dev-tools/environment-variables.mdoc b/docs/dev-tools/environment-variables.mdoc new file mode 100644 index 000000000..d97c3fbc8 --- /dev/null +++ b/docs/dev-tools/environment-variables.mdoc @@ -0,0 +1,159 @@ +--- +status: "published" +description: "The Next.js Supabase Turbo Dev Tools allows you to debug environment variables using contextual information and error messages." +title: "Debugging Environment Variables in Next.js Supabase" +label: "Environment Variables" +order: 0 +--- + +The Next.js Supabase Turbo Dev Tools allows you to debug environment variables using contextual information and error messages. + +{% sequence title="How to debug environment variables using the Next.js Supabase Turbo Dev Tools" description="The Next.js Supabase Turbo Dev Tools allows you to debug environment variables using contextual information and error messages." %} + +[Getting Started with the Dev Tool](#getting-started-with-the-dev-tool) + +[Development Mode](#development-mode) + +[Production Mode](#production-mode) + +[Contextual Validation](#contextual-validation) + +[Using the Dev Tool to Debug Environment Variables](#using-the-dev-tool-to-debug-environment-variables) + +[Debugging Production Environment Variables](#debugging-production-environment-variables) + +[Adding your own Environment Variables](#adding-your-own-environment-variables) + +{% /sequence %} + +{% img src="/assets/images/dev-tools-env-variables.webp" width="1000" +height="600" /%} + +## Getting Started with the Dev Tool + +If you run the `pnpm run dev` command, you will see the Dev Tools at `http://localhost:3010/variables`. + +You can choose two different modes: + +1. **Development Mode**: This mode is used when you run the `pnpm run dev` command +2. **Production Mode**: This mode is used when you run the `pnpm run build` command + +### Development Mode + +In the Development Mode, the Dev Tools will show you the environment variables used during development. This is useful when you are developing your application and want to see the environment variables that are used in your application. + +This mode will use the variables from the following files: + +1. `.env` +2. `.env.development` +3. `.env.local` + +### Production Mode + +In the Production Mode, the Dev Tools will show you the environment variables used during production. This is useful when you are deploying your application and want to see the environment variables that are used in your application. + +This mode will use the variables from the following files: + +1. `.env` +2. `.env.production` +3. `.env.local` +4. `.env.production.local` + +### Generating Environment Variables for Production + +The right-hand side of the Dev Tool shows the effective environment variables that will be used in production. That is, the value that will ultimately be used in your application based on where they're defined. + +The "Copy" button will copy the environment variables to your clipboard in a format that is ready to be pasted into your hosting provider's environment variables. + +The Copy button will merge the variables from each environment file using the effective order of resolution. + +The recommendation is to create a new file at `apps/web/.env.production.local` and paste the copied variables into it, so that they will override the variables in the other files. Afterwards, copy the result using the "Copy" button again and paste it into your hosting provider's environment variables. + +### Contextual Validation + +Thanks to contextual validation, we can validate environment variables based +on the value of other environment variables. For example, if you have a variable +`NEXT_PUBLIC_BILLING_PROVIDER` with the value `stripe`, we can validate that +all the variables required for Stripe are set. + +## Using the Dev Tool to Debug Environment Variables + +The Dev tool shows at a glance the current state of your environment variables. It also shows you the errors that might occur when using the environment variables. + +1. **Valid**: This shows you the valid values for the environment variable. Bear in mind, the fact a value is valid does not mean it is correct. It only means that the data-type is correct for the variable. +2. **Invalid**: This shows you the errors that might occur when using the environment variable. For example, if you try to use an invalid data-type, the Dev Tool will show you an error message. It will also warn when a variable is required but not set. +3. **Overridden**: This shows you if the environment variable is overridden. If the variable is overridden, the Dev Tool will show you the value that is being used. + +Use the filters to narrow down the variables you want to debug. + +### Debugging Production Environment Variables + +Of course, most of your production environment variables will be set in your hosting provider for security reasons. To temporarily debug your production environment variables, you can use the following steps: + +1. Copy the variables from your hosting provider +2. Create a file at `apps/web/.env.production.local`. This file will be ignored by Git. +3. Paste the copied variables into the `apps/web/.env.production.local` file. +4. Use `Production` as the mode in the Dev Tool. +5. Analyze the data in the Dev Tool. + +**Important:** Delete the `apps/web/.env.production.local` file when you're done or store it securely. + +## Adding your own Environment Variables + +During your development workflow, you may need to add new environment +variables. So that you can debug your application, you can add your own +environment variables to the Dev Tool. + +Let's assume you want to add the following environment variables: + +```bash +NEXT_PUBLIC_ANALYTICS_ENABLED=true +NEXT_PUBLIC_ANALYTICS_API_KEY=value +``` + +To add these variables, you need to create a new file in the `apps/dev-tool/app/variables/lib/env-variables-model.ts` file. + +The file should look like this: + +```tsx {% title="apps/dev-tool/app/variables/lib/env-variables-model.ts" %} +[ + { + name: 'NEXT_PUBLIC_ANALYTICS_ENABLED', + description: 'Enables analytics', + category: 'Analytics', + validate: ({ value }) => { + return z + .coerce + .boolean() + .optional() + .safeParse(value); + }, + }, + { + name: 'NEXT_PUBLIC_ANALYTICS_API_KEY', + description: 'API Key for the analytics service', + category: 'Analytics', + contextualValidation: { + dependencies: [{ + variable: 'NEXT_PUBLIC_ANALYTICS_ENABLED', + condition: (value) => { + return value === 'true'; + }, + message: + 'NEXT_PUBLIC_ANALYTICS_API_KEY is required when NEXT_PUBLIC_ANALYTICS_ENABLED is set to "true"', + }], + validate: ({ value }) => { + return z + .string() + .min(1, 'An API key is required when analytics is enabled') + .safeParse(value); + } + } +}] +``` + +In the above, we added two new environment variables: `NEXT_PUBLIC_ANALYTICS_ENABLED` and `NEXT_PUBLIC_ANALYTICS_API_KEY`. + +We also added a validation function for the `NEXT_PUBLIC_ANALYTICS_API_KEY` variable. This function checks if the `NEXT_PUBLIC_ANALYTICS_ENABLED` variable is set to `true`. If it is, the `NEXT_PUBLIC_ANALYTICS_API_KEY` variable becomes required. If it is not, the `NEXT_PUBLIC_ANALYTICS_API_KEY` variable is optional. + +In this way, you can make sure that your environment variables are valid and meet the requirements of your application. \ No newline at end of file diff --git a/docs/dev-tools/translations.mdoc b/docs/dev-tools/translations.mdoc new file mode 100644 index 000000000..4f934d377 --- /dev/null +++ b/docs/dev-tools/translations.mdoc @@ -0,0 +1,39 @@ +--- +status: "published" +description: "The Next.js Supabase Turbo Dev Tools allows you to edit translations and use AI to translate them." +title: "Translations Editor" +label: "Translations Editor" +order: 1 +--- + +The Translations Editor is a tool that allows you to edit translations and use AI to translate them. + +It's a simple editor that allows you to edit translations for your project. + +{% img src="/assets/images/dev-tools-translations.webp" width="1000" +height="600" /%} + +## Set OpenAI API Key + +First, you need to set the OpenAI API Key in the `apps/dev-tool/.env.local` file. + +```bash apps/dev-tool/.env.local +OPENAI_API_KEY=your-openai-api-key +``` + +Either make sure your key has access to the `gpt-4o-mini` model or set the `LLM_MODEL_NAME` environment variable to whichever model you have access to. + +## Adding a new language + +First, you need to add the language to the `packages/i18n/src/locales.tsx` file as described in the [Adding Translations](/docs/next-supabase-turbo/translations/adding-translations) documentation. + +## Generate Translations with AI + +The Translations Editor allows you to generate translations with AI. + +You can use the AI to translate the translations for you by clicking the "Translate missing with AI" button. + +## Editing Translations + +Every time you update a translation, it will be saved automatically to the same file it's defined in. + diff --git a/docs/development/adding-turborepo-app.mdoc b/docs/development/adding-turborepo-app.mdoc new file mode 100644 index 000000000..c08ec6a16 --- /dev/null +++ b/docs/development/adding-turborepo-app.mdoc @@ -0,0 +1,263 @@ +--- +status: "published" +label: "Adding a Turborepo App" +title: "Add a New Application to Your Makerkit Monorepo" +description: "Create additional applications in your Turborepo monorepo using git subtree to maintain updates from Makerkit while building separate products." +order: 13 +--- + +Add new applications to your Makerkit monorepo using `git subtree` to clone the `apps/web` template while maintaining the ability to pull updates from the Makerkit repository. This is useful for building multiple products (e.g., a main app and an admin dashboard) that share the same packages and infrastructure. + +{% alert type="warning" title="Advanced Topic" %} +This guide is for advanced use cases where you need multiple applications in a single monorepo. For most projects, a single `apps/web` application is sufficient. Creating a separate repository may be simpler if you don't need to share code between applications. +{% /alert %} + +{% sequence title="Add a Turborepo Application" description="Create a new application from the web template" %} + +[Create the subtree branch](#step-1-create-the-subtree-branch) + +[Add the new application](#step-2-add-the-new-application) + +[Configure the application](#step-3-configure-the-new-application) + +[Keep it updated](#step-4-pulling-updates) + +{% /sequence %} + +## When to Add a New Application + +Add a new Turborepo application when: + +- **Multiple products**: You're building separate products that share authentication, billing, or UI components +- **Admin dashboard**: You need a separate admin interface with different routing and permissions +- **API server**: You want a dedicated API application separate from your main web app +- **Mobile companion**: You're building a React Native or Expo app that shares business logic + +Keep a single application when: + +- You only need one web application +- Different features can live under different routes in `apps/web` +- Separation isn't worth the complexity + +## Step 1: Create the Subtree Branch + +First, create a branch that contains only the `apps/web` folder. This branch serves as the template for new applications. + +```bash +git subtree split --prefix=apps/web --branch web-branch +``` + +This command: +1. Extracts the history of `apps/web` into a new branch +2. Creates `web-branch` containing only the `apps/web` contents +3. Preserves commit history for that folder + +## Step 2: Add the New Application + +Create your new application by pulling from the subtree branch. + +For example, to create a `pdf-chat` application: + +```bash +git subtree add --prefix=apps/pdf-chat origin web-branch --squash +``` + +This command: +1. Creates `apps/pdf-chat` with the same structure as `apps/web` +2. Squashes the history into a single commit (cleaner git log) +3. Sets up tracking for future updates + +### Verify the Application + +```bash +ls apps/pdf-chat +``` + +You should see the same structure as `apps/web`: + +``` +apps/pdf-chat/ +├── app/ +├── components/ +├── config/ +├── lib/ +├── supabase/ +├── next.config.mjs +├── package.json +└── ... +``` + +## Step 3: Configure the New Application + +### Update package.json + +Change the package name and any app-specific settings: + +```json {% title="apps/pdf-chat/package.json" %} +{ + "name": "pdf-chat", + "version": "0.0.1", + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start --port 3001" + } +} +``` + +### Update Environment Variables + +Create a separate `.env.local` for the new application: + +```bash {% title="apps/pdf-chat/.env.local" %} +NEXT_PUBLIC_SITE_URL=http://localhost:3001 +NEXT_PUBLIC_APP_NAME="PDF Chat" +# ... other environment variables +``` + +### Update Supabase Configuration (if separate) + +If the application needs its own database, update `apps/pdf-chat/supabase/config.toml` with unique ports and project settings. + +### Add Turbo Configuration + +Update the root `turbo.json` to include your new application: + +```json {% title="turbo.json" %} +{ + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**"] + }, + "pdf-chat#dev": { + "dependsOn": ["^build"], + "persistent": true + } + } +} +``` + +### Run the New Application + +```bash +# Run just the new app +pnpm --filter pdf-chat dev + +# Run all apps in parallel +pnpm dev +``` + +## Step 4: Pulling Updates + +When Makerkit releases updates, follow these steps to sync them to your new application. + +### Pull Upstream Changes + +First, pull the latest changes from Makerkit: + +```bash +git pull upstream main +``` + +### Update the Subtree Branch + +Re-extract the `apps/web` folder into the subtree branch: + +```bash +git subtree split --prefix=apps/web --branch web-branch +``` + +### Push the Branch + +Push the updated branch to your repository: + +```bash +git push origin web-branch +``` + +### Pull Into Your Application + +Finally, pull the updates into your new application: + +```bash +git subtree pull --prefix=apps/pdf-chat origin web-branch --squash +``` + +### Resolve Conflicts + +If you've modified files that were also changed upstream, you'll need to resolve conflicts: + +```bash +# After conflicts appear +git status # See conflicted files +# Edit files to resolve conflicts +git add . +git commit -m "Merge upstream changes into pdf-chat" +``` + +## Update Workflow Summary + +```bash +# 1. Get latest from Makerkit +git pull upstream main + +# 2. Update the template branch +git subtree split --prefix=apps/web --branch web-branch +git push origin web-branch + +# 3. Pull into each additional app +git subtree pull --prefix=apps/pdf-chat origin web-branch --squash +git subtree pull --prefix=apps/admin origin web-branch --squash +``` + +## Troubleshooting + +**"fatal: refusing to merge unrelated histories"** + +Add the `--squash` flag to ignore history differences: + +```bash +git subtree pull --prefix=apps/pdf-chat origin web-branch --squash +``` + +**Subtree branch doesn't exist on remote** + +Push it first: + +```bash +git push origin web-branch +``` + +**Application won't start (port conflict)** + +Update the port in `package.json`: + +```json +{ + "scripts": { + "dev": "next dev --port 3001" + } +} +``` + +**Shared packages not resolving** + +Ensure the new app's `package.json` includes the workspace dependencies: + +```json +{ + "dependencies": { + "@kit/ui": "workspace:*", + "@kit/supabase": "workspace:*" + } +} +``` + +Then run `pnpm install` from the repository root. + +## Related Resources + +- [Adding Turborepo Packages](/docs/next-supabase-turbo/development/adding-turborepo-package) for creating shared packages +- [Technical Details](/docs/next-supabase-turbo/installation/technical-details) for monorepo structure +- [Clone Repository](/docs/next-supabase-turbo/installation/clone-repository) for initial setup diff --git a/docs/development/adding-turborepo-package.mdoc b/docs/development/adding-turborepo-package.mdoc new file mode 100644 index 000000000..947a04879 --- /dev/null +++ b/docs/development/adding-turborepo-package.mdoc @@ -0,0 +1,364 @@ +--- +status: "published" +label: "Adding a Turborepo Package" +title: "Add a Shared Package to Your Makerkit Monorepo" +description: "Create reusable packages for shared business logic, utilities, or components across your Turborepo monorepo applications." +order: 14 +--- + +Create shared packages in your Makerkit monorepo using `turbo gen` to scaffold a new package at `packages/@kit/<name>`. Shared packages let you reuse business logic, utilities, or components across multiple applications while maintaining a single source of truth. + +{% alert type="default" title="When to Create a Package" %} +Create a package when you have code that needs to be shared across multiple applications or when you want to enforce clear boundaries between different parts of your codebase. For code used only in `apps/web`, a folder within the app is simpler. +{% /alert %} + +{% sequence title="Create a Shared Package" description="Add a new package to your monorepo" %} + +[Generate the package](#step-1-generate-the-package) + +[Configure exports](#step-2-configure-exports) + +[Add to Next.js config](#step-3-add-to-nextjs-config) + +[Use in your application](#step-4-use-the-package) + +{% /sequence %} + +## When to Create a Package + +Create a shared package when: + +- **Multiple applications**: Code needs to be used across `apps/web` and other applications +- **Clear boundaries**: You want to enforce separation between different domains +- **Reusable utilities**: Generic utilities that could be used in any application +- **Shared types**: TypeScript types shared across the codebase + +Keep code in `apps/web` when: + +- It's only used in one application +- It's tightly coupled to specific routes or pages +- Creating a package adds complexity without benefit + +## Step 1: Generate the Package + +Use the Turborepo generator to scaffold a new package: + +```bash +turbo gen +``` + +Follow the prompts: + +1. Select **"Create a new package"** +2. Enter the package name (e.g., `analytics`) +3. Optionally add dependencies + +The generator creates a package at `packages/@kit/analytics` with this structure: + +``` +packages/@kit/analytics/ +├── src/ +│ └── index.ts +├── package.json +└── tsconfig.json +``` + +### Package.json Structure + +```json {% title="packages/@kit/analytics/package.json" %} +{ + "name": "@kit/analytics", + "version": "0.0.1", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.0" + } +} +``` + +## Step 2: Configure Exports + +### Single Export (Simple) + +For packages with a single entry point, export everything from `index.ts`: + +```typescript {% title="packages/@kit/analytics/src/index.ts" %} +export { trackEvent, trackPageView } from './tracking'; +export { AnalyticsProvider } from './provider'; +export type { AnalyticsEvent, AnalyticsConfig } from './types'; +``` + +Import in your application: + +```typescript +import { trackEvent, AnalyticsProvider } from '@kit/analytics'; +``` + +### Multiple Exports (Tree-Shaking) + +For packages with client and server code, use multiple exports for better tree-shaking: + +```json {% title="packages/@kit/analytics/package.json" %} +{ + "name": "@kit/analytics", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./server": "./src/server.ts" + } +} +``` + +Create separate entry points: + +```typescript {% title="packages/@kit/analytics/src/client.ts" %} +// Client-side analytics (runs in browser) +export { useAnalytics } from './hooks/use-analytics'; +export { AnalyticsProvider } from './components/provider'; +``` + +```typescript {% title="packages/@kit/analytics/src/server.ts" %} +// Server-side analytics (runs on server only) +export { trackServerEvent } from './server/tracking'; +export { getAnalyticsClient } from './server/client'; +``` + +Import the specific export: + +```typescript +// In a Client Component +import { useAnalytics } from '@kit/analytics/client'; + +// In a Server Component or Server Action +import { trackServerEvent } from '@kit/analytics/server'; +``` + +### When to Use Multiple Exports + +Use multiple exports when: + +- **Client/server separation**: Code that should only run in one environment +- **Large packages**: Reduce bundle size by allowing apps to import only what they need +- **Optional features**: Features that not all consumers need + +## Step 3: Add to Next.js Config + +For hot module replacement (HMR) to work during development, add your package to the `INTERNAL_PACKAGES` array in `apps/web/next.config.mjs`: + +```javascript {% title="apps/web/next.config.mjs" %} +const INTERNAL_PACKAGES = [ + '@kit/ui', + '@kit/auth', + '@kit/supabase', + // ... existing packages + '@kit/analytics', // Add your new package +]; +``` + +This tells Next.js to: +1. Transpile the package (since it's TypeScript) +2. Watch for changes and trigger HMR +3. Include it in the build optimization + +## Step 4: Use the Package + +### Add as Dependency + +Add the package to your application's dependencies: + +```json {% title="apps/web/package.json" %} +{ + "dependencies": { + "@kit/analytics": "workspace:*" + } +} +``` + +Run `pnpm install` to link the workspace package. + +### Import and Use + +```typescript {% title="apps/web/app/layout.tsx" %} +import { AnalyticsProvider } from '@kit/analytics'; + +export default function RootLayout({ children }) { + return ( + <html> + <body> + <AnalyticsProvider> + {children} + </AnalyticsProvider> + </body> + </html> + ); +} +``` + +```typescript {% title="apps/web/app/home/page.tsx" %} +import { trackPageView } from '@kit/analytics'; + +export default function HomePage() { + trackPageView({ page: 'home' }); + + return <div>Welcome</div>; +} +``` + +## Package Development Patterns + +### Adding Dependencies + +Add dependencies to your package: + +```bash +pnpm --filter @kit/analytics add zod +``` + +### Using Other Workspace Packages + +Reference other workspace packages: + +```json {% title="packages/@kit/analytics/package.json" %} +{ + "dependencies": { + "@kit/shared": "workspace:*" + } +} +``` + +### TypeScript Configuration + +The package's `tsconfig.json` should extend the root configuration: + +```json {% title="packages/@kit/analytics/tsconfig.json" %} +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} +``` + +### Testing Packages + +Add tests alongside your package code: + +``` +packages/@kit/analytics/ +├── src/ +│ ├── index.ts +│ └── tracking.ts +└── tests/ + └── tracking.test.ts +``` + +Run tests: + +```bash +pnpm --filter @kit/analytics test +``` + +## Example: Creating a Feature Package + +Here's a complete example of creating a `notifications` package: + +### 1. Generate + +```bash +turbo gen +# Name: notifications +``` + +### 2. Structure + +``` +packages/@kit/notifications/ +├── src/ +│ ├── index.ts +│ ├── client.ts +│ ├── server.ts +│ ├── components/ +│ │ └── notification-bell.tsx +│ ├── hooks/ +│ │ └── use-notifications.ts +│ └── server/ +│ └── send-notification.ts +└── package.json +``` + +### 3. Exports + +```json {% title="packages/@kit/notifications/package.json" %} +{ + "name": "@kit/notifications", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./server": "./src/server.ts" + }, + "dependencies": { + "@kit/supabase": "workspace:*" + } +} +``` + +### 4. Implementation + +```typescript {% title="packages/@kit/notifications/src/client.ts" %} +export { NotificationBell } from './components/notification-bell'; +export { useNotifications } from './hooks/use-notifications'; +``` + +```typescript {% title="packages/@kit/notifications/src/server.ts" %} +export { sendNotification } from './server/send-notification'; +``` + +### 5. Use + +```typescript +// Client Component +import { NotificationBell } from '@kit/notifications/client'; + +// Server Action +import { sendNotification } from '@kit/notifications/server'; +``` + +## Troubleshooting + +**Module not found** + +1. Ensure the package is in `INTERNAL_PACKAGES` in `next.config.mjs` +2. Run `pnpm install` to link workspace packages +3. Check the export path matches your import + +**Types not resolving** + +Ensure `tsconfig.json` includes the package paths: + +```json +{ + "compilerOptions": { + "paths": { + "@kit/*": ["./packages/@kit/*/src"] + } + } +} +``` + +**HMR not working** + +Verify the package is listed in `INTERNAL_PACKAGES` and restart the dev server. + +## Related Resources + +- [Adding Turborepo Apps](/docs/next-supabase-turbo/development/adding-turborepo-app) for creating new applications +- [Technical Details](/docs/next-supabase-turbo/installation/technical-details) for monorepo architecture diff --git a/docs/development/application-tests.mdoc b/docs/development/application-tests.mdoc new file mode 100644 index 000000000..462e79eda --- /dev/null +++ b/docs/development/application-tests.mdoc @@ -0,0 +1,304 @@ +--- +status: "published" +label: "Application tests (E2E)" +title: "Writing Application Tests (E2E) with Playwright" +description: "Learn how to write Application Tests (E2E) with Playwright to test your application and ensure it works as expected" +order: 11 +--- + +End-to-end (E2E) tests are crucial for ensuring your application works correctly from the user's perspective. This guide covers best practices for writing reliable, maintainable E2E tests using Playwright in your Makerkit application. + +## Core Testing Principles + +### 1. Test Structure and Organization + +Your E2E tests are organized in the `apps/e2e/tests/` directory with the following structure: + +``` +apps/e2e/tests/ +├── authentication/ # Auth-related tests +│ ├── auth.spec.ts # Test specifications +│ └── auth.po.ts # Page Object Model +├── team-accounts/ # Team functionality tests +├── invitations/ # Invitation flow tests +├── utils/ # Shared utilities +│ ├── mailbox.ts # Email testing utilities +│ ├── otp.po.ts # OTP verification utilities +│ └── billing.po.ts # Billing test utilities +└── playwright.config.ts # Playwright configuration +``` + +**Key Principles:** +- Each feature has its own directory with `.spec.ts` and `.po.ts` files +- Shared utilities are in the `utils/` directory +- Page Object Model (POM) pattern is used consistently + +### 2. Page Object Model Pattern + +The Page Object Model encapsulates page interactions and makes tests more maintainable. Here's how it's implemented: + +```typescript +// auth.po.ts +export class AuthPageObject { + private readonly page: Page; + private readonly mailbox: Mailbox; + + constructor(page: Page) { + this.page = page; + this.mailbox = new Mailbox(page); + } + + async signIn(params: { email: string; password: string }) { + await this.page.fill('input[name="email"]', params.email); + await this.page.fill('input[name="password"]', params.password); + await this.page.click('button[type="submit"]'); + } + + async signOut() { + await this.page.click('[data-test="account-dropdown-trigger"]'); + await this.page.click('[data-test="account-dropdown-sign-out"]'); + } +} +``` + +**Best Practices:** +- Group related functionality in Page Objects +- Use descriptive method names that reflect user actions +- Encapsulate complex workflows in single methods +- Return promises or use async/await consistently + +The test file would look like this: + +```typescript +import { expect, test } from '@playwright/test'; + +import { AuthPageObject } from './auth.po'; + +test.describe('Auth flow', () => { + test.describe.configure({ mode: 'serial' }); + + let email: string; + let auth: AuthPageObject; + + test.beforeEach(async ({ page }) => { + auth = new AuthPageObject(page); + }); + + test('will sign-up and redirect to the home page', async ({ page }) => { + await auth.goToSignUp(); + + email = auth.createRandomEmail(); + + console.log(`Signing up with email ${email} ...`); + + await auth.signUp({ + email, + password: 'password', + repeatPassword: 'password', + }); + + await auth.visitConfirmEmailLink(email); + + await page.waitForURL('**/home'); + }); +}); +``` + +1. The test file instantiates the `AuthPageObject` before each test +2. The Page Object wraps the logic for the auth flow so that we can reuse it in the tests + +## Data-Test Attributes + +Use `data-test` attributes to create stable, semantic selectors that won't break when UI changes. + +### ✅ Good: Using data-test attributes + +```typescript +// In your React component +<button data-test="submit-button" onClick={handleSubmit}> + Submit +</button> + +// In your test +await this.page.click('[data-test="submit-button"]'); +``` + +### ❌ Bad: Using fragile selectors + +```typescript +// Fragile - breaks if class names or text changes +await this.page.click('.btn-primary'); +await this.page.click('button:has-text("Submit")'); +``` + +### Common Data-Test Patterns + +```typescript +// Form elements +<input data-test="email-input" name="email" /> +<input data-test="password-input" name="password" /> +<button data-test="submit-button" type="submit">Submit</button> + +// Navigation +<button data-test="account-dropdown-trigger">Account</button> +<a data-test="settings-link" href="/settings">Settings</a> + +// Lists and rows +<div data-test="team-member-row" data-user-id={user.id}> + <span data-test="member-role-badge">{role}</span> +</div> + +// Forms with specific purposes +<form data-test="create-team-form"> + <input data-test="team-name-input" /> + <button data-test="create-team-button">Create</button> +</form> +``` + +## Retry-ability with expect().toPass() + +Use `expect().toPass()` to wrap operations that might be flaky due to timing issues or async operations. + +### ✅ Good: Using expect().toPass() + +```typescript +async visitConfirmEmailLink(email: string) { + return expect(async () => { + const res = await this.mailbox.visitMailbox(email, { deleteAfter: true }); + expect(res).not.toBeNull(); + }).toPass(); +} + +async openAccountsSelector() { + return expect(async () => { + await this.page.click('[data-test="account-selector-trigger"]'); + return expect( + this.page.locator('[data-test="account-selector-content"]'), + ).toBeVisible(); + }).toPass(); +} +``` + +### ❌ Bad: Not using retry mechanisms + +```typescript +// This might fail due to timing issues +async openAccountsSelector() { + await this.page.click('[data-test="account-selector-trigger"]'); + await expect( + this.page.locator('[data-test="account-selector-content"]'), + ).toBeVisible(); +} +``` + +### When to Use expect().toPass() + +- **Email operations**: Waiting for emails to arrive +- **Navigation**: Waiting for URL changes after actions +- **Async UI updates**: Operations that trigger network requests +- **External dependencies**: Interactions with third-party services + +## Test Isolation and Deterministic Results + +Test isolation is crucial for reliable test suites: + +1. Make sure each tests sets up its own context and data +2. Never rely on data from other tests +3. For maximum isolation, you should create your own data for each test - however this can be time-consuming so you should take it into account when writing your tests + +### 1. Independent Test Data + +```typescript +// Generate unique test data for each test +createRandomEmail() { + const value = Math.random() * 10000000000000; + return `${value.toFixed(0)}@makerkit.dev`; +} + +createTeamName() { + const id = Math.random().toString(36).substring(2, 8); + return { + teamName: `Test Team ${id}`, + slug: `test-team-${id}`, + }; +} +``` + +## Email Testing with Mailbox + +The `Mailbox` utility helps test email-dependent flows using Mailpit. + +### 1. Basic Email Operations + +```typescript +export class Mailbox { + static URL = 'http://127.0.0.1:54324'; + + async visitMailbox(email: string, params: { deleteAfter: boolean; subject?: string }) { + const json = await this.getEmail(email, params); + + if (email !== json.To[0]!.Address) { + throw new Error(`Email address mismatch. Expected ${email}, got ${json.To[0]!.Address}`); + } + + const el = parse(json.HTML); + const linkHref = el.querySelector('a')?.getAttribute('href'); + + return this.page.goto(linkHref); + } +} +``` + +## Race conditions + +Race conditions issues are common in E2E tests. Testing UIs is inherently asynchronous, and you need to be careful about the order of operations. + +In many cases, your application will execute async operations. In such cases, you want to use Playwright's utilities to wait for the operation to complete. + +Below is a common pattern for handling async operations in E2E tests: + +1. Click the button +2. Wait for the async operation to complete +3. Proceed with the test (expectations, assertions, etc.) + +```typescript + +const button = page.locator('[data-test="submit-button"]'); + +const response = page.waitForResponse((resp) => { + return resp.url().includes(`/your-api-endpoint`); +}); + +await Promise.all([button.click(), response]); + +// proceed with the test +``` + +The pattern above ensures that the test will only proceed once the async operation has completed. + +### Handling race conditions using timeouts + +Timeouts are generally discouraged in E2E tests. However, in some cases, you may want to use them to avoid flaky tests when every other solution failed. + +```tsx +await page.waitForTimeout(1000); +``` + +In general, during development, most operations resolve within 50-100ms - so these would be an appropriate amount of time to wait if you hit overly flaky tests. + +## Testing Checklist + +When writing E2E tests, ensure you: + +- [ ] Use `data-test` attributes for element selection +- [ ] Implement Page Object Model pattern +- [ ] Wrap flaky operations in `expect().toPass()` +- [ ] Generate unique test data for each test run +- [ ] Clean up state between tests +- [ ] Handle async operations properly +- [ ] Test both happy path and error scenarios +- [ ] Include proper assertions and validations +- [ ] Follow naming conventions for test files and methods +- [ ] Document complex test scenarios + +By following these best practices, you'll create robust, maintainable E2E tests that provide reliable feedback about your application's functionality. \ No newline at end of file diff --git a/docs/development/approaching-local-development.mdoc b/docs/development/approaching-local-development.mdoc new file mode 100644 index 000000000..cbdbd8164 --- /dev/null +++ b/docs/development/approaching-local-development.mdoc @@ -0,0 +1,215 @@ +--- +status: "published" +label: "Getting Started with Development" +order: 0 +title: "Local Development Guide for the Next.js Supabase Starter Kit" +description: "Set up your development environment, understand Makerkit's architecture patterns, and navigate the development guides." +--- + +Start local development by running `pnpm dev` to launch the Next.js app and Supabase services. Makerkit uses a security-first, account-centric architecture where all business data belongs to accounts (personal or team), protected by Row Level Security (RLS) policies enforced at the database level. + +{% sequence title="Development Setup" description="Get started with local development" %} + +[Start development services](#development-environment) + +[Understand the architecture](#development-philosophy) + +[Navigate the guides](#development-guides-overview) + +[Follow common patterns](#common-development-patterns) + +{% /sequence %} + +## Development Environment + +### Starting Services + +```bash +# Start all services (Next.js app + Supabase) +pnpm dev + +# Or start individually +pnpm --filter web dev # Next.js app (port 3000) +pnpm run supabase:web:start # Local Supabase +``` + +### Key URLs + +| Service | URL | Purpose | +|---------|-----|---------| +| Main app | http://localhost:3000 | Your application | +| Supabase Studio | http://localhost:54323 | Database admin UI | +| Inbucket (email) | http://localhost:54324 | Local email testing | + +### Common Commands + +```bash +# Database +pnpm run supabase:web:reset # Reset database to clean state +pnpm --filter web supabase:typegen # Regenerate TypeScript types + +# Development +pnpm typecheck # Type check all packages +pnpm lint:fix # Fix linting issues +pnpm format:fix # Format code +``` + +## Development Philosophy + +Makerkit is built around three core principles that guide all development decisions: + +### Security by Default + +Every feature leverages Row Level Security (RLS) and the permission system. Access controls are built into the database layer, not application code. When you add a new table, you also add RLS policies that enforce who can read, write, and delete data. + +### Multi-Tenant from Day One + +All business data belongs to accounts (personal or team). This design enables both B2C and B2B use cases while ensuring proper data isolation. Every table that holds user-generated data includes an `account_id` foreign key. + +### Type-Safe Development + +TypeScript types are auto-generated from your database schema. When you modify the database, run `pnpm --filter web supabase:typegen` to update types. This ensures end-to-end type safety from database to UI. + +## Development Guides Overview + +### Database & Data Layer + +Start here to understand the foundation: + +| Guide | Description | +|-------|-------------| +| [Database Architecture](/docs/next-supabase-turbo/development/database-architecture) | Multi-tenant data model, security patterns, core tables | +| [Database Schema](/docs/next-supabase-turbo/development/database-schema) | Add tables, RLS policies, triggers, and relationships | +| [Migrations](/docs/next-supabase-turbo/development/migrations) | Create and apply schema changes | +| [Database Functions](/docs/next-supabase-turbo/development/database-functions) | Built-in functions for permissions, roles, subscriptions | +| [Database Tests](/docs/next-supabase-turbo/development/database-tests) | Test RLS policies with pgTAP | +| [Database Webhooks](/docs/next-supabase-turbo/development/database-webhooks) | React to database changes | + +### Application Development + +| Guide | Description | +|-------|-------------| +| [Loading Data](/docs/next-supabase-turbo/development/loading-data-from-database) | Fetch data in Server Components and Client Components | +| [Writing Data](/docs/next-supabase-turbo/development/writing-data-to-database) | Server Actions, forms, and mutations | +| [Permissions and Roles](/docs/next-supabase-turbo/development/permissions-and-roles) | RBAC implementation and permission checks | + +### Frontend & Marketing + +| Guide | Description | +|-------|-------------| +| [Marketing Pages](/docs/next-supabase-turbo/development/marketing-pages) | Landing pages, pricing, FAQ | +| [Legal Pages](/docs/next-supabase-turbo/development/legal-pages) | Privacy policy, terms of service | +| [SEO](/docs/next-supabase-turbo/development/seo) | Metadata, sitemap, structured data | +| [External Marketing Website](/docs/next-supabase-turbo/development/external-marketing-website) | Redirect to Framer, Webflow, etc. | + +### Architecture & Testing + +| Guide | Description | +|-------|-------------| +| [Application Tests (E2E)](/docs/next-supabase-turbo/development/application-tests) | Playwright E2E testing patterns | +| [Adding Turborepo Apps](/docs/next-supabase-turbo/development/adding-turborepo-app) | Add new applications to the monorepo | +| [Adding Turborepo Packages](/docs/next-supabase-turbo/development/adding-turborepo-package) | Create shared packages | + +## Common Development Patterns + +### The Account-Centric Pattern + +Every business entity references an `account_id`: + +```sql +create table public.projects ( + id uuid primary key default gen_random_uuid(), + account_id uuid not null references public.accounts(id) on delete cascade, + name text not null, + created_at timestamptz not null default now() +); +``` + +### The Security-First Pattern + +Every table has RLS enabled with explicit policies: + +```sql +alter table public.projects enable row level security; + +create policy "Members can view their projects" + on public.projects + for select + to authenticated + using (public.has_role_on_account(account_id)); + +create policy "Users with write permission can create projects" + on public.projects + for insert + to authenticated + with check (public.has_permission(auth.uid(), account_id, 'projects.write'::app_permissions)); +``` + +### The Type-Safe Pattern + +Database types are auto-generated: + +```typescript +import type { Database } from '@kit/supabase/database'; + +type Project = Database['public']['Tables']['projects']['Row']; +type NewProject = Database['public']['Tables']['projects']['Insert']; +``` + +### The Server Action Pattern + +Use `authActionClient` for validated, authenticated server actions: + +```typescript +import { authActionClient } from '@kit/next/safe-action'; +import * as z from 'zod'; + +const schema = z.object({ + name: z.string().min(1), + accountId: z.string().uuid(), +}); + +export const createProject = authActionClient + .inputSchema(schema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // data is validated, user is authenticated + const supabase = getSupabaseServerClient(); + + const { data: project } = await supabase + .from('projects') + .insert({ name: data.name, account_id: data.accountId }) + .select() + .single(); + + return project; + }); +``` + +## Recommended Learning Path + +### 1. Foundation (Start Here) + +1. [Database Architecture](/docs/next-supabase-turbo/development/database-architecture) - Understand the multi-tenant model +2. [Permissions and Roles](/docs/next-supabase-turbo/development/permissions-and-roles) - Learn RBAC implementation +3. [Database Schema](/docs/next-supabase-turbo/development/database-schema) - Build your first feature + +### 2. Core Development + +4. [Loading Data](/docs/next-supabase-turbo/development/loading-data-from-database) - Data fetching patterns +5. [Writing Data](/docs/next-supabase-turbo/development/writing-data-to-database) - Forms and mutations +6. [Migrations](/docs/next-supabase-turbo/development/migrations) - Schema change workflow + +### 3. Advanced (As Needed) + +- [Database Functions](/docs/next-supabase-turbo/development/database-functions) - Custom database logic +- [Database Webhooks](/docs/next-supabase-turbo/development/database-webhooks) - Event-driven features +- [Database Tests](/docs/next-supabase-turbo/development/database-tests) - Test RLS policies + +## Next Steps + +1. **Read [Database Architecture](/docs/next-supabase-turbo/development/database-architecture)** to understand the foundation +2. **Plan your first feature** - define entities, relationships, and access rules +3. **Implement step-by-step** following the [Database Schema](/docs/next-supabase-turbo/development/database-schema) guide +4. **Test your RLS policies** using [Database Tests](/docs/next-supabase-turbo/development/database-tests) + +The guides are designed to be practical and production-ready. Each builds on knowledge from previous ones, developing your expertise with Makerkit's architecture and patterns. diff --git a/docs/development/database-architecture.mdoc b/docs/development/database-architecture.mdoc new file mode 100644 index 000000000..8e8512cd8 --- /dev/null +++ b/docs/development/database-architecture.mdoc @@ -0,0 +1,821 @@ +--- +title: "Database Architecture in Makerkit" +label: "Database Architecture" +description: "Deep dive into Makerkit's database schema, security model, and best practices for building secure multi-tenant SaaS applications" +--- + +Makerkit implements a sophisticated, security-first database architecture designed for multi-tenant SaaS applications. + +This guide provides a comprehensive overview of the database schema, security patterns, and best practices you should follow when extending the system. + +{% sequence title="Database Architecture" description="Deep dive into Makerkit's database schema, security model, and best practices for building secure multi-tenant SaaS applications" %} + +[Multi-Tenant Design](#multi-tenant-design) + +[Core Tables](#core-tables) + +[Authentication & Security](#authentication-security) + +[Billing & Commerce](#billing-commerce) + +[Features & Functionality](#features-functionality) + +[Database Functions & Views](#database-functions-views) + +[Database Schema Relationships](#database-schema-relationships) + +[Understanding the Database Tables](#understanding-the-database-tables) + +[Extending the Database: Decision Trees and Patterns](#extending-the-database-decision-trees-and-patterns) + +[Security Model](#security-model) + +[Summary](#summary) + +{% /sequence %} + +### Multi-Tenant Design + +Makerkit supports two types of accounts, providing flexibility for both B2C and B2B use cases: + +#### Personal Accounts + +Individual user accounts where the user ID equals the account ID. Perfect for B2C applications or personal workspaces. + +```sql +-- Personal account characteristics +- id = auth.uid() (user's ID) +- is_personal_account = true +- slug = NULL (no public URL needed) +- Automatically created on user signup +``` + +#### Team Accounts + +Shared workspaces with multiple members, roles, and permissions. Ideal for B2B applications or collaborative features. + +```sql +-- Team account characteristics +- id = UUID (unique account ID) +- is_personal_account = false +- slug = unique string (for public URLs) +- Members managed through accounts_memberships +``` + +### Complete Database Schema + +Makerkit's database consists of 17 core tables organized across several functional areas: + +#### Core Tables +| Table | Purpose | Key Relationships | +|-------|---------|------------------| +| `accounts` | Multi-tenant accounts (personal/team) | References `auth.users` as owner | +| `accounts_memberships` | Team membership with roles | Links `auth.users` to `accounts` | +| `roles` | Role definitions with hierarchy | Referenced by memberships | +| `role_permissions` | Permissions per role | Links roles to app permissions | + +#### Authentication & Security +| Table | Purpose | Key Features | +|-------|---------|--------------| +| `nonces` | OTP for sensitive operations | Purpose-based, auto-expiring | +| `invitations` | Team invitation system | Token-based with role assignment | + +#### Billing & Commerce +| Table | Purpose | Provider Support | +|-------|---------|-----------------| +| `billing_customers` | Customer records per provider | Stripe, LemonSqueezy, Paddle | +| `subscriptions` | Active subscriptions | Multiple billing providers | +| `subscription_items` | Subscription line items | Flat, per-seat, metered pricing | +| `orders` | One-time purchases | Product sales, licenses | +| `order_items` | Order line items | Detailed purchase records | + +#### Features & Functionality + + +| Table | Purpose | Key Features | +|-------|---------|--------------| +| `notifications` | Multi-channel notifications | In-app, email, real-time | + +#### Database Functions & Views +| Type | Purpose | Security Model | +|------|---------|----------------| +| Views | Data access abstractions | Security invoker for RLS | +| Functions | Business logic & helpers | Security definer with validation | +| Triggers | Data consistency | Automatic field updates | + +### Database Schema Relationships + +{% img src="/images/database-architecture.webp" width="1000" height="1000" alt="Database Architecture" /%} + +## Understanding the Database Tables + +This section provides detailed explanations of each table group, their relationships, and practical guidance on how to work with them effectively. + +### Core Multi-Tenancy Tables + +The foundation of Makerkit's architecture rests on a sophisticated multi-tenant design that seamlessly handles both individual users and collaborative teams. + +#### The `accounts` Table: Your Tenancy Foundation + +The `accounts` table serves as the cornerstone of Makerkit's multi-tenant architecture. Every piece of data in your application ultimately belongs to an account, making this table critical for data isolation and security. + +**When to use personal accounts**: Personal accounts are automatically created when users sign up and are perfect for B2C applications, personal productivity tools, or individual workspaces. The account ID directly matches the user's authentication ID, creating a simple 1:1 relationship that's easy to reason about. + +**When to use team accounts**: Team accounts enable collaborative features essential for B2B SaaS applications. They support multiple members with different permission levels, shared resources, and centralized billing. Each team account gets a unique slug for branded URLs like `yourapp.com/acme-corp`. + +```sql +-- Example: Creating a team account for collaboration +INSERT INTO accounts (name, is_personal_account, slug) +VALUES ('Acme Corporation', false, 'acme-corp'); +``` + +**Key architectural decisions**: The conditional constraint system ensures data integrity - personal accounts cannot have slugs (they don't need public URLs), while team accounts must have them. This prevents common mistakes and enforces the intended usage patterns. + +#### The `accounts_memberships` Table: Team Collaboration Hub + +This junction table manages the many-to-many relationship between users and team accounts. It's where team collaboration comes to life through role-based access control. + +**Understanding membership lifecycle**: When a team account is created, the creator automatically becomes a member with the highest role. Additional members join through invitations or direct assignment. The composite primary key (user_id, account_id) ensures users can't have duplicate memberships in the same account. + +**Role hierarchy in action**: The system uses a numerical hierarchy where lower numbers indicate higher privileges. An owner (hierarchy level 1) can manage all aspects of the account, while members (hierarchy level 2) have limited permissions. This makes it easy to add new roles between existing ones. + +```sql +-- Example: Adding a member to a team +INSERT INTO accounts_memberships (user_id, account_id, account_role) +VALUES ('user-uuid', 'team-account-uuid', 'member'); +``` + +**Best practices for membership management**: Always validate role hierarchy when promoting or demoting members. The system prevents removing the primary owner's membership to maintain account ownership integrity. + +#### The `roles` and `role_permissions` Tables: Granular Access Control + +These tables work together to provide a flexible, hierarchical permission system that can adapt to complex organizational structures. + +**Designing permission systems**: The `roles` table defines named roles with hierarchy levels, while `role_permissions` maps specific permissions to each role. This separation allows you to easily modify what each role can do without restructuring your entire permission system. + +**Permission naming conventions**: Permissions follow a `resource.action` pattern (e.g., `billing.manage`, `members.invite`). This makes them self-documenting and easy to understand. When adding new features, follow this pattern to maintain consistency. + +```sql +-- Example: Creating a custom role with specific permissions +INSERT INTO roles (name, hierarchy_level) VALUES ('manager', 1.5); +INSERT INTO role_permissions (role, permission) VALUES + ('manager', 'members.manage'), + ('manager', 'settings.manage'); +``` + +### Security and Access Control Tables + +Makerkit implements multiple layers of security through specialized tables that handle authentication, authorization, and administrative access. + +#### The `nonces` Table: Secure Operations Gateway + +One-time tokens provide an additional security layer for sensitive operations that go beyond regular authentication. This table manages short-lived, purpose-specific codes that verify user intent for critical actions. + +**Understanding token purposes**: Each token has a specific purpose (email verification, password reset, account deletion) and cannot be reused for other operations. This prevents token reuse attacks and ensures proper authorization flows. + +**Implementation strategies**: Tokens automatically expire and are limited to specific scopes. When a user requests a new token for the same purpose, previous tokens are invalidated. This prevents accumulation of valid tokens and reduces security risks. + +**Security considerations**: Always validate the IP address and user agent when possible. The table tracks these for audit purposes and can help detect suspicious activity. + +Please refer to the [One-Time Tokens](../api/otp-api) documentation for more details. + +#### The `invitations` Table: Secure Team Building + +The invitation system enables secure team expansion while maintaining strict access controls. It bridges the gap between open team joining and secure access management. + +**Invitation workflow design**: Invitations are token-based with automatic expiration. The inviter's permissions are validated at creation time, ensuring only authorized users can extend invitations. Role assignment happens at invitation time, not acceptance, providing clear expectations. + +**Managing invitation security**: Each invitation includes a cryptographically secure token that cannot be guessed. Expired invitations are automatically invalid, and the system tracks who sent each invitation for audit purposes. + +```sql +-- Example: Creating a secure invitation +INSERT INTO invitations (email, account_id, role, invite_token, expires_at, invited_by) +VALUES ('new-member@company.com', 'team-uuid', 'member', 'secure-random-token', now() + interval '7 days', 'inviter-uuid'); +``` + +**Best practices for invitations**: Set reasonable expiration times (typically 7 days), validate email addresses before sending, and provide clear role descriptions in invitation emails. + +#### The `super_admins` Table: Platform Administration + +This table manages platform-level administrators who can perform system-wide operations that transcend individual accounts. It's designed with the highest security standards. + +**Admin privilege model**: Super admin status requires multi-factor authentication and is separate from regular account permissions. This creates a clear separation between application users and platform administrators. + +**Security enforcement**: All super admin operations require MFA verification through the `is_aal2()` function. This ensures that even if an admin's password is compromised, sensitive operations remain protected. + +### Billing and Commerce Infrastructure + +Makerkit's billing system is designed to handle complex pricing models across multiple payment providers while maintaining clean data architecture. + +#### The `billing_customers` Table: Payment Provider Bridge + +This table creates the essential link between your application's accounts and external payment provider customer records. It's the foundation that enables multi-provider billing support. + +**Provider abstraction benefits**: By storing customer IDs for each provider separately, you can migrate between billing providers, support multiple providers simultaneously, or offer region-specific payment options without data loss. + +**Customer lifecycle management**: When an account first needs billing capabilities, a customer record is created with their chosen provider. This lazy creation approach prevents unnecessary external API calls and keeps your billing clean. + +```sql +-- Example: Linking an account to Stripe +INSERT INTO billing_customers (account_id, customer_id, provider) +VALUES ('account-uuid', 'cus_stripe_customer_id', 'stripe'); +``` + +**Multi-provider strategies**: Some applications use different providers for different markets (Stripe for US/EU, local providers for other regions). The table structure supports this with provider-specific customer records. + +#### The `subscriptions` and `subscription_items` Tables: Flexible Pricing Models + +These tables work together to support sophisticated pricing models including flat-rate, per-seat, and usage-based billing across multiple products and features. + +**Subscription architecture**: The parent `subscriptions` table tracks overall subscription status, billing periods, and provider information. Child `subscription_items` handle individual components, enabling complex pricing like "basic plan + extra seats + API usage." + +**Pricing model flexibility**: The `type` field in subscription items enables different billing models: +- **Flat**: Fixed monthly/yearly pricing +- **Per-seat**: Automatically adjusted based on team size +- **Metered**: Based on usage (API calls, storage, etc.) + +```sql +-- Example: Complex subscription with multiple items +-- Base plan + per-seat pricing + metered API usage +INSERT INTO subscription_items (subscription_id, price_id, quantity, type) VALUES + ('sub-uuid', 'price_base_plan', 1, 'flat'), + ('sub-uuid', 'price_per_seat', 5, 'per_seat'), + ('sub-uuid', 'price_api_calls', 0, 'metered'); +``` + +**Automatic seat management**: The per-seat billing service automatically adjusts quantities when team members are added or removed. This eliminates manual billing adjustments and ensures accurate charges. + +#### The `orders` and `order_items` Tables: One-Time Purchases + +These tables handle non-recurring transactions like product purchases, one-time fees, or license sales that complement subscription revenue. + +**Order vs subscription distinction**: Orders represent completed transactions for specific products or services, while subscriptions handle recurring billing. This separation enables hybrid business models with both recurring and one-time revenue streams. + +**Order fulfillment tracking**: Orders include status tracking and detailed line items for complex transactions. This supports scenarios like software licenses, premium features, or physical products. + +### Application Feature Tables + +#### The `notifications` Table: Multi-Channel Communication + +This table powers Makerkit's notification system, supporting both in-app notifications and email delivery with sophisticated targeting and lifecycle management. + +**Channel strategy**: Notifications can target specific channels (in-app, email) or both. This enables rich notification experiences where users see immediate in-app alerts backed by email records for important updates. + +**Lifecycle management**: Notifications include dismissal tracking and automatic expiration. This prevents notification bloat while ensuring important messages reach users. The metadata JSONB field stores channel-specific data like email templates or push notification payloads. + +```sql +-- Example: Creating a billing notification +INSERT INTO notifications (account_id, type, channel, metadata, expires_at) +VALUES ('account-uuid', 'billing_issue', 'in_app', '{"severity": "high", "action_url": "/billing"}', now() + interval '30 days'); +``` + +**Performance considerations**: Index notifications by account_id and dismissed status for fast user queries. Consider archiving old notifications to maintain performance as your application scales. + +## Extending the Database: Decision Trees and Patterns + +Understanding when and how to extend Makerkit's database requires careful consideration of data ownership, security, and scalability. This section provides practical guidance for common scenarios. + +### Adding New Feature Tables + +When building new features, you'll need to decide how they integrate with the existing multi-tenant architecture. Here's a decision framework: + +#### Step 1: Determine Data Ownership + +**Question**: Who owns this data - individual users or accounts? + +**User-owned data**: Data like user preferences, personal settings, or individual activity logs should reference `auth.users` directly. This data follows the user across all their account memberships. + +```sql +-- Example: User preferences that follow the user everywhere +CREATE TABLE user_preferences ( + user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE, + theme varchar(20) DEFAULT 'light', + language varchar(10) DEFAULT 'en', + email_notifications boolean DEFAULT true +); +``` + +**Account-owned data**: Business data, shared resources, and collaborative content should reference `accounts`. This ensures proper multi-tenant isolation and enables team collaboration. + +```sql +-- Example: Account-owned documents with proper tenancy +CREATE TABLE documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid REFERENCES accounts(id) ON DELETE CASCADE, + title text NOT NULL, + content text, + created_by uuid REFERENCES auth.users(id), + -- Always include account_id for multi-tenancy + CONSTRAINT documents_account_ownership CHECK (account_id IS NOT NULL) +); +``` + +#### Step 2: Define Access Patterns + +**Public data within account**: Use standard RLS patterns that allow all account members to read but restrict writes based on permissions. + +**Private data within account**: Add a `created_by` field and restrict access to the creator plus users with specific permissions. + +**Hierarchical data**: Consider department-level or project-level access within accounts for complex organizations. + +### Common Table Patterns + +#### Pattern 1: Simple Account-Owned Resources + +Most feature tables follow this pattern. They belong to an account and have basic RLS policies. + +```sql +-- Template for account-owned resources +CREATE TABLE your_feature ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid REFERENCES accounts(id) ON DELETE CASCADE NOT NULL, + name text NOT NULL, + description text, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + created_by uuid REFERENCES auth.users(id), + updated_by uuid REFERENCES auth.users(id) +); + +-- Standard RLS policy +CREATE POLICY "feature_account_access" ON your_feature + FOR ALL TO authenticated + USING (public.has_role_on_account(account_id)) + WITH CHECK (public.has_permission(auth.uid(), account_id, 'feature.manage')); +``` + +#### Pattern 2: Hierarchical Resources + +For features that need sub-categories or nested structures within accounts. + +```sql +-- Example: Project categories with hierarchy +CREATE TABLE project_categories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid REFERENCES accounts(id) ON DELETE CASCADE NOT NULL, + parent_id uuid REFERENCES project_categories(id) ON DELETE CASCADE, + name text NOT NULL, + path ltree, -- PostgreSQL ltree for efficient tree operations + + -- Ensure hierarchy stays within account + CONSTRAINT categories_same_account CHECK ( + parent_id IS NULL OR + (SELECT account_id FROM project_categories WHERE id = parent_id) = account_id + ) +); +``` + +#### Pattern 3: Permission-Gated Features + +For sensitive features that require specific permissions beyond basic account membership. + +```sql +-- Example: Financial reports requiring special permissions +CREATE TABLE financial_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid REFERENCES accounts(id) ON DELETE CASCADE NOT NULL, + report_data jsonb NOT NULL, + period_start date NOT NULL, + period_end date NOT NULL, + created_by uuid REFERENCES auth.users(id) +); + +-- Restrictive RLS requiring specific permission +CREATE POLICY "financial_reports_access" ON financial_reports + FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'reports.financial')) + WITH CHECK (public.has_permission(auth.uid(), account_id, 'reports.financial')); +``` + +### Integration with Billing + +When adding features that affect billing, consider these patterns: + +#### Feature Access Control + +For subscription-gated features, create lookup tables that determine feature availability. + +```sql +-- Example: Feature access based on subscription +CREATE TABLE subscription_features ( + subscription_id uuid REFERENCES subscriptions(id) ON DELETE CASCADE, + feature_name text NOT NULL, + enabled boolean DEFAULT true, + usage_limit integer, -- NULL means unlimited + PRIMARY KEY (subscription_id, feature_name) +); + +-- Helper function to check feature access +CREATE OR REPLACE FUNCTION has_feature_access( + target_account_id uuid, + feature_name text +) RETURNS boolean AS $$ +DECLARE + has_access boolean := false; +BEGIN + SELECT sf.enabled INTO has_access + FROM subscriptions s + JOIN subscription_features sf ON s.id = sf.subscription_id + WHERE s.account_id = target_account_id + AND sf.feature_name = has_feature_access.feature_name + AND s.active = true; + + RETURN COALESCE(has_access, false); +END; +$$ LANGUAGE plpgsql; +``` + +### Security Best Practices for Extensions + +#### Always Enable RLS + +Never create a table without enabling Row Level Security. This should be your default approach. + +```sql +-- ALWAYS do this for new tables +CREATE TABLE your_new_table (...); +ALTER TABLE your_new_table ENABLE ROW LEVEL SECURITY; +``` + +#### Validate Cross-Account References + +When tables reference multiple accounts, ensure data integrity through constraints. + +```sql +-- Example: Collaboration requests between accounts +CREATE TABLE collaboration_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + from_account_id uuid REFERENCES accounts(id) ON DELETE CASCADE, + to_account_id uuid REFERENCES accounts(id) ON DELETE CASCADE, + status text CHECK (status IN ('pending', 'accepted', 'rejected')), + + -- Prevent self-collaboration + CONSTRAINT no_self_collaboration CHECK (from_account_id != to_account_id) +); +``` + +### Key Design Principles + +1. **Account-Centric**: All data associates with accounts via foreign keys for proper multi-tenancy +2. **Security by Default**: RLS enabled on all tables with explicit permission checks +3. **Provider Agnostic**: Billing supports multiple payment providers (Stripe, LemonSqueezy, Paddle) +4. **Audit Ready**: Comprehensive tracking with created_by, updated_by, timestamps +5. **Scalable**: Proper indexing and cascade relationships for performance + +## Security Model + +### Row Level Security (RLS) + +> **⚠️ CRITICAL WARNING**: Always enable RLS on new tables. This is your first line of defense against unauthorized access. + +Makerkit enforces RLS on all tables with carefully crafted policies: + +```sql +-- Example: Notes table with proper RLS +CREATE TABLE if not exists public.notes ( + id uuid primary key default gen_random_uuid(), + account_id uuid references public.accounts(id) on delete cascade, + content text, + created_by uuid references auth.users(id) +); + +-- Enable RLS (NEVER SKIP THIS!) +ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY; + +-- Read policy: Owner or team member can read +CREATE POLICY "notes_read" ON public.notes FOR SELECT + TO authenticated USING ( + account_id = (select auth.uid()) -- Personal account + OR + public.has_role_on_account(account_id) -- Team member + ); + +-- Write policy: Specific permission required +CREATE POLICY "notes_manage" ON public.notes FOR ALL + TO authenticated USING ( + public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions) + ); +``` + +### Security Helper Functions + +Makerkit provides battle-tested security functions. **Always use these instead of creating your own**: + +#### Account Access Functions +```sql +-- Check if user owns the account +public.is_account_owner(account_id) + +-- Check if user is a team member +public.has_role_on_account(account_id, role?) + +-- Check specific permission +public.has_permission(user_id, account_id, permission) + +-- Check if user can manage another member +public.can_action_account_member(account_id, target_user_id) +``` + +#### Security Check Functions +```sql +-- Verify user is super admin +public.is_super_admin() + +-- Check MFA compliance +public.is_aal2() +public.is_mfa_compliant() + +-- Check feature flags +public.is_set(field_name) +``` + +### SECURITY DEFINER Functions + +> **🚨 DANGER**: SECURITY DEFINER functions bypass RLS. Only use when absolutely necessary and ALWAYS validate permissions first. + +#### ❌ Bad Pattern - Never Do This + +```sql +CREATE FUNCTION dangerous_delete_all() +RETURNS void +SECURITY DEFINER AS $$ +BEGIN + -- This bypasses ALL security! + DELETE FROM sensitive_table; +END; +$$ LANGUAGE plpgsql; +``` + +#### ✅ Good Pattern - Always Validate First + +```sql +CREATE FUNCTION safe_admin_operation(target_account_id uuid) +RETURNS void +SECURITY DEFINER +SET search_path = '' AS $$ +BEGIN + -- MUST validate permissions FIRST + IF NOT public.is_account_owner(target_account_id) THEN + RAISE EXCEPTION 'Access denied: insufficient permissions'; + END IF; + + -- Now safe to proceed + -- Your operation here +END; +$$ LANGUAGE plpgsql; +``` + +## Core Tables Explained + +### Accounts Table + +The heart of the multi-tenant system: + +```sql +public.accounts ( + id -- UUID: Account identifier + primary_owner_user_id -- UUID: Account owner (ref auth.users) + name -- String: Display name + slug -- String: URL slug (NULL for personal) + email -- String: Contact email + is_personal_account -- Boolean: Account type + picture_url -- String: Avatar URL + public_data -- JSONB: Public metadata +) +``` + +**Key Features**: +- Automatic slug generation for team accounts +- Conditional constraints based on account type +- Protected fields preventing unauthorized updates +- Cascade deletion for data cleanup + +### Memberships Table + +Links users to team accounts with roles: + +```sql +public.accounts_memberships ( + user_id -- UUID: Member's user ID + account_id -- UUID: Team account ID + account_role -- String: Role name (owner/member) + PRIMARY KEY (user_id, account_id) +) +``` + +**Key Features**: +- Composite primary key prevents duplicates +- Role-based access control +- Automatic owner membership on account creation +- Prevention of owner removal + +### Roles and Permissions + +Hierarchical permission system: + +```sql +public.roles ( + name -- String: Role identifier + hierarchy_level -- Integer: Permission level (lower = more access) +) + +public.role_permissions ( + role -- String: Role name + permission -- Enum: Specific permission +) +``` + +**Available Permissions**: +- `roles.manage` - Manage team roles +- `billing.manage` - Handle billing +- `settings.manage` - Update settings +- `members.manage` - Manage members +- `invites.manage` - Send invitations + +## Billing Architecture + +### Subscription Model + +```sql +billing_customers ( + account_id -- Account reference + customer_id -- Provider's customer ID + provider -- stripe/lemonsqueezy/paddle +) + ↓ +subscriptions ( + customer_id -- Billing customer + status -- active/canceled/past_due + period_starts_at -- Current period start + period_ends_at -- Current period end +) + ↓ +subscription_items ( + subscription_id -- Parent subscription + price_id -- Provider's price ID + quantity -- Seats or usage + type -- flat/per_seat/metered +) +``` + +## Advanced Features + +### Invitation System + +Secure, token-based invitations: + +```sql +public.invitations ( + email -- Invitee's email + account_id -- Target team + invite_token -- Secure random token + expires_at -- Expiration timestamp + role -- Assigned role +) +``` + +**Security Features**: +- Unique tokens per invitation +- Automatic expiration +- Role hierarchy validation +- Batch invitation support + +Generally speaking, you don't need to use this internally unless you are customizing the invitation system. + +**Use Cases**: +- Email verification +- Password reset +- Sensitive operations +- Account deletion + +### Notifications + +Multi-channel notification system: + +```sql +public.notifications ( + account_id -- Target account + channel -- in_app/email + type -- Notification category + dismissed -- Read status + expires_at -- Auto-cleanup + metadata -- Additional data +) +``` + +### Creating New Tables + +```sql +-- 1. Create table with proper structure +CREATE TABLE if not exists public.your_table ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid REFERENCES accounts(id) ON DELETE CASCADE NOT NULL, + created_at timestamptz DEFAULT now() NOT NULL, + updated_at timestamptz DEFAULT now() NOT NULL, + created_by uuid REFERENCES auth.users(id), + -- your fields here +); + +-- 2. Add comments for documentation +COMMENT ON TABLE public.your_table IS 'Description of your table'; +COMMENT ON COLUMN public.your_table.account_id IS 'Account ownership'; + +-- 3. Create indexes for performance +CREATE INDEX idx_your_table_account_id ON public.your_table(account_id); +CREATE INDEX idx_your_table_created_at ON public.your_table(created_at DESC); + +-- 4. Enable RLS (NEVER SKIP!) +ALTER TABLE public.your_table ENABLE ROW LEVEL SECURITY; + +-- 5. Grant appropriate access +REVOKE ALL ON public.your_table FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.your_table TO authenticated; + +-- 6. Create RLS policies +CREATE POLICY "your_table_select" ON public.your_table + FOR SELECT TO authenticated + USING ( + account_id = (select auth.uid()) + OR public.has_role_on_account(account_id) + ); + +CREATE POLICY "your_table_insert" ON public.your_table + FOR INSERT TO authenticated + WITH CHECK ( + account_id = (select auth.uid()) + OR public.has_permission(auth.uid(), account_id, 'your_feature.create') + ); +``` + +### 3. Creating Views + +```sql +-- Always use security invoker for views +CREATE VIEW public.your_view +WITH (security_invoker = true) AS +SELECT + t.*, + a.name as account_name +FROM your_table t +JOIN accounts a ON a.id = t.account_id; + +-- Grant access +GRANT SELECT ON public.your_view TO authenticated; +``` + +{% alert type="warning" title="Security Invoker for Views" %} +Always use security invoker set to true for views. +{% /alert %} + +### 4. Writing Triggers + +```sql +-- Update timestamp trigger +CREATE TRIGGER update_your_table_updated_at + BEFORE UPDATE ON public.your_table + FOR EACH ROW + EXECUTE FUNCTION kit.trigger_set_timestamps(); + +-- Audit trigger +CREATE TRIGGER track_your_table_changes + BEFORE INSERT OR UPDATE ON public.your_table + FOR EACH ROW + EXECUTE FUNCTION kit.trigger_set_user_tracking(); +``` + +### 5. Storage Security + +When implementing file storage: + +```sql +-- Create bucket with proper RLS +INSERT INTO storage.buckets (id, name, public) +VALUES ('your_files', 'your_files', false); + +-- RLS policy validating account ownership +CREATE POLICY "your_files_policy" ON storage.objects +FOR ALL USING ( + bucket_id = 'your_files' + AND public.has_role_on_account( + (storage.foldername(name))[1]::uuid + ) +); +``` + +**Note:** The above assumes that `(storage.foldername(name))[1]::uuid` is the account id. + +You can scope the account's files with the ID of the account, so that this RLS can protect the files from other accounts. + +## Summary + +Makerkit's database architecture provides: + +- ✅ **Secure multi-tenancy** with RLS and permission checks +- ✅ **Flexible account types** for B2C and B2B use cases +- ✅ **Comprehensive billing** support for multiple providers +- ✅ **Built-in security** patterns and helper functions +- ✅ **Scalable design** with proper indexes and constraints + +By following these patterns and best practices, you can confidently extend Makerkit's database while maintaining security, performance, and data integrity. + +Remember: when in doubt, always err on the side of security and use the provided helper functions rather than creating custom solutions. \ No newline at end of file diff --git a/docs/development/database-functions.mdoc b/docs/development/database-functions.mdoc new file mode 100644 index 000000000..756c8c25c --- /dev/null +++ b/docs/development/database-functions.mdoc @@ -0,0 +1,341 @@ +--- +status: "published" +label: "Database Functions" +order: 3 +title: "PostgreSQL Database Functions for Multi-Tenant SaaS" +description: "Use built-in database functions for permissions, roles, subscriptions, and MFA checks. Includes has_permission, is_account_owner, has_active_subscription, and more." +--- + +Makerkit includes built-in PostgreSQL functions for common multi-tenant operations like permission checks, role verification, and subscription status. Use these functions in RLS policies and application code to enforce consistent security rules across your database. + +{% sequence title="Database Functions Reference" description="Built-in functions for multi-tenant operations" %} + +[Call functions from SQL and RPC](#calling-database-functions) + +[Account ownership and membership](#account-functions) + +[Permission checks](#permission-functions) + +[Subscription and billing](#subscription-functions) + +[MFA and authentication](#authentication-functions) + +{% /sequence %} + +## Calling Database Functions + +### From SQL (RLS Policies) + +Use functions directly in SQL schemas and RLS policies: + +```sql +-- In an RLS policy +create policy "Users can view their projects" + on public.projects + for select + using ( + public.has_role_on_account(account_id) + ); +``` + +### From Application Code (RPC) + +Call functions via Supabase RPC: + +```tsx +const { data: isOwner, error } = await supabase.rpc('is_account_owner', { + account_id: accountId, +}); + +if (isOwner) { + // User owns this account +} +``` + +## Account Functions + +### is_account_owner + +Check if the current user owns an account. Returns `true` if the account is the user's personal account or if they created a team account. + +```sql +public.is_account_owner(account_id uuid) returns boolean +``` + +**Use cases:** +- Restrict account deletion to owners +- Gate billing management +- Control team settings access + +**Example RLS:** + +```sql +create policy "Only owners can delete accounts" + on public.accounts + for delete + using (public.is_account_owner(id)); +``` + +### has_role_on_account + +Check if the current user has membership on an account, optionally with a specific role. + +```sql +public.has_role_on_account( + account_id uuid, + account_role varchar(50) default null +) returns boolean +``` + +**Parameters:** +- `account_id`: The account to check +- `account_role`: Optional role name (e.g., `'owner'`, `'member'`). If omitted, returns `true` for any membership. + +**Example RLS:** + +```sql +-- Any member can view +create policy "Members can view projects" + on public.projects + for select + using (public.has_role_on_account(account_id)); + +-- Only owners can update +create policy "Owners can update projects" + on public.projects + for update + using (public.has_role_on_account(account_id, 'owner')); +``` + +### is_team_member + +Check if a specific user is a member of a team account. + +```sql +public.is_team_member( + account_id uuid, + user_id uuid +) returns boolean +``` + +**Use case:** Verify team membership when the current user context isn't available. + +### can_action_account_member + +Check if the current user can perform actions on another team member (remove, change role, etc.). + +```sql +public.can_action_account_member( + target_team_account_id uuid, + target_user_id uuid +) returns boolean +``` + +**Logic:** +1. If current user is account owner: `true` +2. If target user is account owner: `false` +3. Otherwise: Compare role hierarchy levels + +**Example:** + +```tsx +const { data: canRemove } = await supabase.rpc('can_action_account_member', { + target_team_account_id: teamId, + target_user_id: memberId, +}); + +if (!canRemove) { + throw new Error('Cannot remove a user with equal or higher role'); +} +``` + +## Permission Functions + +### has_permission + +Check if a user has a specific permission on an account. This is the primary function for granular access control. + +```sql +public.has_permission( + user_id uuid, + account_id uuid, + permission_name app_permissions +) returns boolean +``` + +**Parameters:** +- `user_id`: The user to check (use `auth.uid()` for current user) +- `account_id`: The account context +- `permission_name`: A value from the `app_permissions` enum + +**Default permissions:** + +```sql +create type public.app_permissions as enum( + 'roles.manage', + 'billing.manage', + 'settings.manage', + 'members.manage', + 'invites.manage' +); +``` + +**Example RLS:** + +```sql +create policy "Users with tasks.write can insert tasks" + on public.tasks + for insert + with check ( + public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions) + ); +``` + +**Example RPC:** + +```tsx +async function checkTaskWritePermission(accountId: string) { + const { data: hasPermission } = await supabase.rpc('has_permission', { + user_id: (await supabase.auth.getUser()).data.user?.id, + account_id: accountId, + permission: 'tasks.write', + }); + + return hasPermission; +} +``` + +See [Permissions and Roles](/docs/next-supabase-turbo/development/permissions-and-roles) for adding custom permissions. + +## Subscription Functions + +### has_active_subscription + +Check if an account has an active or trialing subscription. + +```sql +public.has_active_subscription(account_id uuid) returns boolean +``` + +**Returns `true` when:** +- Subscription status is `active` +- Subscription status is `trialing` + +**Returns `false` when:** +- No subscription exists +- Status is `canceled`, `past_due`, `unpaid`, `incomplete`, etc. + +**Example RLS:** + +```sql +create policy "Only paid accounts can create projects" + on public.projects + for insert + with check ( + public.has_active_subscription(account_id) + ); +``` + +**Example application code:** + +```tsx +const { data: isPaid } = await supabase.rpc('has_active_subscription', { + account_id: accountId, +}); + +if (!isPaid) { + redirect('/pricing'); +} +``` + +## Authentication Functions + +### is_super_admin + +Check if the current user is a super admin. Requires: +- User is authenticated +- User has `super_admin` role +- User is currently signed in with MFA (AAL2) + +```sql +public.is_super_admin() returns boolean +``` + +**Example RLS:** + +```sql +create policy "Super admins can view all accounts" + on public.accounts + for select + using (public.is_super_admin()); +``` + +### is_mfa_compliant + +Check if the current user meets MFA requirements. Returns `true` when: +- User enabled MFA and is signed in with MFA (AAL2) +- User has not enabled MFA (AAL1 is sufficient) + +```sql +public.is_mfa_compliant() returns boolean +``` + +**Use case:** Allow users without MFA to continue normally while enforcing MFA for users who enabled it. + +### is_aal2 + +Check if the current user is signed in with MFA (AAL2 authentication level). + +```sql +public.is_aal2() returns boolean +``` + +**Use case:** Require MFA for sensitive operations regardless of user settings. + +**Example:** + +```sql +-- Require MFA for billing operations +create policy "MFA required for billing changes" + on public.billing_settings + for all + using (public.is_aal2()); +``` + +## Configuration Functions + +### is_set + +Check if a configuration value is set in the `public.config` table. + +```sql +public.is_set(field_name text) returns boolean +``` + +**Example:** + +```sql +-- Check if a feature flag is enabled +select public.is_set('enable_new_dashboard'); +``` + +## Function Reference Table + +| Function | Purpose | Common Use | +|----------|---------|------------| +| `is_account_owner(account_id)` | Check account ownership | Delete, billing access | +| `has_role_on_account(account_id, role?)` | Check membership/role | View, edit access | +| `is_team_member(account_id, user_id)` | Check specific user membership | Team operations | +| `can_action_account_member(account_id, user_id)` | Check member management rights | Remove, role change | +| `has_permission(user_id, account_id, permission)` | Check granular permission | Feature access | +| `has_active_subscription(account_id)` | Check billing status | Paid features | +| `is_super_admin()` | Check admin status | Admin operations | +| `is_mfa_compliant()` | Check MFA compliance | Security policies | +| `is_aal2()` | Check MFA authentication | Sensitive operations | + +## Related Resources + +- [Permissions and Roles](/docs/next-supabase-turbo/development/permissions-and-roles) for adding custom permissions +- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for extending your schema +- [Row Level Security](/docs/next-supabase-turbo/security/row-level-security) for RLS patterns +- [Database Tests](/docs/next-supabase-turbo/development/database-tests) for testing database functions diff --git a/docs/development/database-schema.mdoc b/docs/development/database-schema.mdoc new file mode 100644 index 000000000..2788b91cd --- /dev/null +++ b/docs/development/database-schema.mdoc @@ -0,0 +1,542 @@ +--- +status: "published" +label: "Extending the DB Schema" +order: 2 +title: "Extending the Database Schema in Next.js Supabase" +description: "Learn how to create new migrations and update the database schema in your Next.js Supabase application" +--- + +{% sequence title="Steps to create a new migration" description="Learn how to create new migrations and update the database schema in your Next.js Supabase application" %} + +[Planning Your Schema Extension](#planning-your-schema-extension) + +[Creating Schema Files](#creating-schema-files) + +[Permissions and Access Control](#permissions-and-access-control) + +[Building Tables with RLS](#building-tables-with-rls) + +[Advanced Patterns](#advanced-patterns) + +[Testing and Deployment](#testing-and-deployment) + +{% /sequence %} + +This guide walks you through extending Makerkit's database schema with new tables and features. We'll use a comprehensive example that demonstrates best practices, security patterns, and integration with Makerkit's multi-tenant architecture. + +## Planning Your Schema Extension + +Before writing any SQL, it's crucial to understand how your new features fit into Makerkit's multi-tenant architecture. + +### Decision Framework + +**Step 1: Determine Data Ownership** +Ask yourself: "Who owns this data - individual users or accounts?" + +- **User-owned data**: Personal preferences, activity logs, user settings +- **Account-owned data**: Business content, shared resources, collaborative features + +**Step 2: Define Access Patterns** +- **Public within account**: All team members can access +- **Private within account**: Only creator + specific permissions +- **Admin-only**: Requires special permissions or super admin access + +**Step 3: Consider Integration Points** +- Does this feature affect billing? (usage tracking, feature gates) +- Does it need notifications? (in-app alerts, email triggers) +- Should it have audit trails? (compliance, change tracking) + +## Creating Schema Files + +Makerkit organizes database schema in numbered files for proper ordering. Follow this workflow: + +### 1. Create Your Schema File + +```bash +# Create a new schema file with the next number +touch apps/web/supabase/schemas/18-notes-feature.sql +``` + +### 2. Apply Development Workflow + +```bash +# Start Supabase +pnpm supabase:web:start + +# Create migration from your schema file +pnpm --filter web run supabase:db:diff -f notes-feature + +# Restart with new schema +pnpm supabase:web:reset + +# Generate TypeScript types +pnpm supabase:web:typegen +``` + +## Permissions and Access Control + +### Adding New Permissions + +Makerkit defines permissions in the `public.app_permissions` enum. Add feature-specific permissions: + +```sql +-- Add new permissions for your feature +ALTER TYPE public.app_permissions ADD VALUE 'notes.create'; +ALTER TYPE public.app_permissions ADD VALUE 'notes.manage'; +ALTER TYPE public.app_permissions ADD VALUE 'notes.delete'; +COMMIT; +``` + +**Note:** The Supabase diff function does not support adding new permissions to enum types. Please add the new permissions manually instead of using the diff function. + +**Permission Naming Convention**: Use the pattern `resource.action` for consistency: +- `notes.create` - Create new notes +- `notes.manage` - Edit existing notes +- `notes.delete` - Delete notes +- `notes.share` - Share with external users + +### Role Assignment + +Consider which roles should have which permissions by default: + +```sql +-- Grant permissions to roles +INSERT INTO public.role_permissions (role, permission) VALUES + ('owner', 'notes.create'), + ('owner', 'notes.manage'), + ('owner', 'notes.delete'), + ('owner', 'notes.share'), + ('member', 'notes.create'), + ('member', 'notes.manage'); +``` + +## Building Tables with RLS + +Let's create a comprehensive notes feature that demonstrates various patterns and best practices. + +### Core Notes Table + +```sql +-- Create the main notes table with all standard fields +CREATE TABLE IF NOT EXISTS public.notes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + title varchar(500) NOT NULL, + content text, + is_published boolean NOT NULL DEFAULT false, + tags text[] DEFAULT '{}', + metadata jsonb DEFAULT '{}', + + -- Audit fields (always include these) + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + created_by uuid REFERENCES auth.users(id), + updated_by uuid REFERENCES auth.users(id), + + -- Data integrity constraints + CONSTRAINT notes_title_length CHECK (length(title) >= 1), + CONSTRAINT notes_account_required CHECK (account_id IS NOT NULL) +); + +-- Add helpful comments for documentation +COMMENT ON TABLE public.notes IS 'User-generated notes with sharing capabilities'; +COMMENT ON COLUMN public.notes.account_id IS 'Account that owns this note (multi-tenant isolation)'; +COMMENT ON COLUMN public.notes.is_published IS 'Whether note is visible to all account members'; +COMMENT ON COLUMN public.notes.tags IS 'Searchable tags for categorization'; +COMMENT ON COLUMN public.notes.metadata IS 'Flexible metadata (view preferences, etc.)'; +``` + +### Performance Indexes + +Consider creating indexes for your query patterns if you are scaling to a large number of records. + +```sql +-- Essential indexes for performance +CREATE INDEX idx_notes_account_id ON public.notes(account_id); +CREATE INDEX idx_notes_created_at ON public.notes(created_at DESC); +CREATE INDEX idx_notes_account_created ON public.notes(account_id, created_at DESC); +CREATE INDEX idx_notes_published ON public.notes(account_id, is_published) WHERE is_published = true; +CREATE INDEX idx_notes_tags ON public.notes USING gin(tags); +``` + +### Security Setup + +```sql +-- Always enable RLS (NEVER skip this!) +ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY; + +-- Revoke default permissions and grant explicitly +REVOKE ALL ON public.notes FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.notes TO authenticated, service_role; +``` + +### RLS Policies + +Create comprehensive policies that handle both personal and team accounts: + +```sql +-- SELECT policy: Read published notes or own private notes +CREATE POLICY "notes_select" ON public.notes + FOR SELECT TO authenticated + USING ( + -- Personal account: direct ownership + account_id = (SELECT auth.uid()) + OR + -- Team account: member can read published notes + (public.has_role_on_account(account_id) AND is_published = true) + OR + -- Team account: creator can read their own drafts + (public.has_role_on_account(account_id) AND created_by = auth.uid()) + OR + -- Team account: users with manage permission can read all + public.has_permission(auth.uid(), account_id, 'notes.manage') + ); + +-- INSERT policy: Must have create permission +CREATE POLICY "notes_insert" ON public.notes + FOR INSERT TO authenticated + WITH CHECK ( + -- Personal account: direct ownership + account_id = (SELECT auth.uid()) + OR + -- Team account: must have create permission + public.has_permission(auth.uid(), account_id, 'notes.create') + ); + +-- UPDATE policy: Owner or manager can edit +CREATE POLICY "notes_update" ON public.notes + FOR UPDATE TO authenticated + USING ( + -- Personal account: direct ownership + account_id = (SELECT auth.uid()) + OR + -- Team account: creator can edit their own + (public.has_role_on_account(account_id) AND created_by = auth.uid()) + OR + -- Team account: users with manage permission + public.has_permission(auth.uid(), account_id, 'notes.manage') + ) + WITH CHECK ( + -- Same conditions for updates + account_id = (SELECT auth.uid()) + OR + (public.has_role_on_account(account_id) AND created_by = auth.uid()) + OR + public.has_permission(auth.uid(), account_id, 'notes.manage') + ); + +-- DELETE policy: Stricter permissions required +CREATE POLICY "notes_delete" ON public.notes + FOR DELETE TO authenticated + USING ( + -- Personal account: direct ownership + account_id = (SELECT auth.uid()) + OR + -- Team account: creator can delete own notes + (public.has_role_on_account(account_id) AND created_by = auth.uid()) + OR + -- Team account: users with delete permission + public.has_permission(auth.uid(), account_id, 'notes.delete') + ); +``` + +### Automatic Triggers + +Add triggers for common patterns: + +```sql +-- Automatically update timestamps +CREATE TRIGGER notes_updated_at + BEFORE UPDATE ON public.notes + FOR EACH ROW + EXECUTE FUNCTION kit.trigger_set_timestamps(); + +-- Track who made changes +CREATE TRIGGER notes_track_changes + BEFORE INSERT OR UPDATE ON public.notes + FOR EACH ROW + EXECUTE FUNCTION kit.trigger_set_user_tracking(); +``` + +## Advanced Patterns + +### 1. Hierarchical Notes (Categories) + +```sql +-- Note categories with hierarchy +CREATE TABLE IF NOT EXISTS public.note_categories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + parent_id uuid REFERENCES public.note_categories(id) ON DELETE CASCADE, + name varchar(255) NOT NULL, + color varchar(7), -- hex color codes + path ltree, -- efficient tree operations + + created_at timestamptz NOT NULL DEFAULT now(), + created_by uuid REFERENCES auth.users(id), + + -- Ensure hierarchy stays within account + CONSTRAINT categories_same_account CHECK ( + parent_id IS NULL OR + (SELECT account_id FROM public.note_categories WHERE id = parent_id) = account_id + ), + + -- Prevent circular references + CONSTRAINT categories_no_self_parent CHECK (id != parent_id) +); + +-- Link notes to categories +ALTER TABLE public.notes ADD COLUMN category_id uuid REFERENCES public.note_categories(id) ON DELETE SET NULL; + +-- Index for tree operations +CREATE INDEX idx_note_categories_path ON public.note_categories USING gist(path); +CREATE INDEX idx_note_categories_account ON public.note_categories(account_id, parent_id); +``` + +### 2. Note Sharing and Collaboration + +```sql +-- External sharing tokens +CREATE TABLE IF NOT EXISTS public.note_shares ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + note_id uuid NOT NULL REFERENCES public.notes(id) ON DELETE CASCADE, + share_token varchar(64) NOT NULL UNIQUE, + expires_at timestamptz, + password_hash varchar(255), -- optional password protection + view_count integer DEFAULT 0, + max_views integer, -- optional view limit + + created_at timestamptz NOT NULL DEFAULT now(), + created_by uuid REFERENCES auth.users(id), + + -- Ensure token uniqueness + CONSTRAINT share_token_format CHECK (share_token ~ '^[a-zA-Z0-9_-]{32,64}$') +); + +-- Function to generate secure share tokens +CREATE OR REPLACE FUNCTION generate_note_share_token() +RETURNS varchar(64) AS $$ +BEGIN + RETURN encode(gen_random_bytes(32), 'base64url'); +END; +$$ LANGUAGE plpgsql; +``` + +### 3. Usage Tracking for Billing + +```sql +-- Track note creation for usage-based billing +CREATE TABLE IF NOT EXISTS public.note_usage_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + action varchar(50) NOT NULL, -- 'create', 'share', 'export' + note_count integer DEFAULT 1, + date date DEFAULT CURRENT_DATE, + + -- Daily aggregation + UNIQUE(account_id, action, date) +); + +-- Function to track note usage +CREATE OR REPLACE FUNCTION track_note_usage( + target_account_id uuid, + usage_action varchar(50) +) RETURNS void AS $$ +BEGIN + INSERT INTO public.note_usage_logs (account_id, action, note_count) + VALUES (target_account_id, usage_action, 1) + ON CONFLICT (account_id, action, date) + DO UPDATE SET note_count = note_usage_logs.note_count + 1; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to track note creation +CREATE OR REPLACE FUNCTION trigger_track_note_creation() +RETURNS trigger AS $$ +BEGIN + PERFORM track_note_usage(NEW.account_id, 'create'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER notes_track_creation + AFTER INSERT ON public.notes + FOR EACH ROW + EXECUTE FUNCTION trigger_track_note_creation(); +``` + +### 4. Feature Access Control + +```sql +-- Check if account has access to advanced note features +CREATE OR REPLACE FUNCTION has_advanced_notes_access(target_account_id uuid) +RETURNS boolean AS $$ +DECLARE + has_access boolean := false; +BEGIN + -- Check active subscription with advanced features + SELECT EXISTS( + SELECT 1 + FROM public.subscriptions s + JOIN public.subscription_items si ON s.id = si.subscription_id + WHERE s.account_id = target_account_id + AND s.status = 'active' + AND si.price_id IN ('price_pro_plan', 'price_enterprise_plan') + ) INTO has_access; + + RETURN has_access; +END; +$$ LANGUAGE plpgsql; + +-- Restrictive policy for advanced features +CREATE POLICY "notes_advanced_features" ON public.notes + AS RESTRICTIVE + FOR ALL TO authenticated + USING ( + -- Basic features always allowed + is_published = true + OR category_id IS NULL + OR tags = '{}' + OR + -- Advanced features require subscription + has_advanced_notes_access(account_id) + ); +``` + +## Security Enhancements + +### MFA Compliance + +For sensitive note operations, enforce MFA: + +```sql +-- Require MFA for note deletion +CREATE POLICY "notes_delete_mfa" ON public.notes + AS RESTRICTIVE + FOR DELETE TO authenticated + USING (public.is_mfa_compliant()); +``` + +### Super Admin Access + +Allow super admins to access all notes for support purposes: + +```sql +-- Super admin read access (for support) +CREATE POLICY "notes_super_admin_access" ON public.notes + FOR SELECT TO authenticated + USING (public.is_super_admin()); +``` + +### Rate Limiting + +Implement basic rate limiting for note creation: + +```sql +-- Rate limiting: max 100 notes per day per account +CREATE OR REPLACE FUNCTION check_note_creation_limit(target_account_id uuid) +RETURNS boolean AS $$ +DECLARE + daily_count integer; +BEGIN + SELECT COALESCE(note_count, 0) INTO daily_count + FROM public.note_usage_logs + WHERE account_id = target_account_id + AND action = 'create' + AND date = CURRENT_DATE; + + RETURN daily_count < 100; -- Adjust limit as needed +END; +$$ LANGUAGE plpgsql; + +-- Policy to enforce rate limiting +CREATE POLICY "notes_rate_limit" ON public.notes + AS RESTRICTIVE + FOR INSERT TO authenticated + WITH CHECK (check_note_creation_limit(account_id)); +``` + +### Type Generation + +After schema changes, always update TypeScript types: + +```bash +# reset the database +pnpm supabase:web:reset + +# Generate new types +pnpm supabase:web:typegen + +# Verify types work in your application +pnpm typecheck +``` + +## Example Usage in Application + +With your schema complete, here's how to use it in your application: + +```typescript +// Server component - automatically inherits RLS protection +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function NotesPage({ params }: { params: Promise<{ account: string }> }) { + const { account } = await params; + const client = getSupabaseServerClient(); + + // RLS automatically filters to accessible notes + const { data: notes } = await client + .from('notes') + .select(` + *, + category:note_categories(name, color), + creator:created_by(name, avatar_url) + `) + .eq('account_id', params.account) + .order('created_at', { ascending: false }); + + return <NotesList notes={notes} />; +} +``` + +From the client component, you can use the `useQuery` hook to fetch the notes. + +```typescript +// Client component with real-time updates +'use client'; +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; + +function useNotes(accountId: string) { + const supabase = useSupabase(); + + return useQuery({ + queryKey: ['notes', accountId], + queryFn: async () => { + const { data } = await supabase + .from('notes') + .select('*, category:note_categories(name)') + .eq('account_id', accountId); + return data; + } + }); +} +``` + +## Summary + +You've now created a comprehensive notes feature that demonstrates: + +✅ **Proper multi-tenancy** with account-based data isolation +✅ **Granular permissions** using Makerkit's role system +✅ **Advanced features** like categories, sharing, and usage tracking +✅ **Security best practices** with comprehensive RLS policies +✅ **Performance optimization** with proper indexing +✅ **Integration patterns** with billing and feature gates + +This pattern can be adapted for any feature in your SaaS application. Remember to always: +- Start with proper planning and data ownership decisions +- Enable RLS and create comprehensive policies +- Add appropriate indexes for your query patterns +- Test thoroughly before deploying +- Update TypeScript types after schema changes + +Your database schema is now production-ready and follows Makerkit's security and architecture best practices! \ No newline at end of file diff --git a/docs/development/database-tests.mdoc b/docs/development/database-tests.mdoc new file mode 100644 index 000000000..750685e2f --- /dev/null +++ b/docs/development/database-tests.mdoc @@ -0,0 +1,961 @@ +--- +status: "published" +label: "Database tests" +title: "Database Testing with pgTAP" +description: "Learn how to write comprehensive database tests using pgTAP to secure your application against common vulnerabilities" +order: 12 +--- + +Database testing is critical for ensuring your application's security and data integrity. This guide covers how to write comprehensive database tests using **pgTAP** and **Basejump utilities** to protect against common vulnerabilities. + +## Why Database Testing Matters + +Database tests verify that your **Row Level Security (RLS)** policies work correctly and protect against: + +- **Unauthorized data access** - Users reading data they shouldn't see +- **Data modification attacks** - Users updating/deleting records they don't own +- **Privilege escalation** - Users gaining higher permissions than intended +- **Cross-account data leaks** - Team members accessing other teams' data +- **Storage security bypasses** - Unauthorized file access + +## Test Infrastructure + +### Required Extensions + +Makerkit uses these extensions for database testing: + +```sql +-- Install Basejump test helpers +create extension "basejump-supabase_test_helpers" version '0.0.6'; + +-- The extension provides authentication simulation +-- and user management utilities +``` + +### Makerkit Test Helpers + +The `/apps/web/supabase/tests/database/00000-makerkit-helpers.sql` file provides essential utilities: + +```sql +-- Authenticate as a test user +select makerkit.authenticate_as('user_identifier'); + +-- Get account by slug +select makerkit.get_account_by_slug('team-slug'); + +-- Get account ID by slug +select makerkit.get_account_id_by_slug('team-slug'); + +-- Set user as super admin +select makerkit.set_super_admin(); + +-- Set MFA level +select makerkit.set_session_aal('aal1'); -- or 'aal2' +``` + +## Test Structure + +Every pgTAP test follows this structure: + +```sql +begin; +create extension "basejump-supabase_test_helpers" version '0.0.6'; + +select no_plan(); -- or select plan(N) for exact test count + +-- Test setup +select makerkit.set_identifier('user1', 'user1@example.com'); +select makerkit.set_identifier('user2', 'user2@example.com'); + +-- Your tests here +select is( + actual_result, + expected_result, + 'Test description' +); + +select * from finish(); +rollback; +``` + +## Bypassing RLS in Tests + +When you need to set up test data or verify data exists independently of RLS policies, use role switching: + +### Role Types for Testing + +```sql +-- postgres: Full superuser access, bypasses all RLS +set local role postgres; + +-- service_role: Service-level access, bypasses RLS +set local role service_role; + +-- authenticated: Normal user with RLS enforced (default for makerkit.authenticate_as) +-- No need to set this explicitly - makerkit.authenticate_as() handles it +``` + +### Common Patterns for Role Switching + +#### Pattern 1: Setup Test Data +```sql +-- Use postgres role to insert test data that bypasses RLS +set local role postgres; +insert into accounts_memberships (account_id, user_id, account_role) +values (team_id, user_id, 'member'); + +-- Test as normal user (RLS enforced) +select makerkit.authenticate_as('member'); +select isnt_empty($$ select * from team_data $$, 'Member can see team data'); +``` + +#### Pattern 2: Verify Data Exists +```sql +-- Test that unauthorized user cannot see data +select makerkit.authenticate_as('unauthorized_user'); +select is_empty($$ select * from private_data $$, 'Unauthorized user sees nothing'); + +-- Use postgres role to verify data actually exists +set local role postgres; +select isnt_empty($$ select * from private_data $$, 'Data exists (confirms RLS filtering)'); +``` + +#### Pattern 3: Grant Permissions for Testing +```sql +-- Use postgres role to grant permissions +set local role postgres; +insert into role_permissions (role, permission) +values ('custom-role', 'invites.manage'); + +-- Test as user with the role +select makerkit.authenticate_as('custom_role_user'); +select lives_ok($$ select create_invitation(...) $$, 'User with permission can invite'); +``` + +### When to Use Each Role + +#### Use `postgres` role when: +- Setting up complex test data with foreign key relationships +- Inserting data that would normally be restricted by RLS +- Verifying data exists independently of user permissions +- Modifying system tables (roles, permissions, etc.) + +#### Use `service_role` when: +- You need RLS bypass but want to stay closer to application-level permissions +- Testing service-level operations +- Working with data that should be accessible to services but not users + +#### Use `makerkit.authenticate_as()` when: +- Testing normal user operations (automatically sets `authenticated` role) +- Verifying RLS policies work correctly +- Testing user-specific access patterns + +### Complete Test Example + +```sql +begin; +create extension "basejump-supabase_test_helpers" version '0.0.6'; +select no_plan(); + +-- Setup test users +select makerkit.set_identifier('owner', 'owner@example.com'); +select makerkit.set_identifier('member', 'member@example.com'); +select makerkit.set_identifier('stranger', 'stranger@example.com'); + +-- Create team (as owner) +select makerkit.authenticate_as('owner'); +select public.create_team_account('TestTeam'); + +-- Add member using postgres role (bypasses RLS) +set local role postgres; +insert into accounts_memberships (account_id, user_id, account_role) +values ( + (select id from accounts where slug = 'testteam'), + tests.get_supabase_uid('member'), + 'member' +); + +-- Test member access (RLS enforced) +select makerkit.authenticate_as('member'); +select isnt_empty( + $$ select * from accounts where slug = 'testteam' $$, + 'Member can see their team' +); + +-- Test stranger cannot see team (RLS enforced) +select makerkit.authenticate_as('stranger'); +select is_empty( + $$ select * from accounts where slug = 'testteam' $$, + 'Stranger cannot see team due to RLS' +); + +-- Verify team actually exists (bypass RLS) +set local role postgres; +select isnt_empty( + $$ select * from accounts where slug = 'testteam' $$, + 'Team exists in database (confirms RLS is working, not missing data)' +); + +select * from finish(); +rollback; +``` + +### Key Principles + +1. **Use `postgres` role for test setup**, then switch back to test actual user permissions +2. **Always verify data exists** using `postgres` role when testing that users cannot see data +3. **Never test application logic as `postgres`** - it bypasses all security +4. **Use role switching to confirm RLS is filtering**, not that data is missing + +## Basic Security Testing Patterns + +### 1. Testing Data Isolation + +Verify users can only access their own data: + +```sql +-- Create test users +select makerkit.set_identifier('owner', 'owner@example.com'); +select tests.create_supabase_user('stranger', 'stranger@example.com'); + +-- Owner creates a record +select makerkit.authenticate_as('owner'); +insert into notes (title, content, user_id) +values ('Secret Note', 'Private content', auth.uid()); + +-- Stranger cannot see the record +select makerkit.authenticate_as('stranger'); +select is_empty( + $$ select * from notes where title = 'Secret Note' $$, + 'Strangers cannot see other users notes' +); +``` + +### 2. Testing Write Protection + +Ensure users cannot modify others' data: + +```sql +-- Owner creates a record +select makerkit.authenticate_as('owner'); +insert into posts (title, user_id) +values ('My Post', auth.uid()) returning id; + +-- Store the post ID for testing +\set post_id (select id from posts where title = 'My Post') + +-- Stranger cannot update the record +select makerkit.authenticate_as('stranger'); +select throws_ok( + $$ update posts set title = 'Hacked!' where id = :post_id $$, + 'update or delete on table "posts" violates row-level security policy', + 'Strangers cannot update other users posts' +); +``` + +### 3. Testing Permission Systems + +Verify role-based access control: + +```sql +-- Test that only users with 'posts.manage' permission can create posts +select makerkit.authenticate_as('member'); + +select throws_ok( + $$ insert into admin_posts (title, content) values ('Test', 'Content') $$, + 'new row violates row-level security policy', + 'Members without permission cannot create admin posts' +); + +-- Grant permission and test again +set local role postgres; +insert into user_permissions (user_id, permission) +values (tests.get_supabase_uid('member'), 'posts.manage'); + +select makerkit.authenticate_as('member'); +select lives_ok( + $$ insert into admin_posts (title, content) values ('Test', 'Content') $$, + 'Members with permission can create admin posts' +); +``` + +## Team Account Security Testing + +### Testing Team Membership Access + +```sql +-- Setup team and members +select makerkit.authenticate_as('owner'); +select public.create_team_account('TestTeam'); + +-- Add member to team +set local role postgres; +insert into accounts_memberships (account_id, user_id, account_role) +values ( + makerkit.get_account_id_by_slug('testteam'), + tests.get_supabase_uid('member'), + 'member' +); + +-- Test member can see team data +select makerkit.authenticate_as('member'); +select isnt_empty( + $$ select * from team_posts where account_id = makerkit.get_account_id_by_slug('testteam') $$, + 'Team members can see team posts' +); + +-- Test non-members cannot see team data +select makerkit.authenticate_as('stranger'); +select is_empty( + $$ select * from team_posts where account_id = makerkit.get_account_id_by_slug('testteam') $$, + 'Non-members cannot see team posts' +); +``` + +### Testing Role Hierarchy + +```sql +-- Test that members cannot promote themselves +select makerkit.authenticate_as('member'); + +select throws_ok( + $$ update accounts_memberships + set account_role = 'owner' + where user_id = auth.uid() $$, + 'Only the account_role can be updated', + 'Members cannot promote themselves to owner' +); + +-- Test that members cannot remove the owner +select throws_ok( + $$ delete from accounts_memberships + where user_id = tests.get_supabase_uid('owner') + and account_id = makerkit.get_account_id_by_slug('testteam') $$, + 'The primary account owner cannot be removed from the account membership list', + 'Members cannot remove the account owner' +); +``` + +## Storage Security Testing + +```sql +-- Test file access control +select makerkit.authenticate_as('user1'); + +-- User can upload to their own folder +select lives_ok( + $$ insert into storage.objects (bucket_id, name, owner, owner_id) + values ('avatars', auth.uid()::text, auth.uid(), auth.uid()) $$, + 'Users can upload files with their own UUID as filename' +); + +-- User cannot upload using another user's UUID as filename +select makerkit.authenticate_as('user2'); +select throws_ok( + $$ insert into storage.objects (bucket_id, name, owner, owner_id) + values ('avatars', tests.get_supabase_uid('user1')::text, auth.uid(), auth.uid()) $$, + 'new row violates row-level security policy', + 'Users cannot upload files with other users UUIDs as filename' +); +``` + +## Common Testing Patterns + +### 1. Cross-Account Data Isolation + +```sql +-- Verify team A members cannot access team B data +select makerkit.authenticate_as('team_a_member'); +insert into documents (title, team_id) +values ('Secret Doc', makerkit.get_account_id_by_slug('team-a')); + +select makerkit.authenticate_as('team_b_member'); +select is_empty( + $$ select * from documents where title = 'Secret Doc' $$, + 'Team B members cannot see Team A documents' +); +``` + +### 2. Function Security Testing + +```sql +-- Test that protected functions check permissions +select makerkit.authenticate_as('regular_user'); + +select throws_ok( + $$ select admin_delete_all_posts() $$, + 'permission denied for function admin_delete_all_posts', + 'Regular users cannot call admin functions' +); + +-- Test with proper permissions +select makerkit.set_super_admin(); +select lives_ok( + $$ select admin_delete_all_posts() $$, + 'Super admins can call admin functions' +); +``` + +### 3. Invitation Security Testing + +```sql +-- Test invitation creation permissions +select makerkit.authenticate_as('member'); + +-- Members can invite to same or lower roles +select lives_ok( + $$ insert into invitations (email, account_id, role, invite_token) + values ('new@example.com', makerkit.get_account_id_by_slug('team'), 'member', gen_random_uuid()) $$, + 'Members can invite other members' +); + +-- Members cannot invite to higher roles +select throws_ok( + $$ insert into invitations (email, account_id, role, invite_token) + values ('admin@example.com', makerkit.get_account_id_by_slug('team'), 'owner', gen_random_uuid()) $$, + 'new row violates row-level security policy', + 'Members cannot invite owners' +); +``` + +## Advanced Testing Techniques + +### 1. Testing Edge Cases + +```sql +-- Test NULL handling in RLS policies +select lives_ok( + $$ select * from posts where user_id IS NULL $$, + 'Queries with NULL filters should not crash' +); + +-- Test empty result sets +select is_empty( + $$ select * from posts where user_id = '00000000-0000-0000-0000-000000000000'::uuid $$, + 'Invalid UUIDs should return empty results' +); +``` + +### 2. Performance Testing + +```sql +-- Test that RLS policies don't create N+1 queries +select makerkit.authenticate_as('team_owner'); + +-- This should be efficient even with many team members +select isnt_empty( + $$ select p.*, u.name from posts p join users u on p.user_id = u.id + where p.team_id = makerkit.get_account_id_by_slug('large-team') $$, + 'Joined queries with RLS should perform well' +); +``` + +### 3. Testing Trigger Security + +```sql +-- Test that triggers properly validate permissions +select makerkit.authenticate_as('regular_user'); + +select throws_ok( + $$ update sensitive_settings set admin_only_field = 'hacked' $$, + 'You do not have permission to update this field', + 'Triggers should prevent unauthorized field updates' +); +``` + +## Best Practices + +### 1. Always Test Both Positive and Negative Cases +- Verify authorized users CAN access data +- Verify unauthorized users CANNOT access data + +### 2. Test All CRUD Operations +- CREATE: Can users insert the records they should? +- READ: Can users only see their authorized data? +- UPDATE: Can users only modify records they own? +- DELETE: Can users only remove their own records? + +### 3. Use Descriptive Test Names +```sql +select is( + actual_result, + expected_result, + 'Team members should be able to read team posts but not modify other teams data' +); +``` + +### 4. Test Permission Boundaries +- Test the minimum permission level that grants access +- Test that one level below is denied +- Test that users with higher permissions can also access + +### 5. Clean Up After Tests +Always use transactions that rollback: +```sql +begin; +-- Your tests here +rollback; -- This cleans up all test data +``` + +## Common Anti-Patterns to Avoid + +❌ **Don't test only happy paths** +```sql +-- Bad: Only testing that authorized access works +select isnt_empty($$ select * from posts $$, 'User can see posts'); +``` + +✅ **Test both authorized and unauthorized access** +```sql +-- Good: Test both positive and negative cases +select makerkit.authenticate_as('owner'); +select isnt_empty($$ select * from posts where user_id = auth.uid() $$, 'Owner can see own posts'); + +select makerkit.authenticate_as('stranger'); +select is_empty($$ select * from posts where user_id != auth.uid() $$, 'Stranger cannot see others posts'); +``` + +❌ **Don't forget to test cross-account scenarios** +```sql +-- Bad: Only testing within same account +select lives_ok($$ insert into team_docs (title) values ('Doc') $$, 'Can create doc'); +``` + +✅ **Test cross-account isolation** +```sql +-- Good: Test that team A cannot access team B data +select makerkit.authenticate_as('team_a_member'); +insert into team_docs (title, team_id) values ('Secret', team_a_id); + +select makerkit.authenticate_as('team_b_member'); +select is_empty($$ select * from team_docs where title = 'Secret' $$, 'Team B cannot see Team A docs'); +``` + +## Testing Silent RLS Failures + +**Critical Understanding**: RLS policies often fail **silently**. They don't throw errors - they just filter out data or prevent operations. This makes testing RLS policies tricky because you need to verify what **didn't** happen, not just what did. + +### Why RLS Failures Are Silent + +```sql +-- RLS policies work by: +-- 1. INSERT/UPDATE: If the policy check fails, the operation is ignored (no error) +-- 2. SELECT: If the policy fails, rows are filtered out (no error) +-- 3. DELETE: If the policy fails, nothing is deleted (no error) +``` + +### Testing Silent SELECT Filtering + +When RLS policies prevent users from seeing data, queries return empty results instead of errors: + +```sql +-- Setup: Create posts for different users +select makerkit.authenticate_as('user_a'); +insert into posts (title, content, user_id) +values ('User A Post', 'Content A', auth.uid()); + +select makerkit.authenticate_as('user_b'); +insert into posts (title, content, user_id) +values ('User B Post', 'Content B', auth.uid()); + +-- Test: User A cannot see User B's posts (silent filtering) +select makerkit.authenticate_as('user_a'); +select is_empty( + $$ select * from posts where title = 'User B Post' $$, + 'User A cannot see User B posts due to RLS filtering' +); + +-- Test: User A can still see their own posts +select isnt_empty( + $$ select * from posts where title = 'User A Post' $$, + 'User A can see their own posts' +); + +-- Critical: Verify the post actually exists by switching context +select makerkit.authenticate_as('user_b'); +select isnt_empty( + $$ select * from posts where title = 'User B Post' $$, + 'User B post actually exists (not a test data issue)' +); +``` + +### Testing Silent UPDATE/DELETE Prevention + +RLS policies can silently prevent modifications without throwing errors: + +```sql +-- Setup: User A creates a post +select makerkit.authenticate_as('user_a'); +insert into posts (title, content, user_id) +values ('Original Title', 'Original Content', auth.uid()) +returning id; + +-- Store the post ID for testing +\set post_id (select id from posts where title = 'Original Title') + +-- Test: User B attempts to modify User A's post (silently fails) +select makerkit.authenticate_as('user_b'); +update posts set title = 'Hacked Title' where id = :post_id; + +-- Verify the update was silently ignored +select makerkit.authenticate_as('user_a'); +select is( + (select title from posts where id = :post_id), + 'Original Title', + 'User B update attempt was silently ignored by RLS' +); + +-- Test: User B attempts to delete User A's post (silently fails) +select makerkit.authenticate_as('user_b'); +delete from posts where id = :post_id; + +-- Verify the delete was silently ignored +select makerkit.authenticate_as('user_a'); +select isnt_empty( + $$ select * from posts where title = 'Original Title' $$, + 'User B delete attempt was silently ignored by RLS' +); +``` + +### Testing Silent INSERT Prevention + +INSERT operations can also fail silently with restrictive RLS policies: + +```sql +-- Test: Non-admin tries to insert into admin_settings table +select makerkit.authenticate_as('regular_user'); + +-- Attempt to insert (may succeed but be silently filtered on read) +insert into admin_settings (key, value) values ('test_key', 'test_value'); + +-- Critical: Don't just check for errors - verify the data isn't there +select is_empty( + $$ select * from admin_settings where key = 'test_key' $$, + 'Regular user cannot insert admin settings (silent prevention)' +); + +-- Verify an admin can actually insert this data +set local role postgres; +insert into admin_settings (key, value) values ('admin_key', 'admin_value'); + +select makerkit.set_super_admin(); +select isnt_empty( + $$ select * from admin_settings where key = 'admin_key' $$, + 'Admins can insert admin settings (confirms table works)' +); +``` + +### Testing Row-Level Filtering with Counts + +Use count comparisons to detect silent filtering: + +```sql +-- Setup: Create team data +select makerkit.authenticate_as('team_owner'); +insert into team_documents (title, team_id) values + ('Doc 1', (select id from accounts where slug = 'team-a')), + ('Doc 2', (select id from accounts where slug = 'team-a')), + ('Doc 3', (select id from accounts where slug = 'team-a')); + +-- Test: Team member sees all team docs +select makerkit.authenticate_as('team_member_a'); +select is( + (select count(*) from team_documents where team_id = (select id from accounts where slug = 'team-a')), + 3::bigint, + 'Team member can see all team documents' +); + +-- Test: Non-member sees no team docs (silent filtering) +select makerkit.authenticate_as('external_user'); +select is( + (select count(*) from team_documents where team_id = (select id from accounts where slug = 'team-a')), + 0::bigint, + 'External user cannot see any team documents due to RLS filtering' +); +``` + +### Testing Partial Data Exposure + +Sometimes RLS policies expose some fields but not others: + +```sql +-- Test: Public can see user profiles but not sensitive data +select tests.create_supabase_user('public_user', 'public@example.com'); + +-- Create user profile with sensitive data +select makerkit.authenticate_as('profile_owner'); +insert into user_profiles (user_id, name, email, phone, ssn) values + (auth.uid(), 'John Doe', 'john@example.com', '555-1234', '123-45-6789'); + +-- Test: Public can see basic info but not sensitive fields +select makerkit.authenticate_as('public_user'); + +select is( + (select name from user_profiles where user_id = tests.get_supabase_uid('profile_owner')), + 'John Doe', + 'Public can see user name' +); + +-- Critical: Test that sensitive fields are silently filtered +select is( + (select ssn from user_profiles where user_id = tests.get_supabase_uid('profile_owner')), + null, + 'Public cannot see SSN (silently filtered by RLS)' +); + +select is( + (select phone from user_profiles where user_id = tests.get_supabase_uid('profile_owner')), + null, + 'Public cannot see phone number (silently filtered by RLS)' +); +``` + +### Testing Cross-Account Data Isolation + +Verify users cannot access other accounts' data: + +```sql +-- Setup: Create data for multiple teams +select makerkit.authenticate_as('team_a_owner'); +insert into billing_info (team_id, subscription_id) values + ((select id from accounts where slug = 'team-a'), 'sub_123'); + +select makerkit.authenticate_as('team_b_owner'); +insert into billing_info (team_id, subscription_id) values + ((select id from accounts where slug = 'team-b'), 'sub_456'); + +-- Test: Team A members cannot see Team B billing (silent filtering) +select makerkit.authenticate_as('team_a_member'); +select is_empty( + $$ select * from billing_info where subscription_id = 'sub_456' $$, + 'Team A members cannot see Team B billing info' +); + +-- Test: Team A members can see their own billing +select isnt_empty( + $$ select * from billing_info where subscription_id = 'sub_123' $$, + 'Team A members can see their own billing info' +); + +-- Verify both billing records actually exist +set local role postgres; +select is( + (select count(*) from billing_info), + 2::bigint, + 'Both billing records exist in database (not a test data issue)' +); +``` + +### Testing Permission Boundary Edge Cases + +Test the exact boundaries where permissions change: + +```sql +-- Setup users with different permission levels +select makerkit.authenticate_as('admin_user'); +select makerkit.authenticate_as('editor_user'); +select makerkit.authenticate_as('viewer_user'); + +-- Test: Admins can see all data +select makerkit.authenticate_as('admin_user'); +select isnt_empty( + $$ select * from sensitive_documents $$, + 'Admins can see sensitive documents' +); + +-- Test: Editors cannot see sensitive docs (silent filtering) +select makerkit.authenticate_as('editor_user'); +select is_empty( + $$ select * from sensitive_documents $$, + 'Editors cannot see sensitive documents due to RLS' +); + +-- Test: Viewers cannot see sensitive docs (silent filtering) +select makerkit.authenticate_as('viewer_user'); +select is_empty( + $$ select * from sensitive_documents $$, + 'Viewers cannot see sensitive documents due to RLS' +); +``` + +### Testing Multi-Condition RLS Policies + +When RLS policies have multiple conditions, test each condition: + +```sql +-- Policy example: Users can only see posts if they are: +-- 1. The author, OR +-- 2. A team member of the author's team, AND +-- 3. The post is published + +-- Test condition 1: Author can see unpublished posts +select makerkit.authenticate_as('author'); +insert into posts (title, published, user_id) values + ('Draft Post', false, auth.uid()); + +select isnt_empty( + $$ select * from posts where title = 'Draft Post' $$, + 'Authors can see their own unpublished posts' +); + +-- Test condition 2: Team members cannot see unpublished posts (silent filtering) +select makerkit.authenticate_as('team_member'); +select is_empty( + $$ select * from posts where title = 'Draft Post' $$, + 'Team members cannot see unpublished posts from teammates' +); + +-- Test condition 3: Team members can see published posts +select makerkit.authenticate_as('author'); +update posts set published = true where title = 'Draft Post'; + +select makerkit.authenticate_as('team_member'); +select isnt_empty( + $$ select * from posts where title = 'Draft Post' $$, + 'Team members can see published posts from teammates' +); + +-- Test condition boundary: Non-team members cannot see any posts +select makerkit.authenticate_as('external_user'); +select is_empty( + $$ select * from posts where title = 'Draft Post' $$, + 'External users cannot see any posts (even published ones)' +); +``` + +### Common Silent Failure Patterns to Test + +#### 1. The "Empty Result" Pattern +```sql +-- Always test that restricted queries return empty results, not errors +select is_empty( + $$ select * from restricted_table where condition = true $$, + 'Unauthorized users see empty results, not errors' +); +``` + +#### 2. The "No-Effect" Pattern +```sql +-- Test that unauthorized modifications have no effect +update restricted_table set field = 'hacked' where id = target_id; +select is( + (select field from restricted_table where id = target_id), + 'original_value', + 'Unauthorized updates are silently ignored' +); +``` + +#### 3. The "Partial Visibility" Pattern +```sql +-- Test that only authorized fields are visible +select is( + (select public_field from mixed_table where id = target_id), + 'visible_value', + 'Public fields are visible' +); + +select is( + (select private_field from mixed_table where id = target_id), + null, + 'Private fields are silently filtered out' +); +``` + +#### 4. The "Context Switch" Verification Pattern +```sql +-- Always verify data exists by switching to authorized context +select makerkit.authenticate_as('unauthorized_user'); +select is_empty( + $$ select * from protected_data $$, + 'Unauthorized user sees no data' +); + +-- Switch to authorized user to prove data exists +select makerkit.authenticate_as('authorized_user'); +select isnt_empty( + $$ select * from protected_data $$, + 'Data actually exists (confirms RLS filtering, not missing data)' +); +``` + +### Best Practices for Silent Failure Testing + +#### ✅ Do: Test Both Positive and Negative Cases +```sql +-- Test that authorized users CAN access data +select makerkit.authenticate_as('authorized_user'); +select isnt_empty($$ select * from protected_data $$, 'Authorized access works'); + +-- Test that unauthorized users CANNOT access data (silent filtering) +select makerkit.authenticate_as('unauthorized_user'); +select is_empty($$ select * from protected_data $$, 'Unauthorized access silently filtered'); +``` + +#### ✅ Do: Verify Data Exists in Different Context +```sql +-- Don't just test that unauthorized users see nothing +-- Verify the data actually exists by checking as an authorized user +select makerkit.authenticate_as('data_owner'); +select isnt_empty($$ select * from my_data $$, 'Data exists'); + +select makerkit.authenticate_as('unauthorized_user'); +select is_empty($$ select * from my_data $$, 'But unauthorized user cannot see it'); +``` + +#### ✅ Do: Test Modification Boundaries +```sql +-- Test that unauthorized modifications are ignored +update sensitive_table set value = 'hacked'; +select is( + (select value from sensitive_table), + 'original_value', + 'Unauthorized updates silently ignored' +); +``` + +#### ❌ Don't: Expect Errors from RLS Violations +```sql +-- Bad: RLS violations usually don't throw errors +select throws_ok( + $$ select * from protected_data $$, + 'permission denied' +); + +-- Good: RLS violations return empty results +select is_empty( + $$ select * from protected_data $$, + 'Unauthorized users see no data due to RLS filtering' +); +``` + +#### ❌ Don't: Test Only Happy Paths +```sql +-- Bad: Only testing authorized access +select isnt_empty($$ select * from my_data $$, 'I can see my data'); + +-- Good: Test both authorized and unauthorized access +select makerkit.authenticate_as('owner'); +select isnt_empty($$ select * from my_data $$, 'Owner can see data'); + +select makerkit.authenticate_as('stranger'); +select is_empty($$ select * from my_data $$, 'Stranger cannot see data'); +``` + +Remember: **RLS is designed to be invisible to attackers**. Your tests must verify this invisibility by checking for empty results and unchanged data, not for error messages. + +## Running Tests + +To run your database tests: + +```bash +# Start Supabase locally +pnpm supabase:web:start + +# Run all database tests +pnpm supabase:web:test + +# Run specific test file +pnpm supabase test ./tests/database/your-test.test.sql +``` + +Your tests will help ensure your application is secure against common database vulnerabilities and that your RLS policies work as expected. \ No newline at end of file diff --git a/docs/development/database-webhooks.mdoc b/docs/development/database-webhooks.mdoc new file mode 100644 index 000000000..b48af95c3 --- /dev/null +++ b/docs/development/database-webhooks.mdoc @@ -0,0 +1,263 @@ +--- +status: "published" +label: "Database Webhooks" +order: 6 +title: "Database Webhooks in the Next.js Supabase Starter Kit" +description: "Handle database change events with webhooks to send notifications, sync external services, and trigger custom logic when data changes." +--- + +Database webhooks let you execute custom code when rows are inserted, updated, or deleted in your Supabase tables. Makerkit provides a typed webhook handler at `@kit/database-webhooks` that processes these events in a Next.js API route. + +{% sequence title="Database Webhooks Setup" description="Configure and handle database change events" %} + +[Understand the webhook system](#how-database-webhooks-work) + +[Add custom handlers](#adding-custom-webhook-handlers) + +[Configure webhook triggers](#configuring-webhook-triggers) + +[Test webhooks locally](#testing-webhooks-locally) + +{% /sequence %} + +## How Database Webhooks Work + +Supabase database webhooks fire HTTP requests to your application when specified database events occur. The flow is: + +1. A row is inserted, updated, or deleted in a table +2. Supabase sends a POST request to your webhook endpoint +3. Your handler processes the event and executes custom logic +4. The handler returns a success response + +Makerkit includes built-in handlers for: + +- **User deletion**: Cleans up related subscriptions and data +- **User signup**: Sends welcome emails +- **Invitation creation**: Sends invitation emails + +You can extend this with your own handlers. + +## Adding Custom Webhook Handlers + +The webhook endpoint is at `apps/web/app/api/db/webhook/route.ts`. Add your handlers to the `handleEvent` callback: + +```tsx {% title="apps/web/app/api/db/webhook/route.ts" %} +import { getDatabaseWebhookHandlerService } from '@kit/database-webhooks'; +import { enhanceRouteHandler } from '@kit/next/routes'; + +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(); + + await service.handleWebhook({ + body, + signature, + async handleEvent(change) { + // Handle new project creation + if (change.type === 'INSERT' && change.table === 'projects') { + await notifyTeamOfNewProject(change.record); + } + + // Handle subscription cancellation + if (change.type === 'UPDATE' && change.table === 'subscriptions') { + if (change.record.status === 'canceled') { + await sendCancellationSurvey(change.record); + } + } + + // Handle user deletion + if (change.type === 'DELETE' && change.table === 'accounts') { + await cleanupExternalServices(change.old_record); + } + }, + }); + + return new Response(null, { status: 200 }); + } catch (error) { + console.error('Webhook error:', error); + return new Response(null, { status: 500 }); + } + }, + { auth: false }, +); +``` + +### RecordChange Type + +The `change` object is typed to your database schema: + +```tsx +import type { Database } from '@kit/supabase/database'; + +type Tables = Database['public']['Tables']; + +type TableChangeType = 'INSERT' | 'UPDATE' | 'DELETE'; + +interface RecordChange< + Table extends keyof Tables, + Row = Tables[Table]['Row'], +> { + type: TableChangeType; + table: Table; + record: Row; // Current row data (null for DELETE) + schema: 'public'; + old_record: Row | null; // Previous row data (null for INSERT) +} +``` + +### Type-Safe Handlers + +Cast to specific table types for better type safety: + +```tsx +import type { RecordChange } from '@kit/database-webhooks'; + +type ProjectChange = RecordChange<'projects'>; +type SubscriptionChange = RecordChange<'subscriptions'>; + +async function handleEvent(change: RecordChange<keyof Tables>) { + if (change.table === 'projects') { + const projectChange = change as ProjectChange; + // projectChange.record is now typed to the projects table + console.log(projectChange.record.name); + } +} +``` + +### Async Handlers + +For long-running operations, consider using background jobs: + +```tsx +async handleEvent(change) { + if (change.type === 'INSERT' && change.table === 'orders') { + // Queue for background processing instead of blocking + await queueOrderProcessing(change.record.id); + } +} +``` + +## Configuring Webhook Triggers + +Webhooks are configured in Supabase. You can set them up via SQL or the Dashboard. + +### SQL Configuration + +Add a trigger in your schema file at `apps/web/supabase/schemas/`: + +```sql {% title="apps/web/supabase/schemas/webhooks.sql" %} +-- Create the webhook trigger for the projects table +create trigger projects_webhook + after insert or update or delete on public.projects + for each row execute function supabase_functions.http_request( + 'https://your-app.com/api/db/webhook', + 'POST', + '{"Content-Type":"application/json"}', + '{}', + '5000' + ); +``` + +### Dashboard Configuration + +1. Open your Supabase project dashboard +2. Navigate to **Database** > **Webhooks** +3. Click **Create a new hook** +4. Configure: + - **Name**: `projects_webhook` + - **Table**: `projects` + - **Events**: INSERT, UPDATE, DELETE + - **Type**: HTTP Request + - **URL**: `https://your-app.com/api/db/webhook` + - **Method**: POST + +### Webhook Security + +Supabase automatically signs webhook payloads using the `X-Supabase-Event-Signature` header. The `@kit/database-webhooks` package verifies this signature against your `SUPABASE_DB_WEBHOOK_SECRET` environment variable. + +Configure the webhook secret: + +```bash {% title=".env.local" %} +SUPABASE_DB_WEBHOOK_SECRET=your-webhook-secret +``` + +Set the same secret in your Supabase webhook configuration. The handler validates signatures automatically, rejecting requests with missing or invalid signatures. + +## Testing Webhooks Locally + +### Local Development Setup + +When running Supabase locally, webhooks need to reach your Next.js server: + +1. Start your development server on a known port: + ```bash + pnpm run dev + ``` +2. Configure the webhook URL in your local Supabase to point to `http://host.docker.internal:3000/api/db/webhook` (Docker) or `http://localhost:3000/api/db/webhook`. + +### Manual Testing + +Test your webhook handler by sending a mock request: + +```bash +curl -X POST http://localhost:3000/api/db/webhook \ + -H "Content-Type: application/json" \ + -H "X-Supabase-Event-Signature: your-secret-key" \ + -d '{ + "type": "INSERT", + "table": "projects", + "schema": "public", + "record": { + "id": "test-id", + "name": "Test Project", + "account_id": "account-id" + }, + "old_record": null + }' +``` + +Expected response: `200 OK` + +### Debugging Tips + +**Webhook not firing**: Check that the trigger exists in Supabase and the URL is correct. + +**Handler not executing**: Add logging to trace the event flow: + +```tsx +async handleEvent(change) { + console.log('Received webhook:', { + type: change.type, + table: change.table, + recordId: change.record?.id, + }); +} +``` + +**Timeout errors**: Move long operations to background jobs. Webhooks should respond quickly. + +## Common Use Cases + +| Use Case | Trigger | Action | +|----------|---------|--------| +| Welcome email | INSERT on `users` | Send onboarding email | +| Invitation email | INSERT on `invitations` | Send invite link | +| Subscription change | UPDATE on `subscriptions` | Sync with CRM | +| User deletion | DELETE on `accounts` | Clean up external services | +| Audit logging | INSERT/UPDATE/DELETE | Write to audit table | +| Search indexing | INSERT/UPDATE | Update search index | + +## Related Resources + +- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for extending your schema +- [Database Functions](/docs/next-supabase-turbo/development/database-functions) for built-in SQL functions +- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration) for sending emails from webhooks diff --git a/docs/development/external-marketing-website.mdoc b/docs/development/external-marketing-website.mdoc new file mode 100644 index 000000000..37192b489 --- /dev/null +++ b/docs/development/external-marketing-website.mdoc @@ -0,0 +1,210 @@ +--- +status: "published" +label: "External Marketing Website" +title: "External Marketing Website in the Next.js Supabase Turbo Starter Kit" +description: "Configure Makerkit to redirect marketing pages to an external website built with Framer, Webflow, or WordPress." +order: 9 +--- + +Redirect Makerkit's marketing pages to an external website by configuring the `proxy.ts` middleware. This lets you use Framer, Webflow, or WordPress for your marketing site while keeping Makerkit for your SaaS application. + +{% sequence title="External Marketing Website Setup" description="Configure redirects to your external marketing site" %} + +[Understand the architecture](#when-to-use-an-external-marketing-website) + +[Configure the middleware](#configuring-the-middleware) + +[Handle subpaths and assets](#handling-subpaths-and-assets) + +[Verify the redirects](#verify-the-redirects) + +{% /sequence %} + +## When to Use an External Marketing Website + +Use an external marketing website when: + +- **Marketing team independence**: Your marketing team needs to update content without developer involvement +- **Design flexibility**: You want visual builders like Framer or Webflow for landing pages +- **Content management**: WordPress or a headless CMS better fits your content workflow +- **A/B testing**: Your marketing tools integrate better with external platforms + +Keep marketing pages in Makerkit when: + +- You want a unified codebase and deployment +- Your team is comfortable with React and Tailwind +- You need tight integration between marketing and app features + +## Configuring the Middleware + +{% alert type="default" title="Next.js 16+" %} +In Next.js 16+, Makerkit uses `proxy.ts` for middleware. Prior versions used `middleware.ts`. +{% /alert %} + +Edit `apps/web/proxy.ts` to redirect marketing pages: + +```typescript {% title="apps/web/proxy.ts" %} +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +const EXTERNAL_MARKETING_URL = 'https://your-marketing-site.com'; + +const MARKETING_PAGES = [ + '/', + '/pricing', + '/faq', + '/contact', + '/about', + '/blog', + '/privacy-policy', + '/terms-of-service', + '/cookie-policy', +]; + +export function proxy(req: NextRequest) { + if (isMarketingPage(req)) { + const redirectUrl = new URL( + req.nextUrl.pathname, + EXTERNAL_MARKETING_URL + ); + + // Preserve query parameters + redirectUrl.search = req.nextUrl.search; + + return NextResponse.redirect(redirectUrl, { status: 301 }); + } + + // Continue with existing middleware logic + return NextResponse.next(); +} + +function isMarketingPage(req: NextRequest): boolean { + const pathname = req.nextUrl.pathname; + + return MARKETING_PAGES.some((page) => { + if (page === '/') { + return pathname === '/'; + } + return pathname === page || pathname.startsWith(`${page}/`); + }); +} +``` + +### Configuration Options + +| Option | Description | +|--------|-------------| +| `EXTERNAL_MARKETING_URL` | Your external marketing site's base URL | +| `MARKETING_PAGES` | Array of paths to redirect | +| Status code `301` | Permanent redirect (SEO-friendly) | +| Status code `302` | Temporary redirect (for testing) | + +## Handling Subpaths and Assets + +### Blog Posts with Dynamic Paths + +If your blog uses dynamic paths like `/blog/post-slug`, handle them separately: + +```typescript +const MARKETING_PAGES = [ + // ... other pages +]; + +const MARKETING_PREFIXES = [ + '/blog', + '/resources', + '/case-studies', +]; + +function isMarketingPage(req: NextRequest): boolean { + const pathname = req.nextUrl.pathname; + + // Check exact matches + if (MARKETING_PAGES.includes(pathname)) { + return true; + } + + // Check prefix matches + return MARKETING_PREFIXES.some((prefix) => + pathname.startsWith(prefix) + ); +} +``` + +### Excluding Application Routes + +Keep certain routes in Makerkit even if they share a marketing prefix: + +```typescript +const APP_ROUTES = [ + '/blog/admin', // Blog admin panel stays in Makerkit + '/pricing/checkout', // Checkout flow stays in Makerkit +]; + +function isMarketingPage(req: NextRequest): boolean { + const pathname = req.nextUrl.pathname; + + // Never redirect app routes + if (APP_ROUTES.some((route) => pathname.startsWith(route))) { + return false; + } + + // ... rest of the logic +} +``` + +## Verify the Redirects + +After configuring, verify redirects work correctly: + +```bash +# Start the development server +pnpm run dev + +# Test a redirect (should return 301) +curl -I http://localhost:3000/pricing +``` + +Expected output: + +``` +HTTP/1.1 301 Moved Permanently +Location: https://your-marketing-site.com/pricing +``` + +### Common Issues + +**Redirect loops**: Ensure your external site doesn't redirect back to Makerkit. + +**Missing query parameters**: The example code preserves query params. Verify UTM parameters pass through correctly. + +**Asset requests**: Don't redirect asset paths like `/images/` or `/_next/`. The middleware should only match page routes. + +## Environment-Based Configuration + +Use environment variables for different environments: + +```typescript {% title="apps/web/proxy.ts" %} +const EXTERNAL_MARKETING_URL = process.env.EXTERNAL_MARKETING_URL; + +export function proxy(req: NextRequest) { + // Only redirect if external URL is configured + if (!EXTERNAL_MARKETING_URL) { + return NextResponse.next(); + } + + // ... redirect logic +} +``` + +```bash {% title=".env.production" %} +EXTERNAL_MARKETING_URL=https://your-marketing-site.com +``` + +This lets you keep marketing pages in Makerkit during development while redirecting in production. + +## Related Resources + +- [Marketing Pages](/docs/next-supabase-turbo/development/marketing-pages) for customizing built-in marketing pages +- [SEO Configuration](/docs/next-supabase-turbo/development/seo) for sitemap and meta tag setup +- [Legal Pages](/docs/next-supabase-turbo/development/legal-pages) for privacy policy and terms pages diff --git a/docs/development/legal-pages.mdoc b/docs/development/legal-pages.mdoc new file mode 100644 index 000000000..d6efbd72b --- /dev/null +++ b/docs/development/legal-pages.mdoc @@ -0,0 +1,221 @@ +--- +status: "published" +label: "Legal Pages" +title: "Legal Pages in the Next.js Supabase Turbo Starter Kit" +description: "Create and customize legal pages including Terms of Service, Privacy Policy, and Cookie Policy in your Makerkit application." +order: 8 +--- + +Legal pages in Makerkit are TSX files located at `apps/web/app/[locale]/(marketing)/(legal)/`. The kit includes placeholder files for Terms of Service, Privacy Policy, and Cookie Policy that you must customize with your own content. + +{% sequence title="Legal Pages Setup" description="Configure your SaaS application's legal pages" %} + +[Understand the included pages](#included-legal-pages) + +[Customize the content](#customizing-legal-pages) + +[Add new legal pages](#adding-new-legal-pages) + +[Use a CMS for legal content](#using-a-cms-for-legal-pages) + +{% /sequence %} + +## Included Legal Pages + +Makerkit includes three legal page templates: + +| Page | File Location | URL | +|------|---------------|-----| +| Terms of Service | `apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx` | `/terms-of-service` | +| Privacy Policy | `apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx` | `/privacy-policy` | +| Cookie Policy | `apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx` | `/cookie-policy` | + +{% alert type="error" title="Required: Add Your Own Content" %} +The included legal pages contain placeholder text only. You must replace this content with legally compliant policies for your jurisdiction and business model. Consult a lawyer for proper legal documentation. +{% /alert %} + +## Customizing Legal Pages + +### Basic MDX Structure + +Each legal page uses MDX format with frontmatter: + +```mdx {% title="apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx" %} +--- +title: "Privacy Policy" +description: "How we collect, use, and protect your personal information" +--- + +# Privacy Policy + +**Last updated: January 2026** + +## Information We Collect + +We collect information you provide directly... + +## How We Use Your Information + +We use the information we collect to... + +## Contact Us + +If you have questions about this Privacy Policy, contact us at... +``` + +### Adding Last Updated Dates + +Include a visible "Last updated" date in your legal pages. This helps with compliance and user trust: + +```mdx +**Last updated: January 15, 2026** + +*This policy is effective as of the date above and replaces any prior versions.* +``` + +### Structuring Long Documents + +For complex legal documents, use clear heading hierarchy: + +```mdx +# Privacy Policy + +## 1. Information Collection +### 1.1 Information You Provide +### 1.2 Information Collected Automatically +### 1.3 Information from Third Parties + +## 2. Use of Information +### 2.1 Service Delivery +### 2.2 Communications +### 2.3 Analytics and Improvements + +## 3. Data Sharing +... +``` + +## Adding New Legal Pages + +Create additional legal pages in the `(legal)` directory: + +```bash +# Create a new legal page +mkdir -p apps/web/app/\[locale\]/\(marketing\)/\(legal\)/acceptable-use +touch apps/web/app/\[locale\]/\(marketing\)/\(legal\)/acceptable-use/page.tsx +``` + +Add the content: + +```mdx {% title="apps/web/app/[locale]/(marketing)/(legal)/acceptable-use/page.tsx" %} +--- +title: "Acceptable Use Policy" +description: "Guidelines for using our service responsibly" +--- + +# Acceptable Use Policy + +**Last updated: January 2026** + +This Acceptable Use Policy outlines prohibited activities... +``` + +### Update Navigation + +Add links to new legal pages in your footer or navigation. The footer typically lives in: + +``` +apps/web/app/[locale]/(marketing)/_components/site-footer.tsx +``` + +### Update Sitemap + +Add new legal pages to your sitemap in `apps/web/app/sitemap.xml/route.ts`: + +```typescript +function getPaths() { + const paths = [ + // ... existing paths + '/acceptable-use', // Add new legal page + ]; + + return paths.map((path) => ({ + loc: new URL(path, appConfig.url).href, + lastmod: new Date().toISOString(), + })); +} +``` + +## Using a CMS for Legal Pages + +For organizations that need non-developers to update legal content, use the CMS integration: + +```tsx {% title="apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx" %} +import { createCmsClient } from '@kit/cms'; + +export default async function PrivacyPolicyPage() { + const cms = await createCmsClient(); + + const { title, content } = await cms.getContentBySlug({ + slug: 'privacy-policy', + collection: 'pages', + }); + + return ( + <article className="prose prose-gray max-w-3xl mx-auto py-12"> + <h1>{title}</h1> + <div dangerouslySetInnerHTML={{ __html: content }} /> + </article> + ); +} +``` + +### CMS Setup for Legal Pages + +1. Create a `pages` collection in your CMS (Keystatic, WordPress, or custom) +2. Add entries for each legal page with slugs matching the URL paths +3. Use the CMS admin interface to edit content without code changes + +See the [CMS documentation](/docs/next-supabase-turbo/content/cms) for detailed setup instructions. + +## Legal Page Best Practices + +### What to Include + +**Privacy Policy** should cover: +- What data you collect (personal info, usage data, cookies) +- How you use the data +- Third-party services (analytics, payment processors) +- User rights (access, deletion, portability) +- Contact information + +**Terms of Service** should cover: +- Service description and limitations +- User responsibilities and prohibited uses +- Payment terms (if applicable) +- Intellectual property rights +- Limitation of liability +- Termination conditions + +**Cookie Policy** should cover: +- Types of cookies used (essential, analytics, marketing) +- Purpose of each cookie type +- How to manage cookie preferences +- Third-party cookies + +### Compliance Considerations + +| Regulation | Requirements | +|------------|--------------| +| GDPR (EU) | Privacy policy, cookie consent, data subject rights | +| CCPA (California) | Privacy policy with specific disclosures, opt-out rights | +| LGPD (Brazil) | Privacy policy, consent mechanisms, data protection officer | + +{% alert type="warning" title="Not Legal Advice" %} +This documentation provides technical guidance only. Consult qualified legal counsel to ensure your policies comply with applicable laws and regulations. +{% /alert %} + +## Related Resources + +- [CMS Configuration](/docs/next-supabase-turbo/content/cms) for managing legal content through a CMS +- [Marketing Pages](/docs/next-supabase-turbo/development/marketing-pages) for customizing other marketing content +- [SEO Configuration](/docs/next-supabase-turbo/development/seo) for proper indexing of legal pages diff --git a/docs/development/loading-data-from-database.mdoc b/docs/development/loading-data-from-database.mdoc new file mode 100644 index 000000000..27114fa66 --- /dev/null +++ b/docs/development/loading-data-from-database.mdoc @@ -0,0 +1,231 @@ +--- +status: "published" +label: "Loading data from the DB" +order: 4 +title: "Learn how to load data from the Supabase database" +description: "In this page we learn how to load data from the Supabase database and display it in our Next.js application." +--- + +Now that our database supports the data we need, we can start loading it into our application. We will use the `@makerkit/data-loader-supabase-nextjs` package to load data from the Supabase database. + +Please check the [documentation](https://github.com/makerkit/makerkit/tree/main/packages/data-loader/supabase/nextjs) for the `@makerkit/data-loader-supabase-nextjs` package to learn more about how to use it. + +This nifty package allows us to load data from the Supabase database and display it in our server components with support for pagination. + +In the snippet below, we will: + +1. Load the user's workspace data from the database. This allows us to get the user's account ID without further round-trips because the workspace is loaded by the user layout. +2. Load the user's tasks from the database. +3. Display the tasks in a table. +4. Use a search input to filter the tasks by title. + +Let's take a look at the code: + +```tsx +import { use } from 'react'; + +import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { Button } from '@kit/ui/button'; +import { Heading } from '@kit/ui/heading'; +import { If } from '@kit/ui/if'; +import { Input } from '@kit/ui/input'; +import { PageBody } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +import { getTranslations } from 'next-intl/server'; + +import { TasksTable } from './_components/tasks-table'; +import { UserAccountHeader } from './_components/user-account-header'; +import { loadUserWorkspace } from './_lib/server/load-user-workspace'; + +interface SearchParams { + page?: string; + query?: string; +} + +export const generateMetadata = async () => { + const t = await getTranslations('account'); + const title = t('homePage'); + + return { + title, + }; +}; + +async function UserHomePage(props: { searchParams: Promise<SearchParams> }) { + const client = getSupabaseServerClient(); + const { user } = use(loadUserWorkspace()); + + const searchParams = await props.searchParams; + const page = parseInt(searchParams.page ?? '1', 10); + const query = searchParams.query ?? ''; + + return ( + <> + <UserAccountHeader + title={<Trans i18nKey={'common.homeTabLabel'} />} + description={<Trans i18nKey={'common.homeTabDescription'} />} + /> + + <PageBody className={'space-y-4'}> + <div className={'flex items-center justify-between'}> + <div> + <Heading level={4}> + <Trans i18nKey={'tasks.tasksTabLabel'} defaults={'Tasks'} /> + </Heading> + </div> + + <div className={'flex items-center space-x-2'}> + <form className={'w-full'}> + <Input + name={'query'} + defaultValue={query} + className={'w-full lg:w-[18rem]'} + placeholder={'Search tasks'} + /> + </form> + </div> + </div> + + <ServerDataLoader + client={client} + table={'tasks'} + page={page} + where={{ + account_id: { + eq: user.id, + }, + title: { + textSearch: query ? `%${query}%` : undefined, + }, + }} + > + {(props) => { + return ( + <div className={'flex flex-col space-y-8'}> + <If condition={props.count === 0 && query}> + <div className={'flex flex-col space-y-2.5'}> + <p> + <Trans + i18nKey={'tasks.noTasksFound'} + values={{ query }} + /> + </p> + + <form> + <input type="hidden" name={'query'} value={''} /> + + <Button variant={'outline'} size={'sm'}> + <Trans i18nKey={'tasks.clearSearch'} /> + </Button> + </form> + </div> + </If> + + <TasksTable {...props} /> + </div> + ); + }} + </ServerDataLoader> + </PageBody> + </> + ); +} + +export default UserHomePage; +``` + +Let's break this down a bit: + +1. We import the necessary components and functions. +2. We define the `SearchParams` interface to type the search parameters. +3. We define the `generateMetadata` function to generate the page metadata. +4. We define the `UserHomePage` component that loads the user's workspace and tasks from the database. +5. We define the `ServerDataLoader` component that loads the tasks from the database. +6. We render the tasks in a table and provide a search input to filter the tasks by title. +7. We export the `UserHomePage` component. + +### Displaying the tasks in a table + +Now, let's show the tasks table component: + +```tsx +'use client'; + +import Link from 'next/link'; + +import { ColumnDef } from '@tanstack/react-table'; +import { Pencil } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +import { Button } from '@kit/ui/button'; +import { DataTable } from '@kit/ui/enhanced-data-table'; + +import { Database } from '~/lib/database.types'; + +type Task = Database['public']['Tables']['tasks']['Row']; + +export function TasksTable(props: { + data: Task[]; + page: number; + pageSize: number; + pageCount: number; +}) { + const columns = useGetColumns(); + + return ( + <div> + <DataTable {...props} columns={columns} /> + </div> + ); +} + +function useGetColumns(): ColumnDef<Task>[] { + const t = useTranslations('tasks'); + + return [ + { + header: t('task'), + cell: ({ row }) => ( + <Link + className={'hover:underline'} + href={`/home/tasks/${row.original.id}`} + > + {row.original.title} + </Link> + ), + }, + { + header: t('createdAt'), + accessorKey: 'created_at', + }, + { + header: t('updatedAt'), + accessorKey: 'updated_at', + }, + { + header: '', + id: 'actions', + cell: ({ row }) => { + const id = row.original.id; + + return ( + <div className={'flex justify-end space-x-2'}> + <Link href={`/home/tasks/${id}`}> + <Button variant={'ghost'} size={'icon'}> + <Pencil className={'h-4'} /> + </Button> + </Link> + </div> + ); + }, + }, + ]; +} +``` + +In this snippet, we define the `TasksTable` component that renders the tasks in a table. We use the `DataTable` component from the `@kit/ui/enhanced-data-table` package to render the table. + +We also define the `useGetColumns` hook that returns the columns for the table. We use the `useTranslations` hook from `next-intl` to translate the column headers. \ No newline at end of file diff --git a/docs/development/marketing-pages.mdoc b/docs/development/marketing-pages.mdoc new file mode 100644 index 000000000..026733650 --- /dev/null +++ b/docs/development/marketing-pages.mdoc @@ -0,0 +1,409 @@ +--- +status: "published" +label: "Marketing Pages" +title: "Customize Marketing Pages in the Next.js Supabase Turbo Starter Kit" +description: "Build and customize landing pages, pricing pages, FAQ, and other marketing content using Next.js App Router and Tailwind CSS." +order: 7 +--- + +Marketing pages in Makerkit live at `apps/web/app/[locale]/(marketing)/` and include landing pages, pricing, FAQ, blog, documentation, and contact forms. These pages use Next.js App Router with React Server Components for fast initial loads and SEO optimization. + +{% sequence title="Marketing Pages Development" description="Customize and extend your marketing pages" %} + +[Understand the structure](#marketing-pages-structure) + +[Customize existing pages](#customizing-existing-pages) + +[Create new marketing pages](#creating-new-marketing-pages) + +[Configure navigation and footer](#navigation-and-footer) + +{% /sequence %} + +## Marketing Pages Structure + +The marketing pages follow Next.js App Router conventions with a route group: + +``` +apps/web/app/[locale]/(marketing)/ +├── layout.tsx # Shared layout with header/footer +├── page.tsx # Home page (/) +├── (legal)/ # Legal pages group +│ ├── cookie-policy/ +│ ├── privacy-policy/ +│ └── terms-of-service/ +├── blog/ # Blog listing and posts +├── changelog/ # Product changelog +├── contact/ # Contact form +├── docs/ # Documentation +├── faq/ # FAQ page +├── pricing/ # Pricing page +└── _components/ # Shared marketing components + ├── header.tsx + ├── footer.tsx + └── site-navigation.tsx +``` + +### Route Groups Explained + +The `(marketing)` folder is a route group that shares a layout without affecting the URL structure. Pages inside render at the root level: + +| File Path | URL | +|-----------|-----| +| `app/[locale]/(marketing)/page.tsx` | `/` | +| `app/[locale]/(marketing)/pricing/page.tsx` | `/pricing` | +| `app/[locale]/(marketing)/blog/page.tsx` | `/blog` | + +## Customizing Existing Pages + +### Home Page + +The home page at `apps/web/app/[locale]/(marketing)/page.tsx` typically includes: + +```tsx {% title="apps/web/app/[locale]/(marketing)/page.tsx" %} +import { Hero } from './_components/hero'; +import { Features } from './_components/features'; +import { Testimonials } from './_components/testimonials'; +import { Pricing } from './_components/pricing-section'; +import { CallToAction } from './_components/call-to-action'; + +export default function HomePage() { + return ( + <> + <Hero /> + <Features /> + <Testimonials /> + <Pricing /> + <CallToAction /> + </> + ); +} +``` + +Each section is a separate component in `_components/` for easy customization. + +### Pricing Page + +The pricing page displays your billing plans. It reads configuration from `apps/web/config/billing.config.ts`: + +```tsx {% title="apps/web/app/[locale]/(marketing)/pricing/page.tsx" %} +import { PricingTable } from '@kit/billing-gateway/marketing'; +import billingConfig from '~/config/billing.config'; + +export default function PricingPage() { + return ( + <div className="container py-16"> + <h1 className="text-4xl font-bold text-center mb-4"> + Simple, Transparent Pricing + </h1> + <p className="text-muted-foreground text-center mb-12"> + Choose the plan that fits your needs + </p> + + <PricingTable config={billingConfig} /> + </div> + ); +} +``` + +See [Billing Configuration](/docs/next-supabase-turbo/billing/overview) for customizing plans and pricing. + +### FAQ Page + +The FAQ page uses an accordion component with content from a configuration file or CMS: + +```tsx {% title="apps/web/app/[locale]/(marketing)/faq/page.tsx" %} +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@kit/ui/accordion'; + +const faqs = [ + { + question: 'How do I get started?', + answer: 'Sign up for a free account and follow our getting started guide.', + }, + { + question: 'Can I cancel anytime?', + answer: 'Yes, you can cancel your subscription at any time with no penalties.', + }, + // ... more FAQs +]; + +export default function FAQPage() { + return ( + <div className="container max-w-3xl py-16"> + <h1 className="text-4xl font-bold text-center mb-12"> + Frequently Asked Questions + </h1> + + <Accordion type="single" collapsible> + {faqs.map((faq, index) => ( + <AccordionItem key={index} value={`item-${index}`}> + <AccordionTrigger>{faq.question}</AccordionTrigger> + <AccordionContent>{faq.answer}</AccordionContent> + </AccordionItem> + ))} + </Accordion> + </div> + ); +} +``` + +### Contact Page + +The contact page includes a form that sends emails via your configured mailer: + +```tsx {% title="apps/web/app/[locale]/(marketing)/contact/page.tsx" %} +import { ContactForm } from './_components/contact-form'; + +export default function ContactPage() { + return ( + <div className="container max-w-xl py-16"> + <h1 className="text-4xl font-bold text-center mb-4"> + Contact Us + </h1> + <p className="text-muted-foreground text-center mb-8"> + Have a question? We'd love to hear from you. + </p> + + <ContactForm /> + </div> + ); +} +``` + +#### Contact Form Configuration + +Configure the recipient email address in your environment: + +```bash {% title=".env.local" %} +CONTACT_EMAIL=support@yourdomain.com +``` + +The form submission uses your [email configuration](/docs/next-supabase-turbo/emails/email-configuration). Ensure your mailer is configured before the contact form will work. + +## Creating New Marketing Pages + +### Basic Page Structure + +Create a new page with proper metadata: + +```tsx {% title="apps/web/app/[locale]/(marketing)/about/page.tsx" %} +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'About Us | Your SaaS Name', + description: 'Learn about our mission, team, and the story behind our product.', +}; + +export default function AboutPage() { + return ( + <div className="container py-16"> + <h1 className="text-4xl font-bold mb-8">About Us</h1> + + <div className="prose prose-gray max-w-none"> + <p>Your company story goes here...</p> + </div> + </div> + ); +} +``` + +### MDX Pages for Content-Heavy Pages + +For content-heavy pages, use MDX: + +```bash +# Create an MDX page +mkdir -p apps/web/app/\(marketing\)/about +touch apps/web/app/\(marketing\)/about/page.mdx +``` + +```mdx {% title="apps/web/app/[locale]/(marketing)/about/page.mdx" %} +--- +title: "About Us" +description: "Learn about our mission and team" +--- + +# About Us + +We started this company because... + +## Our Mission + +To help developers ship faster... + +## The Team + +Meet the people behind the product... +``` + +### Dynamic Pages with Data + +For pages that need dynamic data, combine Server Components with data fetching: + +```tsx {% title="apps/web/app/[locale]/(marketing)/customers/page.tsx" %} +import { createCmsClient } from '@kit/cms'; + +export default async function CustomersPage() { + const cms = await createCmsClient(); + + const caseStudies = await cms.getContentItems({ + collection: 'case-studies', + limit: 10, + }); + + return ( + <div className="container py-16"> + <h1 className="text-4xl font-bold mb-12">Customer Stories</h1> + + <div className="grid md:grid-cols-2 gap-8"> + {caseStudies.map((study) => ( + <CaseStudyCard key={study.slug} {...study} /> + ))} + </div> + </div> + ); +} +``` + +## Navigation and Footer + +### Header Navigation + +Configure navigation links in the header component: + +```tsx {% title="apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx" %} +const navigationItems = [ + { label: 'Features', href: '/#features' }, + { label: 'Pricing', href: '/pricing' }, + { label: 'Blog', href: '/blog' }, + { label: 'Docs', href: '/docs' }, + { label: 'Contact', href: '/contact' }, +]; +``` + +### Footer Links + +The footer typically includes multiple link sections: + +```tsx {% title="apps/web/app/[locale]/(marketing)/_components/footer.tsx" %} +const footerSections = [ + { + title: 'Product', + links: [ + { label: 'Features', href: '/#features' }, + { label: 'Pricing', href: '/pricing' }, + { label: 'Changelog', href: '/changelog' }, + ], + }, + { + title: 'Resources', + links: [ + { label: 'Documentation', href: '/docs' }, + { label: 'Blog', href: '/blog' }, + { label: 'FAQ', href: '/faq' }, + ], + }, + { + title: 'Legal', + links: [ + { label: 'Privacy Policy', href: '/privacy-policy' }, + { label: 'Terms of Service', href: '/terms-of-service' }, + { label: 'Cookie Policy', href: '/cookie-policy' }, + ], + }, +]; +``` + +### Customizing the Layout + +All marketing pages inherit from `apps/web/app/[locale]/(marketing)/layout.tsx`. This layout includes: + +- Header with navigation +- Footer with links +- Common metadata +- Analytics scripts + +Edit this file to change the shared structure across all marketing pages. + +## SEO for Marketing Pages + +### Metadata API + +Use Next.js Metadata API for SEO: + +```tsx {% title="apps/web/app/[locale]/(marketing)/pricing/page.tsx" %} +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Pricing | Your SaaS Name', + description: 'Choose from flexible pricing plans. Start free, upgrade when ready.', + openGraph: { + title: 'Pricing | Your SaaS Name', + description: 'Choose from flexible pricing plans.', + images: ['/images/og/pricing.png'], + }, +}; +``` + +### Structured Data + +Add JSON-LD structured data for rich search results. See the [Next.js JSON-LD guide](https://nextjs.org/docs/app/guides/json-ld) for more details: + +```tsx +// JSON-LD structured data using Next.js metadata + +export default function PricingPage() { + return ( + <> + <script + type="application/ld+json" + dangerouslySetInnerHTML={{ + __html: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Your SaaS Name', + offers: { + '@type': 'AggregateOffer', + lowPrice: '0', + highPrice: '99', + priceCurrency: 'USD', + }, + }), + }} + /> + {/* Page content */} + </> + ); +} +``` + +### Sitemap + +Add new marketing pages to your sitemap at `apps/web/app/sitemap.xml/route.ts`: + +```typescript +function getPaths() { + return [ + '/', + '/pricing', + '/faq', + '/blog', + '/docs', + '/contact', + '/about', // Add new pages here + ]; +} +``` + +## Related Resources + +- [SEO Configuration](/docs/next-supabase-turbo/development/seo) for detailed SEO setup +- [Legal Pages](/docs/next-supabase-turbo/development/legal-pages) for privacy policy and terms +- [External Marketing Website](/docs/next-supabase-turbo/development/external-marketing-website) for using Framer or Webflow +- [CMS Setup](/docs/next-supabase-turbo/content/cms) for blog configuration +- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration) for contact form setup diff --git a/docs/development/migrations.mdoc b/docs/development/migrations.mdoc new file mode 100644 index 000000000..ee70eaa3c --- /dev/null +++ b/docs/development/migrations.mdoc @@ -0,0 +1,309 @@ +--- +status: "published" +label: "Migrations" +order: 1 +title: "Database Migrations in the Next.js Supabase Starter Kit" +description: "Create and manage database migrations using Supabase's declarative schema and diffing tools to evolve your PostgreSQL schema safely." +--- + +Database migrations in Makerkit use Supabase's declarative schema approach. Define your schema in SQL files at `apps/web/supabase/schemas/`, then generate migration files that track changes over time. This keeps your schema version-controlled and deployable across environments. + +{% sequence title="Database Migration Workflow" description="Create and apply schema changes safely" %} + +[Edit the declarative schema](#editing-the-declarative-schema) + +[Generate a migration file](#generating-a-migration-file) + +[Test locally](#testing-locally) + +[Push to production](#pushing-to-production) + +{% /sequence %} + +## Why Declarative Schema? + +Makerkit uses declarative schema files instead of incremental migrations for several reasons: + +- **Readable**: See your entire schema in one place +- **Mergeable**: Schema changes are easier to review in PRs +- **Recoverable**: Always know the intended state of your database +- **Automated**: Supabase generates migration diffs for you + +{% alert type="warning" title="Avoid Supabase Studio for Schema Changes" %} +Don't use the hosted Supabase Studio to modify your schema. Changes made there won't be tracked in your codebase. Use your local Supabase instance and generate migrations from schema files. +{% /alert %} + +## Schema File Organization + +Schema files live in `apps/web/supabase/schemas/`: + +``` +apps/web/supabase/ +├── config.toml # Supabase configuration +├── seed.sql # Seed data for development +├── schemas/ # Declarative schema files +│ ├── 00-extensions.sql +│ ├── 01-enums.sql +│ ├── 02-accounts.sql +│ ├── 03-roles.sql +│ ├── 04-memberships.sql +│ ├── 05-subscriptions.sql +│ └── your-feature.sql # Your custom schema +└── migrations/ # Generated migration files + ├── 20240101000000_initial.sql + └── 20240115000000_add_projects.sql +``` + +Files are loaded alphabetically, so prefix with numbers to control order. + +## Editing the Declarative Schema + +### Adding a New Table + +Create a schema file for your feature: + +```sql {% title="apps/web/supabase/schemas/20-projects.sql" %} +-- Projects table for team workspaces +create table if not exists public.projects ( + id uuid primary key default gen_random_uuid(), + account_id uuid not null references public.accounts(id) on delete cascade, + name text not null, + description text, + status text not null default 'active' check (status in ('active', 'archived')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Enable RLS +alter table public.projects enable row level security; + +-- RLS policies +create policy "Users can view their account's projects" + on public.projects + for select + using ( + account_id in ( + select account_id from public.accounts_memberships + where user_id = auth.uid() + ) + ); + +create policy "Users with write permission can insert projects" + on public.projects + for insert + with check ( + public.has_permission(auth.uid(), account_id, 'projects.write'::app_permissions) + ); + +-- Updated at trigger +create trigger set_projects_updated_at + before update on public.projects + for each row execute function public.set_updated_at(); +``` + +### Modifying an Existing Table + +Edit the schema file directly. For example, to add a column: + +```sql {% title="apps/web/supabase/schemas/20-projects.sql" %} +create table if not exists public.projects ( + id uuid primary key default gen_random_uuid(), + account_id uuid not null references public.accounts(id) on delete cascade, + name text not null, + description text, + status text not null default 'active' check (status in ('active', 'archived')), + priority integer not null default 0, -- New column + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +``` + +### Adding Indexes + +Add indexes for frequently queried columns: + +```sql +-- Add to your schema file +create index if not exists projects_account_id_idx + on public.projects(account_id); + +create index if not exists projects_status_idx + on public.projects(status) + where status = 'active'; +``` + +## Generating a Migration File + +After editing schema files, generate a migration that captures the diff: + +```bash +# Generate migration from schema changes +pnpm --filter web supabase:db:diff -f add_projects +``` + +This creates a timestamped migration file in `apps/web/supabase/migrations/`: + +```sql {% title="apps/web/supabase/migrations/20260119000000_add_projects.sql" %} +-- Generated by Supabase CLI + +create table public.projects ( + id uuid primary key default gen_random_uuid(), + account_id uuid not null references public.accounts(id) on delete cascade, + name text not null, + description text, + status text not null default 'active' check (status in ('active', 'archived')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table public.projects enable row level security; + +-- ... policies and triggers +``` + +{% alert type="error" title="Always Review Generated Migrations" %} +The diffing tool has [known caveats](https://supabase.com/docs/guides/local-development/declarative-database-schemas#known-caveats). Always review generated migrations before applying them. Check for: +- Destructive operations (DROP statements) +- Missing or incorrect constraints +- Order of operations issues +{% /alert %} + +## Testing Locally + +Apply and test your migration locally before pushing to production: + +```bash +# Stop Supabase if running +pnpm run supabase:web:stop + +# Start with fresh database +pnpm run supabase:web:start + +# Or reset to apply all migrations +pnpm run supabase:web:reset +``` + +### Verify the Schema + +Check that your changes applied correctly: + +```bash +# Open local Supabase Studio +open http://localhost:54323 +``` + +Navigate to **Table Editor** and verify your table exists with the correct columns. + +### Run Database Tests + +If you have pgTAP tests, run them to verify RLS policies: + +```bash +pnpm --filter web supabase:test +``` + +See [Database Tests](/docs/next-supabase-turbo/development/database-tests) for writing tests. + +## Pushing to Production + +After testing locally, push migrations to your remote Supabase instance: + +```bash +# Link to your Supabase project (first time only) +pnpm --filter web supabase link --project-ref your-project-ref + +# Push migrations +pnpm --filter web supabase db push +``` + +### Migration Commands Reference + +| Command | Description | +|---------|-------------| +| `pnpm run supabase:web:start` | Start local Supabase | +| `pnpm run supabase:web:stop` | Stop local Supabase | +| `pnpm run supabase:web:reset` | Reset and apply all migrations | +| `pnpm --filter web supabase:db:diff -f <name>` | Generate migration from schema diff | +| `pnpm --filter web supabase db push` | Push migrations to remote | +| `pnpm --filter web supabase:typegen` | Regenerate TypeScript types | + +## Regenerating TypeScript Types + +After schema changes, regenerate the TypeScript types: + +```bash +pnpm --filter web supabase:typegen +``` + +This updates `packages/supabase/src/database.types.ts` with your new tables and columns. Import types in your code: + +```tsx +import type { Database } from '@kit/supabase/database'; + +type Project = Database['public']['Tables']['projects']['Row']; +type NewProject = Database['public']['Tables']['projects']['Insert']; +``` + +## Common Patterns + +### Adding a Lookup Table + +```sql +-- Status enum as lookup table +create table if not exists public.project_statuses ( + id text primary key, + label text not null, + sort_order integer not null default 0 +); + +insert into public.project_statuses (id, label, sort_order) values + ('active', 'Active', 1), + ('archived', 'Archived', 2), + ('deleted', 'Deleted', 3) +on conflict (id) do nothing; +``` + +### Adding a Junction Table + +```sql +-- Many-to-many relationship +create table if not exists public.project_members ( + project_id uuid not null references public.projects(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'member', + created_at timestamptz not null default now(), + primary key (project_id, user_id) +); +``` + +### Data Migration + +For data transformations, use a separate migration: + +```sql {% title="apps/web/supabase/migrations/20260120000000_backfill_priority.sql" %} +-- Backfill priority based on status +update public.projects +set priority = case + when status = 'active' then 1 + when status = 'archived' then 0 + else 0 +end +where priority is null; +``` + +## Troubleshooting + +**Diff shows no changes**: Ensure your schema file is being loaded. Check file naming (alphabetical order matters). + +**Migration fails on production**: The diff tool may generate invalid SQL. Review and manually fix the migration file. + +**Type mismatch after migration**: Regenerate types with `pnpm --filter web supabase:typegen`. + +**RLS policy errors**: Check that your policies reference valid columns and functions. Test with [database tests](/docs/next-supabase-turbo/development/database-tests). + +## Related Resources + +- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for detailed schema patterns +- [Database Architecture](/docs/next-supabase-turbo/development/database-architecture) for understanding the data model +- [Database Functions](/docs/next-supabase-turbo/development/database-functions) for built-in SQL functions +- [Database Tests](/docs/next-supabase-turbo/development/database-tests) for testing migrations diff --git a/docs/development/permissions-and-roles.mdoc b/docs/development/permissions-and-roles.mdoc new file mode 100644 index 000000000..2a0f06881 --- /dev/null +++ b/docs/development/permissions-and-roles.mdoc @@ -0,0 +1,526 @@ +--- +status: "published" +label: 'RBAC: Roles and Permissions' +title: 'Role-Based Access Control (RBAC) in Next.js Supabase' +description: 'Implement granular permissions with roles, hierarchy levels, and the app_permissions enum. Use has_permission in RLS policies and application code.' +order: 6 +--- + +Makerkit implements RBAC through three components: the `roles` table (defines role names and hierarchy), the `role_permissions` table (maps roles to permissions), and the `app_permissions` enum (lists all available permissions). Use the `has_permission` function in RLS policies and application code for granular access control. + +{% sequence title="RBAC Implementation" description="Set up and use roles and permissions" %} + +[Understand the data model](#rbac-data-model) + +[Add custom permissions](#adding-custom-permissions) + +[Enforce in RLS policies](#using-permissions-in-rls) + +[Check permissions in code](#checking-permissions-in-application-code) + +[Show/hide UI elements](#client-side-permission-checks) + +{% /sequence %} + +## RBAC Data Model + +### The roles Table + +Defines available roles and their hierarchy: + +```sql +create table public.roles ( + name varchar(50) primary key, + hierarchy_level integer not null default 0 +); + +-- Default roles +insert into public.roles (name, hierarchy_level) values + ('owner', 1), + ('member', 2); +``` + +**Hierarchy levels** determine which roles can manage others. Lower numbers indicate higher privilege. Owners (level 1) can manage members (level 2), but members cannot manage owners. + +### The role_permissions Table + +Maps roles to their permissions: + +```sql +create table public.role_permissions ( + id serial primary key, + role varchar(50) references public.roles(name) on delete cascade, + permission app_permissions not null, + unique (role, permission) +); +``` + +### The app_permissions Enum + +Lists all available permissions: + +```sql +create type public.app_permissions as enum( + 'roles.manage', + 'billing.manage', + 'settings.manage', + 'members.manage', + 'invites.manage' +); +``` + +### Default Permission Assignments + +| Role | Permissions | +|------|-------------| +| `owner` | All permissions | +| `member` | `settings.manage`, `invites.manage` | + +## Adding Custom Permissions + +### Step 1: Add to the Enum + +Create a migration to add new permissions: + +```sql {% title="apps/web/supabase/migrations/add_task_permissions.sql" %} +-- Add new permissions to the enum +alter type public.app_permissions add value 'tasks.read'; +alter type public.app_permissions add value 'tasks.write'; +alter type public.app_permissions add value 'tasks.delete'; +commit; +``` + +{% alert type="warning" title="Enum Values Cannot Be Removed" %} +PostgreSQL enum values cannot be removed once added. Plan your permission names carefully. Use a consistent naming pattern like `resource.action`. +{% /alert %} + +### Step 2: Assign to Roles + +```sql +-- Owners get all task permissions +insert into public.role_permissions (role, permission) values + ('owner', 'tasks.read'), + ('owner', 'tasks.write'), + ('owner', 'tasks.delete'); + +-- Members can read and write but not delete +insert into public.role_permissions (role, permission) values + ('member', 'tasks.read'), + ('member', 'tasks.write'); +``` + +### Step 3: Add Custom Roles (Optional) + +```sql +-- Add a new role +insert into public.roles (name, hierarchy_level) values + ('admin', 1); -- Between owner (0) and member (2) + +-- Assign permissions to the new role +insert into public.role_permissions (role, permission) values + ('admin', 'tasks.read'), + ('admin', 'tasks.write'), + ('admin', 'tasks.delete'), + ('admin', 'members.manage'), + ('admin', 'invites.manage'); +``` + +## Using Permissions in RLS + +The `has_permission` function checks if a user has a specific permission on an account. + +### Function Signature + +```sql +public.has_permission( + user_id uuid, + account_id uuid, + permission_name app_permissions +) returns boolean +``` + +### Read Access Policy + +```sql +create policy "Users with tasks.read can view tasks" + on public.tasks + for select + to authenticated + using ( + public.has_permission(auth.uid(), account_id, 'tasks.read'::app_permissions) + ); +``` + +### Write Access Policy + +```sql +create policy "Users with tasks.write can create tasks" + on public.tasks + for insert + to authenticated + with check ( + public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions) + ); +``` + +### Update Policy + +```sql +create policy "Users with tasks.write can update tasks" + on public.tasks + for update + to authenticated + using ( + public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions) + ) + with check ( + public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions) + ); +``` + +### Delete Policy + +```sql +create policy "Users with tasks.delete can delete tasks" + on public.tasks + for delete + to authenticated + using ( + public.has_permission(auth.uid(), account_id, 'tasks.delete'::app_permissions) + ); +``` + +### Complete Example + +Here's a full schema with RLS: + +```sql {% title="apps/web/supabase/schemas/20-tasks.sql" %} +-- Tasks table +create table if not exists public.tasks ( + id uuid primary key default gen_random_uuid(), + account_id uuid not null references public.accounts(id) on delete cascade, + title text not null, + description text, + status text not null default 'pending', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Enable RLS +alter table public.tasks enable row level security; + +-- RLS policies +create policy "tasks_select" on public.tasks + for select to authenticated + using (public.has_permission(auth.uid(), account_id, 'tasks.read'::app_permissions)); + +create policy "tasks_insert" on public.tasks + for insert to authenticated + with check (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions)); + +create policy "tasks_update" on public.tasks + for update to authenticated + using (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions)) + with check (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions)); + +create policy "tasks_delete" on public.tasks + for delete to authenticated + using (public.has_permission(auth.uid(), account_id, 'tasks.delete'::app_permissions)); +``` + +## Checking Permissions in Application Code + +### Server-Side Check (Server Actions) + +```tsx {% title="apps/web/lib/server/tasks/create-task.action.ts" %} +'use server'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import * as z from 'zod'; + +const schema = z.object({ + accountId: z.string().uuid(), + title: z.string().min(1), +}); + +export async function createTask(data: z.infer<typeof schema>) { + const supabase = getSupabaseServerClient(); + + // Get current user + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + throw new Error('Not authenticated'); + } + + // Check permission via RPC + const { data: hasPermission } = await supabase.rpc('has_permission', { + user_id: user.id, + account_id: data.accountId, + permission: 'tasks.write', + }); + + if (!hasPermission) { + throw new Error('You do not have permission to create tasks'); + } + + // Create the task (RLS will also enforce this) + const { data: task, error } = await supabase + .from('tasks') + .insert({ + account_id: data.accountId, + title: data.title, + }) + .select() + .single(); + + if (error) { + throw error; + } + + return task; +} +``` + +### Permission Check Helper + +Create a reusable helper: + +```tsx {% title="apps/web/lib/server/permissions.ts" %} +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function checkPermission( + accountId: string, + permission: string, +): Promise<boolean> { + const supabase = getSupabaseServerClient(); + + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return false; + } + + const { data: hasPermission } = await supabase.rpc('has_permission', { + user_id: user.id, + account_id: accountId, + permission, + }); + + return hasPermission ?? false; +} + +export async function requirePermission( + accountId: string, + permission: string, +): Promise<void> { + const hasPermission = await checkPermission(accountId, permission); + + if (!hasPermission) { + throw new Error(`Permission denied: ${permission}`); + } +} +``` + +Usage: + +```tsx +import { requirePermission } from '~/lib/server/permissions'; + +export async function deleteTask(taskId: string, accountId: string) { + await requirePermission(accountId, 'tasks.delete'); + + // Proceed with deletion +} +``` + +## Client-Side Permission Checks + +The Team Account Workspace loader provides permissions for UI rendering. + +### Loading Permissions + +```tsx {% title="apps/web/app/[locale]/home/[account]/tasks/page.tsx" %} +import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function TasksPage({ params }: Props) { + const { account } = await params; + const workspace = await loadTeamWorkspace(account); + const permissions = workspace.account.permissions; + + // permissions is string[] of permission names the user has + + return ( + <TasksPageClient permissions={permissions} /> + ); +} +``` + +### Conditional UI Rendering + +```tsx {% title="apps/web/app/[locale]/home/[account]/tasks/_components/tasks-page-client.tsx" %} +'use client'; + +interface TasksPageClientProps { + permissions: string[]; +} + +export function TasksPageClient({ permissions }: TasksPageClientProps) { + const canWrite = permissions.includes('tasks.write'); + const canDelete = permissions.includes('tasks.delete'); + + return ( + <div> + <h1>Tasks</h1> + + {canWrite && ( + <Button onClick={openCreateDialog}> + Create Task + </Button> + )} + + <TaskList + onDelete={canDelete ? handleDelete : undefined} + /> + </div> + ); +} +``` + +### Permission Gate Component + +Create a reusable component: + +```tsx {% title="apps/web/components/permission-gate.tsx" %} +'use client'; + +interface PermissionGateProps { + permissions: string[]; + required: string | string[]; + children: React.ReactNode; + fallback?: React.ReactNode; +} + +export function PermissionGate({ + permissions, + required, + children, + fallback = null, +}: PermissionGateProps) { + const requiredArray = Array.isArray(required) ? required : [required]; + const hasPermission = requiredArray.every((p) => permissions.includes(p)); + + if (!hasPermission) { + return fallback; + } + + return children; +} +``` + +Usage: + +```tsx +<PermissionGate permissions={permissions} required="tasks.delete"> + <DeleteButton onClick={handleDelete} /> +</PermissionGate> + +<PermissionGate + permissions={permissions} + required={['tasks.write', 'tasks.delete']} + fallback={<span>Read-only access</span>} +> + <EditControls /> +</PermissionGate> +``` + +### Page-Level Access Control + +```tsx {% title="apps/web/app/[locale]/home/[account]/admin/page.tsx" %} +import { redirect } from 'next/navigation'; + +import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function AdminPage({ params }: Props) { + const { account } = await params; + const workspace = await loadTeamWorkspace(account); + const permissions = workspace.account.permissions; + + if (!permissions.includes('settings.manage')) { + redirect('/home'); + } + + return <AdminDashboard />; +} +``` + +## Permission Naming Conventions + +Use a consistent `resource.action` pattern: + +| Pattern | Examples | +|---------|----------| +| `resource.read` | `tasks.read`, `reports.read` | +| `resource.write` | `tasks.write`, `settings.write` | +| `resource.delete` | `tasks.delete`, `members.delete` | +| `resource.manage` | `billing.manage`, `roles.manage` | + +The `.manage` suffix typically implies all actions on that resource. + +## Testing Permissions + +Test RLS policies with pgTAP: + +```sql {% title="apps/web/supabase/tests/tasks-permissions.test.sql" %} +begin; + +select plan(3); + +-- Create test user and account +select tests.create_supabase_user('test-user'); +select tests.authenticate_as('test-user'); + +-- Get the user's personal account +select set_config('test.account_id', + (select id::text from accounts where primary_owner_user_id = tests.get_supabase_uid('test-user')), + true +); + +-- Test: User with tasks.write can insert +select lives_ok( + $$ + insert into tasks (account_id, title) + values (current_setting('test.account_id')::uuid, 'Test Task') + $$, + 'User with tasks.write permission can create tasks' +); + +-- Test: User without tasks.delete cannot delete +select throws_ok( + $$ + delete from tasks + where account_id = current_setting('test.account_id')::uuid + $$, + 'User without tasks.delete permission cannot delete tasks' +); + +select * from finish(); +rollback; +``` + +See [Database Tests](/docs/next-supabase-turbo/development/database-tests) for more testing patterns. + +## Related Resources + +- [Database Functions](/docs/next-supabase-turbo/development/database-functions) for the `has_permission` function +- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for creating tables with RLS +- [Database Tests](/docs/next-supabase-turbo/development/database-tests) for testing permissions +- [Row Level Security](/docs/next-supabase-turbo/security/row-level-security) for RLS patterns diff --git a/docs/development/seo.mdoc b/docs/development/seo.mdoc new file mode 100644 index 000000000..56be232f5 --- /dev/null +++ b/docs/development/seo.mdoc @@ -0,0 +1,432 @@ +--- +status: "published" +label: "SEO" +title: "SEO Configuration for the Next.js Supabase Starter Kit" +description: "Configure sitemaps, metadata, structured data, and search engine optimization for your Makerkit SaaS application." +order: 10 +--- + +SEO in Makerkit starts with Next.js Metadata API for page-level optimization, an auto-generated sitemap at `/sitemap.xml`, and proper robots.txt configuration. The kit handles technical SEO out of the box, so you can focus on content quality and backlink strategy. + +{% sequence title="SEO Configuration" description="Set up search engine optimization for your SaaS" %} + +[Configure page metadata](#page-metadata) + +[Customize the sitemap](#sitemap-configuration) + +[Add structured data](#structured-data) + +[Submit to Google Search Console](#google-search-console) + +{% /sequence %} + +## Page Metadata + +### Next.js Metadata API + +Use the Next.js Metadata API to set page-level SEO: + +```tsx {% title="apps/web/app/[locale]/(marketing)/pricing/page.tsx" %} +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Pricing | Your SaaS Name', + description: 'Simple, transparent pricing. Start free, upgrade when you need more.', + openGraph: { + title: 'Pricing | Your SaaS Name', + description: 'Simple, transparent pricing for teams of all sizes.', + images: ['/images/og/pricing.png'], + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'Pricing | Your SaaS Name', + description: 'Simple, transparent pricing for teams of all sizes.', + images: ['/images/og/pricing.png'], + }, +}; + +export default function PricingPage() { + // ... +} +``` + +### Dynamic Metadata + +For pages with dynamic content, use `generateMetadata`: + +```tsx {% title="apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx" %} +import type { Metadata } from 'next'; + +import { createCmsClient } from '@kit/cms'; + +interface Props { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const { slug } = await params; + const cms = await createCmsClient(); + const post = await cms.getContentBySlug({ slug, collection: 'posts' }); + + return { + title: `${post.title} | Your SaaS Blog`, + description: post.description, + openGraph: { + title: post.title, + description: post.description, + images: [post.image], + type: 'article', + publishedTime: post.publishedAt, + }, + }; +} +``` + +### Global Metadata + +Set default metadata in your root layout at `apps/web/app/layout.tsx`: + +```tsx {% title="apps/web/app/layout.tsx" %} +import type { Metadata } from 'next'; + +import appConfig from '~/config/app.config'; + +export const metadata: Metadata = { + title: { + default: appConfig.name, + template: `%s | ${appConfig.name}`, + }, + description: appConfig.description, + metadataBase: new URL(appConfig.url), + openGraph: { + type: 'website', + locale: 'en_US', + siteName: appConfig.name, + }, + robots: { + index: true, + follow: true, + }, +}; +``` + +## Sitemap Configuration + +Makerkit auto-generates a sitemap at `/sitemap.xml`. The configuration lives in `apps/web/app/sitemap.xml/route.ts`. + +### Adding Static Pages + +Add new pages to the `getPaths` function: + +```tsx {% title="apps/web/app/sitemap.xml/route.ts" %} +import appConfig from '~/config/app.config'; + +function getPaths() { + const paths = [ + '/', + '/pricing', + '/faq', + '/blog', + '/docs', + '/contact', + '/about', // Add new pages + '/features', + '/privacy-policy', + '/terms-of-service', + '/cookie-policy', + ]; + + return paths.map((path) => ({ + loc: new URL(path, appConfig.url).href, + lastmod: new Date().toISOString(), + })); +} +``` + +### Dynamic Content + +Blog posts and documentation pages are automatically added to the sitemap. The CMS integration handles this: + +```tsx +// Blog posts are added automatically +const posts = await cms.getContentItems({ collection: 'posts' }); + +posts.forEach((post) => { + sitemap.push({ + loc: new URL(`/blog/${post.slug}`, appConfig.url).href, + lastmod: post.updatedAt || post.publishedAt, + }); +}); +``` + +### Excluding Pages + +Exclude pages from the sitemap by not including them in `getPaths()`. For pages that should not be indexed at all, use the `robots` metadata: + +```tsx +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + }, +}; +``` + +## Structured Data + +Add JSON-LD structured data for rich search results. See the [Next.js JSON-LD guide](https://nextjs.org/docs/app/guides/json-ld) for the recommended approach. + +### Organization Schema + +Add to your home page or layout: + +```tsx {% title="apps/web/app/[locale]/(marketing)/page.tsx" %} +// JSON-LD structured data using a script tag + +export default function HomePage() { + return ( + <> + <script + type="application/ld+json" + dangerouslySetInnerHTML={{ + __html: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'Your SaaS Name', + url: 'https://yoursaas.com', + logo: 'https://yoursaas.com/logo.png', + sameAs: [ + 'https://twitter.com/yoursaas', + 'https://github.com/yoursaas', + ], + }), + }} + /> + {/* Page content */} + </> + ); +} +``` + +### Product Schema + +Add to your pricing page: + +```tsx {% title="apps/web/app/[locale]/(marketing)/pricing/page.tsx" %} +<script + type="application/ld+json" + dangerouslySetInnerHTML={{ + __html: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'Your SaaS Name', + applicationCategory: 'BusinessApplication', + offers: { + '@type': 'AggregateOffer', + lowPrice: '0', + highPrice: '99', + priceCurrency: 'USD', + offerCount: 3, + }, + }), + }} +/> +``` + +### FAQ Schema + +Use the Markdoc FAQ node for automatic FAQ schema: + +```markdown +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "How do I get started?", "answer": "Sign up for a free account..."}, + {"question": "Can I cancel anytime?", "answer": "Yes, you can cancel..."} + ] +/%} +``` + +### Article Schema + +Add to blog posts: + +```tsx +<script + type="application/ld+json" + dangerouslySetInnerHTML={{ + __html: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Article', + headline: post.title, + description: post.description, + image: post.image, + datePublished: post.publishedAt, + dateModified: post.updatedAt, + author: { + '@type': 'Person', + name: post.author, + }, + }), + }} +/> +``` + +## Robots.txt + +The robots.txt is generated dynamically at `apps/web/app/robots.ts`: + +```typescript {% title="apps/web/app/robots.ts" %} +import type { MetadataRoute } from 'next'; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: ['/home/', '/admin/', '/api/'], + }, + sitemap: 'https://yoursaas.com/sitemap.xml', + }; +} +``` + +Update the sitemap URL to your production domain. + +## Google Search Console + +### Verification + +1. Go to [Google Search Console](https://search.google.com/search-console) +2. Add your property (URL prefix method) +3. Choose verification method: + - **HTML tag**: Add to your root layout's metadata + - **HTML file**: Upload to `public/` + +```tsx +// HTML tag verification +export const metadata: Metadata = { + verification: { + google: 'your-verification-code', + }, +}; +``` + +### Submit Sitemap + +After verification: + +1. Navigate to **Sitemaps** in Search Console +2. Enter `sitemap.xml` in the input field +3. Click **Submit** + +Google will crawl and index your sitemap within a few days. + +### Monitor Indexing + +Check Search Console regularly for: + +- **Coverage**: Pages indexed vs. excluded +- **Enhancements**: Structured data validation +- **Core Web Vitals**: Performance metrics +- **Mobile Usability**: Mobile-friendly issues + +## SEO Best Practices + +### Content Quality + +Content quality matters more than technical SEO. Focus on: + +- **Helpful content**: Solve problems your customers search for +- **Unique value**: Offer insights competitors don't have +- **Regular updates**: Keep content fresh and accurate +- **Comprehensive coverage**: Answer related questions + +### Keyword Strategy + +| Element | Recommendation | +|---------|----------------| +| Title | Primary keyword near the beginning | +| Description | Include keyword naturally, focus on click-through | +| H1 | One per page, include primary keyword | +| URL | Short, descriptive, include keyword | +| Content | Use variations naturally, don't stuff | + +### Image Optimization + +```tsx +import Image from 'next/image'; + +<Image + src="/images/feature-screenshot.webp" + alt="Dashboard showing project analytics with team activity" + width={1200} + height={630} + priority={isAboveFold} +/> +``` + +- Use WebP format for better compression +- Include descriptive alt text with keywords +- Use descriptive filenames (`project-dashboard.webp` not `img1.webp`) +- Size images appropriately for their display size + +### Internal Linking + +Link between related content: + +```tsx +// In your blog post about authentication +<p> + Learn more about{' '} + <Link href="/docs/authentication/setup"> + setting up authentication + </Link>{' '} + in our documentation. +</p> +``` + +### Page Speed + +Makerkit is optimized for performance out of the box: + +- Next.js automatic code splitting +- Image optimization with `next/image` +- Font optimization with `next/font` +- Static generation for marketing pages + +Check your scores with [PageSpeed Insights](https://pagespeed.web.dev/). + +## Backlinks + +Backlinks remain the strongest ranking factor. Strategies that work: + +| Strategy | Effort | Impact | +|----------|--------|--------| +| Create linkable content (guides, tools, research) | High | High | +| Guest posting on relevant blogs | Medium | Medium | +| Product directories (Product Hunt, etc.) | Low | Medium | +| Open source contributions | Medium | Medium | +| Podcast appearances | Medium | Medium | + +Focus on quality over quantity. One link from a high-authority site beats dozens of low-quality links. + +## Timeline Expectations + +SEO takes time. Typical timelines: + +| Milestone | Timeline | +|-----------|----------| +| Initial indexing | 1-2 weeks | +| Rankings for low-competition terms | 1-3 months | +| Rankings for medium-competition terms | 3-6 months | +| Rankings for high-competition terms | 6-12+ months | + +Keep creating content and building backlinks. Results compound over time. + +## Related Resources + +- [Marketing Pages](/docs/next-supabase-turbo/development/marketing-pages) for building optimized landing pages +- [CMS Setup](/docs/next-supabase-turbo/content/cms) for content marketing +- [App Configuration](/docs/next-supabase-turbo/configuration/application-configuration) for base URL and metadata settings diff --git a/docs/development/writing-data-to-database.mdoc b/docs/development/writing-data-to-database.mdoc new file mode 100644 index 000000000..3196a2e03 --- /dev/null +++ b/docs/development/writing-data-to-database.mdoc @@ -0,0 +1,294 @@ +--- +status: "published" +label: "Writing data to Database" +order: 5 +title: "Learn how to write data to the Supabase database in your Next.js app" +description: "In this page we learn how to write data to the Supabase database in your Next.js app" +--- + +In this page, we will learn how to write data to the Supabase database in your Next.js app. + +{% sequence title="How to write data to the Supabase database" description="In this page we learn how to write data to the Supabase database in your Next.js app" %} + +[Writing a Server Action to Add a Task](#writing-a-server-action-to-add-a-task) + +[Defining a Schema for the Task](#defining-a-schema-for-the-task) + +[Writing the Server Action to Add a Task](#writing-the-server-action-to-add-a-task) + +[Creating a Form to Add a Task](#creating-a-form-to-add-a-task) + +[Using a Dialog component to display the form](#using-a-dialog-component-to-display-the-form) + +{% /sequence %} + + +## Writing a Server Action to Add a Task + +Server Actions are defined by adding `use server` at the top of the function or file. When we define a function as a Server Action, it will be executed on the server-side. + +This is useful for various reasons: +1. By using Server Actions, we can revalidate data fetched through Server Components +2. We can execute server side code just by calling the function from the client side + +In this example, we will write a Server Action to add a task to the database. + +### Defining a Schema for the Task + +We use Zod to validate the data that is passed to the Server Action. This ensures that the data is in the correct format before it is written to the database. + +The convention in Makerkit is to define the schema in a separate file and import it where needed. We use the convention `file.schema.ts` to define the schema. + +```tsx +import * as z from 'zod'; + +export const WriteTaskSchema = z.object({ + title: z.string().min(1), + description: z.string().nullable(), +}); +``` + +### Writing the Server Action to Add a Task + +In this example, we write a Server Action to add a task to the database. We use the `revalidatePath` function to revalidate the `/home` page after the task is added. + +```tsx +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { getLogger } from '@kit/shared/logger'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { authActionClient } from '@kit/next/safe-action'; + +import { WriteTaskSchema } from '~/home/(user)/_lib/schema/write-task.schema'; + +export const addTaskAction = authActionClient + .inputSchema(WriteTaskSchema) + .action(async ({ parsedInput: task, ctx: { user } }) => { + const logger = await getLogger(); + const client = getSupabaseServerClient(); + + logger.info(task, `Adding task...`); + + const { data, error } = await client + .from('tasks') + .insert({ ...task, account_id: user.id }); + + if (error) { + logger.error(error, `Failed to add task`); + + throw new Error(`Failed to add task`); + } + + logger.info(data, `Task added successfully`); + + revalidatePath('/home', 'page'); + }); +``` + +Let's focus on this bit for a second: + +```tsx +const { data, error } = await client + .from('tasks') + .insert({ ...task, account_id: auth.data.id }); +``` + +Do you see the `account_id` field? This is a foreign key that links the task to the user who created it. This is a common pattern in database design. + +Now that we have written the Server Action to add a task, we can call this function from the client side. But we need a form, which we define in the next section. + +### Creating a Form to Add a Task + +We create a form to add a task. The form is a React component that accepts a `SubmitButton` prop and an `onSubmit` prop. + +```tsx +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { + Form, + FormControl, + FormDescription, + 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 { WriteTaskSchema } from '../_lib/schema/write-task.schema'; + +export function TaskForm(props: { + task?: z.infer<typeof WriteTaskSchema>; + onSubmit: (task: z.infer<typeof WriteTaskSchema>) => void; + SubmitButton: React.ComponentType; +}) { + const form = useForm({ + resolver: zodResolver(WriteTaskSchema), + defaultValues: props.task, + }); + + return ( + <Form {...form}> + <form + className={'flex flex-col space-y-4'} + onSubmit={form.handleSubmit(props.onSubmit)} + > + <FormField + render={(item) => { + return ( + <FormItem> + <FormLabel> + <Trans i18nKey={'tasks:taskTitle'} /> + </FormLabel> + + <FormControl> + <Input required {...item.field} /> + </FormControl> + + <FormDescription> + <Trans i18nKey={'tasks:taskTitleDescription'} /> + </FormDescription> + + <FormMessage /> + </FormItem> + ); + }} + name={'title'} + /> + + <FormField + render={(item) => { + return ( + <FormItem> + <FormLabel> + <Trans i18nKey={'tasks:taskDescription'} /> + </FormLabel> + + <FormControl> + <Textarea {...item.field} /> + </FormControl> + + <FormDescription> + <Trans i18nKey={'tasks:taskDescriptionDescription'} /> + </FormDescription> + + <FormMessage /> + </FormItem> + ); + }} + name={'description'} + /> + + <props.SubmitButton /> + </form> + </Form> + ); +} +``` + +### Using a Dialog component to display the form + +We use the Dialog component from the `@kit/ui/dialog` package to display the form in a dialog. The dialog is opened when the user clicks on a button. + +```tsx +'use client'; + +import { useState, useTransition } from 'react'; + +import { PlusCircle } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@kit/ui/dialog'; +import { Trans } from '@kit/ui/trans'; + +import { TaskForm } from '../_components/task-form'; +import { addTaskAction } from '../_lib/server/server-actions'; + +export function NewTaskDialog() { + const [pending, startTransition] = useTransition(); + const [isOpen, setIsOpen] = useState(false); + + return ( + <Dialog open={isOpen} onOpenChange={setIsOpen}> + <DialogTrigger asChild> + <Button> + <PlusCircle className={'mr-1 h-4'} /> + <span> + <Trans i18nKey={'tasks:addNewTask'} /> + </span> + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle> + <Trans i18nKey={'tasks:addNewTask'} /> + </DialogTitle> + + <DialogDescription> + <Trans i18nKey={'tasks:addNewTaskDescription'} /> + </DialogDescription> + </DialogHeader> + + <TaskForm + SubmitButton={() => ( + <Button> + {pending ? ( + <Trans i18nKey={'tasks:addingTask'} /> + ) : ( + <Trans i18nKey={'tasks:addTask'} /> + )} + </Button> + )} + onSubmit={(data) => { + startTransition(async () => { + await addTaskAction(data); + setIsOpen(false); + }); + }} + /> + </DialogContent> + </Dialog> + ); +} +``` + +We can now import `NewTaskDialog` in the `/home` page and display the dialog when the user clicks on a button. + +Let's go back to the home page and add the component right next to the input filter: + +```tsx {18} +<div className={'flex items-center justify-between'}> + <div> + <Heading level={4}> + <Trans i18nKey={'tasks:tasksTabLabel'} defaults={'Tasks'} /> + </Heading> + </div> + + <div className={'flex items-center space-x-2'}> + <form className={'w-full'}> + <Input + name={'query'} + defaultValue={query} + className={'w-full lg:w-[18rem]'} + placeholder={'Search tasks'} + /> + </form> + + <NewTaskDialog /> + </div> +</div> +``` \ No newline at end of file diff --git a/docs/emails/authentication-emails.mdoc b/docs/emails/authentication-emails.mdoc new file mode 100644 index 000000000..50e5d8dbc --- /dev/null +++ b/docs/emails/authentication-emails.mdoc @@ -0,0 +1,242 @@ +--- +status: "published" +title: "Supabase Authentication Email Templates" +label: "Authentication Emails" +description: "Configure Supabase Auth email templates for email verification, password reset, and magic links. Learn about PKCE flow and token hash strategy for cross-browser compatibility." +order: 3 +--- + +Supabase Auth sends emails for authentication flows like email verification, password reset, and magic links. MakerKit provides custom templates that use the token hash strategy, which is required for PKCE (Proof Key for Code Exchange) authentication to work correctly across different browsers and devices. + +## Why Custom Templates Matter + +Supabase's default email templates use a redirect-based flow that can break when users open email links in a different browser than where they started authentication. This happens because: + +1. User starts sign-up in Chrome +2. Clicks verification link in their email client (which may open in Safari) +3. Safari doesn't have the PKCE code verifier stored +4. Authentication fails + +MakerKit's templates solve this by using the **token hash strategy**, which passes the verification token directly to a server-side endpoint that exchanges it for a session. + +{% alert type="warning" title="Required for Production" %} +You must replace Supabase's default email templates with MakerKit's templates. Without this change, users may experience authentication failures when clicking email links from different browsers or email clients. +{% /alert %} + +## Template Types + +Supabase Auth uses six email templates: + +| Template | Trigger | Purpose | +|----------|---------|---------| +| Confirm signup | New user registration | Verify email address | +| Magic link | Passwordless login | One-click sign in | +| Change email | Email update request | Verify new email | +| Reset password | Password reset request | Secure password change | +| Invite user | Admin invites user | Join the platform | +| OTP | Code-based verification | Numeric verification code | + +## Template Location + +MakerKit's pre-built templates are in your project: + +``` +apps/web/supabase/templates/ +├── change-email-address.html +├── confirm-email.html +├── invite-user.html +├── magic-link.html +├── otp.html +└── reset-password.html +``` + +## How the Token Hash Strategy Works + +The templates use this URL format: + +``` +{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&callback={{ .RedirectTo }} +``` + +This flow: + +1. Supabase generates a `token_hash` for the email action +2. User clicks the link in their email +3. Request goes to `/auth/confirm` (a server-side route) +4. Server exchanges the token hash for a session using Supabase Admin API +5. User is authenticated and redirected to the callback URL + +This works regardless of which browser opens the link because the token hash is self-contained. + +## Configuring Templates in Supabase + +### Option 1: Using Supabase Dashboard + +1. Go to your Supabase project dashboard +2. Navigate to **Authentication** → **Email Templates** +3. For each template type, replace the content with the corresponding MakerKit template +4. Save changes + +### Option 2: Using Supabase CLI (Recommended) + +The templates in `apps/web/supabase/templates/` are automatically applied when you run migrations: + +```bash +supabase db push +``` + +Or link and push: + +```bash +supabase link --project-ref your-project-ref +supabase db push +``` + +## Customizing Templates + +To customize the templates with your branding: + +### 1. Clone the Email Starter Repository + +MakerKit provides a separate repository for generating email templates: + +```bash +git clone https://github.com/makerkit/makerkit-emails-starter +cd makerkit-emails-starter +pnpm install +``` + +### 2. Customize the Templates + +The starter uses React Email. Edit the templates in `src/emails/`: + +- Update colors to match your brand +- Add your logo +- Modify copy and messaging +- Adjust layout as needed + +### 3. Export the Templates + +Generate the HTML templates: + +```bash +pnpm build +``` + +### 4. Replace Templates in Your Project + +Copy the generated HTML files to your MakerKit project: + +```bash +cp dist/*.html /path/to/your-app/apps/web/supabase/templates/ +``` + +### 5. Push to Supabase + +```bash +cd /path/to/your-app +supabase db push +``` + +## Template Variables + +Supabase provides these variables in email templates: + +| Variable | Description | Available In | +|----------|-------------|--------------| +| `{{ .SiteURL }}` | Your application URL | All templates | +| `{{ .TokenHash }}` | Verification token | All templates | +| `{{ .RedirectTo }}` | Callback URL after auth | All templates | +| `{{ .Email }}` | User's email address | All templates | +| `{{ .Token }}` | OTP code (6 digits) | OTP template only | +| `{{ .ConfirmationURL }}` | Legacy redirect URL | All templates (not recommended) | + +## Example: Confirm Email Template + +Here's the structure of a confirmation email template: + +```html +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> +</head> +<body> + <h1>Confirm your email</h1> + <p>Click the button below to confirm your email address.</p> + + <a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&callback={{ .RedirectTo }}"> + Confirm Email + </a> + + <p>If you didn't create an account, you can safely ignore this email.</p> +</body> +</html> +``` + +The key is the URL structure: `/auth/confirm?token_hash={{ .TokenHash }}&type=email&callback={{ .RedirectTo }}` + +## Configuring the Auth Confirm Route + +MakerKit includes the server-side route that handles token exchange at `apps/web/app/[locale]/auth/confirm/route.ts` in your project. This route: + +1. Receives the token hash from the email link +2. Verifies the token with Supabase +3. Creates a session +4. Redirects the user to their destination + +You don't need to modify this route unless you have custom requirements. + +## Testing Authentication Emails + +### Local Development + +In local development, emails are captured by Mailpit: + +1. Start your development server: `pnpm dev` +2. Trigger an auth action (sign up, password reset, etc.) +3. Open Mailpit at [http://localhost:54324](http://localhost:54324) +4. Find and click the verification link + +### Production Testing + +Before going live: + +1. Create a test account with a real email address +2. Verify the email arrives and looks correct +3. Click the verification link and confirm it works +4. Test in multiple email clients (Gmail, Outlook, Apple Mail) +5. Test opening links in different browsers + +## Troubleshooting + +### Links Not Working + +If email links fail to authenticate: + +- Verify templates use `token_hash` not `ConfirmationURL` +- Check that `{{ .SiteURL }}` matches your production URL +- Ensure the `/auth/confirm` route is deployed + +### Emails Not Arriving + +If emails don't arrive: + +- Check Supabase's email logs in the dashboard +- Verify your email provider (Supabase's built-in or custom SMTP) is configured +- Check spam folders +- For production, configure a custom SMTP provider in Supabase settings + +### Wrong Redirect URL + +If users are redirected incorrectly: + +- Check the `callback` parameter in your auth calls +- Verify `NEXT_PUBLIC_SITE_URL` is set correctly +- Ensure redirect URLs are in Supabase's allowed list + +## Next Steps + +- [Configure your email provider](/docs/next-supabase-turbo/email-configuration) for transactional emails +- [Create custom email templates](/docs/next-supabase-turbo/email-templates) with React Email +- [Test emails locally](/docs/next-supabase-turbo/emails/inbucket) with Mailpit diff --git a/docs/emails/custom-mailer.mdoc b/docs/emails/custom-mailer.mdoc new file mode 100644 index 000000000..72e1c706b --- /dev/null +++ b/docs/emails/custom-mailer.mdoc @@ -0,0 +1,377 @@ +--- +status: "published" +title: "Creating a Custom Mailer" +label: "Custom Mailer" +description: "Integrate third-party email providers like SendGrid, Postmark, or AWS SES into your Next.js Supabase application by creating a custom mailer implementation." +order: 5 +--- + +MakerKit's mailer system is designed to be extensible. While Nodemailer and Resend cover most use cases, you may need to integrate a different email provider like SendGrid, Postmark, Mailchimp Transactional, or AWS SES. + +This guide shows you how to create a custom mailer that plugs into MakerKit's email system. + +## Mailer Architecture + +The mailer system uses a registry pattern with lazy loading: + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Application │────▶│ Mailer Registry │────▶│ Your Provider │ +│ getMailer() │ │ (lazy loading) │ │ (SendGrid etc) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ +``` + +1. Your code calls `getMailer()` +2. The registry checks `MAILER_PROVIDER` environment variable +3. The matching mailer implementation is loaded and returned +4. You call `sendEmail()` on the returned instance + +## Creating a Custom Mailer + +Let's create a mailer for SendGrid as an example. The same pattern works for any provider. + +### Step 1: Implement the Mailer Class + +Create a new file in the mailers package: + +```tsx {% title="packages/mailers/sendgrid/src/index.ts" %} +import 'server-only'; + +import * as z from 'zod'; +import { Mailer, MailerSchema } from '@kit/mailers-shared'; + +type Config = z.infer<typeof MailerSchema>; + +const SENDGRID_API_KEY = z + .string({ + description: 'SendGrid API key', + required_error: 'SENDGRID_API_KEY environment variable is required', + }) + .parse(process.env.SENDGRID_API_KEY); + +export function createSendGridMailer() { + return new SendGridMailer(); +} + +class SendGridMailer implements Mailer { + async sendEmail(config: Config) { + const body = { + personalizations: [ + { + to: [{ email: config.to }], + }, + ], + from: { email: config.from }, + subject: config.subject, + content: [ + { + type: 'text' in config ? 'text/plain' : 'text/html', + value: 'text' in config ? config.text : config.html, + }, + ], + }; + + const response = await fetch('https://api.sendgrid.com/v3/mail/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${SENDGRID_API_KEY}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`SendGrid error: ${response.status} - ${error}`); + } + + return { success: true }; + } +} +``` + +### Step 2: Create Package Structure + +If creating a separate package (recommended), set up the structure: + +``` +packages/mailers/sendgrid/ +├── src/ +│ └── index.ts +├── package.json +└── tsconfig.json +``` + +**package.json:** + +```json {% title="packages/mailers/sendgrid/package.json" %} +{ + "name": "@kit/sendgrid", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@kit/mailers-shared": "workspace:*", + "server-only": "^0.0.1", + "zod": "catalog:" + } +} +``` + +**tsconfig.json:** + +```json {% title="packages/mailers/sendgrid/tsconfig.json" %} +{ + "extends": "@kit/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} +``` + +### Step 3: Install the Package + +Add the new package as a dependency to the mailers core package: + +```bash +pnpm i "@kit/sendgrid:workspace:*" --filter "@kit/mailers" +``` + +### Step 4: Register the Mailer + +Add your mailer to the registry: + +```tsx {% title="packages/mailers/core/src/registry.ts" %} +import { Mailer } from '@kit/mailers-shared'; +import { createRegistry } from '@kit/shared/registry'; + +import { MailerProvider } from './provider-enum'; + +const mailerRegistry = createRegistry<Mailer, MailerProvider>(); + +// Existing mailers +mailerRegistry.register('nodemailer', async () => { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { createNodemailerService } = await import('@kit/nodemailer'); + return createNodemailerService(); + } else { + throw new Error( + 'Nodemailer is not available on the edge runtime. Please use another mailer.', + ); + } +}); + +mailerRegistry.register('resend', async () => { + const { createResendMailer } = await import('@kit/resend'); + return createResendMailer(); +}); + +// Add your custom mailer +mailerRegistry.register('sendgrid', async () => { + const { createSendGridMailer } = await import('@kit/sendgrid'); + return createSendGridMailer(); +}); + +export { mailerRegistry }; +``` + +### Step 5: Update Provider Enum + +Add your provider to the providers array: + +```tsx {% title="packages/mailers/core/src/provider-enum.ts" %} +import * as z from 'zod'; + +const MAILER_PROVIDERS = [ + 'nodemailer', + 'resend', + 'sendgrid', // Add this +] as const; + +const MAILER_PROVIDER = z + .enum(MAILER_PROVIDERS) + .default('nodemailer') + .parse(process.env.MAILER_PROVIDER); + +export { MAILER_PROVIDER }; + +export type MailerProvider = (typeof MAILER_PROVIDERS)[number]; +``` + +### Step 6: Configure Environment Variables + +Set the environment variable to use your mailer: + +```bash +MAILER_PROVIDER=sendgrid +SENDGRID_API_KEY=SG.your-api-key-here +EMAIL_SENDER=YourApp <noreply@yourdomain.com> +``` + +## Edge Runtime Compatibility + +If your mailer uses HTTP APIs (not SMTP), it can run on edge runtimes. The key requirements: + +1. **No Node.js-specific APIs**: Avoid `fs`, `net`, `crypto` (use Web Crypto instead) +2. **Use fetch**: HTTP requests via `fetch` work everywhere +3. **Import server-only**: Add `import 'server-only'` to prevent client-side usage + +### Checking Runtime Compatibility + +```tsx +mailerRegistry.register('my-mailer', async () => { + // This check is optional but recommended for documentation + if (process.env.NEXT_RUNTIME === 'edge') { + // Edge-compatible path + const { createMyMailer } = await import('@kit/my-mailer'); + return createMyMailer(); + } else { + // Node.js path (can use SMTP, etc.) + const { createMyMailer } = await import('@kit/my-mailer'); + return createMyMailer(); + } +}); +``` + +For Nodemailer (SMTP-based), edge runtime is not supported. For HTTP-based providers like Resend, SendGrid, or Postmark, edge runtime works fine. + +## Example Implementations + +### Postmark + +```tsx +import 'server-only'; +import * as z from 'zod'; +import { Mailer, MailerSchema } from '@kit/mailers-shared'; + +const POSTMARK_API_KEY = z.string().parse(process.env.POSTMARK_API_KEY); + +export function createPostmarkMailer() { + return new PostmarkMailer(); +} + +class PostmarkMailer implements Mailer { + async sendEmail(config: z.infer<typeof MailerSchema>) { + const body = { + From: config.from, + To: config.to, + Subject: config.subject, + ...'text' in config + ? { TextBody: config.text } + : { HtmlBody: config.html }, + }; + + const response = await fetch('https://api.postmarkapp.com/email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Postmark-Server-Token': POSTMARK_API_KEY, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Postmark error: ${response.statusText}`); + } + + return response.json(); + } +} +``` + +### AWS SES (HTTP API) + +```tsx +import 'server-only'; +import * as z from 'zod'; +import { Mailer, MailerSchema } from '@kit/mailers-shared'; + +// Using AWS SDK v3 (modular) +import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'; + +const sesClient = new SESClient({ + region: process.env.AWS_REGION ?? 'us-east-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, +}); + +export function createSESMailer() { + return new SESMailer(); +} + +class SESMailer implements Mailer { + async sendEmail(config: z.infer<typeof MailerSchema>) { + const command = new SendEmailCommand({ + Source: config.from, + Destination: { + ToAddresses: [config.to], + }, + Message: { + Subject: { Data: config.subject }, + Body: 'text' in config + ? { Text: { Data: config.text } } + : { Html: { Data: config.html } }, + }, + }); + + return sesClient.send(command); + } +} +``` + +## Testing Your Custom Mailer + +Test your mailer in isolation before integrating: + +```tsx +// test/sendgrid-mailer.test.ts +import { createSendGridMailer } from '@kit/sendgrid'; + +describe('SendGrid Mailer', () => { + it('sends an email', async () => { + const mailer = createSendGridMailer(); + + // Use a test email or mock the API + await mailer.sendEmail({ + to: 'test@example.com', + from: 'noreply@yourdomain.com', + subject: 'Test Email', + text: 'This is a test email', + }); + }); +}); +``` + +## Quick Integration (Without Separate Package) + +For a faster setup without creating a separate package, add your mailer directly to the core package: + +```tsx {% title="packages/mailers/core/src/sendgrid.ts" %} +import 'server-only'; +import * as z from 'zod'; +import { Mailer, MailerSchema } from '@kit/mailers-shared'; + +// ... implementation +``` + +Then register it: + +```tsx {% title="packages/mailers/core/src/registry.ts" %} +mailerRegistry.register('sendgrid', async () => { + const { createSendGridMailer } = await import('./sendgrid'); + return createSendGridMailer(); +}); +``` + +This approach is faster but puts all mailer code in one package. Use separate packages for cleaner separation. + +## Next Steps + +- [Configure your email provider](/docs/next-supabase-turbo/email-configuration) with environment variables +- [Create email templates](/docs/next-supabase-turbo/email-templates) with React Email +- [Test emails locally](/docs/next-supabase-turbo/emails/inbucket) with Mailpit diff --git a/docs/emails/email-configuration.mdoc b/docs/emails/email-configuration.mdoc new file mode 100644 index 000000000..1853530fa --- /dev/null +++ b/docs/emails/email-configuration.mdoc @@ -0,0 +1,184 @@ +--- +status: "published" +label: "Email Configuration" +description: "Configure Nodemailer (SMTP) or Resend to send transactional emails from your Next.js Supabase application. Learn the difference between MakerKit emails and Supabase Auth emails." +title: "Email Configuration in the Next.js Supabase SaaS Starter Kit" +order: 0 +--- + +MakerKit uses the `@kit/mailers` package to send transactional emails like team invitations and account notifications. You can choose between Nodemailer (any SMTP provider) or Resend (HTTP API). + +## MakerKit vs Supabase Auth Emails + +Before configuring your email provider, understand the two email systems in your application: + +| Email Type | Purpose | Configuration | Examples | +|------------|---------|---------------|----------| +| **MakerKit Emails** | Application transactional emails | Environment variables | Team invitations, account deletion, OTP codes | +| **Supabase Auth Emails** | Authentication flows | Supabase Dashboard | Email verification, password reset, magic links | + +This guide covers MakerKit email configuration. For Supabase Auth emails, see the [Authentication Emails](/docs/next-supabase-turbo/authentication-emails) guide. + +## Choosing a Mailer Provider + +MakerKit supports two mailer implementations: + +### Nodemailer (Default) + +Best for: Most production deployments using any SMTP provider (SendGrid, Mailgun, Amazon SES, etc.) + +- Works with any SMTP server +- Requires Node.js runtime (not compatible with Edge) +- Full control over SMTP configuration + +### Resend + +Best for: Edge runtime deployments, simpler setup, or if you're already using Resend + +- HTTP-based API (Edge compatible) +- No SMTP configuration needed +- Requires Resend account and API key + +## Configuring Nodemailer + +Nodemailer is the default provider. Set these environment variables in `apps/web/.env`: + +```bash +MAILER_PROVIDER=nodemailer + +# SMTP Configuration +EMAIL_HOST=smtp.your-provider.com +EMAIL_PORT=587 +EMAIL_USER=your-smtp-username +EMAIL_PASSWORD=your-smtp-password +EMAIL_TLS=true +EMAIL_SENDER=YourApp <hello@yourapp.com> +``` + +### Environment Variables Explained + +| Variable | Description | Example | +|----------|-------------|---------| +| `EMAIL_HOST` | SMTP server hostname | `smtp.sendgrid.net`, `email-smtp.us-east-1.amazonaws.com` | +| `EMAIL_PORT` | SMTP port (587 for STARTTLS, 465 for SSL) | `587` | +| `EMAIL_USER` | SMTP authentication username | Varies by provider | +| `EMAIL_PASSWORD` | SMTP authentication password or API key | Varies by provider | +| `EMAIL_TLS` | Use secure connection (`true` for SSL on port 465, `false` for STARTTLS on port 587) | `true` | +| `EMAIL_SENDER` | Sender name and email address | `MyApp <noreply@myapp.com>` | + +**Note**: `EMAIL_TLS` maps to Nodemailer's `secure` option. When `true`, the connection uses SSL/TLS from the start (typically port 465). When `false`, the connection starts unencrypted and upgrades via STARTTLS (typically port 587). Most modern providers use port 587 with `EMAIL_TLS=false`. + +### Provider-Specific Configuration + +#### SendGrid + +```bash +EMAIL_HOST=smtp.sendgrid.net +EMAIL_PORT=587 +EMAIL_USER=apikey +EMAIL_PASSWORD=SG.your-api-key-here +EMAIL_TLS=true +EMAIL_SENDER=YourApp <verified-sender@yourdomain.com> +``` + +#### Amazon SES + +```bash +EMAIL_HOST=email-smtp.us-east-1.amazonaws.com +EMAIL_PORT=587 +EMAIL_USER=your-ses-smtp-username +EMAIL_PASSWORD=your-ses-smtp-password +EMAIL_TLS=true +EMAIL_SENDER=YourApp <verified@yourdomain.com> +``` + +#### Mailgun + +```bash +EMAIL_HOST=smtp.mailgun.org +EMAIL_PORT=587 +EMAIL_USER=postmaster@your-domain.mailgun.org +EMAIL_PASSWORD=your-mailgun-password +EMAIL_TLS=true +EMAIL_SENDER=YourApp <noreply@your-domain.mailgun.org> +``` + +### EMAIL_SENDER Format + +The `EMAIL_SENDER` variable accepts two formats: + +```bash +# With display name (recommended) +EMAIL_SENDER=YourApp <hello@yourapp.com> + +# Email only +EMAIL_SENDER=hello@yourapp.com +``` + +{% alert type="warn" title="Verify Your Sender Domain" %} +Most email providers require you to verify your sending domain before emails will be delivered. Check your provider's documentation for domain verification instructions. +{% /alert %} + +## Configuring Resend + +To use Resend instead of Nodemailer: + +```bash +MAILER_PROVIDER=resend +RESEND_API_KEY=re_your-api-key +EMAIL_SENDER=YourApp <hello@yourapp.com> +``` + +### Getting a Resend API Key + +1. Create an account at [resend.com](https://resend.com) +2. Add and verify your sending domain +3. Generate an API key from the dashboard +4. Add the key to your environment variables + +{% alert type="default" title="Edge Runtime Compatible" %} +Resend uses HTTP requests instead of SMTP, making it compatible with Vercel Edge Functions and Cloudflare Workers. If you deploy to edge runtimes, Resend is the recommended choice. +{% /alert %} + +## Verifying Your Configuration + +After configuring your email provider, test it by triggering an email in your application: + +1. Start your development server: `pnpm dev` +2. Sign up a new user and invite them to a team +3. Check that the invitation email arrives + +For local development, emails are captured by Mailpit at [http://localhost:54324](http://localhost:54324). See the [Local Development](/docs/next-supabase-turbo/emails/inbucket) guide for details. + +## Common Configuration Errors + +### Connection Timeout + +If emails fail with connection timeout errors: + +- Verify `EMAIL_HOST` and `EMAIL_PORT` are correct +- Check if your hosting provider blocks outbound SMTP (port 25, 465, or 587) +- Some providers like Vercel block raw SMTP; use Resend instead + +### Authentication Failed + +If you see authentication errors: + +- Double-check `EMAIL_USER` and `EMAIL_PASSWORD` +- Some providers use API keys as passwords (e.g., SendGrid uses `apikey` as username) +- Ensure your credentials have sending permissions + +### Emails Not Delivered + +If emails send but don't arrive: + +- Verify your sender domain is authenticated (SPF, DKIM, DMARC) +- Check the spam folder +- Review your email provider's delivery logs +- Ensure `EMAIL_SENDER` uses a verified email address + +## Next Steps + +- [Send your first email](/docs/next-supabase-turbo/sending-emails) using the mailer API +- [Create custom email templates](/docs/next-supabase-turbo/email-templates) with React Email +- [Configure Supabase Auth emails](/docs/next-supabase-turbo/authentication-emails) for verification flows diff --git a/docs/emails/email-templates.mdoc b/docs/emails/email-templates.mdoc new file mode 100644 index 000000000..386d0bea5 --- /dev/null +++ b/docs/emails/email-templates.mdoc @@ -0,0 +1,350 @@ +--- +status: "published" +label: "Email Templates" +description: "Create branded email templates with React Email in your Next.js Supabase application. Learn the template architecture, i18n support, and how to build custom templates." +title: "Email Templates in the Next.js Supabase SaaS Starter Kit" +order: 2 +--- + +MakerKit uses [React Email](https://react.email) to create type-safe, responsive email templates. Templates are stored in the `@kit/email-templates` package and support internationalization out of the box. + +## Template Architecture + +The email templates package is organized as follows: + +``` +packages/email-templates/ +├── src/ +│ ├── components/ # Reusable email components +│ │ ├── body-style.tsx +│ │ ├── content.tsx +│ │ ├── cta-button.tsx +│ │ ├── footer.tsx +│ │ ├── header.tsx +│ │ ├── heading.tsx +│ │ └── wrapper.tsx +│ ├── emails/ # Email templates +│ │ ├── account-delete.email.tsx +│ │ ├── invite.email.tsx +│ │ └── otp.email.tsx +│ ├── lib/ +│ │ └── i18n.ts # i18n initialization +│ └── locales/ # Translation files +│ └── en/ +│ ├── account-delete-email.json +│ ├── invite-email.json +│ └── otp-email.json +``` + +## Built-in Templates + +MakerKit includes three email templates: + +| Template | Function | Purpose | +|----------|----------|---------| +| Team Invitation | `renderInviteEmail` | Invite users to join a team | +| Account Deletion | `renderAccountDeleteEmail` | Confirm account deletion | +| OTP Code | `renderOtpEmail` | Send one-time password codes | + +## Using Templates + +Each template exports an async render function that returns HTML and a subject line: + +```tsx +import { getMailer } from '@kit/mailers'; +import { renderInviteEmail } from '@kit/email-templates'; + +async function sendInvitation() { + const { html, subject } = await renderInviteEmail({ + teamName: 'Acme Corp', + teamLogo: 'https://example.com/logo.png', // optional + inviter: 'John Doe', // can be undefined if inviter is unknown + invitedUserEmail: 'jane@example.com', + link: 'https://app.example.com/invite/abc123', + productName: 'Your App', + language: 'en', // optional, defaults to NEXT_PUBLIC_DEFAULT_LOCALE + }); + + const mailer = await getMailer(); + + await mailer.sendEmail({ + to: 'jane@example.com', + from: process.env.EMAIL_SENDER!, + subject, + html, + }); +} +``` + +## Creating Custom Templates + +To create a new email template: + +### 1. Create the Template File + +Create a new file in `packages/email-templates/src/emails/`: + +```tsx {% title="packages/email-templates/src/emails/welcome.email.tsx" %} +import { + Body, + Head, + Html, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import { EmailContent } from '../components/content'; +import { CtaButton } from '../components/cta-button'; +import { EmailFooter } from '../components/footer'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +interface Props { + userName: string; + productName: string; + dashboardUrl: string; + language?: string; +} + +export async function renderWelcomeEmail(props: Props) { + const namespace = 'welcome-email'; + + const { t } = await initializeEmailI18n({ + language: props.language, + namespace, + }); + + const previewText = t('previewText', { + productName: props.productName, + }); + + const subject = t('subject', { + productName: props.productName, + }); + + const html = await render( + <Html> + <Head> + <BodyStyle /> + </Head> + + <Preview>{previewText}</Preview> + + <Tailwind> + <Body> + <EmailWrapper> + <EmailHeader> + <EmailHeading> + {t('heading', { productName: props.productName })} + </EmailHeading> + </EmailHeader> + + <EmailContent> + <Text className="text-[16px] leading-[24px] text-[#242424]"> + {t('hello', { userName: props.userName })} + </Text> + + <Text className="text-[16px] leading-[24px] text-[#242424]"> + {t('mainText')} + </Text> + + <CtaButton href={props.dashboardUrl}> + {t('ctaButton')} + </CtaButton> + </EmailContent> + + <EmailFooter>{props.productName}</EmailFooter> + </EmailWrapper> + </Body> + </Tailwind> + </Html>, + ); + + return { + html, + subject, + }; +} +``` + +### 2. Create Translation File + +Add a translation file in `packages/email-templates/src/locales/en/`: + +```json {% title="packages/email-templates/src/locales/en/welcome-email.json" %} +{ + "subject": "Welcome to {productName}", + "previewText": "Welcome to {productName} - Let's get started", + "heading": "Welcome to {productName}", + "hello": "Hi {userName},", + "mainText": "Thanks for signing up! We're excited to have you on board. Click the button below to access your dashboard and get started.", + "ctaButton": "Go to Dashboard" +} +``` + +### 3. Export the Template + +Add the export to the package's index file: + +```tsx {% title="packages/email-templates/src/index.ts" %} +export { renderInviteEmail } from './emails/invite.email'; +export { renderAccountDeleteEmail } from './emails/account-delete.email'; +export { renderOtpEmail } from './emails/otp.email'; +export { renderWelcomeEmail } from './emails/welcome.email'; // Add this +``` + +### 4. Use the Template + +```tsx +import { getMailer } from '@kit/mailers'; +import { renderWelcomeEmail } from '@kit/email-templates'; + +async function sendWelcome(user: { email: string; name: string }) { + const { html, subject } = await renderWelcomeEmail({ + userName: user.name, + productName: 'Your App', + dashboardUrl: 'https://app.example.com/dashboard', + }); + + const mailer = await getMailer(); + + await mailer.sendEmail({ + to: user.email, + from: process.env.EMAIL_SENDER!, + subject, + html, + }); +} +``` + +## Reusable Components + +MakerKit provides styled components for consistent email design: + +### EmailWrapper + +The outer container with proper styling: + +```tsx +<EmailWrapper> + {/* Email content */} +</EmailWrapper> +``` + +### EmailHeader and EmailHeading + +Header section with title: + +```tsx +<EmailHeader> + <EmailHeading>Your Email Title</EmailHeading> +</EmailHeader> +``` + +### EmailContent + +Main content area with white background: + +```tsx +<EmailContent> + <Text>Your email body text here.</Text> +</EmailContent> +``` + +### CtaButton + +Call-to-action button: + +```tsx +<CtaButton href="https://example.com/action"> + Click Here +</CtaButton> +``` + +### EmailFooter + +Footer with product name: + +```tsx +<EmailFooter>Your Product Name</EmailFooter> +``` + +## Internationalization + +Templates support multiple languages through the i18n system. The language is determined by: + +1. The `language` prop passed to the render function +2. Falls back to `'en'` if no language specified + +### Adding a New Language + +Create a new locale folder: + +``` +packages/email-templates/src/locales/es/ +``` + +Copy and translate the JSON files: + +```json {% title="packages/email-templates/src/locales/es/invite-email.json" %} +{ + "subject": "Te han invitado a unirte a {teamName}", + "heading": "Únete a {teamName} en {productName}", + "hello": "Hola {invitedUserEmail},", + "mainText": "<strong>{inviter}</strong> te ha invitado a unirte al equipo <strong>{teamName}</strong> en <strong>{productName}</strong>.", + "joinTeam": "Unirse a {teamName}", + "copyPasteLink": "O copia y pega este enlace en tu navegador:", + "invitationIntendedFor": "Esta invitación fue enviada a {invitedUserEmail}." +} +``` + +Pass the language when rendering: + +```tsx +const { html, subject } = await renderInviteEmail({ + // ... other props + language: 'es', +}); +``` + +## Styling with Tailwind + +React Email supports Tailwind CSS classes. The `<Tailwind>` wrapper enables Tailwind styling: + +```tsx +<Tailwind> + <Body> + <Text className="text-[16px] font-bold text-blue-600"> + Styled text + </Text> + </Body> +</Tailwind> +``` + +Note that email clients have limited CSS support. Stick to basic styles: + +- Font sizes and weights +- Colors +- Padding and margins +- Basic flexbox (limited support) + +Avoid: +- CSS Grid +- Complex transforms +- CSS variables +- Advanced selectors + +## Testing Templates + +Please use the Dev Tool to see how emails look like. + +## Next Steps + +- [Send emails](/docs/next-supabase-turbo/sending-emails) using your templates +- [Configure Supabase Auth emails](/docs/next-supabase-turbo/authentication-emails) with custom templates +- [Test emails locally](/docs/next-supabase-turbo/emails/inbucket) with Mailpit diff --git a/docs/emails/inbucket.mdoc b/docs/emails/inbucket.mdoc new file mode 100644 index 000000000..417e1ad3d --- /dev/null +++ b/docs/emails/inbucket.mdoc @@ -0,0 +1,188 @@ +--- +status: "published" +title: "Local Email Development with Mailpit" +label: "Local Development" +description: "Test email functionality locally using Mailpit. Capture authentication emails, team invitations, and transactional emails without sending real messages." +order: 4 +--- + +When developing locally, Supabase automatically runs [Mailpit](https://mailpit.axllent.org) (or InBucket in older versions) to capture all emails. This lets you test email flows without configuring an email provider or sending real emails. + +## Accessing Mailpit + +With Supabase running locally, open Mailpit at: + +``` +http://localhost:54324 +``` + +All emails sent during development appear here, including: + +- Supabase Auth emails (verification, password reset, magic links) +- MakerKit transactional emails (team invitations, account notifications) +- Any custom emails you send via the mailer + +## How It Works + +The local Supabase setup configures both Supabase Auth and MakerKit to use the local SMTP server: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Your App │────▶│ Local SMTP │────▶│ Mailpit UI │ +│ (Auth + Mailer)│ │ (port 54325) │ │ (port 54324) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +The default development environment variables in `apps/web/.env.development` point to this local SMTP: + +```bash +EMAIL_HOST=127.0.0.1 +EMAIL_PORT=54325 +EMAIL_USER= +EMAIL_PASSWORD= +EMAIL_TLS=false +EMAIL_SENDER=test@makerkit.dev +``` + +## Testing Common Flows + +### Email Verification + +1. Start your dev server: `pnpm dev` +2. Sign up with any email address (e.g., `test@example.com`) +3. Open [http://localhost:54324](http://localhost:54324) +4. Find the verification email +5. Click the verification link + +### Password Reset + +1. Go to the sign-in page +2. Click "Forgot password" +3. Enter any email address +4. Check Mailpit for the reset email +5. Click the reset link to set a new password + +### Magic Link Login + +1. Go to the sign-in page +2. Enter an email and request a magic link +3. Check Mailpit for the magic link email +4. Click the link to sign in + +### Team Invitations + +1. Sign in and navigate to team settings +2. Invite a new member by email +3. Check Mailpit for the invitation email +4. Use the invitation link to accept + +## Using a Real Email Provider Locally + +If you need to test with a real email provider during development (e.g., to test email rendering in actual clients), override the development environment variables: + +```bash {% title="apps/web/.env.development.local" %} +# Override to use Resend locally +MAILER_PROVIDER=resend +RESEND_API_KEY=re_your-test-key +EMAIL_SENDER=YourApp <test@yourdomain.com> +``` + +Or for SMTP: + +```bash {% title="apps/web/.env.development.local" %} +# Override to use real SMTP locally +EMAIL_HOST=smtp.your-provider.com +EMAIL_PORT=587 +EMAIL_USER=your-username +EMAIL_PASSWORD=your-password +EMAIL_TLS=true +EMAIL_SENDER=YourApp <test@yourdomain.com> +``` + +{% alert type="default" title="Supabase Auth Emails" %} +Even with a custom email provider, Supabase Auth emails (verification, password reset) still go through Mailpit. To test Supabase Auth emails with a real provider, you need to configure SMTP in the Supabase dashboard. +{% /alert %} + +## Debugging Email Issues + +### Emails Not Appearing in Mailpit + +If emails don't show up: + +1. **Verify Supabase is running**: Check `supabase status` +2. **Check the port**: Mailpit runs on port 54324, SMTP on 54325 +3. **Check environment variables**: Ensure `EMAIL_HOST=127.0.0.1` and `EMAIL_PORT=54325` +4. **Check server logs**: Look for SMTP connection errors in your terminal + +### Wrong Email Content + +If emails appear but content is wrong: + +1. **Check template rendering**: Templates are rendered at send time +2. **Verify template data**: Log the data passed to `renderXxxEmail()` functions +3. **Check i18n**: Ensure translation files exist for your locale + +### Links Not Working + +If email links don't work when clicked: + +1. **Check `NEXT_PUBLIC_SITE_URL`**: Should be `http://localhost:3000` for local dev +2. **Verify route exists**: The link destination route must be implemented +3. **Check token handling**: For auth emails, ensure `/auth/confirm` route works + +## Mailpit Features + +Mailpit provides useful features for testing: + +### Search and Filter + +Filter emails by: +- Sender address +- Recipient address +- Subject line +- Date range + +### View HTML and Plain Text + +Toggle between: +- HTML rendered view +- Plain text view +- Raw source + +### Check Headers + +Inspect email headers for: +- Content-Type +- MIME structure +- Custom headers + +### API Access + +Mailpit has an API for programmatic access: + +```bash +# List all messages +curl http://localhost:54324/api/v1/messages + +# Get specific message +curl http://localhost:54324/api/v1/message/{id} + +# Delete all messages +curl -X DELETE http://localhost:54324/api/v1/messages +``` + +## Production Checklist + +Before deploying, ensure you've: + +1. **Configured a production email provider**: Set `MAILER_PROVIDER`, API keys, and SMTP credentials +2. **Verified sender domain**: Set up SPF, DKIM, and DMARC records +3. **Updated Supabase Auth templates**: Replace default templates with token-hash versions +4. **Tested real email delivery**: Send test emails to verify deliverability +5. **Set up monitoring**: Configure alerts for email delivery failures + +## Next Steps + +- [Configure your production email provider](/docs/next-supabase-turbo/email-configuration) +- [Set up Supabase Auth email templates](/docs/next-supabase-turbo/authentication-emails) +- [Create custom email templates](/docs/next-supabase-turbo/email-templates) with React Email diff --git a/docs/emails/sending-emails.mdoc b/docs/emails/sending-emails.mdoc new file mode 100644 index 000000000..a8506bbcd --- /dev/null +++ b/docs/emails/sending-emails.mdoc @@ -0,0 +1,273 @@ +--- +status: "published" +label: "Sending Emails" +description: "Send transactional emails from your Next.js Supabase application using the MakerKit mailer API. Learn the email schema, error handling, and best practices." +title: "Sending Emails in the Next.js Supabase SaaS Starter Kit" +order: 1 +--- + +The `@kit/mailers` package provides a simple, provider-agnostic API for sending emails. Use it in Server Actions, API routes, or any server-side code to send transactional emails. + +## Basic Usage + +Import `getMailer` and call `sendEmail` with your email data: + +```tsx +import { getMailer } from '@kit/mailers'; + +async function sendWelcomeEmail(userEmail: string) { + const mailer = await getMailer(); + + await mailer.sendEmail({ + to: userEmail, + from: process.env.EMAIL_SENDER!, + subject: 'Welcome to our platform', + text: 'Thanks for signing up! We are excited to have you.', + }); +} +``` + +The `getMailer` function returns the configured mailer instance (Nodemailer or Resend) based on your `MAILER_PROVIDER` environment variable. + +## Email Schema + +The `sendEmail` method accepts an object validated by this Zod schema: + +```tsx +// Simplified representation of the schema +type EmailData = { + to: string; // Recipient email (must be valid email format) + from: string; // Sender (e.g., "App Name <noreply@app.com>") + subject: string; // Email subject line +} & ( + | { text: string } // Plain text body + | { html: string } // HTML body +); +``` + +You must provide **exactly one** of `text` or `html`. This is a discriminated union, not optional fields. Providing both properties or neither will cause a validation error at runtime. + +## Sending HTML Emails + +For rich email content, use the `html` property: + +```tsx +import { getMailer } from '@kit/mailers'; + +async function sendHtmlEmail(to: string) { + const mailer = await getMailer(); + + await mailer.sendEmail({ + to, + from: process.env.EMAIL_SENDER!, + subject: 'Your weekly summary', + html: ` + <h1>Weekly Summary</h1> + <p>Here's what happened this week:</p> + <ul> + <li>5 new team members joined</li> + <li>12 tasks completed</li> + </ul> + `, + }); +} +``` + +For complex HTML emails, use [React Email templates](/docs/next-supabase-turbo/email-templates) instead of inline HTML strings. + +## Using Email Templates + +MakerKit includes pre-built email templates in the `@kit/email-templates` package. These templates use React Email and support internationalization: + +```tsx +import { getMailer } from '@kit/mailers'; +import { renderInviteEmail } from '@kit/email-templates'; + +async function sendTeamInvitation(params: { + invitedEmail: string; + teamName: string; + inviterName: string; + inviteLink: string; +}) { + const mailer = await getMailer(); + + // Render the React Email template to HTML + const { html, subject } = await renderInviteEmail({ + teamName: params.teamName, + inviter: params.inviterName, + invitedUserEmail: params.invitedEmail, + link: params.inviteLink, + productName: 'Your App Name', + }); + + await mailer.sendEmail({ + to: params.invitedEmail, + from: process.env.EMAIL_SENDER!, + subject, + html, + }); +} +``` + +See the [Email Templates guide](/docs/next-supabase-turbo/email-templates) for creating custom templates. + +## Error Handling + +The `sendEmail` method returns a Promise that rejects on failure. Always wrap email sending in try-catch: + +```tsx +import { getMailer } from '@kit/mailers'; + +async function sendEmailSafely(to: string, subject: string, text: string) { + try { + const mailer = await getMailer(); + + await mailer.sendEmail({ + to, + from: process.env.EMAIL_SENDER!, + subject, + text, + }); + + return { success: true }; + } catch (error) { + console.error('Failed to send email:', error); + + // Log to your error tracking service + // Sentry.captureException(error); + + return { success: false, error: 'Failed to send email' }; + } +} +``` + +### Common Error Causes + +| Error | Cause | Solution | +|-------|-------|----------| +| Validation error | Invalid email format or missing fields | Check `to` is a valid email, ensure `text` or `html` is provided | +| Authentication failed | Wrong SMTP credentials | Verify `EMAIL_USER` and `EMAIL_PASSWORD` | +| Connection refused | SMTP server unreachable | Check `EMAIL_HOST` and `EMAIL_PORT`, verify network access | +| Rate limited | Too many emails sent | Implement rate limiting, use a queue for bulk sends | + +## Using in Server Actions + +Email sending works in Next.js Server Actions: + +```tsx {% title="app/actions/send-notification.ts" %} +'use server'; + +import { getMailer } from '@kit/mailers'; + +export async function sendNotificationAction(formData: FormData) { + const email = formData.get('email') as string; + const message = formData.get('message') as string; + + const mailer = await getMailer(); + + await mailer.sendEmail({ + to: email, + from: process.env.EMAIL_SENDER!, + subject: 'New notification', + text: message, + }); + + return { success: true }; +} +``` + +## Using in API Routes + +For webhook handlers or external integrations: + +```tsx {% title="app/api/webhooks/send-email/route.ts" %} +import { NextResponse } from 'next/server'; +import { getMailer } from '@kit/mailers'; + +export async function POST(request: Request) { + const { to, subject, message } = await request.json(); + + try { + const mailer = await getMailer(); + + await mailer.sendEmail({ + to, + from: process.env.EMAIL_SENDER!, + subject, + text: message, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: 'Failed to send email' }, + { status: 500 } + ); + } +} +``` + +## Best Practices + +### Use Environment Variables for Sender + +Never hardcode the sender email: + +```tsx +// Good +from: process.env.EMAIL_SENDER! + +// Bad +from: 'noreply@example.com' +``` + +### Validate Recipient Emails + +Before sending, validate that the recipient email exists in your system: + +```tsx +import { getMailer } from '@kit/mailers'; + +async function sendToUser(userId: string, subject: string, text: string) { + // Fetch user from database first + const user = await getUserById(userId); + + if (!user?.email) { + throw new Error('User has no email address'); + } + + const mailer = await getMailer(); + + await mailer.sendEmail({ + to: user.email, + from: process.env.EMAIL_SENDER!, + subject, + text, + }); +} +``` + +### Queue Bulk Emails + +For sending many emails, use a background job queue to avoid timeouts and handle retries: + +```tsx +// Instead of this: +for (const user of users) { + await sendEmail(user.email); // Slow, no retry handling +} + +// Use a queue like Trigger.dev, Inngest, or BullMQ +await emailQueue.addBulk( + users.map(user => ({ + name: 'send-email', + data: { email: user.email, template: 'weekly-digest' }, + })) +); +``` + +## Next Steps + +- [Create custom email templates](/docs/next-supabase-turbo/email-templates) with React Email +- [Build a custom mailer](/docs/next-supabase-turbo/custom-mailer) for other providers +- [Test emails locally](/docs/next-supabase-turbo/emails/inbucket) with Mailpit diff --git a/docs/going-to-production/authentication-emails.mdoc b/docs/going-to-production/authentication-emails.mdoc new file mode 100644 index 000000000..b0486c861 --- /dev/null +++ b/docs/going-to-production/authentication-emails.mdoc @@ -0,0 +1,309 @@ +--- +status: "published" +title: "Configure Supabase Authentication Email Templates" +label: "Authentication Emails" +description: "Configure custom authentication email templates for your MakerKit application. Fix PKCE issues and customize branding for confirmation, magic link, and password reset emails." +order: 4 +--- + +MakerKit's email templates use token_hash URLs instead of Supabase's default PKCE flow, fixing the common issue where users can't authenticate when clicking email links on different devices. This is required for reliable production authentication since many users check email on mobile but sign up on desktop. + +Copy MakerKit's templates from `apps/web/supabase/templates/` to your Supabase Dashboard to enable cross-browser authentication. + +## Why Custom Email Templates Matter + +### The PKCE Problem + +Supabase uses PKCE (Proof Key for Code Exchange) for secure authentication. Here's how it works: + +1. User signs up in Chrome on their laptop +2. Supabase stores a PKCE verifier in Chrome's session +3. User receives confirmation email on their phone +4. User clicks link, opens in Safari +5. **Authentication fails** because Safari doesn't have the PKCE verifier + +This affects: +- Email confirmations +- Magic link login +- Password reset flows +- Email change confirmations + +### The Solution + +MakerKit's email templates use **token hash URLs** instead of PKCE. This approach: + +1. Includes authentication tokens directly in the URL +2. Works regardless of which browser/device opens the link +3. Maintains security through server-side verification + +--- + +## Step 1: Locate MakerKit Templates + +MakerKit provides pre-designed email templates in your project: + +``` +apps/web/supabase/templates/ +├── confirm-email.html # Email confirmation +├── magic-link.html # Magic link login +├── reset-password.html # Password reset +├── change-email-address.html # Email change confirmation +├── invite-user.html # Team invitation +└── otp.html # One-time password +``` + +These templates: +- Use the token hash URL strategy +- Have modern, clean designs +- Are customizable with your branding + +--- + +## Step 2: Update Templates in Supabase + +### Navigate to Email Templates + +1. Open your [Supabase Dashboard](https://supabase.com/dashboard) +2. Select your project +3. Go to **Authentication > Email Templates** + +### Update Each Template + +For each email type, copy the corresponding template from your MakerKit project and paste it into Supabase. + +#### Confirm Signup Email + +The confirmation email uses this URL format: + +```html +<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"> + Confirm your email +</a> +``` + +Key elements: +- `{{ .SiteURL }}`: Your application URL +- `{{ .TokenHash }}`: Secure token for verification +- `type=email`: Specifies the confirmation type + +#### Magic Link Email + +```html +<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink"> + Sign in to your account +</a> +``` + +#### Password Recovery Email + +```html +<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery"> + Reset your password +</a> +``` + +#### Email Change Email + +```html +<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change"> + Confirm email change +</a> +``` + +--- + +## Step 3: Customize Templates + +### Branding Elements + +Update these elements in each template: + +```html +<!-- Logo --> +<img src="https://yourdomain.com/logo.png" alt="Your App" /> + +<!-- Company name --> +<p>Thanks for signing up for <strong>Your App Name</strong>!</p> + +<!-- Footer --> +<p>© 2026 Your Company Name. All rights reserved.</p> +``` + +### Email Styling + +MakerKit templates use inline CSS for email client compatibility: + +```html +<div style=" + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + max-width: 600px; + margin: 0 auto; + padding: 20px; +"> + <!-- Content --> +</div> +``` + +### Template Variables + +Supabase provides these variables: + +| Variable | Description | +|----------|-------------| +| `{{ .SiteURL }}` | Your Site URL from Supabase settings | +| `{{ .TokenHash }}` | Secure authentication token | +| `{{ .Email }}` | User's email address | +| `{{ .ConfirmationURL }}` | Full confirmation URL (PKCE-based, avoid) | + +{% alert type="warning" title="Avoid ConfirmationURL" %} +Don't use `{{ .ConfirmationURL }}` as it uses the PKCE flow that causes cross-browser issues. Use the token hash approach instead. +{% /alert %} + +--- + +## Step 4: Advanced Customization + +### Using the Email Starter Repository + +For more advanced customization, use MakerKit's email starter: + +1. Clone the repository: + - Run the following command: `git clone https://github.com/makerkit/makerkit-emails-starter` +2. Install dependencies: + - Run the following command: `cd makerkit-emails-starter && npm install` +3. Customize templates using [React Email](https://react.email/) +4. Export templates: + - Run the following command: `npm run export` +5. Copy exported HTML to Supabase Dashboard + - Copy the exported HTML files to the `apps/web/supabase/templates/` folder in your Supabase Dashboard. + +### Benefits of React Email + +- **Component-based**: Reuse header, footer, and button components +- **Preview**: Live preview while developing +- **TypeScript**: Type-safe email templates +- **Responsive**: Built-in responsive design utilities + +--- + +## Template Reference + +### Minimal Confirmation Template + +```html +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> +</head> +<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px;"> + <div style="max-width: 600px; margin: 0 auto;"> + <h1 style="color: #333;">Confirm your email</h1> + <p style="color: #666; line-height: 1.6;"> + Thanks for signing up! Click the button below to confirm your email address. + </p> + <a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email" + style="display: inline-block; background: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;"> + Confirm Email + </a> + <p style="color: #999; font-size: 14px;"> + If you didn't sign up, you can ignore this email. + </p> + </div> +</body> +</html> +``` + +### Minimal Magic Link Template + +```html +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> +</head> +<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px;"> + <div style="max-width: 600px; margin: 0 auto;"> + <h1 style="color: #333;">Sign in to Your App</h1> + <p style="color: #666; line-height: 1.6;"> + Click the button below to sign in. This link expires in 1 hour. + </p> + <a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink" + style="display: inline-block; background: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;"> + Sign In + </a> + <p style="color: #999; font-size: 14px;"> + If you didn't request this, you can ignore this email. + </p> + </div> +</body> +</html> +``` + +--- + +## Troubleshooting + +### "Invalid token" errors + +1. Verify you're using `token_hash` not `confirmation_url` +2. Check the URL includes the correct `type` parameter +3. Ensure `{{ .SiteURL }}` matches your Supabase Site URL setting + +### Emails going to spam + +1. Configure custom SMTP in Supabase +2. Set up SPF, DKIM, and DMARC records for your domain +3. Use a reputable email provider (Resend, SendGrid, Postmark) + +### Template not updating + +1. Clear browser cache and try again +2. Wait a few minutes for changes to propagate +3. Test with a fresh email address + +### Links not working + +1. Verify Site URL is correctly set in Supabase +2. Check your application has the `/auth/confirm` route +3. Ensure your app is deployed and accessible + +--- + +## Testing Email Templates + +### Test in Development + +1. Use [Inbucket](http://localhost:54324) locally (included with Supabase local development) +2. Sign up with a test email +3. Check Inbucket for the email +4. Verify the link works correctly + +### Test in Production + +1. Sign up with a real email address +2. Check the email formatting +3. Click the link from a different device/browser +4. Verify successful authentication + +--- + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Which email templates do I need to update?", "answer": "Update all authentication-related templates: Confirm signup, Magic Link, Reset Password, and Change Email Address. MakerKit provides all of these in apps/web/supabase/templates/. Copy each one to the corresponding template in Supabase Dashboard."}, + {"question": "How do I test email templates?", "answer": "Use Supabase's local development with Inbucket (http://localhost:54324). Sign up with a test email, check Inbucket for the email, verify formatting, and click links to test the full flow. For production, test with a real email address before launch."}, + {"question": "Can I customize the email design?", "answer": "Yes. The templates in apps/web/supabase/templates/ are starting points. Update colors, logos, copy, and layout. Use inline CSS for email client compatibility. For advanced customization, use the MakerKit emails starter repo with React Email for component-based templates."}, + {"question": "Do I need to update templates for both local and production?", "answer": "Templates in your codebase are for reference and local development. For production, copy the templates to Supabase Dashboard > Authentication > Email Templates. Dashboard templates override any local configuration."} + ] +/%} + +--- + +## Next Steps + +- [Authentication Configuration](/docs/next-supabase-turbo/going-to-production/authentication): Configure OAuth providers and SMTP +- [Supabase Deployment](/docs/next-supabase-turbo/going-to-production/supabase): Full Supabase configuration +- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration): Application transactional emails setup diff --git a/docs/going-to-production/authentication.mdoc b/docs/going-to-production/authentication.mdoc new file mode 100644 index 000000000..5fb4e5776 --- /dev/null +++ b/docs/going-to-production/authentication.mdoc @@ -0,0 +1,326 @@ +--- +status: "published" +title: "Configure Supabase Authentication for Production" +label: "Authentication" +description: "Configure Supabase authentication settings for production deployment. Covers URL configuration, SMTP setup, and third-party OAuth providers." +order: 3 +--- + +Configure Supabase authentication for production with proper redirect URLs, SMTP email delivery, and OAuth providers like Google. MakerKit automatically detects enabled providers and displays the appropriate login buttons. + +{% alert type="warning" title="Required for login to work" %} +Skipping these steps will cause authentication failures. Users will not be able to sign up, log in, or reset passwords without proper configuration. +{% /alert %} + +## Overview + +| Configuration | Purpose | Where | +|--------------|---------|-------| +| Site URL | Base URL for your application | Supabase Dashboard | +| Redirect URLs | Allowed callback URLs after auth | Supabase Dashboard | +| Custom SMTP | Reliable email delivery | Supabase Dashboard | +| OAuth Providers | Google, GitHub, etc. login | Provider + Supabase | + +--- + +## Authentication URLs + +Configure redirect URLs so Supabase knows where to send users after authentication. + +### Navigate to Settings + +1. Open your [Supabase Dashboard](https://app.supabase.io/) +2. Select your project +3. Go to **Authentication > URL Configuration** + +### Site URL + +Set this to your production domain: + +``` +https://yourdomain.com +``` + +This is the base URL Supabase uses for all authentication-related redirects. + +### Redirect URLs + +Add your callback URL with a wildcard pattern: + +``` +https://yourdomain.com/auth/callback** +``` + +The `**` wildcard allows MakerKit's various authentication flows to work: + +- `/auth/callback` - Standard OAuth callback +- `/auth/callback/confirm` - Email confirmation +- `/auth/callback/password-reset` - Password reset flow + +{% alert type="default" title="Multiple environments" %} +Add redirect URLs for each environment: +- Production: `https://yourdomain.com/auth/callback**` +- Staging: `https://staging.yourdomain.com/auth/callback**` +- Vercel previews: `https://*-yourproject.vercel.app/auth/callback**` +{% /alert %} + +--- + +## Domain Matching + +A common authentication issue occurs when domains don't match exactly. + +### The Rule + +Your Site URL, Redirect URLs, and actual application URL must match exactly, including: + +- Protocol (`https://`) +- Subdomain (`www.` or no `www`) +- Domain name +- No trailing slash + +### Examples + +| Site URL | Application URL | Result | +|----------|-----------------|--------| +| `https://example.com` | `https://example.com` | Works | +| `https://example.com` | `https://www.example.com` | Fails | +| `https://www.example.com` | `https://www.example.com` | Works | +| `https://example.com/` | `https://example.com` | May fail | + +### Fix Domain Mismatches + +If users report login issues: + +1. Check what URL appears in the browser when users visit your app +2. Ensure Site URL in Supabase matches exactly +3. Update Redirect URLs to match +4. Configure your hosting provider to redirect to a canonical URL + +--- + +## Custom SMTP + +Supabase's default email service has severe limitations: + +- **Rate limit**: 4 emails per hour +- **Deliverability**: Low (often lands in spam) +- **Branding**: Generic Supabase branding + +Configure a real SMTP provider for production. + +### Navigate to SMTP Settings + +1. Go to **Project Settings > Authentication** +2. Scroll to **SMTP Settings** +3. Toggle **Enable Custom SMTP** + +### Configuration + +| Field | Description | +|-------|-------------| +| Host | Your SMTP server hostname | +| Port | Usually 465 (SSL) or 587 (TLS) | +| Username | SMTP authentication username | +| Password | SMTP authentication password or API key | +| Sender email | The "from" address for emails | +| Sender name | Display name for the sender | + +### Provider Examples + +#### Resend + +``` +Host: smtp.resend.com +Port: 465 +Username: resend +Password: re_your_api_key +``` + +#### SendGrid + +``` +Host: smtp.sendgrid.net +Port: 587 +Username: apikey +Password: SG.your_api_key +``` + +#### Mailgun + +``` +Host: smtp.mailgun.org +Port: 587 +Username: postmaster@your-domain.mailgun.org +Password: your_api_key +``` + +### Verify Configuration + +After saving SMTP settings: + +1. Create a test user with a real email address +2. Check that the confirmation email arrives +3. Verify the email doesn't land in spam +4. Confirm links in the email work correctly + +--- + +## Third-Party Providers + +MakerKit supports OAuth login through Supabase. Configure providers in both the provider's developer console and Supabase. + +### How It Works + +1. You enable a provider in Supabase Dashboard +2. MakerKit automatically shows the login button in the UI +3. No code changes required + +### Supported Providers + +MakerKit displays login buttons for any provider you enable in Supabase: + +- Google +- GitHub +- Apple +- Microsoft +- Discord +- Twitter/X +- And others supported by Supabase + +### General Setup Process + +For each provider: + +1. **Create OAuth App**: Register an application in the provider's developer console +2. **Get Credentials**: Copy the Client ID and Client Secret +3. **Set Callback URL**: Add the Supabase callback URL to your OAuth app +4. **Configure Supabase**: Enter credentials in **Authentication > Providers** + +### Google Setup + +Google is the most common OAuth provider. Here's the setup: + +#### In Google Cloud Console + +1. Go to [console.cloud.google.com](https://console.cloud.google.com) +2. Create a new project or select existing +3. Navigate to **APIs & Services > Credentials** +4. Click **Create Credentials > OAuth client ID** +5. Select **Web application** +6. Add authorized redirect URI (from Supabase) + +#### In Supabase Dashboard + +1. Go to **Authentication > Providers** +2. Click **Google** +3. Toggle **Enable Sign in with Google** +4. Enter your Client ID and Client Secret +5. Copy the **Callback URL** to Google Cloud + +For detailed instructions, see [Supabase Google Auth documentation](https://supabase.com/docs/guides/auth/social-login/auth-google). + +--- + +## Email Templates + +MakerKit provides custom email templates that fix a common authentication issue with Supabase. + +### The PKCE Problem + +Supabase uses PKCE (Proof Key for Code Exchange) for secure authentication. The PKCE verifier is stored in the browser that initiated the authentication. + +When a user: +1. Signs up on their laptop +2. Receives confirmation email on their phone +3. Clicks the link on their phone + +Authentication fails because the phone doesn't have the PKCE verifier. + +### The Solution + +MakerKit's email templates use token hash URLs instead of PKCE, which work regardless of which device opens the link. + +See the [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails) guide for setup instructions. + +--- + +## Troubleshooting + +### "Redirect URL not allowed" error + +The callback URL doesn't match any configured Redirect URLs in Supabase. + +1. Check the exact URL in the error message +2. Add it to Redirect URLs in Supabase +3. Include the `**` wildcard for flexibility + +### Users can't log in after email confirmation + +Usually a domain mismatch issue. Verify: + +1. Site URL matches your application URL exactly +2. Redirect URLs match your domain +3. No trailing slashes causing mismatches + +### OAuth login fails silently + +Check browser console for errors. Common issues: + +1. Callback URL in provider doesn't match Supabase +2. OAuth credentials incorrect +3. Provider not properly enabled in Supabase + +### Emails not received + +1. Check spam/junk folders +2. Verify SMTP settings in Supabase +3. Check your email provider's dashboard for delivery logs +4. Ensure sender domain has proper DNS records (SPF, DKIM) + +### "Invalid PKCE verifier" error + +Users clicking email links from different browsers/devices. Update to MakerKit's email templates. See [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails). + +--- + +## Security Considerations + +### Protect Your Credentials + +- Never expose the Supabase Service Role Key in client code +- Store OAuth credentials securely (use environment variables) +- Rotate credentials if they're ever exposed + +### Configure Rate Limiting + +Supabase has built-in rate limiting for authentication. Review settings in **Project Settings > Auth** to prevent abuse. + +### Monitor Authentication Events + +Enable audit logging in Supabase to track: +- Failed login attempts +- Unusual activity patterns +- Password reset requests + +--- + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Why does authentication fail when users click email links?", "answer": "Most likely a PKCE issue. Supabase's default email templates use PKCE which fails if the user opens the link in a different browser. Use MakerKit's custom email templates with token_hash URLs instead. See the Authentication Emails guide."}, + {"question": "How do I add Google login?", "answer": "Enable Google in Supabase Dashboard > Authentication > Providers. Enter your Google Cloud OAuth credentials (Client ID and Secret). Copy the Supabase callback URL to your Google Cloud OAuth app. MakerKit automatically shows the Google login button."}, + {"question": "Can I use multiple OAuth providers?", "answer": "Yes. Enable as many providers as you want in Supabase. MakerKit displays login buttons for all enabled providers automatically. Each provider needs its own OAuth app configured with the Supabase callback URL."}, + {"question": "Why are my authentication emails going to spam?", "answer": "Supabase's default email service has poor deliverability. Configure a real SMTP provider like Resend, SendGrid, or Postmark. Also set up SPF, DKIM, and DMARC DNS records for your sending domain."}, + {"question": "What's the redirect URL wildcard for?", "answer": "The ** wildcard in redirect URLs matches any path. MakerKit uses different callback paths for different flows: /auth/callback for OAuth, /auth/callback/confirm for email confirmation, /auth/callback/password-reset for password recovery. The wildcard covers all of them."}, + {"question": "How do I test authentication locally?", "answer": "Supabase local development uses Inbucket for emails at http://localhost:54324. Sign up with any email, check Inbucket for the confirmation link, and click it. For OAuth, you need to configure providers with localhost callback URLs."} + ] +/%} + +--- + +## Next Steps + +- [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails): Configure email templates with token_hash URLs +- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference +- [Supabase Configuration](/docs/next-supabase-turbo/going-to-production/supabase): Full Supabase setup guide diff --git a/docs/going-to-production/checklist.mdoc b/docs/going-to-production/checklist.mdoc new file mode 100644 index 000000000..9c6bfd578 --- /dev/null +++ b/docs/going-to-production/checklist.mdoc @@ -0,0 +1,338 @@ +--- +status: "published" +title: "Production Deployment Checklist for Next.js Supabase SaaS" +label: "Checklist" +description: "Complete checklist for deploying your MakerKit Next.js Supabase application to production. Follow these steps in order to ensure a successful deployment." +order: 0 +--- + +Deploy your MakerKit Next.js Supabase Turbo application to production by completing this checklist in order. This guide covers Supabase configuration, authentication setup, billing webhooks, and hosting deployment for Next.js 16 with React 19. + +{% alert type="warning" title="Complete all steps before testing" %} +Your application will not function correctly if you skip steps. Budget 2-3 hours for your first deployment. The most common failure we see is missing environment variables, which MakerKit catches at build time with clear error messages. +{% /alert %} + +## Quick Reference + +| Step | What | Where | Time | +|------|------|-------|------| +| 1 | Create Supabase project | [supabase.com](https://supabase.com) | 5 min | +| 2 | Push database migrations | Terminal | 2 min | +| 3 | Configure auth URLs | Supabase Dashboard | 5 min | +| 4 | Set up OAuth providers | Supabase + Provider | 15-30 min | +| 5 | Update auth email templates | Supabase Dashboard | 10 min | +| 6 | Deploy to hosting provider | Vercel/Cloudflare/VPS | 10-20 min | +| 7 | Set environment variables | Hosting provider | 10 min | +| 8 | Configure database webhooks | Supabase Dashboard | 10 min | +| 9 | Set up SMTP for emails | Supabase + Email provider | 15 min | +| 10 | Configure billing provider | Stripe/Lemon Squeezy | 20-30 min | + +--- + +## Pre-Deployment Requirements + +Before starting, ensure you have accounts and API keys for: + +- **Supabase**: Database, authentication, and storage +- **Billing provider**: Stripe or Lemon Squeezy with API keys and webhook secrets +- **Email provider**: Resend, SendGrid, Mailgun, or another SMTP service +- **Hosting provider**: Vercel, Cloudflare, or a VPS + +--- + +## Step 1: Create and Link Supabase Project + +Create a new project in the [Supabase Dashboard](https://supabase.com/dashboard). Save your database password securely as you will need it for the CLI. + +Link your local project to the remote Supabase instance: + +```bash +pnpm --filter web supabase login +pnpm --filter web supabase link +``` + +The CLI will prompt you to select your project and enter the database password. + +**Verification**: Run `supabase projects list` to confirm the link. + +--- + +## Step 2: Push Database Migrations + +Push MakerKit's database schema to your production Supabase instance: + +```bash +pnpm --filter web supabase db push +``` + +Review the migrations when prompted. You should see the core MakerKit tables (accounts, subscriptions, invitations, etc.). + +**Verification**: Check the Supabase Dashboard Table Editor to confirm tables exist. + +--- + +## Step 3: Configure Authentication URLs + +In the Supabase Dashboard, navigate to **Authentication > URL Configuration**. + +Set these values: + +| Field | Value | +|-------|-------| +| Site URL | `https://yourdomain.com` | +| Redirect URLs | `https://yourdomain.com/auth/callback**` | + +{% alert type="default" title="No URL yet?" %} +If you haven't deployed yet, skip this step and return after deployment. Your first deploy will fail without these URLs, which is expected. +{% /alert %} + +**Important**: The redirect URL must include the `**` wildcard to handle all callback paths. + +For detailed instructions, see the [Authentication Configuration](/docs/next-supabase-turbo/going-to-production/authentication) guide. + +--- + +## Step 4: Set Up OAuth Providers (Optional) + +If you want social login (Google, GitHub, etc.), configure providers in **Authentication > Providers** in the Supabase Dashboard. + +Each provider requires: +1. Creating an OAuth app in the provider's developer console +2. Copying the Client ID and Secret to Supabase +3. Setting the callback URL from Supabase in the provider's console + +MakerKit automatically displays configured providers in the login UI. No code changes needed. + +For Google Auth setup, see the [Supabase Google Auth guide](https://supabase.com/docs/guides/auth/social-login/auth-google). + +--- + +## Step 5: Update Authentication Email Templates + +MakerKit provides custom email templates that fix PKCE flow issues when users click email links from different browsers or devices. + +{% alert type="warning" title="Required for reliable authentication" %} +Using Supabase's default email templates will cause authentication failures when users open email links in a different browser. +{% /alert %} + +Update your email templates in **Authentication > Email Templates** in the Supabase Dashboard. Use the templates from `apps/web/supabase/templates/` as your starting point. + +For detailed instructions, see the [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails) guide. + +--- + +## Step 6: Deploy Your Application + +Choose your deployment platform: + +| Platform | Best For | Guide | +|----------|----------|-------| +| [Vercel](/docs/next-supabase-turbo/going-to-production/vercel) | Easiest setup, automatic deployments | Recommended | +| [Cloudflare](/docs/next-supabase-turbo/going-to-production/cloudflare) | Edge runtime, lower costs | Requires config changes | +| [sherpa.sh](/docs/next-supabase-turbo/going-to-production/sherpa) | Cost-effective alternative | Good support | +| [Docker/VPS](/docs/next-supabase-turbo/going-to-production/docker) | Full control, self-hosted | More setup required | + +### Which platform should I choose? + +**Use Vercel when**: You want the simplest setup with preview deployments, need commercial use rights (Pro tier), or are new to deployment. Works out of the box with MakerKit. + +**Use Cloudflare when**: You need zero cold starts for global users, want lower costs at scale, or are comfortable making Edge runtime configuration changes. + +**Use VPS when**: You need full infrastructure control, want predictable costs regardless of traffic, or have compliance requirements for data location. + +**If unsure**: Start with Vercel. You can migrate later since MakerKit supports all platforms. + +**Expected**: Your first deployment will likely fail if you haven't set all environment variables. This is normal. Continue to the next step. + +--- + +## Step 7: Set Environment Variables + +Generate your production environment variables using the built-in tool: + +```bash +pnpm turbo gen env +``` + +This interactive command creates a `.env.local` file at `turbo/generators/templates/env/.env.local` with all required variables. + +Copy these variables to your hosting provider's environment settings. + +**Required variables include**: +- `NEXT_PUBLIC_SITE_URL`: Your production URL +- `NEXT_PUBLIC_SUPABASE_URL`: From Supabase Dashboard > Settings > API +- `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY`: Supabase anon key +- `SUPABASE_SECRET_KEY`: Supabase service role key +- Billing provider keys (Stripe or Lemon Squeezy) +- Email provider configuration + +For the complete list, see [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables). + +After setting variables, redeploy your application. + +--- + +## Step 8: Configure Database Webhooks + +MakerKit uses database webhooks to handle events like subscription cancellations when accounts are deleted. + +### Generate a Webhook Secret + +Create a strong, random secret for `SUPABASE_DB_WEBHOOK_SECRET`: + +```bash +openssl rand -base64 32 +``` + +Add this to your environment variables on your hosting provider. + +### Create the Webhook in Supabase + +In Supabase Dashboard, go to **Database > Webhooks**: + +1. Click **Enable Webhooks** if not already enabled +2. Click **Create a new hook** +3. Configure: + - **Table**: `public.subscriptions` + - **Events**: `DELETE` + - **URL**: `https://yourdomain.com/api/db/webhook` + - **Headers**: Add `X-Supabase-Event-Signature` with your webhook secret value + - **Timeout**: `5000` + +{% alert type="warning" title="Use a public URL" %} +The webhook URL must be publicly accessible. Vercel preview URLs are private and will not receive webhooks. +{% /alert %} + +For detailed webhook setup, see the [Supabase Deployment](/docs/next-supabase-turbo/going-to-production/supabase) guide. + +--- + +## Step 9: Configure Email Service (SMTP) + +Supabase's built-in email service has low rate limits and poor deliverability. Configure a real SMTP provider for production. + +### In Your Environment Variables + +Set the mailer configuration: + +```bash +MAILER_PROVIDER=resend # or nodemailer +EMAIL_SENDER=noreply@yourdomain.com +RESEND_API_KEY=re_xxxxx # if using Resend +``` + +### In Supabase Dashboard + +Navigate to **Project Settings > Authentication > SMTP Settings** and configure your provider's SMTP credentials. + +Recommended providers: [Resend](https://resend.com), [SendGrid](https://sendgrid.com), [Mailgun](https://mailgun.com) + +--- + +## Step 10: Configure Billing Provider + +### Stripe Setup + +1. Create products and prices in the [Stripe Dashboard](https://dashboard.stripe.com) +2. Set environment variables: + ```bash + NEXT_PUBLIC_BILLING_PROVIDER=stripe + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx + STRIPE_SECRET_KEY=sk_live_xxx + STRIPE_WEBHOOK_SECRET=whsec_xxx + ``` +3. Create a webhook endpoint in Stripe pointing to `https://yourdomain.com/api/billing/webhook` +4. Select events: `checkout.session.completed`, `customer.subscription.*`, `invoice.*` + +### Lemon Squeezy Setup + +1. Create products in [Lemon Squeezy](https://lemonsqueezy.com) +2. Set environment variables: + ```bash + NEXT_PUBLIC_BILLING_PROVIDER=lemon-squeezy + LEMON_SQUEEZY_SECRET_KEY=xxx + LEMON_SQUEEZY_STORE_ID=xxx + LEMON_SQUEEZY_SIGNING_SECRET=xxx + ``` +3. Configure webhooks in Lemon Squeezy pointing to `https://yourdomain.com/api/billing/webhook` + +--- + +## Post-Deployment Tasks + +After completing the main deployment: + +{% sequence title="Final Setup Tasks" description="Complete these tasks to finalize your production deployment." %} + +Update legal pages (Privacy Policy, Terms of Service) with your company information at `apps/web/app/[locale]/(marketing)/(legal)/`. + +Replace placeholder blog and documentation content in `apps/web/content/` with your own. + +Update favicon and logo in `apps/web/public/` with your branding. + +Review and update FAQ content on marketing pages. + +Set up monitoring with [Sentry](/docs/next-supabase-turbo/monitoring/sentry) or another error tracking service. + +Configure analytics with [PostHog](/docs/next-supabase-turbo/analytics/posthog-analytics-provider) or your preferred provider. + +{% /sequence %} + +--- + +## Optional: Clear Expired OTPs + +MakerKit stores one-time passwords for various flows. Clean these up periodically by running: + +```sql +SELECT kit.cleanup_expired_nonces(); +``` + +You can run this manually from the Supabase SQL Editor or set up a [pg_cron](https://supabase.com/docs/guides/database/extensions/pg_cron) job to run it automatically. + +--- + +## Troubleshooting + +### Build fails with missing environment variables + +MakerKit validates environment variables at build time using Zod. Check the build logs to see which variables are missing, then add them to your hosting provider. + +### Authentication redirects fail + +Ensure your Site URL and Redirect URLs in Supabase exactly match your domain, including `www` if you use it. + +### Webhooks not received + +1. Verify the URL is publicly accessible (test in incognito mode) +2. Check the `X-Supabase-Event-Signature` header matches your secret +3. Review webhook logs in Supabase Dashboard > Database > Webhooks + +### Emails not sending + +1. Confirm SMTP settings in both environment variables and Supabase Dashboard +2. Check your email provider's logs for delivery issues +3. Verify your domain's DNS records (SPF, DKIM) are configured + +--- + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "How long does the full deployment process take?", "answer": "Plan for 2-3 hours for your first deployment. This includes setting up Supabase, configuring authentication, setting environment variables, and deploying to your hosting provider. Subsequent deployments are much faster since most configuration is one-time."}, + {"question": "Can I deploy to multiple environments (staging, production)?", "answer": "Yes. Create separate Supabase projects for each environment, generate environment variables for each, and configure your hosting provider with environment-specific settings. Most providers like Vercel support automatic preview deployments for pull requests."}, + {"question": "What if my first deployment fails?", "answer": "First deployments commonly fail due to missing environment variables. Check the build logs for specific error messages from Zod validation, add the missing variables in your hosting provider, and redeploy. MakerKit validates all required variables at build time."}, + {"question": "Do I need to configure webhooks before the first deployment?", "answer": "Database webhooks require a publicly accessible URL, so you need to deploy first, then configure webhooks pointing to your production URL. Your app will work without webhooks initially, but subscription cancellation on account deletion won't function until webhooks are set up."}, + {"question": "Can I use a different billing provider later?", "answer": "Yes. MakerKit supports Stripe and Lemon Squeezy. Switching requires updating environment variables and reconfiguring webhooks. Existing subscription data won't migrate automatically between providers."} + ] +/%} + +--- + +## Next Steps + +- [Supabase Production Setup](/docs/next-supabase-turbo/going-to-production/supabase): Configure your Supabase project with migrations, RLS policies, and webhooks +- [Vercel Deployment](/docs/next-supabase-turbo/going-to-production/vercel): Deploy to Vercel with automatic CI/CD +- [Cloudflare Deployment](/docs/next-supabase-turbo/going-to-production/cloudflare): Deploy to Cloudflare Pages with Edge runtime +- [Docker Deployment](/docs/next-supabase-turbo/going-to-production/docker): Self-host with Docker containers +- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference with Zod validation diff --git a/docs/going-to-production/cloudflare.mdoc b/docs/going-to-production/cloudflare.mdoc new file mode 100644 index 000000000..d9f834cdb --- /dev/null +++ b/docs/going-to-production/cloudflare.mdoc @@ -0,0 +1,342 @@ +--- +status: "published" +title: "Deploy Next.js Supabase to Cloudflare" +label: "Deploy to Cloudflare" +order: 6 +description: "Deploy your MakerKit Next.js Supabase application to Cloudflare Pages using the Edge runtime. Covers configuration changes, OpenNext setup, and deployment." +--- + +Deploy your MakerKit Next.js 16 application to Cloudflare Pages using OpenNext for Edge runtime deployment. Cloudflare offers zero cold starts, global distribution, and cost-effective pricing for high-traffic applications. + +## Prerequisites + +Before deploying to Cloudflare: + +1. **Cloudflare Workers Paid Plan**: Required due to bundle size limits on the free tier (starts at $5/month) +2. [Set up Supabase](/docs/next-supabase-turbo/going-to-production/supabase) project +3. [Generate environment variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables) + +--- + +## Edge Runtime Considerations + +Cloudflare uses the Edge runtime, which differs from Node.js. Before proceeding, understand these limitations: + +### What Works Differently + +| Feature | Node.js | Edge Runtime | Solution | +|---------|---------|--------------|----------| +| File system | Full access | No access | Use remote CMS | +| Nodemailer | Supported | Not supported | Use Resend or HTTP mailer | +| Pino logger | Supported | Not supported | Use console logger | +| Stripe SDK | Default config | Needs fetch client | Add `httpClient` option | +| Database latency | Low | Potentially higher | Choose region wisely | + +### Benefits + +- Zero cold starts +- Global edge deployment (runs close to users) +- Lower costs for high-traffic applications +- Excellent caching capabilities + +--- + +## Step 1: Run the Cloudflare Generator + +MakerKit provides a generator that scaffolds all required Cloudflare configuration: + +```bash +pnpm run turbo gen cloudflare +``` + +This command: + +1. Creates `wrangler.jsonc` configuration file +2. Creates `open-next.config.ts` for OpenNext +3. Creates `.dev.vars` for local development variables +4. Adds OpenNext and Wrangler dependencies +5. Updates `next.config.mjs` with OpenNext initialization +6. Adds deployment scripts to `package.json` + +--- + +## Step 2: Switch to Console Logger + +Pino logger uses Node.js APIs unavailable in Edge runtime. Switch to console logging: + +```bash +LOGGER=console +``` + +Add this to both your `.env` file and Cloudflare environment variables. + +--- + +## Step 3: Update Stripe Client + +The default Stripe SDK configuration uses Node.js HTTP which doesn't work in Edge runtime. You need to modify the Stripe client to use the fetch-based HTTP client instead. + +Open `packages/billing/stripe/src/services/stripe-sdk.ts` and add the `httpClient` option to the Stripe constructor: + +```typescript +return new Stripe(stripeServerEnv.secretKey, { + apiVersion: STRIPE_API_VERSION, + httpClient: Stripe.createFetchHttpClient(), // ADD THIS LINE +}); +``` + +{% alert type="warning" title="Manual change required" %} +This modification is not included in MakerKit by default. You must add the `httpClient: Stripe.createFetchHttpClient()` line yourself when deploying to Edge runtime (Cloudflare or Vercel Edge Functions). +{% /alert %} + +The `httpClient` option tells Stripe to use the Fetch API instead of Node.js HTTP, making it compatible with Edge runtime. + +--- + +## Step 4: Rename proxy.ts to middleware.ts + +Rename `apps/web/proxy.ts` to `apps/web/middleware.ts`. + +This is required until OpenNext supports the new `proxy.ts` convention. See [this Github issue](https://github.com/opennextjs/opennextjs-cloudflare/issues/1082) for more details. + +## Step 5: Switch to HTTP-Based Mailer + +Nodemailer relies on Node.js networking APIs. Use Resend instead, which uses the Fetch API: + +```bash +MAILER_PROVIDER=resend +RESEND_API_KEY=re_your_api_key +EMAIL_SENDER=noreply@yourdomain.com +``` + +If you need a different email provider, implement a custom mailer using the abstract class in `packages/mailers`. Ensure your implementation uses only Fetch API for HTTP requests. + +--- + +## Step 6: Switch CMS Provider + +Keystatic's local mode reads from the file system, which isn't available in Edge runtime. Choose one of these alternatives: + +### Option A: WordPress + +Set your CMS to WordPress: + +```bash +CMS_CLIENT=wordpress +``` + +Configure your WordPress instance as the content source. See the [WordPress CMS documentation](/docs/next-supabase-turbo/content/wordpress) for setup. + +### Option B: Keystatic GitHub Mode + +Keep Keystatic but use GitHub as the storage backend instead of local files: + +1. Configure Keystatic for GitHub mode in your `keystatic.config.ts` +2. Set up GitHub App or Personal Access Token +3. Content is stored in your GitHub repository + +See the [Keystatic documentation](/docs/next-supabase-turbo/content/keystatic) for GitHub mode setup. + +--- + +## Step 7: Configure Environment Variables + +### For Local Development + +Add variables to `apps/web/.dev.vars`: + +```bash +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJ... +SUPABASE_SECRET_KEY=eyJ... +LOGGER=console +MAILER_PROVIDER=resend +RESEND_API_KEY=re_... +# ... other variables +``` + +### For Production + +You'll set these during deployment or via the Cloudflare Dashboard. + +--- + +## Step 8: Preview Locally + +Test your application in a Cloudflare-like environment before deploying: + +```bash +pnpm --filter web run preview +``` + +This builds the application with OpenNext and runs it in Wrangler's local development server. Test all critical paths: + +- Authentication flows +- Billing checkout +- Email sending +- Database operations + +{% alert type="warning" title="Test thoroughly" %} +Edge runtime differences may cause unexpected issues. Test your entire application flow before deploying to production. +{% /alert %} + +--- + +## Step 9: Deploy to Cloudflare + +Deploy your application: + +```bash +pnpm --filter web run deploy +``` + +This command: +1. Builds your Next.js application with OpenNext +2. Uploads to Cloudflare Pages +3. Deploys to the edge network + +{% alert type="default" title="Dashboard deployment not supported" %} +At the time of writing, Cloudflare's Dashboard doesn't support OpenNext deployments. Use the CLI command instead. +{% /alert %} + +--- + +## Additional Commands + +### Generate TypeScript Types + +Generate types for Cloudflare environment bindings: + +```bash +pnpm --filter web run cf-typegen +``` + +This creates `cloudflare-env.d.ts` with type definitions for your Cloudflare environment. + +### View Deployment Logs + +Check your Cloudflare Dashboard under **Workers & Pages** for deployment logs and analytics. + +--- + +## Production Configuration + +### Custom Domain + +1. Go to Cloudflare Dashboard > **Workers & Pages** +2. Select your project +3. Go to **Custom Domains** +4. Add your domain + +Cloudflare automatically provisions SSL certificates. + +### Environment Variables in Dashboard + +Add production secrets via the Cloudflare Dashboard: + +1. Go to **Workers & Pages** > Your Project > **Settings** +2. Click **Variables** +3. Add each secret variable + +Or use Wrangler CLI: + +```bash +wrangler secret put STRIPE_SECRET_KEY +``` + +### Caching Strategy + +Cloudflare's edge caching works well with Next.js ISR. Configure cache headers in your `next.config.mjs` for optimal performance. + +--- + +## Troubleshooting + +### "Script size exceeds limit" + +Your bundle exceeds Cloudflare's free tier limit. You need the Workers Paid plan ($5/month). + +### "Cannot find module 'fs'" + +You're using a library that requires Node.js file system APIs. Options: +1. Find an Edge-compatible alternative +2. Use dynamic imports with fallbacks +3. Move the functionality to an external API + +### "fetch is not defined" + +Ensure you're using the Fetch API correctly. In Edge runtime, `fetch` is globally available without importing. + +### Stripe errors + +Verify you've added `httpClient: Stripe.createFetchHttpClient()` to your Stripe configuration. + +### Email sending fails + +Confirm: +1. `MAILER_PROVIDER=resend` is set +2. `RESEND_API_KEY` is configured +3. You're not accidentally importing nodemailer + +### Database timeouts + +Edge functions may have higher latency to your database. Consider: +1. Placing your Supabase project in a region close to your edge deployment +2. Using connection pooling +3. Optimizing query performance + +### Build fails with OpenNext errors + +1. Ensure all dependencies are installed: `pnpm install` +2. Clear build caches: `rm -rf .next .open-next` +3. Check for Node.js-specific code in your pages + +--- + +## Performance Optimization + +### Regional Deployment + +By default, Cloudflare deploys globally. If your users are concentrated in a region, consider: + +1. Deploying Supabase in the same region +2. Using Cloudflare's Smart Placement feature + +### Cache Optimization + +Leverage Cloudflare's caching: + +```typescript +// In your API routes +export const runtime = 'edge'; +export const revalidate = 3600; // Cache for 1 hour +``` + +### Bundle Size + +Keep your bundle small for faster cold starts: + +1. Use dynamic imports for large components +2. Avoid importing entire libraries when you only need specific functions +3. Check your bundle with `next build --analyze` + +--- + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Why do I need the Workers Paid plan?", "answer": "The free tier has a 1MB script size limit, which MakerKit exceeds after bundling. The Workers Paid plan ($5/month) increases this limit and includes more requests. Most production apps need the paid tier regardless."}, + {"question": "Can I use Keystatic with Cloudflare?", "answer": "Not in local file mode. Keystatic's local mode requires file system access, which Edge runtime doesn't support. Use Keystatic's GitHub mode (stores content in your repo) or switch to WordPress as your CMS provider."}, + {"question": "Why isn't nodemailer working?", "answer": "Nodemailer uses Node.js networking APIs unavailable in Edge runtime. Switch to Resend (MAILER_PROVIDER=resend) which uses the Fetch API. This is a one-line environment variable change plus adding your Resend API key."}, + {"question": "How do I debug Edge runtime issues?", "answer": "Run 'pnpm --filter web run preview' locally to test in a Cloudflare-like environment before deploying. Check the Wrangler logs for errors. Common issues are importing Node.js-only modules or using file system APIs."} + ] +/%} + +--- + +## Next Steps + +- [Vercel Deployment](/docs/next-supabase-turbo/going-to-production/vercel): Alternative with full Node.js support +- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference +- [CMS Configuration](/docs/next-supabase-turbo/content/cms): Set up WordPress or Keystatic GitHub mode diff --git a/docs/going-to-production/docker.mdoc b/docs/going-to-production/docker.mdoc new file mode 100644 index 000000000..cb2a4300c --- /dev/null +++ b/docs/going-to-production/docker.mdoc @@ -0,0 +1,388 @@ +--- +status: "published" +title: "Deploy Next.js Supabase with Docker" +label: "Deploy with Docker" +order: 10 +description: "Deploy your MakerKit Next.js Supabase application using Docker. Covers Dockerfile generation, image building, container registry, and production deployment." +--- + +Deploy your MakerKit Next.js 16 application using Docker containers for full infrastructure control. This guide covers the standalone build output, multi-stage Dockerfiles, container registries, and production deployment with Docker Compose. + +## Overview + +| Step | Purpose | +|------|---------| +| Generate Dockerfile | Create optimized Docker configuration | +| Configure environment | Set up production variables | +| Build image | Create the container image | +| Push to registry | Upload to DockerHub or GitHub Container Registry | +| Deploy | Run on your server or cloud platform | + +--- + +## Prerequisites + +Before starting: + +1. [Docker](https://docs.docker.com/get-docker/) installed locally +2. [Set up Supabase](/docs/next-supabase-turbo/going-to-production/supabase) project +3. [Generate environment variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables) + +--- + +## Step 1: Generate the Dockerfile + +MakerKit provides a generator that creates an optimized Dockerfile and configures Next.js for standalone output: + +```bash +pnpm run turbo gen docker +``` + +This command: + +1. Creates a `Dockerfile` in the project root +2. Sets `output: "standalone"` in `next.config.mjs` +3. Installs platform-specific dependencies for Tailwind CSS + +{% alert type="default" title="Architecture-specific dependencies" %} +The generator detects your CPU architecture (ARM64 or x64) and installs the correct Tailwind CSS and LightningCSS binaries for Linux builds. +{% /alert %} + +--- + +## Step 2: Configure Environment Variables + +### Create the Production Environment File + +Generate your environment variables: + +```bash +pnpm turbo gen env +``` + +Copy the generated file to `apps/web/.env.production.local`. + +### Separate Build-Time and Runtime Secrets + +Docker images should not contain secrets. Separate your variables into two groups: + +**Build-time variables** (safe to include in image): + +```bash +NEXT_PUBLIC_SITE_URL=https://yourdomain.com +NEXT_PUBLIC_PRODUCT_NAME=MyApp +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJ... +NEXT_PUBLIC_BILLING_PROVIDER=stripe +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... +``` + +**Runtime secrets** (add only when running container): + +```bash +SUPABASE_SECRET_KEY=eyJ... +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +SUPABASE_DB_WEBHOOK_SECRET=your-secret +RESEND_API_KEY=re_... +CAPTCHA_SECRET_TOKEN=... +``` + +### Prepare for Build + +Before building, temporarily remove secrets from your env file to avoid baking them into the image: + +1. Open `apps/web/.env.production.local` +2. Comment out or remove these lines: + ```bash + # SUPABASE_SECRET_KEY=... + # STRIPE_SECRET_KEY=... + # STRIPE_WEBHOOK_SECRET=... + # SUPABASE_DB_WEBHOOK_SECRET=... + # RESEND_API_KEY=... + # CAPTCHA_SECRET_TOKEN=... + ``` +3. Save the file +4. Keep the secrets somewhere safe for later + +--- + +## Step 3: Build the Docker Image + +Build the image for your target architecture: + +### For AMD64 (most cloud servers) + +```bash +docker buildx build --platform linux/amd64 -t myapp:latest . +``` + +### For ARM64 (Apple Silicon, AWS Graviton) + +```bash +docker buildx build --platform linux/arm64 -t myapp:latest . +``` + +### Build Options + +| Flag | Purpose | +|------|---------| +| `--platform` | Target architecture | +| `-t` | Image name and tag | +| `--no-cache` | Force fresh build | +| `--progress=plain` | Show detailed build output | + +Build typically completes in 3-10 minutes depending on your machine. + +--- + +## Step 4: Add Runtime Secrets + +After building, restore the secrets to your environment file: + +```bash +SUPABASE_SECRET_KEY=eyJ... +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +SUPABASE_DB_WEBHOOK_SECRET=your-secret +RESEND_API_KEY=re_... +CAPTCHA_SECRET_TOKEN=... +``` + +--- + +## Step 5: Run the Container + +### Local Testing + +Test the image locally: + +```bash +docker run -d \ + -p 3000:3000 \ + --env-file apps/web/.env.production.local \ + myapp:latest +``` + +Access your app at `http://localhost:3000`. + +### Run Options + +| Flag | Purpose | +|------|---------| +| `-d` | Run in background (detached) | +| `-p 3000:3000` | Map port 3000 | +| `--env-file` | Load environment variables from file | +| `--name myapp` | Name the container | +| `--restart unless-stopped` | Auto-restart on failure | + +--- + +## Step 6: Push to Container Registry + +### GitHub Container Registry + +1. Create a Personal Access Token with `write:packages` scope +2. Login: + ```bash + docker login ghcr.io -u YOUR_USERNAME + ``` +3. Tag your image: + ```bash + docker tag myapp:latest ghcr.io/YOUR_USERNAME/myapp:latest + ``` +4. Push: + ```bash + docker push ghcr.io/YOUR_USERNAME/myapp:latest + ``` + +### DockerHub + +1. Login: + ```bash + docker login + ``` +2. Tag your image: + ```bash + docker tag myapp:latest YOUR_USERNAME/myapp:latest + ``` +3. Push: + ```bash + docker push YOUR_USERNAME/myapp:latest + ``` + +--- + +## Step 7: Deploy to Production + +### Pull and Run on Your Server + +SSH into your server and run: + +```bash +# Login to registry (GitHub example) +docker login ghcr.io + +# Pull the image +docker pull ghcr.io/YOUR_USERNAME/myapp:latest + +# Run the container +docker run -d \ + -p 3000:3000 \ + --env-file .env.production.local \ + --name myapp \ + --restart unless-stopped \ + ghcr.io/YOUR_USERNAME/myapp:latest +``` + +### Using Docker Compose + +Create `docker-compose.yml`: + +```yaml +services: + web: + image: ghcr.io/YOUR_USERNAME/myapp:latest + ports: + - "3000:3000" + env_file: + - .env.production.local + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthcheck"] + interval: 30s + timeout: 10s + retries: 3 +``` + +Run with: + +```bash +docker compose up -d +``` + +--- + +## CI/CD with GitHub Actions + +Automate builds and deployments with GitHub Actions: + +```yaml +# .github/workflows/docker.yml +name: Build and Push Docker Image + +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ghcr.io/${{ github.repository }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max +``` + +--- + +## Production Considerations + +### Health Checks + +MakerKit includes a health check endpoint. Use it for monitoring: + +```bash +curl http://localhost:3000/api/healthcheck +``` + +### Resource Limits + +Set memory and CPU limits in production: + +```bash +docker run -d \ + -p 3000:3000 \ + --memory="512m" \ + --cpus="1.0" \ + --env-file .env.production.local \ + myapp:latest +``` + +### Logging + +View container logs: + +```bash +# Follow logs +docker logs -f myapp + +# Last 100 lines +docker logs --tail 100 myapp +``` + +--- + +## Troubleshooting + +### Build fails with memory error + +Increase Docker's memory allocation or use a more powerful build machine: + +```bash +docker build --memory=4g -t myapp:latest . +``` + +### Container exits immediately + +Check logs for errors: + +```bash +docker logs myapp +``` + +Common causes: +- Missing environment variables +- Port already in use +- Invalid configuration + +### Image too large + +The standalone output mode creates smaller images. If still too large: + +1. Ensure you're using the generated Dockerfile (not a custom one) +2. Check for unnecessary files in your project +3. Use `.dockerignore` to exclude development files + +### Environment variables not working + +1. Verify the env file path is correct +2. Check file permissions +3. Ensure no syntax errors in the env file +4. For `NEXT_PUBLIC_` variables, rebuild the image (they're embedded at build time) + +--- + +## Next Steps + +- [VPS Deployment](/docs/next-supabase-turbo/going-to-production/vps): Deploy Docker containers to Digital Ocean, Hetzner, or Linode +- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference with secrets management +- [Monitoring Setup](/docs/next-supabase-turbo/monitoring/overview): Add Sentry or PostHog for error tracking diff --git a/docs/going-to-production/production-environment-variables.mdoc b/docs/going-to-production/production-environment-variables.mdoc new file mode 100644 index 000000000..c98830920 --- /dev/null +++ b/docs/going-to-production/production-environment-variables.mdoc @@ -0,0 +1,369 @@ +--- +status: "published" +title: "Production Environment Variables for Next.js Supabase SaaS" +label: "Environment Variables" +description: "Complete reference for generating and configuring production environment variables in your MakerKit Next.js Supabase application." +order: 2 +--- + +Generate and configure environment variables for your MakerKit Next.js Supabase Turbo production deployment. MakerKit uses Zod schemas to validate all variables at build time, catching configuration errors before deployment. + +## Generate Environment Variables + +MakerKit provides an interactive generator that walks you through each required variable: + +```bash +pnpm turbo gen env +``` + +This command: +1. Prompts you for each variable value +2. Uses defaults from your existing `.env` files when available +3. Creates a file at `turbo/generators/templates/env/.env.local` + +Copy the contents of this file to your hosting provider's environment variable settings. + +{% alert type="warning" title="Never commit this file" %} +The generated `.env.local` contains secrets. It's git-ignored by default, but verify it's not being tracked. +{% /alert %} + +--- + +## Validate Environment Variables + +After generating or manually setting variables, validate them: + +```bash +turbo gen validate-env +``` + +This checks that all required variables are present and correctly formatted. + +--- + +## Required Variables Reference + +### Application Settings + +| Variable | Description | Example | +|----------|-------------|---------| +| `NEXT_PUBLIC_SITE_URL` | Your production URL (no trailing slash) | `https://yourdomain.com` | +| `NEXT_PUBLIC_PRODUCT_NAME` | Product name shown in UI | `MyApp` | +| `NEXT_PUBLIC_SITE_TITLE` | Browser tab title | `MyApp - Build faster` | +| `NEXT_PUBLIC_SITE_DESCRIPTION` | Meta description for SEO | `The fastest way to build SaaS` | +| `NEXT_PUBLIC_DEFAULT_THEME_MODE` | Default theme | `light`, `dark`, or `system` | +| `NEXT_PUBLIC_DEFAULT_LOCALE` | Default language | `en` | + +### Supabase Configuration + +| Variable | Description | Where to Find | +|----------|-------------|---------------| +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL | Dashboard > Settings > API | +| `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Supabase anon key | Dashboard > Settings > API | +| `SUPABASE_SECRET_KEY` | Supabase service role key | Dashboard > Settings > API | +| `SUPABASE_DB_WEBHOOK_SECRET` | Secret for webhook authentication | Generate with `openssl rand -base64 32` | + +{% alert type="warning" title="Keep secrets private" %} +`SUPABASE_SECRET_KEY` bypasses Row Level Security. Never expose it in client-side code. +{% /alert %} + +### Authentication Settings + +| Variable | Description | Default | +|----------|-------------|---------| +| `NEXT_PUBLIC_AUTH_PASSWORD` | Enable email/password login | `true` | +| `NEXT_PUBLIC_AUTH_MAGIC_LINK` | Enable magic link login | `false` | + +### Feature Flags + +| Variable | Description | Default | +|----------|-------------|---------| +| `NEXT_PUBLIC_ENABLE_THEME_TOGGLE` | Show theme switcher in UI | `true` | +| `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION` | Allow users to delete their account | `false` | +| `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING` | Enable billing for personal accounts | `false` | +| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS` | Enable team/organization accounts | `true` | +| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION` | Allow team deletion | `false` | +| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING` | Enable billing for teams | `false` | +| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION` | Allow creating new teams | `true` | +| `NEXT_PUBLIC_ENABLE_NOTIFICATIONS` | Show notification bell in UI | `true` | +| `NEXT_PUBLIC_REALTIME_NOTIFICATIONS` | Use Supabase realtime for notifications | `false` | +| `NEXT_PUBLIC_ENABLE_VERSION_UPDATER` | Show version update popup | `false` | + +### Billing Configuration + +#### Stripe + +| Variable | Description | Where to Find | +|----------|-------------|---------------| +| `NEXT_PUBLIC_BILLING_PROVIDER` | Set to `stripe` | - | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe publishable key | Stripe Dashboard > API Keys | +| `STRIPE_SECRET_KEY` | Stripe secret key | Stripe Dashboard > API Keys | +| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret | Stripe Dashboard > Webhooks | + +#### Lemon Squeezy + +| Variable | Description | Where to Find | +|----------|-------------|---------------| +| `NEXT_PUBLIC_BILLING_PROVIDER` | Set to `lemon-squeezy` | - | +| `LEMON_SQUEEZY_SECRET_KEY` | API key | Lemon Squeezy > Settings > API | +| `LEMON_SQUEEZY_STORE_ID` | Your store ID | Lemon Squeezy > Settings > Store | +| `LEMON_SQUEEZY_SIGNING_SECRET` | Webhook signing secret | Lemon Squeezy > Settings > Webhooks | + +### Email Configuration + +#### Using Resend + +| Variable | Value | +|----------|-------| +| `MAILER_PROVIDER` | `resend` | +| `EMAIL_SENDER` | `noreply@yourdomain.com` | +| `RESEND_API_KEY` | Your Resend API key | + +#### Using Nodemailer (SMTP) + +| Variable | Description | +|----------|-------------| +| `MAILER_PROVIDER` | `nodemailer` | +| `EMAIL_SENDER` | `noreply@yourdomain.com` | +| `EMAIL_HOST` | SMTP host (e.g., `smtp.sendgrid.net`) | +| `EMAIL_PORT` | SMTP port (usually `587` or `465`) | +| `EMAIL_USER` | SMTP username | +| `EMAIL_PASSWORD` | SMTP password or API key | +| `EMAIL_TLS` | Enable TLS (`true` or `false`) | + +### CMS Configuration + +| Variable | Description | +|----------|-------------| +| `CMS_CLIENT` | CMS provider: `keystatic` or `wordpress` | + +### Captcha Protection (Optional) + +| Variable | Description | +|----------|-------------| +| `NEXT_PUBLIC_CAPTCHA_SITE_KEY` | Cloudflare Turnstile site key | +| `CAPTCHA_SECRET_TOKEN` | Cloudflare Turnstile secret key | + +### Monitoring (Optional) + +| Variable | Description | +|----------|-------------| +| `CONTACT_EMAIL` | Email for receiving contact form submissions | +| `LOGGER` | Logger type: `pino` (default) or `console` | + +--- + +## Environment Variable Groups + +### Minimum Required for Deployment + +These are the absolute minimum variables needed for a working deployment: + +```bash +# Application +NEXT_PUBLIC_SITE_URL=https://yourdomain.com +NEXT_PUBLIC_PRODUCT_NAME=MyApp + +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJ... +SUPABASE_SECRET_KEY=eyJ... + +# Billing (Stripe example) +NEXT_PUBLIC_BILLING_PROVIDER=stripe +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Email +MAILER_PROVIDER=resend +EMAIL_SENDER=noreply@yourdomain.com +RESEND_API_KEY=re_... + +# Webhooks +SUPABASE_DB_WEBHOOK_SECRET=your-secret +``` + +### Full Production Configuration + +Here's a complete example with all common variables: + +```bash +# ============================================ +# APPLICATION +# ============================================ +NEXT_PUBLIC_SITE_URL=https://yourdomain.com +NEXT_PUBLIC_PRODUCT_NAME=MyApp +NEXT_PUBLIC_SITE_TITLE=MyApp - Build SaaS Faster +NEXT_PUBLIC_SITE_DESCRIPTION=The complete SaaS starter kit +NEXT_PUBLIC_DEFAULT_THEME_MODE=light +NEXT_PUBLIC_DEFAULT_LOCALE=en + +# ============================================ +# AUTHENTICATION +# ============================================ +NEXT_PUBLIC_AUTH_PASSWORD=true +NEXT_PUBLIC_AUTH_MAGIC_LINK=false + +# ============================================ +# FEATURES +# ============================================ +NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true +NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true +NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true +NEXT_PUBLIC_REALTIME_NOTIFICATIONS=false + +# ============================================ +# SUPABASE +# ============================================ +NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co +NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJhbGciOiJI... +SUPABASE_SECRET_KEY=eyJhbGciOiJI... +SUPABASE_DB_WEBHOOK_SECRET=your-webhook-secret + +# ============================================ +# BILLING (Stripe) +# ============================================ +NEXT_PUBLIC_BILLING_PROVIDER=stripe +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# ============================================ +# EMAIL +# ============================================ +MAILER_PROVIDER=resend +EMAIL_SENDER=noreply@yourdomain.com +RESEND_API_KEY=re_... + +# ============================================ +# CMS +# ============================================ +CMS_CLIENT=keystatic + +# ============================================ +# OPTIONAL +# ============================================ +CONTACT_EMAIL=support@yourdomain.com +LOGGER=pino +``` + +--- + +## Build-Time vs Runtime Variables + +MakerKit uses Zod schemas to validate environment variables. Understanding when validation occurs helps debug issues: + +### Build-Time Validation + +Variables prefixed with `NEXT_PUBLIC_` are embedded at build time. If missing: +- Build fails with a clear error message +- You must add the variable and rebuild + +### Runtime Validation + +Server-only variables (without `NEXT_PUBLIC_` prefix) are validated when the server starts. If missing: +- The application may start but fail when accessing certain features +- Check server logs for validation errors + +--- + +## Secrets Management + +### What Counts as a Secret + +These variables contain sensitive data and must be protected: + +- `SUPABASE_SECRET_KEY` +- `STRIPE_SECRET_KEY` +- `STRIPE_WEBHOOK_SECRET` +- `LEMON_SQUEEZY_SECRET_KEY` +- `LEMON_SQUEEZY_SIGNING_SECRET` +- `SUPABASE_DB_WEBHOOK_SECRET` +- `RESEND_API_KEY` +- `EMAIL_PASSWORD` +- `CAPTCHA_SECRET_TOKEN` + +### Best Practices + +1. **Never commit secrets**: Use `.gitignore` to exclude `.env*.local` files +2. **Use hosting provider secrets**: Vercel, Cloudflare, etc. have secure environment variable storage +3. **Rotate compromised secrets**: If a secret is exposed, regenerate it immediately +4. **Limit access**: Only give team members access to secrets they need + +--- + +## Platform-Specific Notes + +### Vercel + +- Add variables in **Project Settings > Environment Variables** +- Separate variables by environment (Production, Preview, Development) +- Use Vercel's sensitive variable feature for secrets + +### Cloudflare + +- Add variables to `.dev.vars` for local development +- Use Wrangler secrets for production: `wrangler secret put VARIABLE_NAME` +- Some variables may need to be in `wrangler.toml` for build-time access + +### Docker + +- Pass variables via `--env-file` flag +- Never bake secrets into Docker images +- Use Docker secrets or external secret managers for production + +--- + +## Troubleshooting + +### "Required environment variable X is missing" + +The variable isn't set in your hosting provider. Add it and redeploy. + +### "Invalid value for environment variable X" + +The variable value doesn't match the expected format. Check: +- URLs should start with `https://` and have no trailing slash +- Boolean values should be `true` or `false` (not `"true"`) +- Provider names must match exactly (`stripe`, not `Stripe`) + +### Variables work locally but not in production + +1. Verify variables are set in your hosting provider (not just `.env.local`) +2. Check for typos in variable names +3. Ensure `NEXT_PUBLIC_` prefix is correct for client-side variables +4. Redeploy after adding variables (Vercel caches builds) + +### Secrets appearing in client-side code + +Only variables with `NEXT_PUBLIC_` prefix should be in client code. If server-only secrets appear: +1. Check you're not importing server modules in client components +2. Verify the variable name doesn't have `NEXT_PUBLIC_` prefix +3. Review your bundle with `next build --analyze` + +--- + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Why does MakerKit validate environment variables at build time?", "answer": "Build-time validation catches configuration errors before deployment. Missing a critical variable like STRIPE_SECRET_KEY would otherwise cause runtime errors that are harder to debug. Zod schemas ensure all required variables are present and correctly formatted."}, + {"question": "What's the difference between NEXT_PUBLIC_ and regular variables?", "answer": "Variables prefixed with NEXT_PUBLIC_ are embedded in the client-side JavaScript bundle and visible to users. Never use this prefix for secrets. Regular variables are only available server-side and stay secure. This is a Next.js convention."}, + {"question": "How do I add a new environment variable?", "answer": "Add the variable to your hosting provider's settings, then redeploy. For client-side variables (NEXT_PUBLIC_), you must rebuild since they're embedded at build time. For server-only variables, a restart is usually sufficient."}, + {"question": "Can I use different variables for staging and production?", "answer": "Yes. Most hosting providers support environment-specific variables. Create separate Supabase projects for each environment, generate variables for each, and configure your hosting provider to use the right set based on the deployment environment."}, + {"question": "What if I accidentally commit secrets to Git?", "answer": "Immediately rotate all exposed credentials. Generate new API keys for Supabase, Stripe, and any other affected services. Consider using git-secrets or similar tools to prevent future accidental commits. Review Git history and consider rewriting it if the repo is private."} + ] +/%} + +--- + +## Next Steps + +- [Deployment Checklist](/docs/next-supabase-turbo/going-to-production/checklist): Complete deployment steps +- [Vercel Deployment](/docs/next-supabase-turbo/going-to-production/vercel): Deploy to Vercel with CI/CD +- [Supabase Configuration](/docs/next-supabase-turbo/going-to-production/supabase): Set up Supabase with migrations and RLS diff --git a/docs/going-to-production/supabase.mdoc b/docs/going-to-production/supabase.mdoc new file mode 100644 index 000000000..c814d7666 --- /dev/null +++ b/docs/going-to-production/supabase.mdoc @@ -0,0 +1,348 @@ +--- +status: "published" +title: "Deploy Supabase to Production" +label: "Deploy Supabase" +order: 1 +description: "Complete guide to configuring your Supabase project for production deployment with MakerKit. Covers project setup, migrations, authentication, SMTP, and database webhooks." +--- + +Configure your Supabase project for production with Postgres database, Row Level Security (RLS) policies, authentication, and webhooks. This guide covers the complete setup for your MakerKit Next.js Supabase Turbo application. + +{% alert type="warning" title="Complete all steps" %} +Skipping steps will cause authentication failures, missing data, or broken webhooks. Follow this guide completely before testing your application. +{% /alert %} + +## Overview + +| Task | Purpose | +|------|---------| +| Create project | Set up cloud database and auth | +| Push migrations | Create MakerKit database schema | +| Configure auth URLs | Enable OAuth redirects | +| Set up SMTP | Reliable email delivery | +| Update email templates | Fix PKCE authentication issues | +| Link project locally | Enable CLI deployments | +| Configure webhooks | Handle database events | + +--- + +## Create a Supabase Project + +If you're not self-hosting Supabase, create a project at [supabase.com](https://supabase.com). + +1. Sign in to the [Supabase Dashboard](https://supabase.com/dashboard) +2. Click **New Project** +3. Choose your organization +4. Enter a project name and generate a database password +5. Select a region close to your users + +{% alert type="default" title="Save your database password" %} +Copy the database password immediately. You cannot retrieve it later and will need it to link your local project. +{% /alert %} + +--- + +## Retrieve API Credentials + +Navigate to **Project Settings > API** to find your credentials: + +| Credential | Environment Variable | Usage | +|------------|---------------------|-------| +| Project URL | `NEXT_PUBLIC_SUPABASE_URL` | Client and server connections | +| Anon (public) key | `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Client-side requests | +| Service role key | `SUPABASE_SECRET_KEY` | Server-side admin operations | + +{% img src="/assets/courses/next-turbo/supabase-api-settings.webp" width="2500" height="1262" /%} + +{% alert type="warning" title="Keep the service role key secret" %} +The service role key bypasses Row Level Security. Never expose it in client-side code or commit it to version control. +{% /alert %} + +--- + +## Configure Authentication URLs + +Set up redirect URLs so authentication flows work correctly. + +Navigate to **Authentication > URL Configuration** and configure: + +### Site URL + +Your production domain: + +``` +https://yourdomain.com +``` + +### Redirect URLs + +Add this pattern to allow all auth callbacks: + +``` +https://yourdomain.com/auth/callback** +``` + +The `**` wildcard matches any path after `/auth/callback`, which MakerKit uses for different auth flows. + +{% alert type="default" title="Domain matching" %} +If your production URL includes `www`, use `www` in both the Site URL and Redirect URLs. Mismatched domains cause authentication failures. +{% /alert %} + +--- + +## Configure SMTP + +Supabase's built-in email service has strict rate limits (4 emails per hour) and low deliverability. Configure a real SMTP provider for production. + +Navigate to **Project Settings > Authentication > SMTP Settings**: + +1. Toggle **Enable Custom SMTP** +2. Enter your provider's credentials: + +| Field | Example (Resend) | +|-------|------------------| +| Host | `smtp.resend.com` | +| Port | `465` | +| Username | `resend` | +| Password | Your API key | +| Sender email | `noreply@yourdomain.com` | +| Sender name | Your App Name | + +Recommended SMTP providers: + +- [Resend](https://resend.com): MakerKit has native integration, simple setup +- [SendGrid](https://sendgrid.com): High volume, good deliverability +- [Mailgun](https://mailgun.com): Developer-friendly, detailed analytics +- [Postmark](https://postmarkapp.com): Excellent deliverability, transactional focus + +--- + +## Update Email Templates + +MakerKit provides custom email templates that solve a common Supabase authentication issue. + +### The Problem + +Supabase uses PKCE (Proof Key for Code Exchange) for authentication. When a user clicks a confirmation link in their email and opens it in a different browser than where they signed up, authentication fails because the PKCE verifier is stored in the original browser. + +### The Solution + +MakerKit's templates use token hash URLs instead of PKCE, which work regardless of which browser opens the link. + +### How to Update + +1. Find the templates in your project at `apps/web/supabase/templates/` +2. In Supabase Dashboard, go to **Authentication > Email Templates** +3. For each email type (Confirm signup, Magic Link, etc.), replace the default template with MakerKit's version +4. Customize the templates with your branding + +For detailed instructions, see the [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails) guide. + +--- + +## Link Your Local Project + +Connect your local development environment to your Supabase project using the CLI. + +### Login to Supabase + +```bash +pnpm --filter web supabase login +``` + +Follow the browser prompts to authenticate. + +### Link the Project + +```bash +pnpm --filter web supabase link +``` + +Select your project from the list and enter your database password when prompted. + +**Verification**: Run `supabase projects list` to confirm the connection. + +--- + +## Push Database Migrations + +Deploy MakerKit's database schema to your production Supabase instance: + +```bash +pnpm --filter web supabase db push +``` + +The CLI displays a list of migrations to apply. Review them and confirm. + +**Expected tables**: After pushing, you should see these tables in your Supabase Dashboard Table Editor: + +- `accounts`: Team and personal accounts +- `accounts_memberships`: User-account relationships +- `subscriptions`: Billing subscriptions +- `subscription_items`: Line items for subscriptions +- `invitations`: Team invitations +- `roles`: Custom role definitions +- `role_permissions`: Permission assignments + +{% img src="/assets/courses/next-turbo/supabase-webhooks.webp" width="2062" height="876" /%} + +--- + +## Configure Database Webhooks + +MakerKit uses database webhooks to respond to data changes. The primary webhook handles subscription cleanup when accounts are deleted. + +### Generate a Webhook Secret + +Create a strong secret for authenticating webhook requests: + +```bash +openssl rand -base64 32 +``` + +Save this as `SUPABASE_DB_WEBHOOK_SECRET` in your hosting provider's environment variables. + +### Why Webhooks Matter + +When a user deletes their account, MakerKit needs to: +1. Cancel their subscription with the billing provider +2. Clean up related data + +The webhook triggers this cleanup automatically by calling your application's `/api/db/webhook` endpoint. + +### Create the Webhook + +In Supabase Dashboard, navigate to **Database > Webhooks**: + +1. Click **Enable Webhooks** if prompted +2. Click **Create a new hook** +3. Configure the webhook: + +| Setting | Value | +|---------|-------| +| Name | `subscriptions_delete` | +| Table | `public.subscriptions` | +| Events | `DELETE` | +| Type | `HTTP Request` | +| Method | `POST` | +| URL | `https://yourdomain.com/api/db/webhook` | +| Timeout | `5000` | + +4. Add a header for authentication: + - **Name**: `X-Supabase-Event-Signature` + - **Value**: Your `SUPABASE_DB_WEBHOOK_SECRET` value + +{% alert type="warning" title="Use your production URL" %} +The webhook URL must be publicly accessible. Do not use: +- `localhost` or `127.0.0.1` +- Vercel preview URLs (they require authentication) +- Private network addresses + +Test accessibility by visiting the URL in an incognito browser window. +{% /alert %} + +### Webhook Configuration Reference + +For reference, this is equivalent to the SQL trigger used in local development (from `seed.sql`): + +```sql +create trigger "subscriptions_delete" + after delete + on "public"."subscriptions" + for each row +execute function "supabase_functions"."http_request"( + 'https://yourdomain.com/api/db/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"YOUR_SECRET"}', + '{}', + '5000' +); +``` + +### Webhooks for Older Versions + +If you're using MakerKit version 2.17.1 or earlier, you need additional webhooks: + +| Table | Event | Purpose | +|-------|-------|---------| +| `public.accounts` | `DELETE` | Clean up account data | +| `public.subscriptions` | `DELETE` | Cancel billing subscription | +| `public.invitations` | `INSERT` | Send invitation emails | + +Version 2.17.2+ handles invitations through server actions, so only the subscriptions webhook is required. + +--- + +## Set Up Google Auth (Optional) + +If you want Google login, configure it in both Google Cloud and Supabase. + +### In Google Cloud Console + +1. Create a project at [console.cloud.google.com](https://console.cloud.google.com) +2. Navigate to **APIs & Services > Credentials** +3. Click **Create Credentials > OAuth client ID** +4. Select **Web application** +5. Add authorized redirect URI from Supabase (found in **Authentication > Providers > Google**) + +### In Supabase Dashboard + +1. Go to **Authentication > Providers** +2. Enable **Google** +3. Enter your Client ID and Client Secret from Google Cloud + +For detailed setup, see the [Supabase Google Auth documentation](https://supabase.com/docs/guides/auth/social-login/auth-google). + +MakerKit automatically shows Google login when you enable it in Supabase. No code changes needed. + +--- + +## Troubleshooting + +### "Invalid PKCE verifier" error + +Users see this when clicking email links from a different browser. Update your email templates to use MakerKit's token hash approach. See [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails). + +### Webhooks not triggering + +1. Verify the URL is publicly accessible +2. Check the `X-Supabase-Event-Signature` header matches your environment variable +3. Review logs in **Database > Webhooks** for error messages +4. Ensure your application is deployed and running + +### Authentication redirect fails + +1. Confirm Site URL matches your exact domain (including `www` if used) +2. Verify Redirect URL includes the `**` wildcard +3. Check browser console for specific error messages + +### Emails not delivered + +1. Verify SMTP settings in Supabase Dashboard +2. Check your email provider's dashboard for delivery logs +3. Confirm your sending domain has proper DNS records (SPF, DKIM, DMARC) + +### Database password lost + +If you forgot your database password, reset it in **Project Settings > Database > Database Password**. You'll need to re-link your local project after resetting. + +--- + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Can I use Supabase's free tier for production?", "answer": "The free tier works for early-stage apps with low traffic. It includes 500MB database storage, 1GB bandwidth, and 2GB file storage. For production apps expecting traffic, upgrade to the Pro plan ($25/month) for better performance, daily backups, and no pausing after inactivity."}, + {"question": "How do I migrate from local development to production Supabase?", "answer": "Run 'pnpm --filter web supabase link' to connect your local project to the production instance, then 'pnpm --filter web supabase db push' to apply migrations. The CLI handles schema differences and shows you exactly what will change before applying."}, + {"question": "Do I need to manually create RLS policies?", "answer": "No. MakerKit's migrations include all necessary RLS policies for the core tables (accounts, subscriptions, invitations, etc.). The policies are applied automatically when you push migrations. You only need to add policies for custom tables you create."}, + {"question": "Why do I need database webhooks?", "answer": "Webhooks notify your application when database events occur. MakerKit uses them to cancel billing subscriptions when accounts are deleted."}, + {"question": "Can I self-host Supabase instead of using their cloud?", "answer": "Yes. Supabase is open source and can be self-hosted. See the Supabase self-hosting documentation for Docker and Kubernetes options. You'll need to manage backups, updates, and infrastructure yourself."} + ] +/%} + +--- + +## Next Steps + +- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference with Zod schemas +- [Vercel Deployment](/docs/next-supabase-turbo/going-to-production/vercel): Deploy your Next.js application to Vercel +- [Authentication Configuration](/docs/next-supabase-turbo/going-to-production/authentication): Configure OAuth providers and SMTP diff --git a/docs/going-to-production/vercel.mdoc b/docs/going-to-production/vercel.mdoc new file mode 100644 index 000000000..0419dee45 --- /dev/null +++ b/docs/going-to-production/vercel.mdoc @@ -0,0 +1,309 @@ +--- +status: "published" +title: "Deploy Next.js Supabase to Vercel" +label: "Deploy to Vercel" +order: 5 +description: "Deploy your MakerKit Next.js Supabase application to Vercel. Covers project setup, environment variables, monorepo configuration, and Edge Functions deployment." +--- + +Deploy your MakerKit Next.js 16 application to Vercel with automatic CI/CD, preview deployments, and serverless functions. Vercel is the recommended hosting platform for Next.js apps due to its native support for App Router, Server Actions, and ISR caching. + +## Prerequisites + +Before deploying, complete these steps: + +1. [Set up your Supabase project](/docs/next-supabase-turbo/going-to-production/supabase) +2. [Generate environment variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables) +3. Push your code to a Git repository (GitHub, GitLab, or Bitbucket) + +--- + +## Connect Your Repository + +1. Sign in to [Vercel](https://vercel.com) +2. Click **Add New Project** +3. Import your Git repository +4. Configure the project settings: + +{% img src="/assets/images/docs/vercel-turbo-preset.webp" width="1744" height="854" /%} + +### Required Settings + +| Setting | Value | +|---------|-------| +| Framework Preset | Next.js | +| Root Directory | `apps/web` | +| Build Command | (leave default) | +| Output Directory | (leave default) | + +{% alert type="warning" title="Set the root directory" %} +MakerKit uses a monorepo structure. You must set the root directory to `apps/web` or the build will fail. +{% /alert %} + +--- + +## Configure Environment Variables + +Add your production environment variables in the Vercel project settings. + +### Required Variables + +Generate these using `pnpm turbo gen env` in your local project: + +```bash +# Application +NEXT_PUBLIC_SITE_URL=https://yourdomain.com +NEXT_PUBLIC_PRODUCT_NAME=Your App Name +NEXT_PUBLIC_SITE_TITLE=Your App Title +NEXT_PUBLIC_SITE_DESCRIPTION=Your app description + +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co +NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJhbGciOiJI... +SUPABASE_SECRET_KEY=eyJhbGciOiJI... + +# Billing (Stripe example) +NEXT_PUBLIC_BILLING_PROVIDER=stripe +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Email +MAILER_PROVIDER=resend +EMAIL_SENDER=noreply@yourdomain.com +RESEND_API_KEY=re_... + +# Webhooks +SUPABASE_DB_WEBHOOK_SECRET=your-webhook-secret +``` + +{% img src="/assets/images/docs/vercel-env-variables-turbo.webp" width="1694" height="1874" /%} + +### First Deployment Note + +Your first deployment will likely fail if you set `NEXT_PUBLIC_SITE_URL` to a custom domain you haven't configured yet. This is expected. Options: + +1. **Use the Vercel URL first**: Set `NEXT_PUBLIC_SITE_URL` to `https://your-project.vercel.app`, deploy, then update to your custom domain later +2. **Accept the failure**: Deploy once to get the Vercel URL, then update the environment variable and redeploy + +--- + +## Deploy + +Click **Deploy** in Vercel. The build process: + +1. Installs dependencies with pnpm +2. Builds the Next.js application +3. Validates environment variables (using Zod schemas) +4. Deploys to Vercel's edge network + +### Build Validation + +MakerKit validates environment variables at build time. If variables are missing, the build fails with specific error messages: + +``` +Error: Required environment variable STRIPE_SECRET_KEY is missing +``` + +Check the build logs to identify missing variables, add them in Vercel settings, and redeploy. + +--- + +## Post-Deployment Setup + +After successful deployment: + +### 1. Update Supabase URLs + +In your Supabase Dashboard (**Authentication > URL Configuration**): + +| Field | Value | +|-------|-------| +| Site URL | `https://yourdomain.com` | +| Redirect URLs | `https://yourdomain.com/auth/callback**` | + +### 2. Configure Billing Webhooks + +Point your billing provider's webhooks to your Vercel deployment: + +- **Stripe**: `https://yourdomain.com/api/billing/webhook` +- **Lemon Squeezy**: `https://yourdomain.com/api/billing/webhook` + +### 3. Set Up Database Webhooks + +Configure the Supabase database webhook to point to: + +``` +https://yourdomain.com/api/db/webhook +``` + +See the [Supabase deployment guide](/docs/next-supabase-turbo/going-to-production/supabase#configure-database-webhooks) for details. + +--- + +## Custom Domain + +To use a custom domain: + +1. Go to your Vercel project **Settings > Domains** +2. Add your domain +3. Configure DNS records as instructed by Vercel +4. Update `NEXT_PUBLIC_SITE_URL` to your custom domain +5. Update Supabase Site URL and Redirect URLs + +--- + +## Edge Functions Deployment (Optional) + +Vercel supports Edge Functions for faster cold starts and lower latency. This requires some configuration changes. + +### When to Use Edge Functions + +**Benefits**: +- Zero cold starts +- Lower latency (runs closer to users) +- Lower costs for high-traffic applications + +**Trade-offs**: +- Limited Node.js API support +- Potentially higher database latency (depends on region setup) +- Requires HTTP-based mailer (nodemailer not supported) +- Requires remote CMS (local Keystatic not supported) + +### Configuration Changes + +Apply the same changes as [Cloudflare deployment](/docs/next-supabase-turbo/going-to-production/cloudflare): + +#### 1. Switch to HTTP-Based Mailer + +The default nodemailer uses Node.js APIs unavailable in Edge runtime. Use Resend instead: + +```bash +MAILER_PROVIDER=resend +RESEND_API_KEY=re_... +``` + +#### 2. Switch CMS Mode + +Keystatic's local mode uses the file system, which isn't available in Edge runtime. Options: + +- **WordPress**: Set `CMS_CLIENT=wordpress` +- **Keystatic GitHub mode**: Configure Keystatic to use GitHub as the data source + +See the [CMS documentation](/docs/next-supabase-turbo/content/cms) for setup instructions. + +#### 3. Update Stripe Client (if using Stripe) + +Open `packages/billing/stripe/src/services/stripe-sdk.ts` and add the `httpClient` option to the Stripe constructor: + +```typescript +return new Stripe(stripeServerEnv.secretKey, { + apiVersion: STRIPE_API_VERSION, + httpClient: Stripe.createFetchHttpClient(), // ADD THIS LINE +}); +``` + +{% alert type="warning" title="Manual change required" %} +This modification is not included in MakerKit by default. Add the `httpClient` line when deploying to Vercel Edge Functions. +{% /alert %} + +#### 4. Use Console Logger + +Pino logger isn't compatible with Edge runtime: + +```bash +LOGGER=console +``` + +--- + +## Multiple Apps Deployment + +If you have multiple apps in your monorepo, Vercel automatically deploys the `web` app. + +For additional apps, customize the build command in Vercel project settings: + +```bash +cd ../.. && turbo run build --filter=<app-name> +``` + +Replace `<app-name>` with your app's package name (from its `package.json`). + +Set the root directory to your app's path (e.g., `apps/admin`). + +For more details, see the [Vercel Turborepo documentation](https://vercel.com/docs/monorepos/turborepo). + +--- + +## Troubleshooting + +### Build fails with "command not found: pnpm" + +Vercel may default to npm. In your project settings, explicitly set: + +- **Install Command**: `pnpm i` +- **Build Command**: `pnpm run build` + +### Build fails with missing dependencies + +Ensure your `package.json` dependencies are correctly listed. Turborepo should handle monorepo dependencies automatically. + +### Environment variable validation fails + +MakerKit uses Zod to validate environment variables. The error message shows which variable is missing or invalid. Add or correct it in Vercel settings. + +### Preview deployments have authentication issues + +Vercel preview deployments use unique URLs that may not match your Supabase Redirect URLs. Options: + +1. Add a wildcard pattern to Supabase Redirect URLs: `https://*-your-project.vercel.app/auth/callback**` +2. Disable authentication features in preview environments + +### Webhooks not received on preview deployments + +Preview deployment URLs are not publicly accessible by default. Database and billing webhooks will only work on production deployments with public URLs. + +--- + +## Performance Optimization + +### Enable ISR Caching + +MakerKit supports Incremental Static Regeneration for marketing pages. This is configured by default in the `next.config.mjs`. + +### Configure Regions + +In `vercel.json` (create in project root if needed): + +```json +{ + "regions": ["iad1"] +} +``` + +Choose a region close to your Supabase database for lower latency. + +### Monitor with Vercel Analytics + +Enable Vercel Analytics in your project settings for performance monitoring. MakerKit is compatible with Vercel's built-in analytics. + +--- + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "What's the cost of hosting on Vercel?", "answer": "Vercel's Hobby tier is free and works for personal projects and low-traffic apps. The Pro tier ($20/month) adds team features, more bandwidth, and commercial use rights. Most MakerKit apps start on Hobby and upgrade when they get traction."}, + {"question": "How do I handle preview deployments with Supabase?", "answer": "Preview deployments get unique URLs that won't match your Supabase redirect URLs. Add a wildcard pattern like 'https://*-yourproject.vercel.app/auth/callback**' to your Supabase Redirect URLs, or create a separate Supabase project for staging."}, + {"question": "Why is my build failing with environment variable errors?", "answer": "MakerKit validates environment variables at build time using Zod schemas. Check the build logs for the specific variable name, add it in Vercel's Environment Variables settings, and redeploy. Variables prefixed with NEXT_PUBLIC_ must be set before building."}, + {"question": "How do I deploy multiple apps from the monorepo?", "answer": "Create separate Vercel projects for each app. Set the Root Directory to the app's path (e.g., 'apps/admin') and customize the build command to target that specific app with Turborepo's filter flag."} + ] +/%} + +--- + +## Next Steps + +- [Environment Variables Reference](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable list with Zod validation +- [Supabase Configuration](/docs/next-supabase-turbo/going-to-production/supabase): Database, RLS policies, and webhook setup +- [Cloudflare Deployment](/docs/next-supabase-turbo/going-to-production/cloudflare): Alternative deployment with Edge runtime +- [Monitoring Setup](/docs/next-supabase-turbo/monitoring/overview): Error tracking with Sentry or PostHog diff --git a/docs/going-to-production/vps.mdoc b/docs/going-to-production/vps.mdoc new file mode 100644 index 000000000..d97b23011 --- /dev/null +++ b/docs/going-to-production/vps.mdoc @@ -0,0 +1,418 @@ +--- +status: "published" +title: "Deploy Next.js Supabase to a VPS" +label: "Deploy to VPS" +order: 9 +description: "Deploy your MakerKit Next.js Supabase application to a VPS like Digital Ocean, Hetzner, or Linode. Covers server setup, Docker deployment, Nginx, and SSL configuration." +--- + +Deploy your MakerKit Next.js 16 application to a Virtual Private Server (VPS) for full infrastructure control and predictable costs. This guide covers Ubuntu server setup, Docker deployment, Nginx reverse proxy, and Let's Encrypt SSL. The steps work with Digital Ocean, Hetzner, Linode, Vultr, and other VPS providers. + +## Overview + +| Step | Purpose | +|------|---------| +| Create VPS | Provision your server | +| Install dependencies | Docker, Node.js, Nginx | +| Deploy application | Using Docker or direct build | +| Configure Nginx | Reverse proxy and SSL | +| Set up SSL | HTTPS with Let's Encrypt | + +--- + +## Prerequisites + +Before starting: + +1. [Set up Supabase](/docs/next-supabase-turbo/going-to-production/supabase) project +2. [Generate environment variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables) +3. Domain name pointing to your VPS IP address + +--- + +## Step 1: Create Your VPS + +### Digital Ocean + +1. Go to [Digital Ocean](https://www.digitalocean.com/) +2. Click **Create Droplet** +3. Choose: + - **OS**: Ubuntu 24.04 LTS + - **Plan**: Basic ($12/month minimum recommended for building) + - **Region**: Close to your users and Supabase instance +4. Add your SSH key for secure access +5. Create the Droplet + +### Recommended Specifications + +| Use Case | RAM | CPU | Storage | +|----------|-----|-----|---------| +| Building on VPS | 4GB+ | 2 vCPU | 50GB | +| Running only (pre-built image) | 2GB | 1 vCPU | 25GB | +| Production with traffic | 4GB+ | 2 vCPU | 50GB | + +--- + +## Step 2: Initial Server Setup + +SSH into your server: + +```bash +ssh root@your-server-ip +``` + +### Update System + +```bash +apt update && apt upgrade -y +``` + +### Install Docker + +Follow the [official Docker installation guide](https://docs.docker.com/engine/install/ubuntu/) or run: + +```bash +# Add Docker's official GPG key +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + +# Set up repository +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Install Docker +apt update +apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin +``` + +### Configure Firewall + +```bash +# Allow SSH +ufw allow 22 + +# Allow HTTP and HTTPS +ufw allow 80 +ufw allow 443 + +# Allow app port (if not using Nginx) +ufw allow 3000 + +# Enable firewall +ufw enable +``` + +--- + +## Step 3: Deploy Your Application + +Choose one of two approaches: + +### Option A: Pull Pre-Built Docker Image (Recommended) + +If you built and pushed your image to a container registry (see [Docker guide](/docs/next-supabase-turbo/going-to-production/docker)): + +```bash +# Login to registry +docker login ghcr.io + +# Pull your image +docker pull ghcr.io/YOUR_USERNAME/myapp:latest + +# Create env file +nano .env.production.local +# Paste your environment variables + +# Run container +docker run -d \ + -p 3000:3000 \ + --env-file .env.production.local \ + --name myapp \ + --restart unless-stopped \ + ghcr.io/YOUR_USERNAME/myapp:latest +``` + +### Option B: Build on VPS + +For VPS with enough resources (4GB+ RAM): + +#### Install Node.js and pnpm + +```bash +# Install nvm (check https://github.com/nvm-sh/nvm for latest version) +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash + +# Load nvm +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +# Install Node.js (LTS version) +nvm install --lts + +# Install pnpm +npm install -g pnpm +``` + +#### Clone and Build + +```bash +# Create Personal Access Token on GitHub with repo access +# Clone repository +git clone https://<YOUR_GITHUB_PAT>@github.com/YOUR_USERNAME/your-repo.git +cd your-repo + +# Install dependencies +pnpm install + +# Generate Dockerfile +pnpm run turbo gen docker + +# Create env file +cp turbo/generators/templates/env/.env.local apps/web/.env.production.local +nano apps/web/.env.production.local +# Edit with your production values + +# Build Docker image +docker build -t myapp:latest . + +# Run container +docker run -d \ + -p 3000:3000 \ + --env-file apps/web/.env.production.local \ + --name myapp \ + --restart unless-stopped \ + myapp:latest +``` + +{% alert type="warning" title="Memory during build" %} +If the build fails with memory errors, increase your VPS size temporarily or build locally and push to a registry. +{% /alert %} + +--- + +## Step 4: Configure Nginx + +Install Nginx as a reverse proxy: + +```bash +apt install -y nginx +``` + +### Create Nginx Configuration + +```bash +nano /etc/nginx/sites-available/myapp +``` + +Add: + +``` +server { + listen 80; + server_name yourdomain.com www.yourdomain.com; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 86400; + } +} +``` + +### Enable the Site + +```bash +# Create symlink +ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ + +# Remove default site +rm /etc/nginx/sites-enabled/default + +# Test configuration +nginx -t + +# Restart Nginx +systemctl restart nginx +``` + +--- + +## Step 5: Set Up SSL with Let's Encrypt + +Install Certbot: + +```bash +apt install -y certbot python3-certbot-nginx +``` + +Obtain SSL certificate: + +```bash +certbot --nginx -d yourdomain.com -d www.yourdomain.com +``` + +Certbot automatically: +1. Obtains the certificate +2. Updates Nginx configuration +3. Sets up auto-renewal + +Verify auto-renewal: + +```bash +certbot renew --dry-run +``` + +--- + +## Step 6: Post-Deployment Configuration + +### Update Supabase URLs + +In Supabase Dashboard (**Authentication > URL Configuration**): + +| Field | Value | +|-------|-------| +| Site URL | `https://yourdomain.com` | +| Redirect URLs | `https://yourdomain.com/auth/callback**` | + +### Configure Webhooks + +Point your webhooks to your new domain: + +- **Supabase DB webhook**: `https://yourdomain.com/api/db/webhook` +- **Stripe webhook**: `https://yourdomain.com/api/billing/webhook` +- **Lemon Squeezy webhook**: `https://yourdomain.com/api/billing/webhook` + +--- + +## Monitoring and Maintenance + +### View Logs + +```bash +# Docker logs +docker logs -f myapp + +# Nginx access logs +tail -f /var/log/nginx/access.log + +# Nginx error logs +tail -f /var/log/nginx/error.log +``` + +### Restart Application + +```bash +docker restart myapp +``` + +### Update Application + +```bash +# Pull new image +docker pull ghcr.io/YOUR_USERNAME/myapp:latest + +# Stop old container +docker stop myapp +docker rm myapp + +# Start new container +docker run -d \ + -p 3000:3000 \ + --env-file .env.production.local \ + --name myapp \ + --restart unless-stopped \ + ghcr.io/YOUR_USERNAME/myapp:latest +``` + +### Automated Updates with Watchtower (Optional) + +Auto-update containers when new images are pushed: + +```bash +docker run -d \ + --name watchtower \ + -v /var/run/docker.sock:/var/run/docker.sock \ + containrrr/watchtower \ + --interval 300 \ + myapp +``` + +--- + +## Troubleshooting + +### Application not accessible + +1. Check Docker container is running: `docker ps` +2. Check firewall allows port 3000: `ufw status` +3. Check Nginx is running: `systemctl status nginx` +4. Check Nginx config: `nginx -t` + +### SSL certificate issues + +1. Ensure DNS is properly configured +2. Wait for DNS propagation (up to 48 hours) +3. Check Certbot logs: `cat /var/log/letsencrypt/letsencrypt.log` + +### Container keeps restarting + +Check logs for errors: + +```bash +docker logs myapp +``` + +Common causes: +- Missing environment variables +- Database connection issues +- Port conflicts + +### High memory usage + +Monitor with: + +```bash +docker stats +``` + +Consider: +1. Increasing VPS size +2. Configuring memory limits on container +3. Enabling swap space + +--- + +## Cost Comparison + +| Provider | Basic VPS | Notes | +|----------|-----------|-------| +| Digital Ocean | $12/month | Good documentation | +| Hetzner | $4/month | Best value, EU-based | +| Linode | $12/month | Owned by Akamai | +| Vultr | $12/month | Good global coverage | + +--- + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Which VPS provider should I choose?", "answer": "Hetzner offers the best value at $4-5/month for a capable server. Digital Ocean has better documentation and a simpler interface at $12/month. Choose based on your region needs and whether you value cost or convenience."}, + {"question": "How much RAM do I need?", "answer": "2GB RAM is minimum for running a pre-built Docker container. 4GB+ is needed if building on the VPS itself. For production with traffic, 4GB provides headroom for spikes. Monitor usage and scale up if you see memory pressure."}, + {"question": "Do I need Nginx if I'm using Docker?", "answer": "Yes, for production. Nginx handles SSL termination, serves static files efficiently, and provides a buffer between the internet and your app. It also enables zero-downtime deployments by proxying to new containers while the old ones drain."}, + {"question": "Is VPS cheaper than Vercel?", "answer": "For low traffic, Vercel's free tier is cheaper. For high traffic or predictable workloads, VPS is often cheaper. A $12/month Digital Ocean droplet handles more requests than Vercel's Pro tier at $20/month, but you manage everything yourself."} + ] +/%} + +--- + +## Next Steps + +- [Docker Deployment](/docs/next-supabase-turbo/going-to-production/docker): Build and push Docker images with CI/CD +- [Monitoring Setup](/docs/next-supabase-turbo/monitoring/overview): Add Sentry or PostHog for error tracking +- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference diff --git a/docs/installation/ai-agents.mdoc b/docs/installation/ai-agents.mdoc new file mode 100644 index 000000000..571414a6c --- /dev/null +++ b/docs/installation/ai-agents.mdoc @@ -0,0 +1,439 @@ +--- +status: "published" +title: "AI Agentic Development | Next.js Supabase Turbo" +label: "AI Agentic Development" +order: 11 +description: "Configure Claude Code, Cursor, Windsurf, and other AI coding assistants to work effectively with the Next.js Supabase Turbo SaaS Starter Kit." +--- + +AI Agentic Development uses AI coding assistants like Claude Code or Cursor to write code within your project. MakerKit ships with instruction files (`AGENTS.md`, `CLAUDE.md`) that teach these AI agents your codebase patterns, available skills, and project conventions so they generate code matching your architecture. + +The kit includes pre-configured agent rules for Claude Code, Cursor, Windsurf, Codex, and Gemini. In our extensive testing with state-of-the-art agents like Claude Code, we found that modern tools already crawl existing patterns and learn from your codebase. With a polished codebase like MakerKit, agents have the context they need to produce good code without exhaustive API documentation. + +{% sequence title="AI Agent Setup" description="Get AI agents working with your MakerKit codebase." %} + +[Understand the Agent Files](#agent-configuration-files) + +[Configure Your Editor](#editor-configuration) + +[Add the MCP Server](#mcp-server-optional) + +[Customize Rules for Your App](#customizing-rules) + +{% /sequence %} + +## Agent Configuration Files + +MakerKit uses a hierarchical system of instruction files. Global rules live at the project root, and package-specific rules live in subdirectories. + +### File Locations + +| File | Purpose | Used By | +|------|---------|---------| +| `AGENTS.md` | Primary agent instructions | Cursor, Windsurf | +| `CLAUDE.md` | Claude-specific instructions | Claude Code | +| `.gemini/GEMINI.md` | Gemini instructions | Gemini | + +### Hierarchical Structure + +AI agents read instructions from multiple files based on context. When you work in `apps/web`, the agent reads: + +1. Root `AGENTS.md` (global patterns, commands, tech stack) +2. `apps/web/AGENTS.md` (route organization, component patterns) +3. `apps/web/supabase/AGENTS.md` (database migrations, RLS policies) + +This hierarchy means agents get more specific instructions as they work deeper in the codebase. + +``` +AGENTS.md # Global: tech stack, commands, patterns +├── apps/web/AGENTS.md # App: routes, components, config files +│ ├── apps/web/supabase/AGENTS.md # Database: schemas, migrations, RLS +│ └── apps/web/app/admin/AGENTS.md # Admin: super admin features +├── packages/ui/AGENTS.md # UI: components, design system +├── packages/supabase/AGENTS.md # Supabase: clients, auth, types +├── packages/features/AGENTS.md # Features: feature packages +└── packages/next/AGENTS.md # Next.js: actions, routes, utilities +``` + +### What Agents Learn + +The root `AGENTS.md` teaches agents: + +- **Tech stack**: Next.js 16, React 19, Supabase, Tailwind CSS 4, Turborepo +- **Multi-tenant architecture**: Personal accounts vs team accounts, account_id foreign keys +- **Essential commands**: `pnpm dev`, `pnpm typecheck`, `pnpm supabase:web:reset` +- **Key patterns**: Server Actions with `authActionClient`, Route Handlers with `enhanceRouteHandler` +- **Authorization**: RLS enforces access control, admin client bypasses RLS + +Package-specific files add context for that area: + +```markdown +# packages/supabase/AGENTS.md + +## Client Usage + +### Server Components (Preferred) +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +const client = getSupabaseServerClient(); +// RLS automatically enforced + +### Admin Client (Use Sparingly) +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +// CRITICAL: Bypasses RLS - validate manually! +``` + +## Editor Configuration + +### Claude Code + +Claude Code reads `CLAUDE.md` files automatically. The root file references `@AGENTS.md` to include those instructions. + +No configuration needed. Start Claude Code in your project directory: + +```bash +claude +``` + +Claude Code discovers and reads the instruction files on startup. + +### Cursor + +Cursor reads `AGENTS.md` files automatically when you open a project. The agent instructions appear in context when you use Cursor's AI features. + +For the best experience: + +1. Open the project root in Cursor (not a subdirectory) +2. The agent rules load automatically +3. Use Cmd+K or the chat panel to interact with the agent + +### Windsurf + +Windsurf uses the same `AGENTS.md` files as Cursor. Open your project and the rules apply automatically. + +### Codex + +Codex reads `AGENTS.md` files. Open the project root and the agent instructions apply to generated code. + +### Gemini + +Gemini reads from `.gemini/GEMINI.md`. This file points to the `AGENTS.md` files: + +```markdown +# .gemini/GEMINI.md + +- Check for the presence of AGENTS.md files in the project workspace +- There may be additional AGENTS.md in sub-folders with additional specific instructions +``` + +## MCP Server (Optional) + +The Makerkit MCP Server gives AI agents additional tools for working with the codebase. See the [MCP Server documentation](./mcp) for setup instructions. + +MCP tools help agents: + +- Understand the project structure +- Navigate between related files +- Apply MakerKit-specific patterns + +## Customizing Rules + +The default rules cover MakerKit patterns. Add your own rules for application-specific logic. + +### Adding Business Logic Rules + +Create or edit `AGENTS.md` files to include your domain knowledge: + +```markdown +# apps/web/AGENTS.md (add to existing file) + +## Business Logic + +### Subscription Tiers +- Free: 1 team member, 3 projects +- Pro: 10 team members, unlimited projects +- Enterprise: Unlimited everything, SSO + +### Project Limits +Check limits before creating projects: +- Use `checkProjectLimit(accountId)` from `~/lib/server/projects` +- Returns { allowed: boolean, current: number, limit: number } + +### Naming Conventions +- Projects use kebab-case slugs: "my-project-name" +- Team accounts use slugs, not IDs, in URLs +``` + +### What Makes Effective Agent Rules + +In our testing, we noticed that adding detailed API instructions to agent rules has diminishing returns. Modern agents like Claude Code crawl your existing code, learn the patterns, and apply them. What actually moves the needle: + +1. **Must-do rules**: Things the agent should always do +2. **Must-not-do rules**: Things the agent should never do +3. **Router instructions**: Where to find things the agent needs + +```markdown +# apps/web/AGENTS.md (add to existing file) + +## MUST DO +- Always use `authActionClient` for Server Actions +- Always enable RLS on new tables +- Always run `pnpm typecheck` after changes +- Always use the `Trans` component for user-facing text + +## MUST NOT DO +- Never use the admin client without manual auth validation +- Never commit .env files or secrets +- Never use `any` type in TypeScript +- Never skip RLS policies on tables with user data + +## Where to Find Things +- Auth patterns: `packages/supabase/src/clients/` +- UI components: `packages/ui/src/` +- Billing logic: `packages/billing/` +- Database schemas: `apps/web/supabase/schemas/` +``` + +This approach works better than documenting every API because agents learn APIs from code, but they need explicit guidance on project-specific constraints and conventions. + +### Adding Feature Flags + +Document which features are gated so agents know what's available: + +```markdown +## Feature Flags + +Check `config/feature-flags.config.ts` for current flags: +- `enableTeamAccounts`: Team workspaces enabled +- `enableTeamCreation`: Users can create new teams +- `enableTeamAccountBilling`: Team billing enabled +- `enablePersonalAccountBilling`: Personal account billing enabled +- `enableNotifications`: In-app notifications enabled + +Import the config directly: +import featureFlags from '~/config/feature-flags.config'; +if (featureFlags.enableTeamCreation) { /* ... */ } +``` + +## Claude Code Directory Structure + +Claude Code uses a `.claude/` directory for additional configuration: + +``` +.claude/ +├── agents/ # Custom agent definitions +│ └── code-quality-reviewer.md +├── commands/ # Slash commands +│ └── feature-builder.md +├── evals/ # Evaluation scripts +├── skills/ # Reusable skills +│ ├── playwright-e2e/ +│ ├── postgres-expert/ +│ ├── react-form-builder/ +│ └── server-action-builder/ +└── settings.local.json # Local permissions (git-ignored) +``` + +## Built-in Skills + +The kit includes skills for Claude Code that provide specialized guidance for common tasks. + +{% callout title="Skills may require explicit invocation" %} +Claude can automatically invoke skills based on the context of the conversation. However, you can also explicitly invoke skills by typing the skill name at the start of your message when you need specialized guidance, such as '/postgres-supabase-expert' "review the database schema for the projects feature". +{% /callout %} + +| Skill | Command | Purpose | +|-------|---------|---------| +| Postgres & Supabase Expert | `/postgres-supabase-expert` | Database schemas, RLS policies, migrations, PgTAP tests | +| Server Action Builder | `/server-action-builder` | Server Actions with validation and error handling | +| React Form Builder | `/react-form-builder` | Forms with Zod validation and Shadcn UI | +| Playwright E2E | `/playwright-e2e` | End-to-end test patterns | + +### Using Skills + +Invoke a skill by typing its command at the start of your message: + +``` +/postgres-supabase-expert Create a projects table with RLS policies for team access +``` + +``` +/server-action-builder Create an action to update user settings +``` + +### Feature Builder Command + +The `/feature-builder` command orchestrates multiple skills for end-to-end feature implementation: + +``` +/feature-builder Add a projects feature with CRUD operations +``` + +This command guides you through: +1. Database schema with `/postgres-supabase-expert` +2. Server Actions with `/server-action-builder` +3. Forms with `/react-form-builder` + +## Adding External Skills + +The [Agent Skills](https://github.com/vercel-labs/agent-skills) project provides additional skills you can install: + +```bash +npx add-skill vercel-labs/agent-skills +``` + +Available skills: + +- **[react-best-practices](https://vercel.com/blog/introducing-react-best-practices)**: Performance optimization guidelines from Vercel Engineering (40+ rules across 8 categories) +- **web-design-guidelines**: UI audit with accessibility checks (100+ rules covering accessibility, performance, UX) +- **vercel-deploy-claimable**: Deploy directly from conversations with live preview URLs + +## Verification Workflow + +Agents are instructed to run verification after making changes: + +```bash +pnpm typecheck # Must pass +pnpm lint:fix # Auto-fix issues +pnpm format:fix # Format code +``` + +If an agent skips verification, remind it: + +``` +Run typecheck and lint:fix to verify the changes +``` + +## Common Patterns Agents Know + +### Server Actions + +Agents use `authActionClient` for Server Actions: + +```typescript +'use server'; + +import { authActionClient } from '@kit/next/safe-action'; +import * as z from 'zod'; + +const schema = z.object({ + name: z.string().min(1), +}); + +export const createProjectAction = authActionClient + .inputSchema(schema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // Implementation + }); +``` + +### Route Handlers + +Agents use `enhanceRouteHandler` for API routes: + +```typescript +import { enhanceRouteHandler } from '@kit/next/routes'; + +export const POST = enhanceRouteHandler( + async ({ body, user }) => { + // Implementation + return NextResponse.json({ success: true }); + }, + { schema: requestSchema } +); +``` + +### Database Queries + +Agents prefer Server Components with the standard client: + +```typescript +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +async function ProjectList() { + const client = getSupabaseServerClient(); + const { data } = await client.from('projects').select('*'); + // RLS enforced automatically +} +``` + +## Best Practices for Using AI Agents + +After extensive use of AI agents with MakerKit, we've identified what works: + +### Keep AGENTS.md Files Focused + +Long instruction files cause context drift where agents forget earlier rules. Keep each file under 500 lines. Split by concern rather than cramming everything into the root file. + +### Use Rules as a Router + +Rather than documenting every API, tell agents where to find things: + +```markdown +## Where to Look +- Need a UI component? Check `packages/ui/src/` first +- Building a form? See `packages/ui/src/shadcn/form.tsx` for patterns +- Adding a Server Action? Reference `apps/web/app/home/_lib/server/` +``` + +Agents are excellent at reading code once they know where to look. + +### Explicit Constraints Beat Implicit Conventions + +"Always use `authActionClient`" works better than "We prefer using authActionClient for most cases." Agents follow explicit rules; they guess at preferences. + +### Let Agents Learn from Your Codebase + +With a consistent codebase like MakerKit, agents pick up patterns automatically. You don't need to document that Server Components fetch data or that forms use Zod. Focus your rules on project-specific decisions and gotchas. + +## Troubleshooting + +### Agent Ignores Rules + +1. Verify files exist at the expected locations +2. Check file names match exactly (case-sensitive) +3. Restart your editor to reload configuration + +### Agent Generates Wrong Patterns + +Add explicit examples to your `AGENTS.md`: + +```markdown +## Patterns + +### CORRECT - Use authActionClient +export const action = authActionClient.inputSchema(schema).action(async ({ parsedInput: data, ctx: { user } }) => {}); + +### INCORRECT - Raw Server Action +export async function action(data: FormData) {} // Missing validation +``` + +### Agent Misunderstands Structure + +Add a quick reference section: + +```markdown +## Quick Reference + +- App routes: `apps/web/app/` +- Shared components: `packages/ui/src/` +- Database schemas: `apps/web/supabase/schemas/` +- Feature packages: `packages/features/*/` +``` + +## Next Steps + +- Configure the [MCP Server](./mcp) for enhanced agent capabilities +- Add [LLM rules](./llms) for additional context from documentation +- Review [conventions](./conventions) to understand the patterns agents follow + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Which AI coding tool works best with MakerKit?", "answer": "Claude Code and Cursor have the deepest integration through AGENTS.md and CLAUDE.md files. Both support the hierarchical instruction system and built-in skills. Choose based on your preferred workflow."}, + {"question": "Do I need the MCP Server?", "answer": "No, but it helps. The MCP Server gives agents additional tools for understanding the codebase. Agents work without it, but may need more guidance for complex tasks."}, + {"question": "How do I update agent rules when I change my app?", "answer": "Edit the relevant AGENTS.md file. Changes apply immediately to new agent sessions. Document your domain logic, API patterns, and feature flags so agents generate code that fits your application."}, + {"question": "Can I use multiple AI tools on the same project?", "answer": "Yes. The instruction files work across tools. AGENTS.md covers Cursor, Windsurf, and Codex. CLAUDE.md adds Claude-specific context. You can switch between tools without reconfiguring."}, + {"question": "Why does the agent generate code that doesn't match my patterns?", "answer": "Add explicit examples to your AGENTS.md showing correct and incorrect patterns. Agents learn from examples better than abstract rules. Include the specific imports, function signatures, and patterns you want."}, + {"question": "How do skills work with AI agents?", "answer": "Skills are specialized instruction sets you invoke explicitly with /skill-name syntax in Claude Code. For example, /postgres-supabase-expert provides database guidance, /server-action-builder helps with Server Actions."} + ] +/%} diff --git a/docs/installation/clone-repository.mdoc b/docs/installation/clone-repository.mdoc new file mode 100644 index 000000000..7906f875e --- /dev/null +++ b/docs/installation/clone-repository.mdoc @@ -0,0 +1,217 @@ +--- +status: "published" +title: "Clone the Next.js Supabase SaaS Kit Repository" +label: "Clone the Repository" +order: 4 +description: "Set up your development environment and clone the Next.js Supabase SaaS Kit Turbo repository." +--- + +{% sequence title="Setup Steps" description="Get your development environment ready and clone the repository." %} + +[Install prerequisites](#prerequisites) + +[Clone the repository](#cloning-the-repository) + +[Install dependencies](#install-dependencies) + +[Configure Git remotes](#configure-git-remotes) + +{% /sequence %} + +## Prerequisites + +Before cloning, install these tools: + +| Tool | Required Version | Installation | +|------|-----------------|--------------| +| Node.js | **20.10.0+** | [nodejs.org](https://nodejs.org) or use nvm | +| pnpm | **10.19.0+** | `npm i -g pnpm` | +| Docker | Latest | [Docker Desktop](https://www.docker.com/products/docker-desktop/) | +| Git | Latest | [git-scm.com](https://git-scm.com) | + +{% alert type="error" title="Node.js Version" %} +The kit requires Node.js 20.10.0 or later. Earlier versions will fail during installation due to engine requirements in package.json. +{% /alert %} + +### Verify Your Setup + +```bash +node -v # Should output v20.10.0 or higher +pnpm -v # Should output 10.19.0 or higher +docker -v # Should output Docker version +``` + +### Why Docker? + +Supabase runs locally using Docker containers. You don't need to understand Docker internals; just have it running when you start the development server. + +**macOS alternatives**: [OrbStack](https://orbstack.dev/) is faster than Docker Desktop and works as a drop-in replacement. + +## Cloning the Repository + +### Using SSH (recommended) + +```bash +git clone git@github.com:makerkit/next-supabase-saas-kit-turbo my-saas +cd my-saas +``` + +### Using HTTPS + +```bash +git clone https://github.com/makerkit/next-supabase-saas-kit-turbo my-saas +cd my-saas +``` + +If you haven't configured SSH keys, see [GitHub's SSH setup guide](https://docs.github.com/en/authentication/connecting-to-github-with-ssh). + +### Windows Users + +Place the repository near your drive root (e.g., `C:\projects\my-saas`) to avoid path length issues with Node.js modules. + +**Avoid**: OneDrive folders, deeply nested paths, or paths with spaces. + +## Install Dependencies + +```bash +pnpm i +``` + +This installs all workspace dependencies across the monorepo. The first install takes a few minutes. + +### Warnings in the terminal + +You may see the following warnings in the terminal: + +``` +WARN  Failed to create bin at /vercel/path0/node_modules/.pnpm/node_modules/.bin/supabase. ENOENT: no such file or directory +``` + +This is just a warning [caused by PNPM and Supabase](https://github.com/supabase/cli/issues/3489) and can be safely ignored. + +Another warning you may see is: + +``` +Ignored build scripts: core-js-pure, sharp, unrs-resolver. +Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts. +``` + +This is by design! We don't want to run scripts from dependencies that are not needed for the project to run properly. This is a security precaution. + +## Configure Git Remotes + +### Automatic Setup (recommended) + +Run the setup generator to configure remotes and create your own repository: + +```bash +pnpm turbo gen setup +``` + +This interactive script: +1. Removes the original `origin` remote +2. Adds `upstream` pointing to the MakerKit repository +3. Prompts you to create and connect your own repository + +### Manual Setup + +If the automatic setup fails or you prefer manual control: + +**1. Remove the original origin:** + +```bash +git remote rm origin +``` + +**2. Add upstream for updates:** + +```bash +git remote add upstream git@github.com:makerkit/next-supabase-saas-kit-turbo +``` + +**3. Create your repository on GitHub, then add it as origin:** + +```bash +git remote add origin git@github.com:your-username/your-repo.git +git push -u origin main +``` + +**4. Verify remotes:** + +```bash +git remote -v +# origin git@github.com:your-username/your-repo.git (fetch) +# origin git@github.com:your-username/your-repo.git (push) +# upstream git@github.com:makerkit/next-supabase-saas-kit-turbo (fetch) +# upstream git@github.com:makerkit/next-supabase-saas-kit-turbo (push) +``` + +## Pulling Updates + +To get the latest changes from MakerKit: + +```bash +git pull upstream main +``` + +Run this regularly to stay current with bug fixes and new features. + +### Automate Post-Merge Dependency Install + +Create a Git hook to automatically run `pnpm i` after pulling: + +```bash +cat > .git/hooks/post-merge << 'EOF' +#!/bin/bash +pnpm i +EOF +chmod +x .git/hooks/post-merge +``` + +This prevents the common issue of missing dependencies after pulling updates. + +## Common Pitfalls + +Avoid these issues that trip up most users: + +1. **Wrong Node.js version**: The kit requires Node.js 20.10.0+. Using Node 18 or earlier causes silent failures and missing features. +2. **Docker not running**: Supabase commands hang indefinitely if Docker isn't started. Always open Docker Desktop before running `supabase:web:start`. +3. **Windows long paths**: Node.js modules create deeply nested folders. Place your project near the drive root (e.g., `C:\projects\my-saas`). +4. **OneDrive sync conflicts**: OneDrive can corrupt `node_modules`. Keep your project outside OneDrive-synced folders. +5. **Forgetting to set upstream**: Without the `upstream` remote, you can't pull MakerKit updates. Run `git remote -v` to verify both `origin` and `upstream` exist. +6. **Skipping `pnpm i` after updates**: Pulling changes often adds new dependencies. Always run `pnpm i` after `git pull upstream main`. + +## Troubleshooting + +### "Permission denied" when cloning + +Your SSH key isn't configured. Either: +- Set up SSH keys following [GitHub's guide](https://docs.github.com/en/authentication/connecting-to-github-with-ssh) +- Use HTTPS instead: `git clone https://github.com/makerkit/next-supabase-saas-kit-turbo` + +### "Engine requirements not met" + +Your Node.js version is too old. Install Node.js 20.10.0+ using nvm: + +```bash +nvm install 20 +nvm use 20 +``` + +### Git username mismatch + +If you encounter permission issues, verify your Git username matches your GitHub account: + +```bash +git config user.username +``` + +Set it if needed: + +```bash +git config --global user.username "your-github-username" +``` + +## Next Steps + +With the repository cloned, proceed to [running the project](/docs/next-supabase-turbo/installation/running-project) to start development. diff --git a/docs/installation/code-health.mdoc b/docs/installation/code-health.mdoc new file mode 100644 index 000000000..0197c2a7c --- /dev/null +++ b/docs/installation/code-health.mdoc @@ -0,0 +1,82 @@ +--- +status: "published" +label: "Code Health and Testing" +title: "Code Health and Testing in Next.js Supabase" +order: 10 +description: "Learn how to set up Github Actions, pre-commit hooks, and more in Makerkit to ensure code health and quality." +--- + +Makerkit provides a set of tools to ensure code health and quality in your project. This includes setting up Github Actions, pre-commit hooks, and more. + +## Github Actions + +By default, Makerkit sets up Github Actions to run tests on every push to the repository. You can find the Github Actions configuration in the `.github/workflows` directory. + +The workflow has two jobs: + +1. `typescript` - runs the Typescript compiler to check for type errors. +2. `test` - runs the Playwright tests in the `tests` directory. + +### Enable E2E Tests + +Since it needs some setup - these are not enabled by default. To enable E2E tests, you need to set the following environment variables in the Github Actions workflow: + +```bash +ENABLE_E2E_JOB=true +``` + +### Configuring the E2E environment for Github Actions +To set up Github Actions for your project, please add the following secrets to your repository Github Actions settings: + +1. `SUPABASE_SERVICE_ROLE_KEY` - the service role key for Supabase. +2. `SUPABASE_DB_WEBHOOK_SECRET` - the webhook secret for Supabase. +3. `STRIPE_SECRET_KEY` - the secret key for Stripe. +4. `STRIPE_WEBHOOK_SECRET` - the webhook secret for Stripe. + +These are the same as the ones you have running the project locally. Don't use real secrets here - use the development app secrets that you use locally. **This is a testing environment**, and you don't want to expose your production secrets. + +Additionally, please set the following environment variables in the Github Actions workflow: + +1. `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` - this is the publishable key for Stripe. +2. `SUPABASE_DB_WEBHOOK_SECRET`: Use the value `WEBHOOKSECRET` for the webhook secret. Again, this is a testing environment, so we add testing values. + +### Enable Stripe Tests + +Since it needs some setup - these are not enabled by default. To enable Stripe tests, you need to set the following environment variables in the Github Actions workflow: + +```bash +ENABLE_BILLING_TESTS=true +``` + +Of course - make sure you have the Stripe secrets set up in the Github Actions secrets. + +## Pre-Commit Hook + +It's recommended to set up a pre-commit hook to ensure that your code is linted correctly and passes the typechecker before committing. + +### Setting up the Pre-Commit Hook + +To do so, create a `pre-commit` file in the `./.git/hooks` directory with the following content: + +```bash +#!/bin/sh + +pnpm run typecheck +pnpm run lint +``` + +Turborepo will execute the commands for all the affected packages - while skipping the ones that are not affected. + +### Make the Pre-Commit Hook Executable + +Now, make the file executable: + +```bash +chmod +x ./.git/hooks/pre-commit +``` + +To test the pre-commit hook, try to commit a file with linting errors or type errors. The commit should fail, and you should see the error messages in the console. + +### What about the e2e tests? + +You could also add tests - but it'll slow down your commits. It's better to run tests in Github Actions. diff --git a/docs/installation/common-commands.mdoc b/docs/installation/common-commands.mdoc new file mode 100644 index 000000000..01206832d --- /dev/null +++ b/docs/installation/common-commands.mdoc @@ -0,0 +1,177 @@ +--- +status: "published" +title: 'Essential Commands for the Next.js Supabase SaaS Kit' +label: 'Common Commands' +order: 6 +description: 'Quick reference for development, database, testing, and code quality commands in the Next.js Supabase SaaS Kit.' +--- + +A quick reference for the commands you'll use daily. All commands run from the project root. + +## Daily Development + +| Command | What It Does | +|---------|-------------| +| `pnpm dev` | Start Next.js dev server + dev tools | +| `pnpm run supabase:web:start` | Start local Supabase | +| `pnpm run supabase:web:stop` | Stop local Supabase | +| `pnpm run stripe:listen` | Forward Stripe webhooks locally | + +## Database Operations + +### Starting and Stopping + +```bash +# Start Supabase (requires Docker running) +pnpm run supabase:web:start + +# Stop Supabase +pnpm run supabase:web:stop +``` + +### Migrations + +```bash +# Reset database (re-run all migrations + seed) +pnpm run supabase:web:reset + +# Generate types after schema changes +pnpm run supabase:web:typegen + +# Run database tests +pnpm run supabase:web:test +``` + +### Creating New Migrations + +After modifying tables in Supabase Studio, create a migration: + +```bash +# See what changed +pnpm --filter web supabase db diff + +# Create a named migration file +pnpm --filter web supabase db diff -f add-projects-table +``` + +This creates a new file in `apps/web/supabase/migrations/`. + +### Running Supabase CLI Commands + +The Supabase CLI is scoped to `apps/web`. To run any Supabase command: + +```bash +pnpm --filter web supabase <command> +``` + +Examples: + +```bash +# Link to remote project +pnpm --filter web supabase link --project-ref your-project-ref + +# Push migrations to production +pnpm --filter web supabase db push + +# Pull remote schema +pnpm --filter web supabase db pull +``` + +## Code Quality + +Run these before committing: + +```bash +# Type checking +pnpm typecheck + +# Lint and auto-fix +pnpm lint:fix + +# Format code +pnpm format:fix +``` + +Or run all three: + +```bash +pnpm typecheck && pnpm lint:fix && pnpm format:fix +``` + +## Testing + +```bash +# Run all tests +pnpm test + +# Run E2E tests (requires app running) +pnpm --filter e2e test +``` + +## Environment Variables + +```bash +# Generate environment variables from template +pnpm turbo gen env + +# Validate environment variables +pnpm turbo gen validate-env +``` + +## Cleaning Up + +When dependencies get out of sync or caches cause issues: + +```bash +# Clean all workspaces +pnpm run clean:workspaces + +# Clean root +pnpm run clean + +# Reinstall everything +pnpm i +``` + +## Package Management + +```bash +# Install dependencies +pnpm i + +# Update all dependencies +pnpm update -r + +# Check for version mismatches +pnpm syncpack:list + +# Fix version mismatches +pnpm syncpack:fix +``` + +## Building for Production + +```bash +# Build all packages and apps +pnpm build + +# Analyze bundle size +pnpm --filter web analyze +``` + +## Quick Reference Card + +```bash +# Start everything +pnpm run supabase:web:start && pnpm dev + +# Database workflow +pnpm run supabase:web:reset # Reset to clean state +pnpm run supabase:web:typegen # Update TypeScript types + +# Before committing +pnpm typecheck && pnpm lint:fix && pnpm format:fix + +# When things break +pnpm run clean:workspaces && pnpm run clean && pnpm i +``` \ No newline at end of file diff --git a/docs/installation/conventions.mdoc b/docs/installation/conventions.mdoc new file mode 100644 index 000000000..6a8038bf9 --- /dev/null +++ b/docs/installation/conventions.mdoc @@ -0,0 +1,47 @@ +--- +status: "published" +description: "Makerkit uses conventions to ensure consistency and readability in the codebase." +title: "Conventions in the Next.js Supabase Turbo Starter Kit" +label: "Conventions" +order: 3 +--- + +Below are some of the conventions used in the Next.js Supabase Turbo Starter Kit. + +**You're not required to follow these conventions**: they're simply a standard set of practices used in the core kit. If you like them - I encourage you to keep these during your usage of the kit - so to have consistent code style that you and your teammates understand. + +### Turborepo Packages + +In this project, we use Turborepo packages to define reusable code that can be shared across multiple applications. + +- **Apps** are used to define the main application, including routing, layout, and global styles. +- Apps pass down configuration to the packages, and the packages provide the corresponding logic and components. + +### Creating Packages + +**Should you create a package for your app code?** + +- **Recommendation:** Do not create a package for your app code unless you plan to reuse it across multiple applications or are experienced in writing library code. +- If your application is not intended for reuse, keep all code in the app folder. This approach saves time and reduces complexity, both of which are beneficial for fast shipping. +- **Experienced developers:** If you have the experience, feel free to create packages as needed. + +### Imports and Paths + +When importing modules from packages or apps, use the following conventions: + +- **From a package:** Use `@kit/package-name` (e.g., `@kit/ui`, `@kit/shared`, etc.). +- **From an app:** Use `~/` (e.g., `~/lib/components`, `~/config`, etc.). + +### Non-Route Folders + +Non-route folders within the `app` directory are prefixed with an underscore (e.g., `_components`, `_lib`, etc.). + +- This prefix indicates that these folders are not routes but are used for shared components, utilities, etc. + +### Server Code + +Files located in `server` folders are intended for server-side code only. They should not be used in client-side code. + +- This convention helps clarify where the code is meant to run, which is particularly important in Next.js where the distinction can be blurry. +- For example, server-related code for a part of the app can be found in the `_lib/server` folder. +- Include the `server-only` package at the top of the file to ensure it is not accidentally imported in client-side code. \ No newline at end of file diff --git a/docs/installation/faq.mdoc b/docs/installation/faq.mdoc new file mode 100644 index 000000000..dfba8dfab --- /dev/null +++ b/docs/installation/faq.mdoc @@ -0,0 +1,77 @@ +--- +status: "published" +title: "FAQ - Questions about the Next.js SaaS Boilerplate" +label: "FAQ" +order: 13 +description: "Frequently asked questions about the Next.js SaaS Boilerplate." +--- + +The below is a technical FAQ about this kit. For general questions about Makerkit, please see the [Makerkit FAQ](/faq). + +## Technical FAQ + +### Do I need to know Supabase to use the Next.js SaaS Boilerplate? + +Yes, you should have a basic understanding of Supabase to use the Next.js SaaS Boilerplate. You'll need to know how to: + +- Create a Supabase project +- Set up the database +- Understand PostgreSQL +- Use the Supabase client in your Next.js app + +While you can use the kit to learn, it does not teach you how to use Supabase. For that, please refer to the [Supabase documentation](https://supabase.com/docs). + +### Do I need to know Next.js to use the Next.js SaaS Boilerplate? + +Yes, you should have a basic understanding of Next.js to use the Next.js SaaS Boilerplate. + +### I don't know Supabase! Should I buy the Next.js SaaS Boilerplate? + +You should be prepared for a learning curve or consider learning Supabase first. The Next.js SaaS Boilerplate is built on top of Supabase, so knowing how to use Supabase is essential. + +### I don't know Turborepo! Should I buy the Next.js SaaS Boilerplate? + +Yes, you can still use the Next.js SaaS Boilerplate without prior knowledge of Turborepo. Turborepo is used to manage the monorepo structure of the boilerplate. Your main focus will be on building your SaaS product within the `apps/web` directory, not on the tools used to build the boilerplate. Even without experience using Turborepo, you won't need to interact with it directly unless you plan to customize the core code in the `packages` directory. + +### Will you add more packages in the future? + +Very likely! This kit is designed to be modular, allowing for new features and packages to be added without interfering with your existing code. There are many ideas for new packages and features that may be added in the future. + +### Can I use this kit for a non-SaaS project? + +This kit is primarily intended for SaaS projects. If you're building a non-SaaS project, the Next.js SaaS Boilerplate might be overkill. You can still use it, but you might need to remove some features specific to SaaS projects. + +### Can I use personal accounts only? + +Yes, you can set a feature flag to disable team accounts and use personal accounts only. + +### Can I use the React package X with this kit? + +Yes, you can use any React package with this kit. The kit is a simple Next.js application, so you are generally only constrained by the underlying technologies (Next.js, Stripe, Supabase, etc.) and not by the kit itself. Since you own and can edit all the code, you can adapt the kit to your needs. However, if there are limitations with the underlying technology, you might need to work around them. + +### Does Makerkit set up the production instance for me? + +No, Makerkit does not set up the production instance for you. This includes setting up Supabase, Stripe, and any other services you need. Makerkit does not have access to your Stripe or Supabase accounts, so setup on your end is required. Makerkit provides the codebase and documentation to help you set up your SaaS project. + +### How do I get support if I encounter issues? + +For support, you can: + +1. Visit our Discord +2. Contact us via support email + +### Are there any example projects or demos? + +Yes - you get access to the OpenAI demo. + +### How do I deploy my application? + +Please check the [production checklist](../going-to-production/checklist) for more information. + +### How do I contribute to the Next.js SaaS Boilerplate? + +We welcome contributions! Please ping me if you'd like to contribute (licensees only). + +### How do I update my project when a new version of the boilerplate is released? + +Please [read the documentation for updating your Makerkit project](updating-codebase). diff --git a/docs/installation/functional-walkthrough.mdoc b/docs/installation/functional-walkthrough.mdoc new file mode 100644 index 000000000..1f19ee6fc --- /dev/null +++ b/docs/installation/functional-walkthrough.mdoc @@ -0,0 +1,282 @@ +--- +status: "published" +title: 'Functional Walkthrough - Next.js Supabase Turbo Starter Kit' +label: 'Walkthrough' +order: 8 +description: 'A functional walkthrough of the Next.js Supabase Turbo Starter Kit. Understand the features and how to use the kit.' +--- + +This is a functional walkthrough of the Next.js Supabase Turbo Starter Kit. In this guide, you'll learn about the functional aspects of the kit. + +## Overview of the Next.js Supabase Turbo Starter Kit + +We can break down the Next.js Supabase Turbo Starter Kit into the following functional sections: + +1. **Marketing / External Section** - the public-facing part of the application. This also includes the blog and documentation. +2. **Authentication** - the authentication system of the application. +3. **Personal Account Features** - the features available to personal accounts. +4. **Team Account Features** - the features available to team accounts. +5. **Invitations** - the invitation system of the application. +6. **Super Admin** - the super admin features of the application. + +## Marketing / External Section + +The marketing section is the public-facing part of the application. It is where users can learn about the product, the pricing and the legal information. + +### Home Page + +The home page is the landing page of the application. It showcases the features of the product and encourages users to sign up. + +{% img src="/assets/images/docs/walkthrough/home-page.webp" width="3420" height="2142" /%} + +### Pricing + +The pricing page is where users can learn about the different pricing plans of the product. + +{% img src="/assets/images/docs/walkthrough/pricing.webp" width="3420" height="2142" /%} + +This section is also added to the home page. + +### FAQ + +The FAQ page is where users can find answers to common questions about the product. + +{% img src="/assets/images/docs/walkthrough/faq.webp" width="3420" height="2142" /%} + +### Contact + +The contact page is where users can get in touch with the company. It includes a contact form that allows users to send messages to the company directly. + +{% img src="/assets/images/docs/walkthrough/contact.webp" width="3420" height="2142" /%} + +### Content Pages + +Content pages can be displayed using the CMS that you have setup. By default, the kit implements a Blog and a Documentation systems using either Keystatic or Wordpress. You can choose which one you prefer. + +#### Blog + +The blog is where the company can publish articles about the product, the industry, and other topics. + +Below is the page where all the latest blog posts are listed: + +{% img src="/assets/images/docs/walkthrough/blog.webp" width="3420" height="2142" /%} + +And here is an example of a blog post: + +{% img src="/assets/images/docs/walkthrough/blog-post.webp" width="3420" height="2142" /%} + +#### Documentation + +The documentation is where users can learn how to use the product. It includes guides, tutorials, and reference material. + +{% img src="/assets/images/docs/walkthrough/walkthrough-documentation.webp" width="3420" height="2142" /%} + +### Legal Pages + +The legal pages are, of course, empty and need to be filled in with the appropriate legal information. + +Don't use ChatGPT to fill them up. It's a bad idea. + +## Authentication + +The authentication system is where users can sign up, sign in, reset their password. It also includes multi-factor authentication. + +### Sign Up + +The sign-up page is where users can create an account. They need to provide their email address and password. + +{% img src="/assets/images/docs/walkthrough/sign-up.webp" width="3420" height="2142" /%} + +Once successful, users are asked to confirm their email address. This is enabled by default - and due to security reasons, it's not possible to disable it. + +{% img src="/assets/images/docs/walkthrough/sign-up-success.webp" width="3420" height="2142" /%} + +### Sign In + +The sign-in page is where users can log in to their account. They need to provide their email address and password. + +{% img src="/assets/images/docs/walkthrough/sign-in.webp" width="3420" height="2142" /%} + +### Password Reset + +The password reset page is where users can reset their password. They need to provide their email address. + +{% img src="/assets/images/docs/walkthrough/password-reset.webp" width="3420" height="2142" /%} + +### Multi-Factor Authentication + +Multi-Factor Authentication (MFA) is an additional layer of security that requires users to provide two or more verification factors to sign in to their account. + +First, users need to enable MFA and add a factor: + +{% img src="/assets/images/docs/walkthrough/setup-mfa.webp" width="3420" height="2142" /%} + +Then, after signing in, users need to provide the verification code: + +{% img src="/assets/images/docs/walkthrough/verify-mfa.webp" width="3420" height="2142" /%} + +## Internal / Behind authentication pages + +After signing in - users are redirected to the internal pages of the application. These pages are only accessible to authenticated users. + +The internal part of the application is divided into two workspaces: + +1. The user workspace +2. The team workspace (optional) + +This is how this works: + +1. **Personal Account** - all users have a personal account. This is where they can: manage their settings, choose a team account - and **optionally** subscribe to a plan, or access the features you provide. +2. **Team Account (optional)** - users can create a team account - and invite other users to join. The team account has its own workspace - where users can manage the team settings, members, and billing. + +Generally speaking, **it's up to you** to decide what features are available to personal accounts and team accounts. You can choose to disable billing for personal accounts - and only enable it for team accounts - or vice versa. + +One simple rule of a thumb is that personal accounts are for individuals - and team accounts are for organizations. Personal accounts cannot be disabled, as that would disallow users from accessing the application should they not be part of a team - which doesn't make sense. + +## Personal Account Features + +The personal account workspace is where users can access the features available to their personal account. + +This is the home page after logging in - and it's the user workspace: + +1. Home Page - empty by default (but you can optionally provide the list of teams the user is part of) +2. Account Settings +3. Billing (if enabled) + +### Home Page of the user workspace + +By default - the user home is an empty page - as it's likely you will want to place some content that is unique to your SaaS. + +However, we provide a component that allows you to lists the team an account is part of: this is useful for B2B SaaS rather than B2C. + +The internal pages have two layouts: + +1. A sidebar layout (default) +2. A top navigation layout + +You can choose any of the two - and also choose either one for the user layout or the account layout. + +Below is the user home page with the sidebar layout: + +{% img src="/assets/images/docs/walkthrough/user-home-sidebar.webp" width="3420" height="2142" /%} + +And below is the user home page with the top navigation layout: + +{% img src="/assets/images/docs/walkthrough/user-home-header.webp" width="3420" height="2142" /%} + +You can choose the one that fits your needs. + +### Account Settings of the user workspace + +From the navigation - users can access their account settings. Here they can update their profile information, change their password, language, multi-factor authentication, and more. + +We've used light mode so far - how about dark mode? Let's switch to dark mode: + +{% img src="/assets/images/docs/walkthrough/user-account-settings.webp" width="3420" height="2142" /%} + +### Billing of the user workspace + +Users can check out and subscribe to a plan - or visit the billing portal - from the billing page. + +**This is only visible if billing is enabled**: you can choose to disable billing for a personal account - and only enable it for team accounts - or vice versa. + +{% img src="/assets/images/docs/walkthrough/user-billing.webp" width="3420" height="2142" /%} + +Once choosing a plan - we load the embedded checkout form from Stripe (or Lemon Squeezy). + +After subscribing, the billing page will show the subscription details. + +{% img src="/assets/images/docs/walkthrough/user-billing-plan.webp" width="3420" height="2142" /%} + +## Team Account Features + +From the profile dropdown, users can choose: + +1. Switch to a team account +2. Create a team account + +{% img src="/assets/images/docs/walkthrough/user-profile-dropdown.webp" width="876" height="796" /%} + +In a team account workspace - users can access the following features: + +1. A team account home page: by default - we display a mock dashboard, just as an example. +2. Account settings: users can update the team account settings. +3. Members: users can view the members of the team account. +4. Billing: users can view the billing of the team account. + +### Home Page of the team workspace + +By default - the team home is a mock dashboard - just as an example. You can replace this with your own dashboard - or any other content. + +{% img src="/assets/images/docs/walkthrough/team-account-dashboard.webp" width="3420" height="2142" /%} + +### Account Settings of the team workspace + +From the navigation - users can access the team account settings. Here they can update the team account information, or delete the team account (if owner), or leave the team account (if member). + +{% img src="/assets/images/docs/walkthrough/team-account-settings.webp" width="3420" height="2142" /%} + +### Members page of the team workspace + +From the navigation - users can access the members of the team account. + +Here they can: + +1. view the members +2. invite new members +3. remove or update an existing member +4. transfer ownership to another member +5. remove or update an invitation + +{% img src="/assets/images/docs/walkthrough/team-account-members.webp" width="3420" height="2142" /%} + +### Billing of the team workspace + +If enabled - users can view the billing of the team account - and subscribe to a plan or visit the billing portal. + +{% img src="/assets/images/docs/walkthrough/team-account-billing.webp" width="3420" height="2142" /%} + +## Joining a team account + +When a user is invited to join a team account - they receive an email with an invitation link. After signing up or signing in - they are redirected to the join page. + +{% img src="/assets/images/docs/walkthrough/sign-up-invite.webp" width="3420" height="2142" /%} + +### Join Page + +The join page is where users can join a team account. + +{% img src="/assets/images/docs/walkthrough/join-team.webp" width="3420" height="2142" /%} + +## Super Admin + +The super admin is the administrator of the SaaS. They have access to a special set of features that allow them to manage the accounts of the SaaS. + +### Home Page of the super admin + +The home page is a small overview of the SaaS. + +You can easily customize this page to show the most important metrics of your SaaS. + +{% img src="/assets/images/docs/walkthrough/super-admin-dashboard.jpg" width="3420" height="2142" /%} + +### Listing the accounts of the SaaS + +The super admin can view all the accounts of the SaaS. They can filter the accounts by personal accounts, team accounts, or all accounts. + +{% img src="/assets/images/docs/walkthrough/super-admin-accounts.webp" width="3420" height="2142" /%} + +### Viewing the account details + +The super admin can view the details of an account. They can see the account information, the members of the account, and the billing information. + +Additionally, they can perform the following actions: + +1. Ban the account (or unban) +2. Delete the account + +{% img src="/assets/images/docs/walkthrough/super-admin-account.webp" width="3420" height="2142" /%} + +## Conclusion + +This concludes the functional walkthrough of the Next.js Supabase Turbo Starter Kit. You should now have a good understanding of the features of the kit and how to use it. If you have any questions, feel free to reach out to us. We're here to help! diff --git a/docs/installation/introduction.mdoc b/docs/installation/introduction.mdoc new file mode 100644 index 000000000..5171c8b83 --- /dev/null +++ b/docs/installation/introduction.mdoc @@ -0,0 +1,109 @@ +--- +status: "published" +title: 'Introduction to the Next.js Supabase SaaS Starter Kit' +label: 'Introduction' +order: 1 +description: 'A production-ready Next.js 16 and Supabase SaaS starter kit with authentication, billing, teams, and admin dashboard built on React 19 and Tailwind CSS 4.' +--- + +The Next.js Supabase SaaS Kit is a production-ready starter for building multi-tenant SaaS applications. It ships with authentication, team management, subscription billing, and an admin dashboard out of the box. + +Built on **Next.js 16**, **React 19**, **Supabase**, and **Tailwind CSS 4**, this Turborepo monorepo gives you a solid foundation to launch faster without sacrificing code quality or flexibility. + +## What You Get + +### Authentication and Security + +- Email/password and OAuth sign-in (Google, GitHub, etc.) +- Magic link authentication +- Multi-factor authentication (TOTP) +- Password reset and email verification +- Session management with Supabase Auth + +### Multi-Tenant Account System + +- **Personal accounts**: Every user has a personal workspace +- **Team accounts**: Create organizations with multiple members +- **Role-based access**: Owner, Admin, Member roles with customizable permissions +- **Invitations**: Invite users via email with role assignment +- **RLS enforcement**: Row Level Security policies protect data at the database level + +### Subscription Billing + +- **Stripe** and **Lemon Squeezy** integrations +- Subscription models: flat-rate, tiered, per-seat pricing +- Customer portal for self-service management +- Webhook handling for subscription lifecycle events +- Support for both personal and team billing + +### Admin Dashboard + +- User management (view, impersonate, ban) +- Subscription overview and management +- Analytics and metrics dashboard +- Super admin role with MFA requirement + +### Developer Experience + +- **Turborepo monorepo** with shared packages +- **TypeScript** throughout with strict mode +- **Shadcn UI** components (Base UI-based) +- **React Query** for client-side data fetching +- **Zod** for runtime validation +- **next-intl** for i18n with locale-prefixed URL routing +- **Keystatic** or **WordPress** CMS integration +- **Sentry** and **Baselime** monitoring support +- Pre-configured **LLM rules** for Cursor, Claude Code, and Windsurf + +## Monorepo Architecture + +The kit separates concerns into reusable packages: + +``` +apps/ + web/ # Main Next.js application + e2e/ # Playwright end-to-end tests + +packages/ + features/ # Feature modules (auth, accounts, admin, etc.) + ui/ # Shared UI components (@kit/ui) + supabase/ # Supabase client and types (@kit/supabase) + billing/ # Billing logic and gateway (@kit/billing) + mailers/ # Email providers (@kit/mailers) + i18n/ # Internationalization (@kit/i18n) + monitoring/ # Error tracking (@kit/monitoring) + analytics/ # User analytics (@kit/analytics) +``` + +This structure lets you modify features without touching core packages, and makes it straightforward to add new applications that share the same backend logic. + +## Prerequisites + +Before diving in, you should be comfortable with: + +- **Next.js App Router**: Server Components, Server Actions, route handlers +- **Supabase**: PostgreSQL, Row Level Security, Auth +- **React**: Hooks, TypeScript, component patterns + +The kit builds on these technologies rather than teaching them. If you're new to Supabase or the App Router, spend time with their official docs first. + +## Documentation Scope + +This documentation covers kit-specific configuration, patterns, and customization. For underlying technology details: + +- [Next.js Documentation](https://nextjs.org/docs) +- [Supabase Documentation](https://supabase.com/docs) +- [Stripe Documentation](https://stripe.com/docs) +- [Turborepo Documentation](https://turbo.build/repo/docs) + +## Upgrading from v2 + +{% callout title="Differences with v2" %} +In v3, the kit migrated from Radix to Base UI primitives, from `i18next` to `next-intl`, and from `enhanceAction` to `next-safe-action`. See [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration) for the full migration guide. +{% /callout %} + +## Next Steps + +1. [Check the technical details](/docs/next-supabase-turbo/installation/technical-details) to understand the full stack +2. [Clone the repository](/docs/next-supabase-turbo/installation/clone-repository) and set up your environment +3. [Run the project locally](/docs/next-supabase-turbo/installation/running-project) to explore the features diff --git a/docs/installation/mcp.mdoc b/docs/installation/mcp.mdoc new file mode 100644 index 000000000..aade42801 --- /dev/null +++ b/docs/installation/mcp.mdoc @@ -0,0 +1,98 @@ +--- +status: "published" +title: "MCP Server for Next.js Supabase" +label: "MCP Server" +order: 12 +description: "Configure MCP for Next.js Supabase for best AI assistance using AI Agents" +--- + +The Makerkit MCP Server provides tools to AI Agents for working with the codebase. + +## Build MCP Server + +Run the command: + +```bash +pnpm --filter "@kit/mcp-server" build +``` + +The command will build the MCP Server at `packages/mcp-server/build/index.js`. + +## Adding MCP Servers to AI Coding tools + +In general, tools should automatically detect the MCP Server and use it as we ship the configuration in the `.mcp.json` file. However, you can add it manually to your AI coding tool by following the steps below. + +Before getting started, retrieve the absolute path to the `index.js` file created above. You can normally do this in your IDE by right-clicking the `index.js` file and selecting `Copy Path`. + +I will reference this as `<full-path>` in the steps below: please replace it with the full path to your `index.js`. + +### Claude Code + +Claude will automatically detect the MCP Server and use it as we ship the configuration in the `.mcp.json` file. + +### Codex + +Open the Codex TOML config and add the following: + +```toml +[mcp_servers.makerkit] +command = "node" +args = ["<path-to-mcp-server>"] +``` + +Please change the `<path-to-mcp-server>` to the actual path of the MCP Server. + +### Cursor + +Open the `mcp.json` config in Cursor and add the following config: + +```json +{ + "mcpServers": { + "makerkit": { + "command": "node", + "args": ["<path-to-mcp-server>"] + } + } +} +``` + +Please change the `<path-to-mcp-server>` to the actual path of the MCP Server. + +### Other Agents + +Please refer to the documentation for other AI Agents. The format is normally similar with minimal differences. + +## CLI MCP Server + +The CLI MCP Server is a separate MCP server that ships with the MakerKit CLI. It lets your AI assistant create projects, install plugins, pull upstream updates, and resolve merge conflicts. + +To add it, drop this into your editor's MCP config: + +Using npx: + +```json +{ + "mcpServers": { + "makerkit-cli": { + "command": "npx", + "args": ["--yes", "--quiet", "@makerkit/cli@latest", "makerkit-cli-mcp"] + } + } +} +``` + +Using pnpm: + +```json +{ + "mcpServers": { + "makerkit-cli": { + "command": "pnpm", + "args": ["--silent", "dlx", "@makerkit/cli@latest", "makerkit-cli-mcp"] + } + } +} +``` + +The `--quiet` flag (npx) and `--silent` flag (pnpm) prevent package manager output from interfering with the MCP JSON-RPC stream. diff --git a/docs/installation/migration-from-makerkit-v1.mdoc b/docs/installation/migration-from-makerkit-v1.mdoc new file mode 100644 index 000000000..135acac8e --- /dev/null +++ b/docs/installation/migration-from-makerkit-v1.mdoc @@ -0,0 +1,55 @@ +--- +status: "published" +title: "Migrating from Makerkit v1 to Next.js Supabase" +label: "Migrating from v1" +order: 9 +description: "Guidelines for migrating from Makerkit v1 to Next.js SaaS Boilerplate." +--- + +🚀 Welcome to your journey from Makerkit v1 to the Next.js SaaS Boilerplate! + +This guide is designed to help you understand the changes between the two versions and navigate your project migration to the new v2. Whether you're a seasoned developer or just starting out, we've got you covered! + +Here's a roadmap of the steps you'll take: + +1. **Bootstrap a new project**: Kickstart a new project using the Next.js SaaS Boilerplate 🎉 +2. **Update Schema**: Tweak the Postgres schema foreign key references, and integrate your existing tables 🧩 +3. **Move files from older app**: Transport your files to the new app structure 📦 +4. **Update imports to existing files**: Refresh imports to align with the new file structure 🔄 +5. **Update configuration**: Modify the configuration files to match the new schemas ⚙️ + +## 1. Bootstrap a new project + +The Next.js SaaS Boilerplate is a fresh take on Makerkit v1. You'll need to create a new project using this innovative boilerplate. Follow the [installation guide](clone-repository) to get your new project up and running in no time! + +## 2. Update Schema + +The schema in the Next.js SaaS Boilerplate has evolved significantly from Makerkit v1. You'll need to update your Postgres schema to match the new one. + +Previously, you'd have a foreign key set to the organization ID: + +```sql +organization_id bigint not null references organizations(id) on delete cascade, +``` + +Now, you'll have a foreign key set to the account ID as a UUID: + +```sql +account_id uuid not null references public.accounts(id) on delete cascade, +``` + +In v2, an account can be both a user or an organization. So, instead of referencing the organization ID as in v1, you'll now reference the account ID. + +## 3. Move files from older app + +You have the flexibility to add these files to either the "user scope" (`/home`) or the "account scope" (`/home/[account]`). Note that in v3, all routes live under the `[locale]` segment (e.g., `app/[locale]/home/[account]/`). Choose the one that best suits your project needs. + +## 4. Update imports to existing files + +You'll need to update the imports to your existing files to match the new file structure. This applies to all the components and utilities that you imported from Makerkit. For instance, a button previously imported from `~/core/ui/Button`, will now be imported from `@kit/ui/button`. + +## 5. Update configuration + +Lastly, you'll need to update the configuration files to match the new schemas. The configuration is now split across various files at `apps/web/config`. Pay special attention to the billing schema, which is now more versatile (and a bit more complex). + +Ready to get started? Let's dive in! 🏊‍♀️ \ No newline at end of file diff --git a/docs/installation/navigating-codebase.mdoc b/docs/installation/navigating-codebase.mdoc new file mode 100644 index 000000000..bfaf5ff89 --- /dev/null +++ b/docs/installation/navigating-codebase.mdoc @@ -0,0 +1,184 @@ +--- +status: "published" +title: "Navigating the Next.js Supabase SaaS Kit Codebase" +label: "Navigating the Codebase" +order: 7 +description: "Understand the Turborepo monorepo structure, key directories, and where to add your custom code." +--- + +The kit uses Turborepo to organize code into reusable packages. Understanding this structure helps you know where to find things and where to add your own code. + +## Top-Level Structure + +``` +├── apps/ +│ ├── web/ # Main Next.js application +│ ├── dev-tool/ # Development debugging tool +│ └── e2e/ # Playwright end-to-end tests +├── packages/ +│ ├── features/ # Feature modules (auth, accounts, admin) +│ ├── ui/ # Shared UI components +│ ├── supabase/ # Database client and types +│ ├── billing/ # Payment integrations +│ ├── mailers/ # Email providers +│ └── ... # Other shared packages +└── turbo.json # Turborepo configuration +``` + +## Where You'll Work Most + +**90% of your work** happens in `apps/web/`. The packages provide infrastructure; you build your product in the app. + +### apps/web/ Directory + +``` +apps/web/ +├── app/ # Next.js App Router (routes) +├── components/ # App-specific components +├── config/ # Application configuration +├── lib/ # App-specific utilities +├── content/ # CMS content (Keystatic) +├── styles/ # Global CSS +└── supabase/ # Migrations and database tests +``` + +| Directory | What Goes Here | +|-----------|---------------| +| `app/` | All routes and pages | +| `components/` | Components specific to this app | +| `config/` | App settings, feature flags, navigation | +| `lib/` | Utilities that don't belong in packages | +| `supabase/` | Database migrations and seed data | + +## App Router Structure + +The `app/` directory follows Next.js App Router conventions: + +``` +app/ +├── [locale]/ # i18n locale segment (wraps all routes) +│ ├── (marketing)/ # Public pages (landing, pricing, blog) +│ ├── auth/ # Authentication pages +│ ├── home/ # Authenticated dashboard +│ │ ├── (user)/ # Personal account routes +│ │ └── [account]/ # Team account routes (dynamic) +│ ├── admin/ # Super admin dashboard +│ ├── join/ # Team invitation acceptance +│ ├── update-password/ # Password reset completion +│ └── identities/ # OAuth identity linking +├── api/ # API route handlers (outside [locale]) +└── layout.tsx # Minimal root layout +``` + +### Route Groups Explained + +**`(marketing)`** - Pathless group for public pages. URLs don't include "marketing": +- `app/[locale]/(marketing)/page.tsx` → `/` +- `app/[locale]/(marketing)/pricing/page.tsx` → `/pricing` + +**`home/(user)`** - Personal account dashboard. Pathless group under /home: +- `app/[locale]/home/(user)/page.tsx` → `/home` +- `app/[locale]/home/(user)/settings/page.tsx` → `/home/settings` + +**`home/[account]`** - Team account dashboard. Dynamic segment for team slug: +- `app/[locale]/home/[account]/page.tsx` → `/home/acme-corp` +- `app/[locale]/home/[account]/settings/page.tsx` → `/home/acme-corp/settings` + +The `[locale]` segment is optional for the default language (en). Non-default locales use a prefix (e.g., `/es/pricing`). + +## Packages Overview + +Packages provide reusable functionality. Import from them; don't modify unless necessary. + +### Feature Packages (`packages/features/`) + +| Package | Import | Contains | +|---------|--------|----------| +| `@kit/auth` | `@kit/auth/*` | Sign in/up forms, auth hooks | +| `@kit/accounts` | `@kit/accounts/*` | Personal account components | +| `@kit/team-accounts` | `@kit/team-accounts/*` | Team management, invitations | +| `@kit/admin` | `@kit/admin/*` | Super admin dashboard | +| `@kit/notifications` | `@kit/notifications/*` | In-app notifications | + +### Infrastructure Packages + +| Package | Import | Contains | +|---------|--------|----------| +| `@kit/ui` | `@kit/ui/*` | Shadcn components, design system | +| `@kit/supabase` | `@kit/supabase/*` | Database clients, types | +| `@kit/next` | `@kit/next/*` | Server actions, route helpers | +| `@kit/billing` | `@kit/billing/*` | Subscription logic | +| `@kit/mailers` | `@kit/mailers/*` | Email sending | + +## Adding Your Own Code + +### New Pages + +Add routes in `apps/web/app/[locale]/`: + +``` +# Public page +apps/web/app/[locale]/(marketing)/features/page.tsx → /features + +# Authenticated page (personal) +apps/web/app/[locale]/home/(user)/dashboard/page.tsx → /home/dashboard + +# Authenticated page (team) +apps/web/app/[locale]/home/[account]/projects/page.tsx → /home/[slug]/projects +``` + +### New Components + +Add to `apps/web/components/` for app-specific components: + +``` +apps/web/components/ +├── dashboard/ +│ ├── stats-card.tsx +│ └── activity-feed.tsx +└── projects/ + ├── project-list.tsx + └── project-form.tsx +``` + +### New Database Tables + +1. Create migration in `apps/web/supabase/migrations/` +2. Run `pnpm run supabase:web:reset` to apply +3. Run `pnpm run supabase:web:typegen` to update types + +### New API Routes + +Add to `apps/web/app/api/`: + +```typescript +// apps/web/app/api/projects/route.ts +import { enhanceRouteHandler } from '@kit/next/routes'; + +export const GET = enhanceRouteHandler(async ({ user }) => { + // Your logic here +}); +``` + +## Configuration Files + +Located in `apps/web/config/`: + +| File | Purpose | When to Edit | +|------|---------|--------------| +| `app.config.ts` | App name, URLs | During initial setup | +| `auth.config.ts` | Auth providers | Adding OAuth providers | +| `billing.config.ts` | Plans, prices | Setting up billing | +| `feature-flags.config.ts` | Feature toggles | Enabling/disabling features | +| `paths.config.ts` | Route constants | Adding new routes | +| `*-navigation.config.tsx` | Sidebar menus | Customizing navigation | + +## Next Steps + +- [Review common commands](/docs/next-supabase-turbo/installation/common-commands) for daily development +- [Configure the app](/docs/next-supabase-turbo/configuration/application-configuration) for your product +- [Add marketing pages](/docs/next-supabase-turbo/development/marketing-pages) to start building + + + + diff --git a/docs/installation/running-project.mdoc b/docs/installation/running-project.mdoc new file mode 100644 index 000000000..b46b4de1d --- /dev/null +++ b/docs/installation/running-project.mdoc @@ -0,0 +1,165 @@ +--- +status: "published" +title: "Running the Next.js Supabase SaaS Kit Locally" +label: "Running the Project" +order: 5 +description: "Start the local development environment with Supabase, Next.js, and optional Stripe webhook forwarding." +--- + +Running the project requires starting three services: Supabase (database), Next.js (web app), and optionally Stripe (billing webhooks). + +{% sequence title="Startup Order" description="Start services in this order for a working development environment." %} + +[Start Docker](#1-start-docker) + +[Start Supabase](#2-start-supabase) + +[Start Next.js](#3-start-nextjs) + +[Start Stripe (optional)](#4-start-stripe-optional) + +{% /sequence %} + +## 1. Start Docker + +Supabase runs in Docker containers. Open Docker Desktop (or OrbStack on macOS) before proceeding. + +{% alert type="warning" title="Docker Required" %} +Supabase cannot start without Docker running. If you see "Cannot connect to Docker daemon", open your Docker application first. +{% /alert %} + +## 2. Start Supabase + +```bash +pnpm run supabase:web:start +``` + +This starts: +- PostgreSQL database on port 54322 +- Supabase Studio (database UI) at [http://localhost:54323](http://localhost:54323) +- Mailpit (email testing) at [http://localhost:54324](http://localhost:54324) +- Auth, Storage, and other Supabase services + +First startup downloads Docker images and runs migrations. This can take a few minutes. + +**Bookmark these URLs:** +- [localhost:54323](http://localhost:54323) - Supabase Studio (view your database) +- [localhost:54324](http://localhost:54324) - Mailpit (check auth emails) + +### If Supabase Fails to Start + +Common fixes: + +1. **Docker resources**: Increase Docker memory to 4GB+ in Docker Desktop settings +2. **Port conflicts**: Stop other services using ports 54321-54324 +3. **Clean restart**: `pnpm run supabase:web:stop` then try again + +## 3. Start Next.js + +```bash +pnpm dev +``` + +This starts: +- Web application at [http://localhost:3000](http://localhost:3000) +- Dev Tools at [http://localhost:3010](http://localhost:3010) + +The dev tools provide debugging utilities for environment variables, feature flags, and authentication state. + +### Test Credentials + +The database is seeded with test users: + +**Regular User:** +``` +Email: test@makerkit.dev +Password: testingpassword +``` + +**Super Admin (requires MFA):** +``` +Email: super-admin@makerkit.dev +Password: testingpassword +``` + +### Super Admin MFA Setup + +Super admin login requires a TOTP code. Use any authenticator app with this secret: + +``` +NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE +``` + +Or use [totp.danhersam.com](https://totp.danhersam.com/) with this secret to generate codes. + +### If Login Fails + +Reset the database to restore seeded users: + +```bash +pnpm run supabase:web:reset +``` + +This re-runs migrations and seeds fresh test data. + +## 4. Start Stripe (Optional) + +For testing subscription billing, forward Stripe webhooks to your local server: + +```bash +pnpm run stripe:listen +``` + +This requires: +1. [Stripe CLI](https://stripe.com/docs/stripe-cli) installed +2. Stripe account authenticated (`stripe login`) + +The command outputs a webhook signing secret. Add it to your `.env.local`: + +```bash +STRIPE_WEBHOOK_SECRET=whsec_xxxxx +``` + +## Local Development URLs + +| Service | URL | Purpose | +|---------|-----|---------| +| Web App | [localhost:3000](http://localhost:3000) | Main application | +| Dev Tools | [localhost:3010](http://localhost:3010) | Debugging utilities | +| Supabase Studio | [localhost:54323](http://localhost:54323) | Database management | +| Mailpit | [localhost:54324](http://localhost:54324) | Email testing | + +## Stopping Services + +Stop Supabase: +```bash +pnpm run supabase:web:stop +``` + +Stop Next.js: Press `Ctrl+C` in the terminal. + +## Common Issues + +### "Module not found: Can't resolve 'react-dom/client'" + +Windows path length issue. Move your project closer to the drive root (e.g., `C:\projects\my-saas`). + +See [troubleshooting module not found](../troubleshooting/troubleshooting-module-not-found) for details. + +### Database connection errors + +Supabase isn't running. Start it with `pnpm run supabase:web:start`. + +### Seeded users not working + +Database may have stale data. Reset with `pnpm run supabase:web:reset`. + +### Emails not arriving + +Check Mailpit at [localhost:54324](http://localhost:54324). All local emails go there, not to real email addresses. + +## Next Steps + +- [Navigate the codebase](/docs/next-supabase-turbo/installation/navigating-codebase) to understand the project structure +- [Configure the application](/docs/next-supabase-turbo/configuration/application-configuration) to customize for your product +- Review the [production checklist](/docs/next-supabase-turbo/going-to-production/checklist) before deploying diff --git a/docs/installation/technical-details.mdoc b/docs/installation/technical-details.mdoc new file mode 100644 index 000000000..c91537f8e --- /dev/null +++ b/docs/installation/technical-details.mdoc @@ -0,0 +1,167 @@ +--- +status: "published" +title: "Technical Details of the Next.js Supabase SaaS Starter Kit" +label: "Technical Details" +order: 2 +description: "Tech stack overview: Next.js 16, React 19, Supabase, Tailwind CSS 4, Turborepo monorepo with TypeScript, Shadcn UI, and Zod." +--- + +The kit is built as a [Turborepo](https://turbo.build) monorepo using these core technologies: + +| Technology | Version | Purpose | +|------------|---------|---------| +| Next.js | 16 | App Router with Turbopack | +| React | 19 | UI framework | +| Supabase | Latest | Database, Auth, Storage | +| Tailwind CSS | 4 | Styling | +| TypeScript | 5.9+ | Type safety | +| pnpm | 10.19+ | Package manager | +| Node.js | 20.10+ | Runtime (required) | + +### Key Libraries + +- **Shadcn UI** (Base UI-based) for accessible UI components +- **React Query** (@tanstack/react-query) for client-side data fetching +- **next-intl** for internationalization with locale-prefixed URL routing +- **next-safe-action** for type-safe server actions +- **Zod** for schema validation +- **Lucide** for icons +- **react.email** for email templates +- **Nodemailer** or **Resend** for sending emails + +The kit deploys to Vercel, Cloudflare, or any Node.js hosting. Edge runtime support enables deployment to Cloudflare Workers. + +## Monorepo Structure + +### Core Packages + +| Package | Import | Purpose | +|---------|--------|---------| +| `@kit/ui` | `@kit/ui/*` | Shadcn UI components and custom components | +| `@kit/supabase` | `@kit/supabase/*` | Supabase clients, types, and queries | +| `@kit/next` | `@kit/next/*` | Server Actions, route handlers, middleware utilities | +| `@kit/shared` | `@kit/shared/*` | Shared utilities and helpers | +| `@kit/i18n` | `@kit/i18n/*` | Internationalization utilities | + +### Feature Packages + +Located in `packages/features/`: + +| Package | Purpose | +|---------|---------| +| `@kit/auth` | Authentication flows (Supabase Auth) | +| `@kit/accounts` | Personal account management | +| `@kit/team-accounts` | Team/organization management | +| `@kit/admin` | Super admin dashboard | +| `@kit/notifications` | In-app notifications | + +### Billing Packages + +Located in `packages/billing/`: + +| Package | Purpose | +|---------|---------| +| `@kit/billing` | Core billing logic and types | +| `@kit/billing-gateway` | Payment provider abstraction | +| `@kit/stripe` | Stripe integration | +| `@kit/lemon-squeezy` | Lemon Squeezy integration | + +### Infrastructure Packages + +| Package | Purpose | +|---------|---------| +| `@kit/mailers` | Email provider abstraction (Resend, Nodemailer, SendGrid) | +| `@kit/email-templates` | React Email templates | +| `@kit/monitoring` | Error tracking (Sentry, Baselime) | +| `@kit/analytics` | User behavior tracking | +| `@kit/database-webhooks` | Database trigger handlers | +| `@kit/cms` | Content management abstraction | +| `@kit/keystatic` | Keystatic CMS integration | +| `@kit/wordpress` | WordPress headless CMS integration | +| `@kit/policies` | Authorization policy helpers | +| `@kit/otp` | One-time password utilities | + +## Application Configuration + +Configuration files live in `apps/web/config/`: + +| File | Purpose | +|------|---------| +| `app.config.ts` | App name, description, URLs | +| `auth.config.ts` | Auth providers, redirect paths | +| `billing.config.ts` | Billing provider, plans | +| `feature-flags.config.ts` | Feature toggles | +| `paths.config.ts` | Route definitions | +| `personal-account-navigation.config.tsx` | Personal dashboard sidebar | +| `team-account-navigation.config.tsx` | Team dashboard sidebar | + +## Key Patterns + +### Server Actions + +Use `authActionClient` from `@kit/next/safe-action` for type-safe, validated server actions: + +```typescript +import { authActionClient, publicActionClient } from '@kit/next/safe-action'; +import * as z from 'zod'; + +const schema = z.object({ name: z.string().min(1) }); + +// Authenticated action +export const updateName = authActionClient + .inputSchema(schema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // data is validated, user is authenticated + }); + +// Public action (no auth required) +export const submitContact = publicActionClient + .inputSchema(schema) + .action(async ({ parsedInput: data }) => { + // data is validated, no user required + }); +``` + +Use `authActionClient` for authenticated actions and `publicActionClient` for public actions like contact forms. + +### Route Handlers + +Use `enhanceRouteHandler` from `@kit/next/routes` for API routes: + +```typescript +import { enhanceRouteHandler } from '@kit/next/routes'; + +export const POST = enhanceRouteHandler(async ({ body, user }) => { + // body is parsed, user is available + return NextResponse.json({ success: true }); +}, { schema: yourSchema }); +``` + +### Supabase Clients + +```typescript +// Server Components and Server Actions +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +// Admin operations (bypasses RLS) +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +``` + +## Deployment Targets + +The kit supports multiple deployment targets: + +- **Vercel**: Zero-config deployment with environment variables +- **Cloudflare Pages/Workers**: Edge runtime support +- **Docker**: Self-hosted deployments +- **Any Node.js host**: Standard Next.js deployment + +For production deployment, see the [going to production checklist](/docs/next-supabase-turbo/going-to-production/checklist). + +## Upgrading from v2 + +{% callout title="Differences with v2" %} +In v2, the kit used Radix UI primitives, `enhanceAction` for server actions, `i18next` for translations, and `import { z } from 'zod'`. In v3, it uses Base UI primitives, `authActionClient`/`publicActionClient` from `next-safe-action`, `next-intl` for i18n, and `import * as z from 'zod'`. + +For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration). +{% /callout %} diff --git a/docs/installation/update-tailwindcss-v4.mdoc b/docs/installation/update-tailwindcss-v4.mdoc new file mode 100644 index 000000000..0ea34c1cb --- /dev/null +++ b/docs/installation/update-tailwindcss-v4.mdoc @@ -0,0 +1,240 @@ +--- +status: "published" +title: "Guidelines for updating Makerkit to Tailwind CSS v4" +label: "Updating to Tailwind CSS v4" +order: 9 +description: "A guide to updating Makerkit to Tailwind CSS v4. All you need to know to migrate your codebase to the latest Tailwind CSS version." +--- + +Makerkit was originally built with Tailwind CSS v3, and has since been updated to Tailwind CSS v4. This guide will walk you through the changes and migration steps required to update your codebase to the latest version should you choose to update manually. + +If you don't want to update manually, please pull the latest changes from Makerkit. You can use the below as a reference for the changes you need to make for the code you've written so far. + +## Major Changes Overview + +1. **Tailwind CSS Version Update**: Upgraded from v3.4.17 to v4.0.0 +2. **CSS Architecture**: Moved to a component-based CSS architecture with separate stylesheets +3. **File Structure**: Reorganized CSS files into modular components +4. **Styling Changes**: Updated various UI components with new styling patterns + +## File Structure Changes + +The CSS structure has been reorganized into multiple files: + +```txt {% title="apps/web/styles/*.css" %} +styles/ +├── globals.css +├── theme.css +├── theme.utilities.css +├── shadcn-ui.css +├── markdoc.css +└── makerkit.css +``` + +1. `globals.css`: Global styles for the entire application +2. `theme.css`: Theme variables and colors +3. `theme.utilities.css`: Utility classes for the theme +4. `shadcn-ui.css`: ShadcN UI specific styles +5. `markdoc.css`: Markdown/documentation styles +6. `makerkit.css`: Makerkit specific components + +### Retaining your existing styles + +If you wish to keep your existing theme colors, please update the `shadcn-ui.css` file and keep the same variables for the theme. + +**Note:** You have to use the `hsl` function to update the theme colors. + +```css {% title="apps/web/styles/shadcn-ui.css" %} +@layer base { + :root { + --background: hsl(0 0% 100%); + --foreground: hsl(224 71.4% 4.1%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(224 71.4% 4.1%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(224 71.4% 4.1%); + --primary: hsl(220.9 39.3% 11%); + --primary-foreground: hsl(210 20% 98%); + --secondary: hsl(220 14.3% 95.9%); + --secondary-foreground: hsl(220.9 39.3% 11%); + --muted: hsl(220 14.3% 95.9%); + --muted-foreground: hsl(220 8.9% 46.1%); + --accent: hsl(220 14.3% 95.9%); + --accent-foreground: hsl(220.9 39.3% 11%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(210 20% 98%); + --border: hsl(214.3 31.8% 94.4%); + --input: hsl(214.3 31.8% 91.4%); + --ring: hsl(224 71.4% 4.1%); + --radius: 0.5rem; + + --chart-1: hsl(12 76% 61%); + --chart-2: hsl(173 58% 39%); + --chart-3: hsl(197 37% 24%); + --chart-4: hsl(43 74% 66%); + --chart-5: hsl(27 87% 67%); + + --sidebar-background: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + } + + .dark { + --background: hsl(224 71.4% 4.1%); + --foreground: hsl(210 20% 98%); + --card: hsl(224 71.4% 4.1%); + --card-foreground: hsl(210 20% 98%); + --popover: hsl(224 71.4% 4.1%); + --popover-foreground: hsl(210 20% 98%); + --primary: hsl(210 20% 98%); + --primary-foreground: hsl(220.9 39.3% 11%); + --secondary: hsl(215 27.9% 13%); + --secondary-foreground: hsl(210 20% 98%); + --muted: hsl(215 27.9% 13%); + --muted-foreground: hsl(217.9 10.6% 64.9%); + --accent: hsl(215 27.9% 13%); + --accent-foreground: hsl(210 20% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(210 20% 98%); + --border: hsl(215 27.9% 13%); + --input: hsl(215 27.9% 13%); + --ring: hsl(216 12.2% 83.9%); + + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + + --sidebar-background: hsl(224 71.4% 4.1%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(215 27.9% 13%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + } +} +``` + +## Breaking Changes + +### 1. Class Name Updates + +- Replace `space-x-` and `space-y-` with `gap-x-` and `gap-y-` +- Update shadow utilities: +- `shadow` → `shadow-xs` +- `shadow-sm` → `shadow-xs` +- `shadow-lg` → `shadow-xl` + +**Note:** The spacing has changed from v3 using dynamic spacing, so you may need to update your custom spacing values. + +### 2. Border Radius Changes + +- Update rounded utilities: +- `rounded-sm` → `rounded-xs` +- Keep other rounded values the same + +### 3. Color System Changes + +- The theme has been updated with a better looking color system, especially on dark mode + +### 4. Layout Updates + +- Flex gap spacing: +- Replace `space-x-` with `gap-x-` +- Replace `space-y-` with `gap-y-` + +### 5. Container Changes + +```css +/* Old */ +.container { + @apply max-sm:px-4; +} + +/* New */ +@utility container { + margin-inline: auto; + @apply xl:max-w-[80rem] px-8; +} +``` + +#### **Outline Utilities** + +```css +/* Old */ +focus:outline-none + +/* New */ +focus:outline-hidden +``` + +#### **Shadow Utilities** + +```css +/* Old */ +shadow-sm + +/* New */ +shadow-xs +``` + +## Migration Steps + +#### **Update Dependencies** + +```json +{ + "dependencies": { + "tailwindcss": "4.0.0", + "@tailwindcss/postcss": "^4.0.0" + } +} +``` + +The `tailwindcss-animate` dependency is now part of `apps/web/package.json` and should be removed from `package.json` in the Tailwind CSS v4 upgrade. + +#### **Update PostCSS Config** + +```js +module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; +``` + +#### **CSS Files** +- Move existing global styles to appropriate new CSS files +- Update import statements to include new CSS files +- Remove old tailwind.config.ts and replace with new CSS structure + +#### **Component Updates** +- Review all components using spacing utilities +- Update shadow utilities +- Review and update color usage +- Update flex and grid gap utilities + +## Testing Checklist + +- [ ] Verify all components render correctly +- [ ] Check responsive layouts +- [ ] Test dark mode functionality +- [ ] Verify shadow and elevation styles +- [ ] Test container layouts +- [ ] Verify color system implementation +- [ ] Check form component styling +- [ ] Test navigation components +- [ ] Verify modal and overlay styling +- [ ] Check typography scaling + +## Additional Resources + +- [Tailwind CSS v4 Documentation](https://tailwindcss.com/) \ No newline at end of file diff --git a/docs/installation/updating-codebase.mdoc b/docs/installation/updating-codebase.mdoc new file mode 100644 index 000000000..3499cb6f5 --- /dev/null +++ b/docs/installation/updating-codebase.mdoc @@ -0,0 +1,128 @@ +--- +status: "published" +title: "Updating your Next.js Supabase Turbo Starter Kit" +label: "Updating the Codebase" +order: 6 +description: "Learn how to update your Next.js Supabase Turbo Starter Kit to the latest version." +--- + +This guide will walk you through the process of updating your codebase by pulling the latest changes from the GitHub repository and merging them into your project. This ensures you're always equipped with the latest features and bug fixes. + +If you've been following along with our previous guides, you should already have a Git repository set up for your project, with an `upstream` remote pointing to the original repository. + +Updating your project involves fetching the latest changes from the `upstream` remote and merging them into your project. Let's dive into the steps! + +{% sequence title="Steps to update your codebase" description="Learn how to update your Next.js Supabase Turbo Starter Kit to the latest version." %} +[Stashing your changes (if any)](#0.-stashing-your-changes-(if-any)) + +[Refresh the `upstream` remote](#1.-refresh-the-remote) + +[Resolve any conflicts](#2.-resolve-any-conflicts) + +[Run a health check on your project](#run-a-health-check-on-your-project-after-resolving-conflicts) + +[Merge the changes](#3.-merge-the-changes) +{% /sequence %} + +## 0. Stashing your changes (if any) + +If you have uncommited changes, before updating your project, it's a good idea to stash your changes to avoid any conflicts during the update process. You can stash your changes by running: + +```bash +git stash +``` + +This will save your changes temporarily, allowing you to update your project without any conflicts. Once you've updated your project, you can apply your changes back by running: + +```bash +git stash pop +``` + +If you don't have any changes to stash, you can skip this step and proceed with the update process. 🛅 + +Alternatively, you can commit your changes. + +## 1. Refresh the `upstream` remote + +Create a new branch for your updates from the `main` branch: + +```bash +git checkout -b update-codebase-<date> +``` + +In this way, you can keep track of your updates and visualize the changes in the branch before merging them into your main branch. + +Now, fetch the latest changes from the `upstream` remote. You can do this by running the following command: + +```bash +git pull upstream main +``` + +When prompted the first time, please opt for **merging instead of rebasing**. + +Now, run `pnpm i` to update the dependencies: + +```bash +pnpm i +``` + +## 2. Resolve any conflicts + +Encountered conflicts during the merge? No worries! You'll need to resolve them manually. Git will highlight the files with conflicts, and you can edit them to resolve the issues. + +### 2.1 Conflicts in the lock file "pnpm-lock.yaml" + +If you find conflicts in the `pnpm-lock.yaml` file, accept either of the two changes (avoid manual edits), then run: + +```bash +pnpm i +``` + +Your lock file will now reflect both your changes and the updates from the `upstream` repository. 🎉 + +### 2.2 Conflicts in the DB types "database.types.ts" + +Your types might differ from those in the `upstream` repository, so you'll need to rebuild them and accept the latest state of the DB. + +To do this, first you want to reset the DB to apply the latest changes from the `upstream` repository: + +```bash +pnpm run supabase:web:reset +``` + +Next, regenerate the types with the following command: + +```bash +pnpm run supabase:web:typegen +``` + +Your types will now reflect the changes from both the `upstream` repository and your project. 🚀 + +### Run a health check on your project after resolving conflicts + +After resolving the conflicts, it's time to test your project to ensure everything is working as expected. Run your project locally and navigate through the various features to verify that everything is functioning correctly. + +You can run the following commands for a quick health check: + +```bash +pnpm run typecheck +``` + +And lint your code with: + +```bash +pnpm run lint +``` + +## 3. Merge the changes + +If everything looks good, commit the changes and push them to your remote repository: + +```bash +git commit -m "COMMIT_MESSAGE" +git push origin update-codebase-<date> +``` + +Once the changes are pushed, you can create a pull request to merge the changes into the `main` branch, assuming all is working fine. + +Your project is now up to date with the latest changes from the `upstream` repository. 🎉 \ No newline at end of file diff --git a/docs/installation/v3-migration.mdoc b/docs/installation/v3-migration.mdoc new file mode 100644 index 000000000..2f64838c3 --- /dev/null +++ b/docs/installation/v3-migration.mdoc @@ -0,0 +1,1326 @@ +--- +status: "published" +title: "Migrate to Next.js Supabase v3" +label: "Upgrading from v2 to v3" +order: 9 +description: "A guide to updating this kit from v2 to v3 using git and AI Agents" +--- + +v3 is a major upgrade that modernizes the entire stack: + +- **Zod v4** — faster validation, smaller bundle, cleaner API +- **Base UI** — headless primitives from the MUI team, replacing Radix +- **next-intl** — first-class Next.js i18n with locale-prefixed routes +- **next-safe-action** — type-safe server actions with built-in validation +- **Teams-only mode** — ship team-based apps without personal accounts +- **Async dialogs** — dialogs that won't close while operations are pending +- **Oxc** — blazing-fast linting and formatting, replacing ESLint + Prettier +- **PNPM catalogs** — one place to manage all dependency versions + +This guide covers every breaking change and what you need to update if +you customized the codebase. + +## How long will it take? + +If you haven't customized much, the entire upgrade can be done in **under an hour** — most steps are just `git pull` + `pnpm install` with no conflicts. + +If you've heavily customized the codebase (custom UI components with Radix primitives, custom server actions, modified layouts), expect **3-6 hours** depending on how many areas you've touched. The AI-assisted prompts do most of the heavy lifting — you're mainly reviewing and approving. + +The migration is split into 10 steps for a reason — each step is self-contained and your app should build after each one. You don't have to do it all at once. Merge one step, verify it works, ship it to production if you want, then come back to the next step tomorrow or next week. There is no rush. + +## Should I just start from scratch? + +**No.** Starting from scratch means losing all your customizations, git history, and deployed infrastructure. The incremental upgrade preserves everything and lets you ship each step independently. + +The only scenario where starting fresh might make sense is if you've barely customized the kit or have only started using it in the past week or so. + +## Not Ready to Upgrade? + +That's okay! + +The `v2` branch is available as a long-term support (LTS) release. +It will receive important updates. + +If you're not ready to upgrade now, you can switch to the `v2` branch: + +```bash +git checkout v2 +``` + +From now on, pull updates exclusively from `v2`: + +```bash +git pull upstream v2 +``` + +We recommend upgrading to v3 when you can - but you should not feel under any rush to do so - your users don't care if you use Zod 3 or 4. + +## How the Upgrade Works + +v3 is delivered as **10 incremental PRs**, each merged in order. Every PR is +a self-contained step — your app should build and run after each one. + +This means you can upgrade gradually: merge one PR, resolve any conflicts in +your custom code, verify everything works, then move to the next. You don't +have to do it all at once. + +If you haven't customized a particular area, `git pull` handles it +automatically — only read the sections relevant to your changes. + +### Merge Order + +Merge these in exact order. Each step depends on the previous ones. +Each step is tagged so you can merge incrementally: + +| # | Tag | What It Does | +|---|-----|-------------| +| 1 | `v3-step/zodv4` | Updates Zod from v3 to v4 across all packages | +| 2 | `v3-step/baseui` | Swaps Radix → Base UI primitives, react-i18next → next-intl | +| 3 | `v3-step/next-safe-action` | Replaces `enhanceAction` with `next-safe-action` | +| 4 | `v3-step/locale-routes` | Wraps all routes in `[locale]` segment | +| 5 | `v3-step/teams-only` | Adds feature flag for team-only apps | +| 6 | `v3-step/workspace-dropdown` | Unifies account/team switching UI | +| 7 | `v3-step/async-dialogs` | Prevents dialog dismissal during pending operations | +| 8 | `v3-step/oxc` | Replaces ESLint + Prettier with Oxc | +| 9 | `v3-step/remove-edge-csrf` | Drops CSRF middleware in favor of Server Actions | +| 10 | `v3-step/final` | Centralizes dependency versions | + +### Before starting the migration + +Please make sure your `main` branch is up to date with the branch `v2`. Also, +make sure that the `typecheck`, `lint`, and `format` commands run without errors. + +If you're behind `v2`, please update it: + +```bash +git pull upstream v2 +``` + +Now, make sure these commands run without errors: + +```bash +pnpm typecheck +pnpm lint +pnpm format +``` + +If any of these return errors, please fix them before starting the migration. + +### Work on a separate branch + +We recommend performing the migration on a dedicated branch. This keeps +your `main` branch stable and deployable while you upgrade, and gives you +an easy escape hatch if anything goes wrong. + +You have two options: + +**Option A: One branch per step** — Create a branch for each step (e.g. `v3/zodv4`, `v3/baseui`), merge it into `main` after verifying, then start the next step from `main`. This lets you deploy each step independently and keeps your commits clean. + +**Option B: Single migration branch** — Create one `v3-migration` branch, merge all 10 steps into it, then merge the whole thing into `main` at the end. + +Either way works. Option A is safer for production apps since you can deploy +and verify each step in isolation before moving on. + +### Step-by-Step Process + +For each step below, follow this process: + +**1. Create a branch and merge the tag:** + +```bash +git checkout -b v3/<step-name> +git pull upstream <TAG> +pnpm install +``` + +Always run `pnpm install` after each `git pull` — every step changes dependencies. + +**2. Resolve conflicts and install packages:** + +If easy enough, perform an initial conflict resolution manually. + +**3. Run the AI-assisted review** using an AI coding agent (Claude Code, Cursor, etc.). + +Each step below includes a tailored prompt. Copy the prompt for the step you're on and paste it into your AI agent. + +**4. Validate** — check the "Validate Before Continuing" checklist for that step. + +**5. Commit and optionally merge** — commit your changes to your migration branch. If using one branch per step, merge into `main` and deploy before starting the next step. + +In general, always run `pnpm typecheck`. Make sure this +command returns no errors prior to starting the migration. + +--- + +## Table of Contents + +1. [Zod v4](#1-zod-v4) +2. [Base UI + next-intl](#2-base-ui--next-intl) +3. [next-safe-action](#3-next-safe-action) +4. [Locale Route Prefix](#4-locale-route-prefix) +5. [Teams-Only Mode](#5-teams-only-mode) +6. [Workspace Dropdown](#6-workspace-dropdown) +7. [Async Dialogs](#7-async-dialogs) +8. [Oxc (ESLint/Prettier Replacement)](#8-oxc) +9. [Remove Edge CSRF](#9-remove-edge-csrf) +10. [Final](#10-final) +11. [After Upgrading](#after-upgrading) + +--- + +## 1. Zod v4 + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/zodv4 +git pull upstream v3-step/zodv4 +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/zodv4`. + +This step updates Zod from v3 to v4. In my custom code, find and fix: + +1. `required_error:` -> `error:` in Zod type constructors +2. Remove `description` from Zod type constructors +3. Remove `return true` from `.superRefine()` / refinement callbacks +4. `z.record(valueSchema)` -> `z.record(keySchema, valueSchema)` (now requires 2 args) +5. `z.string().url()` -> `z.url()` (new top-level validator) +6. Remove `errorMap` from `z.enum()` calls +7. Remove `z.ZodTypeDef` from any custom generic type parameters + +Optionally: + +1. `import { z } from 'zod'` -> `import * as z from 'zod'` +2. `z.infer<typeof Schema>` -> `z.output<typeof Schema>` + +Run `git diff HEAD~1` to see upstream changes, then search my custom +files (not in node_modules) for any remaining old Zod patterns. +Fix them, then run `pnpm typecheck` to verify. + +Use migration guide: https://zod.dev/v4/changelog +``` + +Zod has been updated from v3 to v4. + +### Import Style + +To reduce bundle size, use namespace imports for Zod: + +```diff +- import { z } from 'zod'; ++ import * as z from 'zod'; +``` + +This applies to every file that imports Zod. + +### Type Inference + +```diff +- type MyType = z.infer<typeof MySchema>; ++ type MyType = z.output<typeof MySchema>; +``` + +### Error Messages + +```diff + z.string({ +- required_error: 'Field is required', +- description: 'Some description', ++ error: 'Field is required', + }) +``` + +- `required_error` → `error` +- `description` in type constructors is removed + +### Refinement Functions + +```diff + function validatePassword(password: string, ctx: z.RefinementCtx) { + if (password.length < 8) { + ctx.addIssue({ code: 'custom', message: 'Too short' }); + } +- return true; + } +``` + +Remove `return true` from refinement callbacks. + +### Record Schemas + +`z.record()` now requires two arguments (key schema + value schema): + +```diff +- z.record(z.string()) ++ z.record(z.string(), z.string()) +``` + +### URL Validation + +```diff +- z.string().url() ++ z.url() +``` + +### Enum Error Maps + +Custom `errorMap` on `z.enum()` is no longer supported: + +```diff +- z.enum(['a', 'b'], { errorMap: () => ({ message: 'Invalid' }) }) ++ z.enum(['a', 'b']) +``` + +### What to Do + +If you added custom Zod schemas: + +1. Replace `required_error` with `error` in type constructors +2. Remove `description` from type constructors +3. Remove `return true` from refinement functions +4. Update `z.record(valueSchema)` to `z.record(keySchema, valueSchema)` +5. Replace `z.string().url()` with `z.url()` +6. Remove `errorMap` from `z.enum()` calls +7. (Optional) Find/replace `import { z } from 'zod'` → `import * as z from 'zod'` +8. (Optional) Find/replace `z.infer<` → `z.output<` + +### Validate Before Continuing + +```bash +pnpm typecheck +``` + +- [ ] No Zod import errors +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 2. Base UI + next-intl + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/baseui +git pull upstream v3-step/baseui +pnpm install +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/baseui`. + +This step swaps Radix UI -> Base UI and react-i18next -> next-intl. +In my custom code, find and fix: + +**UI changes:** +1. Replace `@radix-ui/react-icons` imports with `lucide-react` equivalents +2. Replace any direct Radix primitive imports with `@base-ui/react/*` +3. Replace `asChild` prop with `render` prop (except on Command components). + Example: `<Button asChild><Link href="/x">Go</Link></Button>` becomes + `<Button nativeButton={false} render={<Link href="/x">Go</Link>} />` +4. Update data attributes: `data-[state=open]` -> `data-open`, + `data-[state=closed]` -> `data-closed` +5. `<Button>` components require nativeButton={false} if they wrap other elements, such as links + +**i18n changes:** +6. Replace all translation keys from colon to dot notation: + `i18nKey="namespace:key"` -> `i18nKey="namespace.key"` +7. Update translation interpolation from double to single curly braces: + `"Hello {{name}}"` -> `"Hello {name}"` in all JSON translation files +8. Replace `createI18nServerInstance` / `getTranslation` with + `import { getTranslations } from 'next-intl/server'` + Usage: `const t = await getTranslations('namespace')` then `t('key')` +9. Remove `withI18n` HOC wrappers from page exports +10. Move custom translation files to `apps/web/i18n/messages/{locale}/` + +**Config changes:** +11. If you customized navigation configs, replace the `end` property + (boolean) with `highlightMatch` (regex string) +12. If you imported from `@kit/ui/shadcn-sidebar`, change to `@kit/ui/sidebar` + +Search my custom files for any remaining `radix-ui`, `react-i18next`, +`createI18nServerInstance`, `withI18n`, colon-notation i18n keys, +or double-curly-brace interpolation `{{`. +Fix them, then run `pnpm typecheck` to verify. +``` + +Two major library swaps in one step. + +### UI: Radix → Base UI + +The underlying primitives changed from Radix UI to Base UI (`@base-ui/react`). +The `@kit/ui/*` component APIs mostly remain the same — **this only affects you if you +built custom components using Radix primitives directly.** + +If you did: + +```diff +- import { Dialog as DialogPrimitive } from 'radix-ui'; ++ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'; +``` + +Data attributes changed: + +```diff +- className="data-[state=open]:animate-in" ++ className="data-open:animate-in data-closed:animate-out" +``` + +### Icons: @radix-ui/react-icons → lucide-react + +```diff +- import { Cross2Icon } from '@radix-ui/react-icons'; ++ import { XIcon } from 'lucide-react'; +``` + +Replace all `@radix-ui/react-icons` imports with the equivalent from `lucide-react`. + +### asChild -> render + +All `asChild` (with the exception of the `<Command>` components, which still use Radix) must be migrated to Base UI's `render` prop: + +```diff +- <Button asChild> +- <Link href="/dashboard">Go</Link> +- </Button> ++ <Button nativeButton={false} render={<Link href="/dashboard" />}> ++ Go ++ </Button> +``` + +When a component wraps a non-button element (like `Link`), add `nativeButton={false}`. + +### Primitive Sub-Component Renames + +If you used Radix primitives directly, these sub-components were renamed: + +| Radix | Base UI | +|-------|---------| +| `DialogPrimitive.Overlay` | `DialogPrimitive.Backdrop` | +| `DialogPrimitive.Content` | `DialogPrimitive.Popup` | +| `AccordionPrimitive.Content` | `AccordionPrimitive.Panel` | +| `TabsPrimitive.Content` | `TabsPrimitive.Panel` | +| `TabsPrimitive.Trigger` | `TabsPrimitive.Tab` | +| `CollapsiblePrimitive.Content` | `CollapsiblePrimitive.Panel` | + +Base UI also introduces a **Positioner** wrapper for floating components (Popover, Tooltip, Select, DropdownMenu). Props like `align`, `side`, `sideOffset` move from `Content`/`Popup` to the `Positioner`. + +### Sidebar Import Path Change + +The shadcn sidebar component moved: + +```diff +- import { Sidebar } from '@kit/ui/shadcn-sidebar'; ++ import { Sidebar } from '@kit/ui/sidebar'; +``` + +The old makerkit sidebar is now at `@kit/ui/sidebar-navigation`. + +### i18n: react-i18next → next-intl + +The entire i18n system changed. + +**Translation key syntax:** + +```diff +- <Trans i18nKey="namespace:key" /> ++ <Trans i18nKey="namespace.key" /> +``` + +Colon (`:`) becomes dot (`.`) in translation keys. + +**Server-side translations:** + +```diff +- import { getTranslation } from '~/lib/i18n/i18n.server'; +- const { t } = await getTranslation(locale); ++ import { getTranslations } from 'next-intl/server'; ++ const t = await getTranslations('namespace'); +``` + +### Translation Interpolation + +The interpolation syntax changed from double to single curly braces: + +```diff + // In translation JSON files: +- "greeting": "Hello {{name}}" ++ "greeting": "Hello {name}" +``` + +This applies to **every custom translation string** that uses variables. + +### withI18n Removal + +The `withI18n` higher-order component is removed. If you wrapped page exports +with it, remove the wrapper: + +```diff +- export default withI18n(MyPage); ++ export default MyPage; +``` + +### next.config.mjs + +Your `next.config.mjs` must be wrapped with `createNextIntlPlugin`: + +```typescript +import createNextIntlPlugin from 'next-intl/plugin'; +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); + +// ... your config ... + +export default withNextIntl(config); +``` + +Without this wrapper, `next-intl` will not work. + +### Messages Files + +**Message files** moved to `apps/web/i18n/messages/{locale}/`. + +Please migrate your existing messages to `apps/web/i18n/messages/{locale}/`. + +### Navigation Config + +The `end` property on route items changed from a boolean to a regex string +called `highlightMatch`: + +```diff + { + path: '/home/settings', +- end: true, ++ highlightMatch: '^/home/settings$', + } +``` + +### CSS Theme + +Theme CSS variables migrated from HSL to **oklch** color format. If you +customized theme colors in `theme.css`, update your values: + +```diff +- --background: hsl(0 0% 100%); ++ --background: oklch(1 0 0); +``` + +The dark variant syntax also changed: + +```diff +- @variant dark (&:where(.dark, .dark *)); ++ @custom-variant dark (&:is(.dark *)); +``` + +### recharts v3 + +`recharts` was bumped from v2 to v3. If you built custom charts, check the +[recharts v3 migration guide](https://recharts.org/en-US/guide/migration-to-v3) +for API changes. + +### Removed Components + +These components were removed. If you used them, replace as noted: + +| Removed | Replacement | +|---------|-------------| +| `MultiStepForm` | Build with `Form` + conditional step rendering | +| `MobileNavigationMenu` | Use `Sheet` from `@kit/ui/sheet` | +| `AuthenticityToken` | Removed — not needed with Server Actions | + +### What to Do + +If you added custom UI components: + +1. Replace `@radix-ui/react-icons` imports with `lucide-react` +2. Update any direct Radix primitive imports to Base UI +3. Replace `asChild` with `render` prop (add `nativeButton={false}` for non-button elements) +4. Update `data-[state=open]` → `data-open` in custom styles +5. Update `@kit/ui/shadcn-sidebar` imports to `@kit/ui/sidebar` + +If you customized i18n or translations: + +6. Change all translation keys from colon to dot notation (`namespace:key` → `namespace.key`) +7. Update interpolation in translation JSON files (`{{var}}` → `{var}`) +8. Replace `react-i18next` hooks with `next-intl` equivalents +9. Remove `withI18n` HOC wrappers from page exports +10. Move translation files to `apps/web/i18n/messages/{locale}/` + +If you customized config or theme: + +11. Wrap `next.config.mjs` with `createNextIntlPlugin` +12. Update navigation config `end` → `highlightMatch` +13. Update custom theme CSS variables to oklch format + +### Validate Before Continuing + +```bash +pnpm typecheck +``` + +- [ ] No `radix-ui` or `react-i18next` import errors +- [ ] Translation keys use dot notation and single curly braces +- [ ] `next.config.mjs` wrapped with `createNextIntlPlugin` +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 3. next-safe-action + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/next-safe-action +git pull upstream v3-step/next-safe-action +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/next-safe-action`. + +This step migrates the built-in server actions to `next-safe-action`. +My custom actions using `enhanceAction` still work — no immediate +migration needed. Just resolve any merge conflicts in files that +upstream changed. + +1. Run `git diff HEAD~1` to see what upstream changed. +2. If I modified any of the built-in server action files (billing, + team management, contact form, etc.), resolve merge conflicts + by adopting the new `next-safe-action` pattern from upstream. +3. My own custom server actions using `enhanceAction` can stay as-is. + +Run `pnpm install && pnpm typecheck` to verify. +``` + +Server actions migrated from `enhanceAction` to `next-safe-action`. + +### Optional: Migrate Custom Actions + +When you're ready to migrate your custom actions, use this prompt: + +``` +Migrate my custom server actions from `enhanceAction` to `next-safe-action`. + +**Server action files ('use server'):** +1. Replace `import { enhanceAction } from '@kit/next/actions'` with + `import { authActionClient } from '@kit/next/safe-action'` + (or `publicActionClient` for unauthenticated actions) +2. Rewrite: `enhanceAction(async (data, user) => {...}, { schema })` becomes + `authActionClient.schema(Schema).action(async ({ parsedInput: data, ctx: { user } }) => {...})` +3. For actions without a schema: `authActionClient.action(async () => {...})` +4. For actions with `auth: false`: use `publicActionClient` instead + +**Client component files:** +5. Replace `useTransition` + `startTransition(async () => await action(data))` + with `import { useAction } from 'next-safe-action/hooks'` and + `const { execute, isPending } = useAction(myAction, { onSuccess, onError })` +6. Replace `<form action={myAction}>` with `<form onSubmit={...}>` using `execute()` +7. Remove hidden input fields — pass objects to `execute()` directly +8. Replace `pending` from `useTransition` with `isPending` from `useAction` + +Search my custom files for `enhanceAction`, `useFormStatus`, and +`startTransition` patterns used with server actions. +Fix them, then run `pnpm typecheck` to verify. +``` + +The `enhanceAction` function is still available so your existing Server Actions will keep working just fine. Migrate when you have time. + +### Server Action Definition + +```diff + 'use server'; + +- import { enhanceAction } from '@kit/next/actions'; ++ import { authActionClient } from '@kit/next/safe-action'; + +- export const myAction = enhanceAction( +- async (formData: FormData, user) => { +- const data = MySchema.parse(Object.fromEntries(formData)); +- // ... logic +- }, +- { schema: MySchema }, +- ); ++ export const myAction = authActionClient ++ .schema(MySchema) ++ .action(async ({ parsedInput: data, ctx: { user } }) => { ++ // data is already validated, user is in ctx ++ }); +``` + +### Available Clients + +| Client | Import | Use Case | +|--------|--------|----------| +| `publicActionClient` | `@kit/next/safe-action` | No auth required | +| `authActionClient` | `@kit/next/safe-action` | Requires authenticated user | +| `captchaActionClient` | `@kit/next/safe-action` | Requires CAPTCHA + auth | + +### Client Components + +```diff +- import { useFormStatus } from 'react-dom'; ++ import { useAction } from 'next-safe-action/hooks'; + + function MyForm() { +- return ( +- <form action={myAction}> +- <input type="hidden" name="field" value={value} /> +- <SubmitButton /> +- </form> +- ); ++ const { execute, isPending } = useAction(myAction, { ++ onSuccess: ({ data }) => { /* ... */ }, ++ onError: ({ error }) => { /* ... */ }, ++ }); ++ ++ return ( ++ <form onSubmit={(e) => { e.preventDefault(); execute({ field: value }); }}> ++ <button disabled={isPending}>Submit</button> ++ </form> ++ ); + } +``` + +Key differences: +- Pass objects to `execute()`, not FormData +- `isPending` replaces `useFormStatus` +- No hidden input fields needed +- Error/success callbacks on the hook + +### Error Handling + +Actions now throw errors instead of returning failure objects: + +```diff +- return { success: false }; ++ throw new Error('Something went wrong'); +``` + +`next-safe-action` routes thrown errors to the `onError` callback on the client. + +### What to Do + +If you added custom server actions: + +1. Replace `enhanceAction` import with `authActionClient` (or `publicActionClient`) +2. Rewrite action using `.schema().action()` chain +3. Access user via `ctx.user` instead of second parameter +4. Replace `return { success: false }` with `throw new Error(...)` +5. Update client components to use `useAction` hook +6. Remove hidden input fields and FormData patterns + +### Validate Before Continuing + +```bash +pnpm install && pnpm typecheck +``` + +- [ ] No `enhanceAction` import errors (if you migrated custom actions) +- [ ] Client components using `useAction` compile correctly +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 4. Locale Route Prefix + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/locale-routes +git pull upstream v3-step/locale-routes +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/locale-routes`. + +This step moves all routes under `app/[locale]/`. In my custom code: + +1. Check if I have any custom route folders still under `apps/web/app/` + that should now be under `apps/web/app/[locale]/`. Move them. +2. If I customized `apps/web/app/layout.tsx`, move those customizations + to `apps/web/app/[locale]/layout.tsx`. The root layout is now minimal + (just `return children` with a CSS import). +3. If I customized `apps/web/proxy.ts` (middleware), the i18n middleware + is now integrated. Check for conflicts — `handleI18nRouting` runs first, + then secure headers and other middleware run on its response. +4. Verify `tsconfig.json` paths — `~/*` should resolve to + `["./app/[locale]/*", "./app/*"]`. +5. If I have any API routes, they should stay under `apps/web/app/api/` + (NOT inside `[locale]`). + +Search for any `~/` imports that might break with the new path resolution. +Fix them, then run `pnpm typecheck` to verify. +``` + +All routes now live under a `[locale]` dynamic segment. + +### Directory Structure + +```diff + apps/web/app/ ++ ├── [locale]/ ++ │ ├── (marketing)/ ++ │ ├── admin/ ++ │ ├── auth/ ++ │ ├── home/ ++ │ ├── layout.tsx ← i18n-aware layout (moved here) ++ │ └── not-found.tsx + ├── layout.tsx ← minimal (just renders children) +``` + +### Root Layout Simplified + +```typescript +// apps/web/app/layout.tsx — now just: +import '../styles/globals.css'; + +export default function RootLayout({ children }: React.PropsWithChildren) { + return children; +} +``` + +All providers, theme, and i18n setup moved to `apps/web/app/[locale]/layout.tsx`. + +### Middleware + +The `proxy.ts` middleware now integrates `next-intl` routing: + +```typescript +import createNextIntlMiddleware from 'next-intl/middleware'; +import { routing } from '@kit/i18n/routing'; + +const handleI18nRouting = createNextIntlMiddleware(routing); + +export default async function proxy(request: NextRequest) { + const response = handleI18nRouting(request); + // ... rest of middleware +} +``` + +### TypeScript Paths + +```diff + // apps/web/tsconfig.json + "paths": { +- "~/*": ["./app/*"] ++ "~/*": ["./app/[locale]/*", "./app/*"] + } +``` + +### What to Do + +If you added custom routes: + +1. Move your route folders into `apps/web/app/[locale]/` +2. If you customized the root layout, move your changes to `[locale]/layout.tsx` + +### Validate Before Continuing + +```bash +pnpm typecheck +``` + +- [ ] Custom routes are inside `app/[locale]/` +- [ ] `~/*` path aliases resolve correctly +- [ ] App builds and runs, routes work with `/en/` prefix + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 5. Teams-Only Mode + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/teams-only +git pull upstream v3-step/teams-only +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/teams-only`. + +This step adds a teams-only feature flag. It also changes the +AccountSelector to support `showPersonalAccount` and stores the +last selected team in a cookie. + +1. If I customized the personal account layout + (`app/[locale]/home/(user)/layout.tsx`), check that the new + `redirectIfTeamsOnly()` function doesn't conflict with my changes. +2. If I customized the AccountSelector or team switching logic, check + for conflicts with the new `last-selected-team` cookie and the + `showPersonalAccount` prop. +3. If I added custom routes under `home/(user)/`, verify they still + work with the teams-only redirect logic. + +Run `pnpm typecheck` to verify. No action needed unless I plan +to enable `NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY=true`. +``` + +New feature flag for apps that only use team accounts (no personal accounts). + +### New Config + +```typescript +// apps/web/config/feature-flags.config.ts +enableTeamsOnly: import.meta.env.NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY === 'true', +``` + +### New Env Variable + +``` +NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY=false +``` + +Set to `true` if your app should skip personal accounts entirely. + +### What to Do + +No action required unless you want to enable teams-only mode. Add the env +variable and set it to `true`. + +### Validate Before Continuing + +```bash +pnpm typecheck +``` + +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 6. Workspace Dropdown + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/workspace-dropdown +git pull upstream v3-step/workspace-dropdown +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/workspace-dropdown`. + +This step replaces the sidebar account selector and profile dropdown +with a unified `WorkspaceDropdown` component. It also refactors +page layouts (billing, members, settings) to move `PageBody` wrapping. + +1. If I customized the sidebar (`home-sidebar.tsx` or + `team-account-layout-sidebar.tsx`), the `SidebarFooter` with + `ProfileAccountDropdownContainer` is removed. The new + `WorkspaceDropdown` is in the `SidebarHeader` instead. +2. If I customized page layouts for billing, members, or settings, + check that `PageBody` is now in the right place — it moved from + inside the page to the layout in some cases. `PageHeader` should be moved as a child of `PageBody`, rather than as a sibling, across the whole repository. Find all instances of `PageHeader` and ensure it appears within `PageBody` +3. If I customized `personal-account-dropdown-container.tsx`, review + changes — it now accepts an `accountSlug` prop. +4. If I added custom content to the sidebar footer, move it elsewhere + since the footer section was removed. + +Run `pnpm typecheck` to verify. +``` + +Account/team switching moved from sidebar navigation to a unified dropdown +component. + +### What Changed + +- New `WorkspaceDropdown` component handles both personal and team switching +- Billing and member management page layouts refactored +- Notifications popover integrated + +### What to Do + +If you customized the sidebar account selector, migrate to the new +`WorkspaceDropdown` component. If you only used the default navigation, +no changes needed. + +### Validate Before Continuing + +```bash +pnpm typecheck +``` + +- [ ] Workspace switching works (personal + team accounts) +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 7. Async Dialogs + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/async-dialogs +git pull upstream v3-step/async-dialogs +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/async-dialogs`. + +This step adds `useAsyncDialog` to all built-in dialogs. My custom +dialogs don't need to change — only resolve merge conflicts if I +modified any of the built-in dialogs (admin dialogs, team dialogs, +invitation dialogs, MFA setup). + +1. Run `git diff HEAD~1` to see what upstream changed. +2. If I modified any built-in dialog files, resolve merge conflicts + by adopting the upstream `useAsyncDialog` pattern. +3. My custom dialogs still work fine without `useAsyncDialog`. + +Run `pnpm typecheck` to verify. +``` + +New `useAsyncDialog` hook prevents dialogs from closing during pending +operations (form submissions, API calls). + +### Optional: Adopt useAsyncDialog in Custom Dialogs + +When you're ready, you can adopt `useAsyncDialog` in your own dialogs +to prevent accidental closure during submissions: + +#### Usage + +```typescript +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; + +function MyDialog({ open, onOpenChange }) { + const { dialogProps, isPending, setIsPending } = useAsyncDialog({ + open, + onOpenChange, + }); + + const { execute } = useAction(myAction, { + onExecute: () => setIsPending(true), + onSettled: () => setIsPending(false), + }); + + return ( + <Dialog {...dialogProps}> + {/* Dialog blocks ESC/backdrop click while isPending */} + <Button disabled={isPending}>Submit</Button> + </Dialog> + ); +} +``` + +### What to Do + +If you built custom dialogs with forms, adopt `useAsyncDialog` to prevent +accidental closure during submissions. Existing dialogs still work without it — +this is an improvement, not a requirement. + +### Validate Before Continuing + +```bash +pnpm typecheck +``` + +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 8. Oxc + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/oxc +git pull upstream v3-step/oxc +pnpm install +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/oxc`. + +This step replaces ESLint + Prettier with Oxc (oxlint + oxfmt). + +1. If I added custom `eslint.config.mjs` files in any package, delete + them and translate any custom rules to `.oxlintrc.json` format. +2. If I added `.prettierrc` or `.prettierignore` files, delete them. + Formatting config is now in `.oxfmtrc.jsonc` at the root. +3. If I have CI/CD pipelines that run `pnpm lint` or `pnpm format`, + update them to `pnpm lint:fix` and `pnpm format:fix`. +4. If I added custom ESLint plugins, check if oxlint has equivalent + built-in rules (it covers most common cases). + +After resolving conflicts, run: +`pnpm install && pnpm lint:fix && pnpm format:fix && pnpm typecheck` +``` + +ESLint + Prettier replaced with Oxc (`oxlint` + `oxfmt`). + +### Commands + +```diff +- pnpm lint # ESLint +- pnpm format # Prettier ++ pnpm lint:fix # oxlint ++ pnpm format:fix # oxfmt +``` + +### Config Files + +```diff +- eslint.config.mjs (removed from all packages) +- .prettierignore (removed) +- .prettierrc (removed) ++ .oxlintrc.json (root) ++ .oxfmtrc.jsonc (root) +``` + +### What to Do + +If you added custom ESLint rules: + +1. Translate them to `.oxlintrc.json` format +2. Delete any `eslint.config.mjs` files you added +3. Delete Prettier config files +4. Run `pnpm lint:fix && pnpm format:fix` to reformat + +### Validate Before Continuing + +```bash +pnpm install && pnpm typecheck && pnpm lint:fix && pnpm format:fix +``` + +- [ ] No ESLint/Prettier config files remain +- [ ] `pnpm lint:fix` runs without errors +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 9. Remove Edge CSRF + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/remove-edge-csrf +git pull upstream v3-step/remove-edge-csrf +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/remove-edge-csrf`. + +This step removes `@edge-csrf/nextjs` and the `useCsrfToken` hook. +Server Actions handle CSRF protection natively in Next.js. + +1. Search my custom files for `useCsrfToken` — remove those imports + and any CSRF token passing logic. +2. If I customized `proxy.ts` (middleware), the CSRF middleware + (`createCsrfProtect`, `CsrfError`) is removed. Resolve any + conflicts in my middleware customizations. +3. If I added custom Route Handlers (not Server Actions) that relied + on CSRF tokens, those no longer have CSRF protection — consider + migrating them to Server Actions. + +Run `pnpm install && pnpm typecheck` to verify. +``` + +The `@edge-csrf/nextjs` package and `useCsrfToken` hook are removed. +Next.js + Server Actions handle CSRF protection natively. + +### What Changed + +```diff +- import { useCsrfToken } from '@kit/shared/hooks/use-csrf-token'; + // Removed — no replacement needed +``` + +CSRF middleware removed from `proxy.ts`. Server Actions are inherently +protected by Next.js. + +### What to Do + +If you used `useCsrfToken()` in custom components, remove those calls. +No replacement is needed — Server Actions handle CSRF automatically. + +### Validate Before Continuing + +```bash +pnpm install && pnpm typecheck +``` + +- [ ] No `useCsrfToken` or `@edge-csrf` import errors +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## 10. Final + +Create a branch and pull in the changes: + +```bash +git checkout -b v3/final +git pull upstream v3-step/final +``` + +Then run the AI-assisted review with this prompt: + +``` +I'm upgrading Makerkit from v2 to v3. I just merged `v3-step/final`. + +This step centralizes all dependency versions in `pnpm-workspace.yaml` +using PNPM catalogs. + +1. If I added custom dependencies to any `package.json` in the monorepo, + check if those deps are now in the catalog. If so, change the version + to `"catalog:"` in my package.json. +2. If I added a new package to the monorepo, make sure its shared deps + (react, next, zod, typescript, etc.) use `"catalog:"` references. +3. Resolve any merge conflicts in `pnpm-workspace.yaml` — keep the + upstream catalog entries and add my custom ones alongside. + +Run `pnpm install && pnpm typecheck` to verify all catalog +references resolve correctly. +``` + +Dependency versions are now centralized in `pnpm-workspace.yaml` using +PNPM catalogs. + +### PNPM Version + +This step requires **pnpm 10.30.3** or later. Update your pnpm version: + +```bash +corepack prepare pnpm@10.30.3 --activate +``` + +### enhanceAction Deprecated + +As introduced in [Step 3](#3-next-safe-action), `enhanceAction` is now +officially marked as `@deprecated`. It still works but you should migrate +custom actions to `authActionClient` / `publicActionClient` from +`@kit/next/safe-action` when you have time. + +### How It Works + +```yaml +# pnpm-workspace.yaml +catalog: + react: 19.2.4 + next: 16.2.0 + zod: 4.3.6 + # ... all shared versions here +``` + +```json +// Individual package.json files now reference the catalog: +{ + "dependencies": { + "react": "catalog:", + "next": "catalog:" + } +} +``` + +This tag also adds inline documentation to the repository for use with AI coding agents. + +### What to Do + +If you added custom dependencies to individual packages: + +- **Shared deps** (used by multiple packages): Add to `catalog:` in +`pnpm-workspace.yaml`, then reference as `"catalog:"` in package.json +- **Package-specific deps**: Can still use direct version strings + +You can use the PNPM catalog codemod to automatically migrate your custom +dependencies to catalog references: + +```bash +npx codemod@latest pnpm/catalog +``` + +This scans all `package.json` files and moves eligible versions into the +catalog, reducing merge conflicts in future updates. + +### Validate Before Continuing + +```bash +pnpm install && pnpm typecheck +``` + +- [ ] `pnpm install` resolves all catalog references +- [ ] App builds and runs + +Once verified, commit your changes. You can merge to `main` and deploy before continuing. + +--- + +## After Upgrading + +### Run all quality checks + +```bash +pnpm install +pnpm typecheck +pnpm lint:fix +pnpm format:fix +``` + +### Test core flows manually + +Start the dev server and verify these flows work: + +- [ ] Sign up / sign in +- [ ] Team creation and switching +- [ ] Inviting team members +- [ ] Billing portal access +- [ ] Account settings and profile updates + +### Update CI/CD + +If you have CI/CD pipelines, update the lint and format commands: + +```diff +- pnpm lint +- pnpm format ++ pnpm lint:fix ++ pnpm format:fix +``` + +### Switch to main for future updates + +After completing the migration, pull future updates from `main` instead of `v2`: + +```bash +git pull upstream main +``` + +### Recommendations + +- **Commit the migration**: Once everything works, commit your changes and tag the result so you can easily roll back if needed. +- **Migrate enhanceAction**: When you have time, migrate remaining custom actions from `enhanceAction` to `authActionClient` / `publicActionClient` (see [Step 3](#3-next-safe-action)). \ No newline at end of file diff --git a/docs/monitoring/capturing-errors.mdoc b/docs/monitoring/capturing-errors.mdoc new file mode 100644 index 000000000..c378352b4 --- /dev/null +++ b/docs/monitoring/capturing-errors.mdoc @@ -0,0 +1,356 @@ +--- +status: "published" +title: 'Manual Error Capturing' +label: 'Capturing Errors' +description: 'Learn how to manually capture errors and exceptions in both client and server code using Makerkit monitoring hooks and services.' +order: 2 +--- + +While Makerkit automatically captures unhandled errors, you often need to capture errors manually in try-catch blocks, form submissions, or API calls. This guide shows you how to capture errors programmatically. + +{% sequence title="Error Capturing Guide" description="Manually capture errors in your application" %} + +[Client-Side Error Capturing](#client-side-error-capturing) + +[Server-Side Error Capturing](#server-side-error-capturing) + +[Adding Context to Errors](#adding-context-to-errors) + +[Identifying Users](#identifying-users) + +{% /sequence %} + +## Client-Side Error Capturing + +### Using the useCaptureException Hook + +The simplest way to capture errors in React components is the `useCaptureException` hook: + +```typescript {% title="components/error-boundary.tsx" %} +'use client'; + +import { useCaptureException } from '@kit/monitoring/hooks'; + +export default function ErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + // Automatically captures the error when the component mounts + useCaptureException(error); + + return ( + <div> + <h2>Something went wrong</h2> + <button onClick={reset}>Try again</button> + </div> + ); +} +``` + +This hook captures the error once when the component mounts. It's ideal for error boundary pages. + +### Using the useMonitoring Hook + +For more control, use the `useMonitoring` hook to access the monitoring service directly: + +```typescript {% title="components/form.tsx" %} +'use client'; + +import { useMonitoring } from '@kit/monitoring/hooks'; + +export function ContactForm() { + const monitoring = useMonitoring(); + + const handleSubmit = async (formData: FormData) => { + try { + await submitForm(formData); + } catch (error) { + // Capture the error with context + monitoring.captureException(error as Error, { + formData: Object.fromEntries(formData), + action: 'contact_form_submit', + }); + + // Show user-friendly error + toast.error('Failed to submit form'); + } + }; + + return ( + <form action={handleSubmit}> + {/* form fields */} + </form> + ); +} +``` + +### Capturing Events + +Track custom monitoring events (not errors) using `captureEvent`: + +```typescript {% title="Example: Tracking important actions" %} +'use client'; + +import { useMonitoring } from '@kit/monitoring/hooks'; + +export function DangerZone() { + const monitoring = useMonitoring(); + + const handleDeleteAccount = async () => { + // Track the action before attempting + monitoring.captureEvent('account_deletion_attempted', { + userId: user.id, + timestamp: new Date().toISOString(), + }); + + try { + await deleteAccount(); + } catch (error) { + monitoring.captureException(error as Error); + } + }; + + return ( + <button onClick={handleDeleteAccount}> + Delete Account + </button> + ); +} +``` + +## Server-Side Error Capturing + +### In Server Actions + +```typescript {% title="lib/actions/create-project.ts" %} +'use server'; + +import { getServerMonitoringService } from '@kit/monitoring/server'; + +export async function createProject(formData: FormData) { + const monitoring = await getServerMonitoringService(); + await monitoring.ready(); + + try { + const project = await db.project.create({ + data: { + name: formData.get('name') as string, + }, + }); + + return { success: true, project }; + } catch (error) { + await monitoring.captureException(error as Error, { + action: 'createProject', + formData: Object.fromEntries(formData), + }); + + return { success: false, error: 'Failed to create project' }; + } +} +``` + +### In API Routes + +```typescript {% title="app/api/webhook/route.ts" %} +import { getServerMonitoringService } from '@kit/monitoring/server'; + +export async function POST(request: Request) { + const monitoring = await getServerMonitoringService(); + await monitoring.ready(); + + try { + const data = await request.json(); + await processWebhook(data); + + return Response.json({ received: true }); + } catch (error) { + await monitoring.captureException( + error as Error, + { webhook: 'stripe' }, + { + path: request.url, + method: 'POST', + } + ); + + return Response.json( + { error: 'Webhook processing failed' }, + { status: 500 } + ); + } +} +``` + +## Adding Context to Errors + +The `captureException` method accepts two optional parameters for adding context: + +```typescript +captureException( + error: Error, + extra?: Record<string, unknown>, // Additional data + config?: Record<string, unknown> // Provider-specific config +) +``` + +### Extra Data + +Add any relevant information that helps debug the error: + +```typescript {% title="Example: Rich error context" %} +monitoring.captureException(error, { + // User context + userId: user.id, + userEmail: user.email, + userPlan: user.subscription.plan, + + // Action context + action: 'checkout', + productId: product.id, + quantity: cart.items.length, + + // Environment context + feature_flags: getEnabledFlags(), + app_version: process.env.APP_VERSION, +}); +``` + +### Configuration + +Pass provider-specific configuration: + +```typescript {% title="Example: Sentry-specific config" %} +monitoring.captureException( + error, + { userId: user.id }, + { + // Sentry-specific options + level: 'error', + tags: { + component: 'checkout', + flow: 'payment', + }, + } +); +``` + +## Identifying Users + +Associate errors with users to track issues per user: + +```typescript {% title="Example: Identify user after login" %} +'use client'; + +import { useMonitoring } from '@kit/monitoring/hooks'; + +export function useIdentifyUser(user: { id: string; email: string }) { + const monitoring = useMonitoring(); + + useEffect(() => { + if (user) { + monitoring.identifyUser({ + id: user.id, + email: user.email, + }); + } + }, [user, monitoring]); +} +``` + +Once identified, all subsequent errors from that session are associated with the user in your monitoring dashboard. + +## Best Practices + +### Capture at Boundaries + +Capture errors at the boundaries of your application: + +- Form submissions +- API calls +- Third-party integrations +- File uploads +- Payment processing + +```typescript {% title="Pattern: Error boundary function" %} +async function withErrorCapture<T>( + fn: () => Promise<T>, + context: Record<string, unknown> +): Promise<T | null> { + const monitoring = await getServerMonitoringService(); + await monitoring.ready(); + + try { + return await fn(); + } catch (error) { + await monitoring.captureException(error as Error, context); + return null; + } +} + +// Usage +const result = await withErrorCapture( + () => processPayment(paymentData), + { action: 'processPayment', amount: paymentData.amount } +); +``` + +### Don't Over-Capture + +Not every error needs to be captured: + +```typescript {% title="Example: Selective capturing" %} +try { + await fetchUserData(); +} catch (error) { + if (error instanceof NetworkError) { + // Expected error, handle gracefully + return fallbackData; + } + + if (error instanceof AuthError) { + // Expected error, redirect to login + redirect('/login'); + } + + // Unexpected error, capture it + monitoring.captureException(error as Error); + throw error; +} +``` + +### Include Actionable Context + +Add context that helps you fix the issue: + +```typescript {% title="Good context" %} +monitoring.captureException(error, { + userId: user.id, + action: 'export_csv', + rowCount: data.length, + filters: appliedFilters, + exportFormat: 'csv', +}); +``` + +```typescript {% title="Less useful context" %} +monitoring.captureException(error, { + error: 'something went wrong', + time: Date.now(), +}); +``` + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Should I capture validation errors?", "answer": "Generally no. Validation errors are expected and user-facing. Capture unexpected errors like database failures, third-party API errors, or logic errors that shouldn't happen."}, + {"question": "How do I avoid capturing the same error multiple times?", "answer": "Capture at the highest level where you handle the error. If you rethrow an error, don't capture it at the lower level. Let it bubble up to where it's finally handled."}, + {"question": "What's the difference between captureException and captureEvent?", "answer": "captureException is for errors and exceptions. captureEvent is for tracking important actions or milestones that aren't errors, like 'user_deleted_account' or 'large_export_started'."}, + {"question": "Does capturing errors affect performance?", "answer": "Minimally. Error capturing is asynchronous and non-blocking. However, avoid capturing in hot paths or loops. Capture at boundaries, not in every function."} + ] +/%} + +**Next:** [Creating a Custom Monitoring Provider →](custom-provider) diff --git a/docs/monitoring/custom-monitoring-provider.mdoc b/docs/monitoring/custom-monitoring-provider.mdoc new file mode 100644 index 000000000..fed1e2495 --- /dev/null +++ b/docs/monitoring/custom-monitoring-provider.mdoc @@ -0,0 +1,334 @@ +--- +status: "published" +title: 'Creating a Custom Monitoring Provider' +label: 'Custom Provider' +description: 'Integrate LogRocket, Bugsnag, Datadog, or any monitoring service by implementing the MonitoringService interface.' +order: 1 +--- + +{% sequence title="How to create a custom monitoring provider" description="Add your preferred monitoring service to the kit." %} + +[Implement the MonitoringService interface](#implement-the-monitoringservice-interface) + +[Register the provider](#register-the-provider) + +[Configure for server-side](#server-side-configuration) + +{% /sequence %} + +The monitoring system uses a registry pattern that loads providers dynamically based on the `NEXT_PUBLIC_MONITORING_PROVIDER` environment variable. You can add support for LogRocket, Bugsnag, Datadog, or any other service. + +## Implement the MonitoringService Interface + +Create a new package or add to the existing monitoring packages: + +```typescript {% title="packages/monitoring/logrocket/src/logrocket-monitoring.service.ts" %} +import LogRocket from 'logrocket'; +import { MonitoringService } from '@kit/monitoring-core'; + +export class LogRocketMonitoringService implements MonitoringService { + private readonly readyPromise: Promise<unknown>; + private readyResolver?: (value?: unknown) => void; + + constructor() { + this.readyPromise = new Promise( + (resolve) => (this.readyResolver = resolve), + ); + + void this.initialize(); + } + + async ready() { + return this.readyPromise; + } + + captureException(error: Error, extra?: Record<string, unknown>) { + LogRocket.captureException(error, { + extra, + }); + } + + captureEvent(event: string, extra?: Record<string, unknown>) { + LogRocket.track(event, extra); + } + + identifyUser(user: { id: string; email?: string; name?: string }) { + LogRocket.identify(user.id, { + email: user.email, + name: user.name, + }); + } + + private async initialize() { + const appId = process.env.NEXT_PUBLIC_LOGROCKET_APP_ID; + + if (!appId) { + console.warn('LogRocket app ID not configured'); + this.readyResolver?.(); + return; + } + + if (typeof window !== 'undefined') { + LogRocket.init(appId); + } + + this.readyResolver?.(); + } +} +``` + +### Package Configuration + +Create the package structure: + +```json {% title="packages/monitoring/logrocket/package.json" %} +{ + "name": "@kit/logrocket", + "version": "0.0.1", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@kit/monitoring-core": "workspace:*", + "logrocket": "^3.0.0" + } +} +``` + +```typescript {% title="packages/monitoring/logrocket/src/index.ts" %} +export { LogRocketMonitoringService } from './logrocket-monitoring.service'; +``` + +## Register the Provider + +### Client-Side Registration + +Update the monitoring provider registry: + +```typescript {% title="packages/monitoring/api/src/components/provider.tsx" %} +import { lazy } from 'react'; +import { createRegistry } from '@kit/shared/registry'; +import { + MonitoringProvider as MonitoringProviderType, + getMonitoringProvider, +} from '../get-monitoring-provider'; + +type ProviderComponent = { + default: React.ComponentType<React.PropsWithChildren>; +}; + +const provider = getMonitoringProvider(); + +const Provider = provider + ? lazy(() => monitoringProviderRegistry.get(provider)) + : null; + +const monitoringProviderRegistry = createRegistry< + ProviderComponent, + NonNullable<MonitoringProviderType> +>(); + +// Existing Sentry registration +monitoringProviderRegistry.register('sentry', async () => { + const { SentryProvider } = await import('@kit/sentry/provider'); + return { + default: function SentryProviderWrapper({ children }) { + return <SentryProvider>{children}</SentryProvider>; + }, + }; +}); + +// Add LogRocket registration +monitoringProviderRegistry.register('logrocket', async () => { + const { LogRocketProvider } = await import('@kit/logrocket/provider'); + return { + default: function LogRocketProviderWrapper({ children }) { + return <LogRocketProvider>{children}</LogRocketProvider>; + }, + }; +}); +``` + +### Add Provider Type + +Update the provider enum: + +```typescript {% title="packages/monitoring/api/src/get-monitoring-provider.ts" %} +import * as z from 'zod'; + +const MONITORING_PROVIDERS = [ + 'sentry', + 'logrocket', // Add your provider + '', +] as const; + +export const MONITORING_PROVIDER = z + .enum(MONITORING_PROVIDERS) + .optional() + .transform((value) => value || undefined); + +export type MonitoringProvider = z.output<typeof MONITORING_PROVIDER>; + +export function getMonitoringProvider() { + const result = MONITORING_PROVIDER.safeParse(process.env.NEXT_PUBLIC_MONITORING_PROVIDER); + + if (result.success) { + return result.data; + } + + return undefined; +} +``` + +### Create the Provider Component + +```typescript {% title="packages/monitoring/logrocket/src/provider.tsx" %} +import { MonitoringContext } from '@kit/monitoring-core'; +import { LogRocketMonitoringService } from './logrocket-monitoring.service'; + +const logrocket = new LogRocketMonitoringService(); + +export function LogRocketProvider({ children }: React.PropsWithChildren) { + return ( + <MonitoringContext.Provider value={logrocket}> + {children} + </MonitoringContext.Provider> + ); +} +``` + +## Server-Side Configuration + +Register the provider for server-side error capture: + +```typescript {% title="packages/monitoring/api/src/services/get-server-monitoring-service.ts" %} +import { + ConsoleMonitoringService, + MonitoringService, +} from '@kit/monitoring-core'; +import { createRegistry } from '@kit/shared/registry'; +import { + MonitoringProvider, + getMonitoringProvider, +} from '../get-monitoring-provider'; + +const serverMonitoringRegistry = createRegistry< + MonitoringService, + NonNullable<MonitoringProvider> +>(); + +// Existing Sentry registration +serverMonitoringRegistry.register('sentry', async () => { + const { SentryMonitoringService } = await import('@kit/sentry'); + return new SentryMonitoringService(); +}); + +// Add LogRocket registration +serverMonitoringRegistry.register('logrocket', async () => { + const { LogRocketMonitoringService } = await import('@kit/logrocket'); + return new LogRocketMonitoringService(); +}); + +export async function getServerMonitoringService() { + const provider = getMonitoringProvider(); + + if (!provider) { + return new ConsoleMonitoringService(); + } + + return serverMonitoringRegistry.get(provider); +} +``` + +## Environment Variables + +Add your provider's configuration: + +```bash {% title="apps/web/.env.local" %} +# Enable LogRocket as the monitoring provider +NEXT_PUBLIC_MONITORING_PROVIDER=logrocket + +# LogRocket configuration +NEXT_PUBLIC_LOGROCKET_APP_ID=your-org/your-app +``` + +## Example: Datadog Integration + +Here's a complete example for Datadog RUM: + +```typescript {% title="packages/monitoring/datadog/src/datadog-monitoring.service.ts" %} +import { datadogRum } from '@datadog/browser-rum'; +import { MonitoringService } from '@kit/monitoring-core'; + +export class DatadogMonitoringService implements MonitoringService { + private readonly readyPromise: Promise<unknown>; + private readyResolver?: (value?: unknown) => void; + + constructor() { + this.readyPromise = new Promise( + (resolve) => (this.readyResolver = resolve), + ); + + void this.initialize(); + } + + async ready() { + return this.readyPromise; + } + + captureException(error: Error, extra?: Record<string, unknown>) { + datadogRum.addError(error, { + ...extra, + }); + } + + captureEvent(event: string, extra?: Record<string, unknown>) { + datadogRum.addAction(event, extra); + } + + identifyUser(user: { id: string; email?: string; name?: string }) { + datadogRum.setUser({ + id: user.id, + email: user.email, + name: user.name, + }); + } + + private async initialize() { + if (typeof window === 'undefined') { + this.readyResolver?.(); + return; + } + + datadogRum.init({ + applicationId: process.env.NEXT_PUBLIC_DATADOG_APP_ID!, + clientToken: process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN!, + site: process.env.NEXT_PUBLIC_DATADOG_SITE ?? 'datadoghq.com', + service: process.env.NEXT_PUBLIC_DATADOG_SERVICE ?? 'my-saas', + env: process.env.NEXT_PUBLIC_DATADOG_ENV ?? 'production', + sessionSampleRate: 100, + sessionReplaySampleRate: 20, + trackUserInteractions: true, + trackResources: true, + trackLongTasks: true, + }); + + this.readyResolver?.(); + } +} +``` + +## Common Gotchas + +1. **Browser-only initialization** - Check `typeof window !== 'undefined'` before accessing browser APIs. +2. **Ready state** - The `ready()` method must resolve after initialization completes. Server contexts call `await service.ready()` before capturing. +3. **Provider enum** - Remember to add your provider to the `MONITORING_PROVIDERS` array in `get-monitoring-provider.ts`. +4. **Lazy loading** - Providers are loaded lazily through the registry. Don't import the monitoring service directly in your main bundle. +5. **Server vs client** - Some providers (like LogRocket) are browser-only. Return a no-op or console fallback for server contexts. + +This monitoring system is part of the [Next.js Supabase SaaS Kit](/next-supabase-turbo). + +--- + +**Previous:** [Sentry Configuration ←](./sentry) \ No newline at end of file diff --git a/docs/monitoring/honeybadger.mdoc b/docs/monitoring/honeybadger.mdoc new file mode 100644 index 000000000..55487c475 --- /dev/null +++ b/docs/monitoring/honeybadger.mdoc @@ -0,0 +1,39 @@ +--- +status: "published" +title: "Configuring Honeybadger Monitoring in Your Next.js Supabase SaaS Kit" +label: "Honeybadger" +order: 5 +description: "Set up Honeybadger as your error monitoring provider in Makerkit, combining analytics and error tracking in one platform." +--- + +[Honeybadger](https://honeybadger.io/) is a platform for error monitoring and uptime tracking with zero-config alerts. + +## Installing the Honeybadger Plugin + +Honeybadger is distributed as a Makerkit plugin. Install it using the CLI: + +```bash +npx @makerkit/cli@latest plugins add honeybadger +``` + +The Makerkit CLI will automatically wire up the plugin in your project, so you don't have to do anything manually. + +Please review the changes with `git diff`. + +## Environment Variables + +Set the monitoring provider and Honeybadger configuration: + +```bash title="apps/web/.env.local" +# Enable Honeybadger as the monitoring provider +NEXT_PUBLIC_MONITORING_PROVIDER=honeybadger + +# Honeybadger configuration +NEXT_PUBLIC_HONEYBADGER_API_KEY=your_api_key_here +``` + +Please add these environment variables to your hosting provider when you deploy your application to production. + +## Scope of this plugin + +This plugin is only responsible for capturing errors and exceptions in the browser and server. Uploading sourcemaps is not supported yet. Please use the [Honeybadger guide](https://docs.honeybadger.io/lib/javascript/integration/nextjs/) for more information on all the available options. \ No newline at end of file diff --git a/docs/monitoring/overview.mdoc b/docs/monitoring/overview.mdoc new file mode 100644 index 000000000..bd32ed9a8 --- /dev/null +++ b/docs/monitoring/overview.mdoc @@ -0,0 +1,347 @@ +--- +title: "Monitoring and Error Tracking in Makerkit" +status: "published" +label: "How Monitoring Works" +order: 0 +description: "Set up error tracking and performance monitoring in your Next.js Supabase SaaS app with Sentry, PostHog, or SigNoz." +--- + +{% sequence title="Steps to configure monitoring" description="Learn how to configure monitoring in the Next.js Supabase Starter Kit." %} + +[Understanding the monitoring architecture](#understanding-the-monitoring-architecture) + +[Supported monitoring providers](#supported-monitoring-providers) + +[Configuring your monitoring provider](#configuring-your-monitoring-provider) + +[What gets monitored automatically](#what-gets-monitored-automatically) + +[Manually capturing exceptions](#manually-capturing-exceptions) + +[Identifying users in error reports](#identifying-users-in-error-reports) + +{% /sequence %} + +## Understanding the Monitoring Architecture + +Makerkit's monitoring system uses a **provider-based architecture** that lets you swap monitoring services without changing your application code. The system lives in the `@kit/monitoring` package and handles: + +- **Error tracking**: Capture client-side and server-side exceptions +- **Performance monitoring**: Track server response times via OpenTelemetry instrumentation +- **User identification**: Associate errors with specific users for debugging + +The architecture follows a registry pattern. When you set `NEXT_PUBLIC_MONITORING_PROVIDER`, Makerkit loads the appropriate service implementation at runtime: + +``` +MonitoringProvider (React context) + │ + ▼ + Registry lookup + │ + ▼ +┌───────┴───────┐ +│ sentry │ +│ posthog │ +│ signoz │ +└───────────────┘ +``` + +This means your components interact with a consistent `MonitoringService` interface regardless of which provider you choose. + +## Supported Monitoring Providers + +Makerkit provides first-class support for these monitoring providers: + +| Provider | Error Tracking | Performance | Self-Hostable | Notes | +|----------|---------------|-------------|---------------|-------| +| [Sentry](/docs/next-supabase-turbo/monitoring/sentry) | Yes | Yes | Yes | Built-in, recommended for most apps | +| [PostHog](/docs/next-supabase-turbo/monitoring/posthog) | Yes | No | Yes | Plugin, doubles as analytics | +| [SigNoz](/docs/next-supabase-turbo/monitoring/signoz) | Yes | Yes | Yes | Plugin, OpenTelemetry-native | + +**Sentry** is included out of the box. PostHog and SigNoz require installing plugins via the Makerkit CLI. + +{% alert type="default" title="Custom providers" %} +You can add support for any monitoring service by implementing the `MonitoringService` interface and registering it in the provider registry. See [Adding a custom monitoring provider](#adding-a-custom-monitoring-provider) below. +{% /alert %} + +## Configuring Your Monitoring Provider + +Set these environment variables to enable monitoring: + +```bash title=".env.local" +# Required: Choose your provider (sentry, posthog, or signoz) +NEXT_PUBLIC_MONITORING_PROVIDER=sentry + +# Provider-specific configuration +# See the individual provider docs for required variables +``` + +The `NEXT_PUBLIC_MONITORING_PROVIDER` variable determines which service handles your errors. Leave it empty to disable monitoring entirely (errors still log to console in development). + +## What Gets Monitored Automatically + +Once configured, Makerkit captures errors without additional code: + +### Client-side exceptions + +The `MonitoringProvider` component wraps your app and captures uncaught exceptions in React components. This includes: + +- Runtime errors in components +- Unhandled promise rejections +- Errors thrown during rendering + +### Server-side exceptions + +Next.js 15+ includes an instrumentation hook that captures server errors automatically. Makerkit hooks into this via `instrumentation.ts`: + +```typescript title="apps/web/instrumentation.ts" +import { type Instrumentation } from 'next'; + +export const onRequestError: Instrumentation.onRequestError = async ( + err, + request, + context, +) => { + const { getServerMonitoringService } = await import('@kit/monitoring/server'); + + const service = await getServerMonitoringService(); + await service.ready(); + + await service.captureException( + err as Error, + {}, + { + path: request.path, + headers: request.headers, + method: request.method, + routePath: context.routePath, + }, + ); +}; +``` + +This captures errors from Server Components, Server Actions, Route Handlers, and Middleware. + +## Manually Capturing Exceptions + +For expected errors (like validation failures or API errors), capture them explicitly: + +### In Server Actions or Route Handlers + +```typescript +import { getServerMonitoringService } from '@kit/monitoring/server'; + +export async function createProject(data: FormData) { + try { + // ... your logic + } catch (error) { + const monitoring = await getServerMonitoringService(); + await monitoring.ready(); + + monitoring.captureException(error, { + action: 'createProject', + userId: user.id, + }); + + throw error; // Re-throw or handle as needed + } +} +``` + +### In React Components + +Use the `useMonitoring` hook for client-side error capture: + +```tsx +'use client'; + +import { useMonitoring } from '@kit/monitoring/hooks'; + +export function DataLoader() { + const monitoring = useMonitoring(); + + async function loadData() { + try { + const response = await fetch('/api/data'); + + if (!response.ok) { + throw new Error(`Failed to load data: ${response.status}`); + } + + return response.json(); + } catch (error) { + monitoring.captureException(error, { + component: 'DataLoader', + }); + + throw error; + } + } + + // ... +} +``` + +### The `useCaptureException` Hook + +For error boundaries or components that receive errors as props: + +```tsx +'use client'; + +import { useCaptureException } from '@kit/monitoring/hooks'; + +export function ErrorDisplay({ error }: { error: Error }) { + // Automatically captures the error when the component mounts + useCaptureException(error); + + return ( + <div> + <h2>Something went wrong</h2> + <p>{error.message}</p> + </div> + ); +} +``` + +## Identifying Users in Error Reports + +Associate errors with users to debug issues faster. Makerkit's monitoring providers support user identification: + +```typescript +const monitoring = useMonitoring(); + +// After user signs in +monitoring.identifyUser({ + id: user.id, + email: user.email, + // Additional fields depend on your provider +}); +``` + +Makerkit automatically identifies users when they sign in if you've configured the analytics/events system. The `user.signedIn` event triggers user identification in both analytics and monitoring. + +## Adding a Custom Monitoring Provider + +To add a provider not included in Makerkit: + +### 1. Implement the MonitoringService interface + +```typescript title="packages/monitoring/my-provider/src/my-provider.service.ts" +import { MonitoringService } from '@kit/monitoring-core'; + +export class MyProviderMonitoringService implements MonitoringService { + private readyPromise: Promise<void>; + private readyResolver?: () => void; + + constructor() { + this.readyPromise = new Promise((resolve) => { + this.readyResolver = resolve; + }); + + this.initialize(); + } + + async ready() { + return this.readyPromise; + } + + captureException(error: Error, extra?: Record<string, unknown>) { + // Send to your monitoring service + myProviderSDK.captureException(error, { extra }); + } + + captureEvent(event: string, extra?: Record<string, unknown>) { + myProviderSDK.captureEvent(event, extra); + } + + identifyUser(user: { id: string }) { + myProviderSDK.setUser(user); + } + + private initialize() { + // Initialize your SDK + myProviderSDK.init({ dsn: process.env.MY_PROVIDER_DSN }); + this.readyResolver?.(); + } +} +``` + +### 2. Register the provider + +Add your provider to the monitoring registries: + +```typescript title="packages/monitoring/api/src/get-monitoring-provider.ts" +const MONITORING_PROVIDERS = [ + 'sentry', + 'my-provider', // Add your provider + '', +] as const; +``` + +```typescript title="packages/monitoring/api/src/services/get-server-monitoring-service.ts" +serverMonitoringRegistry.register('my-provider', async () => { + const { MyProviderMonitoringService } = await import('@kit/my-provider'); + return new MyProviderMonitoringService(); +}); +``` + +```typescript title="packages/monitoring/api/src/components/provider.tsx" +monitoringProviderRegistry.register('my-provider', async () => { + const { MyProviderProvider } = await import('@kit/my-provider/provider'); + + return { + default: function MyProviderWrapper({ children }: React.PropsWithChildren) { + return <MyProviderProvider>{children}</MyProviderProvider>; + }, + }; +}); +``` + +{% alert type="default" title="Telegram notifications" %} +We wrote a tutorial showing how to add Telegram notifications for error monitoring: [Send SaaS errors to Telegram](/blog/tutorials/telegram-saas-error-monitoring). +{% /alert %} + +## Best Practices + +### Do capture context with errors + +```typescript +// Good: Includes debugging context +monitoring.captureException(error, { + userId: user.id, + accountId: account.id, + action: 'updateBillingPlan', + planId: newPlanId, +}); + +// Less useful: No context +monitoring.captureException(error); +``` + +### Don't capture expected validation errors + +```typescript +// Avoid: This clutters your error dashboard +if (!isValidEmail(email)) { + monitoring.captureException(new Error('Invalid email')); + return { error: 'Invalid email' }; +} + +// Better: Only capture unexpected failures +try { + await sendEmail(email); +} catch (error) { + monitoring.captureException(error, { + extra: { email: maskEmail(email) }, + }); +} +``` + +## Next Steps + +Choose a monitoring provider and follow its setup guide: + +- [Configure Sentry](/docs/next-supabase-turbo/monitoring/sentry) (recommended for most apps) +- [Configure PostHog](/docs/next-supabase-turbo/monitoring/posthog) (if you already use PostHog for analytics) +- [Configure SigNoz](/docs/next-supabase-turbo/monitoring/signoz) (self-hosted, OpenTelemetry-native) diff --git a/docs/monitoring/posthog.mdoc b/docs/monitoring/posthog.mdoc new file mode 100644 index 000000000..20e75cfb7 --- /dev/null +++ b/docs/monitoring/posthog.mdoc @@ -0,0 +1,146 @@ +--- +status: "published" +title: "Configuring PostHog Monitoring in Your Next.js Supabase SaaS Kit" +label: "PostHog" +order: 3 +description: "Set up PostHog as your error monitoring provider in Makerkit, combining analytics and error tracking in one platform." +--- + +{% sequence title="Steps to configure PostHog monitoring" description="Learn how to configure PostHog for error monitoring in your Next.js Supabase SaaS kit." %} + +[Installing the PostHog plugin](#installing-the-posthog-plugin) + +[Registering the monitoring service](#registering-the-monitoring-service) + +[Environment variables](#environment-variables) + +[How PostHog monitoring works](#how-posthog-monitoring-works) + +{% /sequence %} + +[PostHog](https://posthog.com) combines product analytics, session replay, feature flags, and error tracking in one platform. If you're already using PostHog for analytics, adding it as your monitoring provider lets you correlate errors with user behavior without switching between tools. + +{% alert type="default" title="Already using PostHog for analytics?" %} +If you've set up PostHog using the [PostHog Analytics guide](../analytics/posthog-analytics-provider), you already have the plugin installed. Skip to [Registering the monitoring service](#registering-the-monitoring-service). +{% /alert %} + +## Installing the PostHog Plugin + +PostHog is distributed as a Makerkit plugin. Install it using the CLI: + +```bash +npx @makerkit/cli@latest plugins add posthog +``` + +The Makerkit CLI will automatically wire up the plugin in your project, so you don't have to do anything manually. Please review the changes with `git diff`. + +## Environment Variables + +Set the monitoring provider and PostHog configuration: + +```bash title=".env.local" +# Enable PostHog as the monitoring provider +NEXT_PUBLIC_MONITORING_PROVIDER=posthog + +# PostHog configuration (same as analytics setup) +NEXT_PUBLIC_POSTHOG_KEY=phc_your_key_here +NEXT_PUBLIC_POSTHOG_HOST=https://eu.posthog.com +``` + +If you haven't configured PostHog yet, see the [PostHog Analytics guide](/docs/next-supabase-turbo/analytics/posthog-analytics-provider) for details on: + +- Finding your API key +- Choosing your region (EU vs US) +- Setting up ingestion rewrites to bypass ad blockers + +## How PostHog Monitoring Works + +When PostHog is your monitoring provider, errors are captured and sent to PostHog's error tracking system: + +### Exception capture + +PostHog captures: + +- Client-side React errors +- Unhandled promise rejections +- Server-side exceptions via the Next.js instrumentation hook + +Errors appear in PostHog under **Error Tracking** in the sidebar. + +### Session correlation + +The main benefit of using PostHog for monitoring is that errors are automatically linked to session replays. When you view an error in PostHog, you can: + +1. See the exact session where the error occurred +2. Watch the user's actions leading up to the error +3. Correlate errors with feature flag states +4. See the user's full journey through your app + +This is particularly useful for debugging errors that only happen in specific user flows or under certain conditions. + +### User identification + +When a user signs in, Makerkit identifies them in PostHog. This links errors to specific users, so you can: + +- See all errors for a specific user +- Contact users affected by critical bugs +- Filter errors by user properties (plan, account type, etc.) + +## Limitations + +PostHog's error tracking is newer than dedicated tools like Sentry. Consider these limitations: + +| Feature | PostHog | Sentry | +|---------|---------|--------| +| Error tracking | Yes | Yes | +| Stack trace deobfuscation | Limited | Full source map support | +| Performance monitoring | Via analytics | Full APM | +| Release tracking | No | Yes | +| Issue assignment | No | Yes | +| Slack/PagerDuty integration | Limited | Full | + +**When to choose PostHog for monitoring:** + +- You're already using PostHog for analytics +- You want errors correlated with session replays +- Your error volume is moderate +- You prefer fewer tools to manage + +**When to choose Sentry instead:** + +- You need detailed stack traces with source maps +- You have high error volume +- You need advanced alerting and issue management +- You want dedicated performance monitoring + +## Using Both PostHog and Sentry + +You can use PostHog for analytics and Sentry for monitoring. Set `NEXT_PUBLIC_MONITORING_PROVIDER=sentry` while keeping PostHog configured for analytics. This gives you: + +- PostHog: Analytics, session replay, feature flags +- Sentry: Error tracking, performance monitoring, source maps + +## Verification + +After setup: + +1. Trigger a test error in your application +2. Open PostHog and navigate to **Error Tracking** +3. Verify the error appears with: + - Error message and stack trace + - User information (if logged in) + - Link to session replay + +## Troubleshooting + +### Errors not appearing in PostHog + +1. **Check the provider setting**: Verify `NEXT_PUBLIC_MONITORING_PROVIDER=posthog` +2. **Check PostHog initialization**: Open browser DevTools and look for PostHog network requests +3. **Verify the registrations**: Ensure all three files are updated with PostHog registrations +4. **Check ad blockers**: If using PostHog directly (no ingestion rewrites), ad blockers may block requests + +## Next Steps +- [Learn about PostHog's error tracking features](https://posthog.com/docs/error-tracking) +- [Configure session replay settings](https://posthog.com/docs/session-replay) +- [Return to monitoring overview](/docs/next-supabase-turbo/monitoring/overview) diff --git a/docs/monitoring/sentry.mdoc b/docs/monitoring/sentry.mdoc new file mode 100644 index 000000000..c721bccb3 --- /dev/null +++ b/docs/monitoring/sentry.mdoc @@ -0,0 +1,302 @@ +--- +status: "published" +title: "Configuring Sentry in Your Next.js Supabase SaaS Kit" +label: "Sentry" +order: 3 +description: "Set up Sentry for error tracking, performance monitoring, and session replay in your Makerkit application." +--- + +{% sequence title="Steps to configure Sentry" description="Learn how to configure Sentry in your Next.js Supabase SaaS kit." %} + +[Installing the Sentry SDK](#installing-the-sentry-sdk) + +[Environment variables](#environment-variables) + +[Configuring source maps](#configuring-source-maps) + +[Customizing the Sentry configuration](#customizing-the-sentry-configuration) + +[Sentry features in Makerkit](#sentry-features-in-makerkit) + +{% /sequence %} + +[Sentry](https://sentry.io) is the recommended monitoring provider for Makerkit applications. It provides error tracking, performance monitoring, and session replay out of the box. Sentry is included in Makerkit's core packages, so no plugin installation is required. + +## Installing the Sentry SDK + +Install the `@sentry/nextjs` package in your web application: + +```bash +pnpm add @sentry/nextjs --filter web +``` + +This package provides the Next.js-specific integrations for Sentry, including automatic instrumentation for Server Components, Server Actions, and Route Handlers. + +## Environment Variables + +Add these variables to your `.env.local` file: + +```bash title=".env.local" +# Required: Enable Sentry as your monitoring provider +NEXT_PUBLIC_MONITORING_PROVIDER=sentry + +# Required: Your Sentry DSN (found in Sentry project settings) +NEXT_PUBLIC_SENTRY_DSN=https://abc123@o123456.ingest.sentry.io/123456 + +# Optional: Set the environment (defaults to VERCEL_ENV if not set) +NEXT_PUBLIC_SENTRY_ENVIRONMENT=production +``` + +You can find your DSN in the Sentry dashboard under **Project Settings > Client Keys (DSN)**. + +## Configuring Source Maps + +Source maps let Sentry show you the original source code in error stack traces instead of minified production code. This is essential for debugging production errors. + +### 1. Update your Next.js configuration + +Wrap your Next.js configuration with Sentry's build plugin: + +```typescript title="next.config.mjs" +import { withSentryConfig } from '@sentry/nextjs'; + +const nextConfig = { + // Your existing Next.js config +}; + +export default withSentryConfig(nextConfig, { + // Sentry organization slug + org: 'your-sentry-org', + + // Sentry project name + project: 'your-sentry-project', + + // Auth token for uploading source maps (set in CI) + authToken: process.env.SENTRY_AUTH_TOKEN, + + // Suppress logs in non-production builds + silent: process.env.NODE_ENV !== 'production', + + // Upload source maps from all packages in the monorepo + widenClientFileUpload: true, + + // Disable automatic server function instrumentation + // (Makerkit handles this via the monitoring package) + autoInstrumentServerFunctions: false, +}); +``` + +### 2. Create a Sentry auth token + +Generate an auth token in your Sentry account: + +1. Go to **Settings > Auth Tokens** in Sentry +2. Click **Create New Token** +3. Select the `project:releases` and `org:read` scopes +4. Copy the token + +### 3. Add the token to your CI environment + +Add the `SENTRY_AUTH_TOKEN` to your deployment platform's environment variables: + +```bash +# Vercel, Railway, Render, etc. +SENTRY_AUTH_TOKEN=sntrys_eyJ... +``` + +{% alert type="warning" title="Don't commit this token" %} +The `SENTRY_AUTH_TOKEN` should only exist in your CI/CD environment, not in your `.env.local` or committed to git. It has write access to your Sentry project. +{% /alert %} + +## Customizing the Sentry Configuration + +Makerkit initializes Sentry with sensible defaults. You can customize these by modifying the configuration in the Sentry package. + +### Client-side configuration + +Edit `packages/monitoring/sentry/src/sentry.client.config.ts`: + +```typescript title="packages/monitoring/sentry/src/sentry.client.config.ts" +import { init } from '@sentry/nextjs'; + +export function initializeSentryBrowserClient( + props: Parameters<typeof init>[0] = {}, +) { + return init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + // Sample 100% of transactions for performance monitoring + // Reduce this in high-traffic applications + tracesSampleRate: props?.tracesSampleRate ?? 1.0, + + // Capture 10% of sessions for replay + replaysSessionSampleRate: 0.1, + + // Capture 100% of sessions with errors for replay + replaysOnErrorSampleRate: 1.0, + + // Add custom integrations + integrations: [ + // Example: Add breadcrumbs for console logs + // Sentry.breadcrumbsIntegration({ console: true }), + ], + + ...props, + }); +} +``` + +### Server-side configuration + +Edit `packages/monitoring/sentry/src/sentry.server.config.ts`: + +```typescript title="packages/monitoring/sentry/src/sentry.server.config.ts" +import { init } from '@sentry/nextjs'; + +export function initializeSentryServerClient( + props: Parameters<typeof init>[0] = {}, +) { + return init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + // Sample rate for server-side transactions + tracesSampleRate: props?.tracesSampleRate ?? 1.0, + + ...props, + }); +} +``` + +### Adjusting sample rates for production + +For high-traffic applications, sampling 100% of transactions can be expensive. Adjust the sample rates based on your traffic: + +| Monthly requests | Recommended `tracesSampleRate` | +|-----------------|-------------------------------| +| < 100k | 1.0 (100%) | +| 100k - 1M | 0.1 - 0.5 (10-50%) | +| > 1M | 0.01 - 0.1 (1-10%) | + +Error capture is not affected by sampling. All errors are captured regardless of the `tracesSampleRate` setting. + +## Sentry Features in Makerkit + +### Error tracking + +All uncaught exceptions are automatically captured: + +- **Client-side**: React errors, unhandled promise rejections +- **Server-side**: Server Component errors, Server Action errors, Route Handler errors, Middleware errors + +Each error includes: + +- Full stack trace (with source maps) +- Request context (URL, method, headers) +- User information (if identified) +- Environment and release information + +### Performance monitoring + +When `tracesSampleRate` is greater than 0, Sentry tracks: + +- Page load times +- API route response times +- Server Component render times +- Database query durations (if using Sentry's database integrations) + +### Session replay + +Sentry can record user sessions and replay them when errors occur. This helps you see exactly what the user did before encountering an error. + +Session replay is enabled by default with these settings: + +- 10% of normal sessions are recorded +- 100% of sessions with errors are recorded + +To disable replay, set both sample rates to 0 in your client config. + +### User identification + +Makerkit automatically identifies users when they sign in through the events system. You can also manually identify users: + +```typescript +import { useMonitoring } from '@kit/monitoring/hooks'; + +function UserProfile({ user }) { + const monitoring = useMonitoring(); + + useEffect(() => { + monitoring.identifyUser({ + id: user.id, + email: user.email, + username: user.name, + }); + }, [user]); + + // ... +} +``` + +## Testing Your Setup + +After configuration, verify Sentry is working: + +### 1. Trigger a test error + +Add a temporary button to trigger an error: + +```tsx +'use client'; + +export function TestSentry() { + return ( + <button + onClick={() => { + throw new Error('Test Sentry error'); + }} + > + Test Sentry + </button> + ); +} +``` + +### 2. Check the Sentry dashboard + +The error should appear in your Sentry project within a few seconds. Verify: + +- The stack trace shows your original source code (not minified) +- The environment is correct +- User information is attached (if logged in) + +### 3. Remove the test code + +Delete the test button after verifying the setup. + +## Troubleshooting + +### Errors not appearing in Sentry + +1. **Check the DSN**: Verify `NEXT_PUBLIC_SENTRY_DSN` is set correctly +2. **Check the provider**: Verify `NEXT_PUBLIC_MONITORING_PROVIDER=sentry` +3. **Check the console**: Look for Sentry initialization errors +4. **Check ad blockers**: Some ad blockers block Sentry's ingestion endpoint + +### Source maps not working + +1. **Verify the auth token**: Check `SENTRY_AUTH_TOKEN` is set in your CI environment +2. **Check the build logs**: Look for "Uploading source maps" in your build output +3. **Verify the release**: Make sure the release version matches between your build and Sentry + +### High Sentry costs + +1. **Reduce `tracesSampleRate`**: Lower the performance monitoring sample rate +2. **Reduce `replaysSessionSampleRate`**: Only capture error sessions +3. **Filter events**: Use Sentry's `beforeSend` hook to drop low-value errors + +## Next Steps + +- [View the Sentry Next.js documentation](https://docs.sentry.io/platforms/javascript/guides/nextjs/) +- [Set up Sentry alerts](https://docs.sentry.io/product/alerts/) +- [Configure Slack notifications](https://docs.sentry.io/product/integrations/notification-incidents/slack/) +- [Return to monitoring overview](/docs/next-supabase-turbo/monitoring/overview) diff --git a/docs/monitoring/signoz.mdoc b/docs/monitoring/signoz.mdoc new file mode 100644 index 000000000..0de490625 --- /dev/null +++ b/docs/monitoring/signoz.mdoc @@ -0,0 +1,270 @@ +--- +status: "published" +title: "Configuring SigNoz in Your Next.js Supabase SaaS Kit" +label: "SigNoz" +order: 6 +description: "Set up SigNoz for OpenTelemetry-native observability with self-hosted error tracking, traces, logs, and metrics." +--- + +{% sequence title="Steps to configure SigNoz" description="Learn how to configure SigNoz in your Next.js Supabase SaaS kit." %} + +[Installing the SigNoz plugin](#installing-the-signoz-plugin) + +[Registering the monitoring services](#registering-the-monitoring-services) + +[Environment variables](#environment-variables) + +[Running SigNoz locally](#running-signoz-locally) + +[Configuring logging with Winston](#configuring-logging-with-winston) + +{% /sequence %} + +[SigNoz](https://signoz.io) is an open-source, self-hostable observability platform built on OpenTelemetry. It provides traces, metrics, logs, and error tracking in one interface. Choose SigNoz if you want full control over your observability data and prefer OpenTelemetry standards. + +## Installing the SigNoz Plugin + +SigNoz is distributed as a Makerkit plugin. Install it using the CLI: + +```bash +npx @makerkit/cli@latest plugins add signoz +``` + +This creates the plugin at `packages/plugins/signoz`. + +Our codemod will wire up the plugin in your project, so you don't have to do anything manually. Please review the changes with `git diff`. + +## Environment Variables + +SigNoz requires several OpenTelemetry environment variables: + +```bash title=".env.local" +# Enable SigNoz as the monitoring provider +NEXT_PUBLIC_MONITORING_PROVIDER=signoz + +# Service identification +NEXT_PUBLIC_OTEL_SERVICE_NAME=makerkit +OTEL_RESOURCE_ATTRIBUTES="service.name=makerkit,service.version=1.0.0" + +# Client-side SigNoz configuration +NEXT_PUBLIC_SIGNOZ_INGESTION_KEY=your_ingestion_key +NEXT_PUBLIC_SIGNOZ_INGESTION_URL=http://localhost:4318/v1/logs + +# Server-side OpenTelemetry configuration +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces +OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf +OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:4318/v1/logs +OTEL_EXPORTER_OTLP_HEADERS="signoz-ingestion-key=your_ingestion_key" +``` + +### Variable reference + +| Variable | Description | +|----------|-------------| +| `NEXT_PUBLIC_OTEL_SERVICE_NAME` | Name shown in SigNoz dashboards | +| `OTEL_RESOURCE_ATTRIBUTES` | Service metadata (name, version) | +| `NEXT_PUBLIC_SIGNOZ_INGESTION_KEY` | Your SigNoz ingestion API key | +| `NEXT_PUBLIC_SIGNOZ_INGESTION_URL` | Logs ingestion endpoint | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Traces ingestion endpoint | +| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Logs ingestion endpoint | + +## Running SigNoz Locally + +For development, run SigNoz in Docker: + +```bash +# Clone SigNoz +git clone -b main https://github.com/SigNoz/signoz.git && cd signoz/deploy/ + +# Start SigNoz +docker compose -f docker/clickhouse-setup/docker-compose.yaml up -d +``` + +SigNoz will be available at `http://localhost:3301`. + +For detailed installation options, see the [SigNoz Docker installation guide](https://signoz.io/docs/install/docker/). + +### Local environment variables + +When running SigNoz locally, use these endpoints: + +```bash title=".env.local" +NEXT_PUBLIC_SIGNOZ_INGESTION_URL=http://localhost:4318/v1/logs +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces +OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:4318/v1/logs +``` + +For local development, you can leave `OTEL_EXPORTER_OTLP_HEADERS` empty since no authentication is required. + +## Production Configuration + +When deploying SigNoz to production (self-hosted or SigNoz Cloud): + +### Self-hosted + +Update the endpoints to point to your SigNoz instance: + +```bash title=".env.production" +NEXT_PUBLIC_SIGNOZ_INGESTION_URL=https://signoz.yourdomain.com/v1/logs +OTEL_EXPORTER_OTLP_ENDPOINT=https://signoz.yourdomain.com +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://signoz.yourdomain.com/v1/traces +OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://signoz.yourdomain.com/v1/logs +``` + +### SigNoz Cloud + +If using SigNoz Cloud, update the endpoints and add your ingestion key: + +```bash title=".env.production" +NEXT_PUBLIC_SIGNOZ_INGESTION_KEY=your_cloud_ingestion_key +NEXT_PUBLIC_SIGNOZ_INGESTION_URL=https://ingest.{region}.signoz.cloud/v1/logs +OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.{region}.signoz.cloud +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://ingest.{region}.signoz.cloud/v1/traces +OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://ingest.{region}.signoz.cloud/v1/logs +OTEL_EXPORTER_OTLP_HEADERS="signoz-ingestion-key=your_cloud_ingestion_key" +``` + +Replace `{region}` with your SigNoz Cloud region (e.g., `us`, `eu`). + +## Configuring Logging with Winston + +{% alert type="warning" title="Pino logging limitation" %} +Due to compatibility issues between Next.js and the OpenTelemetry transport for Pino (Makerkit's default logger), logs cannot be sent to SigNoz using Pino. Switch to Winston for log ingestion. +{% /alert %} + +### 1. Switch to Winston + +Set the logger environment variable: + +```bash title=".env.local" +LOGGER=winston +``` + +### 2. Register the Winston logger + +```typescript title="packages/shared/src/logger/index.ts" +// Register the Winston logger implementation +loggerRegistry.register('winston', async () => { + const { Logger: WinstonLogger } = await import('./impl/winston'); + + return WinstonLogger; +}); +``` + +### 3. Configure Winston with OpenTelemetry + +Follow the [SigNoz Winston integration guide](https://signoz.io/docs/logs-management/send-logs/nodejs-winston-logs/) to configure the OpenTelemetry transport for Winston. + +Example Winston configuration: + +```typescript title="packages/shared/src/logger/impl/winston.ts" +import winston from 'winston'; + +const { combine, timestamp, json } = winston.format; + +export const Logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: combine( + timestamp(), + json() + ), + transports: [ + new winston.transports.Console(), + // Add OpenTelemetry transport for SigNoz + ], +}); +``` + +## What SigNoz Captures + +### Traces + +SigNoz captures distributed traces across your application: + +- HTTP request traces +- Database query traces +- External API call traces +- Server Component render times + +View traces in SigNoz under **Traces** to see the full request lifecycle. + +### Metrics + +OpenTelemetry metrics are automatically collected: + +- Request duration histograms +- Error rates +- Request counts by endpoint + +### Logs + +When configured with Winston, logs flow to SigNoz: + +- Application logs at all levels (debug, info, warn, error) +- Correlated with traces via trace IDs +- Searchable and filterable + +### Exceptions + +Errors captured via the monitoring service appear in SigNoz with: + +- Stack traces +- Request context +- Correlation with traces and logs + +## SigNoz vs Sentry + +| Feature | SigNoz | Sentry | +|---------|--------|--------| +| Self-hostable | Yes (primary use case) | Yes (limited) | +| OpenTelemetry native | Yes | No | +| Traces | Yes | Yes (via APM) | +| Logs | Yes | No | +| Metrics | Yes | No | +| Error tracking | Yes | Yes (primary focus) | +| Session replay | No | Yes | +| Source maps | Limited | Full support | +| Pricing | Free (self-hosted) | Usage-based | + +**Choose SigNoz when:** + +- You want to self-host your observability stack +- You prefer OpenTelemetry standards +- You need traces, logs, and metrics in one place +- You want predictable costs (self-hosted = infrastructure cost only) + +**Choose Sentry when:** + +- You want managed service with minimal setup +- You need advanced error tracking features +- You want session replay +- You prefer detailed source map integration + +## Troubleshooting + +### Traces not appearing + +1. **Check OTLP endpoints**: Verify all `OTEL_EXPORTER_OTLP_*` variables are set +2. **Check connectivity**: Ensure your app can reach the SigNoz endpoints +3. **Check the SigNoz logs**: Look for ingestion errors in SigNoz container logs + +### Logs not appearing + +1. **Verify Winston is configured**: Check `LOGGER=winston` is set +2. **Check the OpenTelemetry transport**: Ensure the Winston transport is correctly configured +3. **Check log levels**: Verify your log level includes the logs you expect + +### Authentication errors + +1. **Check ingestion key**: Verify `signoz-ingestion-key` header is set correctly +2. **Check key format**: The header value should be just the key, not `Bearer key` + +## Next Steps + +- [SigNoz documentation](https://signoz.io/docs/) +- [OpenTelemetry SDK documentation](https://opentelemetry.io/docs/languages/js/) +- [SigNoz GitHub repository](https://github.com/SigNoz/signoz) +- [Return to monitoring overview](/docs/next-supabase-turbo/monitoring/overview) diff --git a/docs/notifications/notifications-components.mdoc b/docs/notifications/notifications-components.mdoc new file mode 100644 index 000000000..8f8094259 --- /dev/null +++ b/docs/notifications/notifications-components.mdoc @@ -0,0 +1,294 @@ +--- +status: "published" +title: "Notification UI Components" +label: "UI Components" +description: "Use the NotificationsPopover component or build custom notification UIs with the provided React hooks." +order: 2 +--- + +MakerKit provides a ready-to-use `NotificationsPopover` component and React hooks for building custom notification interfaces. + +## NotificationsPopover + +The default notification UI: a bell icon with badge that opens a dropdown list. + +```tsx +import { NotificationsPopover } from '@kit/notifications/components'; + +function AppHeader({ accountId }: { accountId: string }) { + return ( + <header> + <NotificationsPopover + accountIds={[accountId]} + realtime={false} + /> + </header> + ); +} +``` + +### Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `accountIds` | `string[]` | Yes | Account IDs to fetch notifications for | +| `realtime` | `boolean` | Yes | Enable Supabase Realtime subscriptions | +| `onClick` | `(notification) => void` | No | Custom click handler | + +### How accountIds works + +Pass all account IDs the user has access to. For a user with a personal account and team memberships: + +```tsx +import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace'; + +function NotificationsWithAllAccounts() { + const { account, accounts } = useUserWorkspace(); + + // Include personal account + all team accounts + const accountIds = [ + account.id, + ...accounts.filter(a => !a.is_personal_account).map(a => a.id) + ]; + + return ( + <NotificationsPopover + accountIds={accountIds} + realtime={false} + /> + ); +} +``` + +The built-in layouts handle this automatically. You only need to configure `accountIds` for custom implementations. + +### Custom click handling + +By default, clicking a notification with a `link` navigates using an anchor tag. Override this with `onClick`: + +```tsx +import { useRouter } from 'next/navigation'; + +function CustomNotifications({ accountId }: { accountId: string }) { + const router = useRouter(); + + return ( + <NotificationsPopover + accountIds={[accountId]} + realtime={false} + onClick={(notification) => { + if (notification.link) { + // Custom navigation logic + router.push(notification.link); + } + }} + /> + ); +} +``` + +### What the component renders + +- **Bell icon** with red badge showing unread count +- **Popover dropdown** with notification list on click +- **Each notification** shows: + - Type icon (info/warning/error with color coding) + - Message body (truncated at 100 characters) + - Relative timestamp ("2 minutes ago", "Yesterday") + - Dismiss button (X icon) +- **Empty state** when no notifications + +The component uses Shadcn UI's Popover, Button, and Separator components with Lucide icons. + +## React hooks + +Build custom notification UIs using these hooks from `@kit/notifications/hooks`. + +### useFetchNotifications + +Fetches initial notifications and optionally subscribes to real-time updates. + +```tsx +'use client'; + +import { useState, useCallback } from 'react'; +import { useFetchNotifications } from '@kit/notifications/hooks'; + +type Notification = { + id: number; + body: string; + dismissed: boolean; + type: 'info' | 'warning' | 'error'; + created_at: string; + link: string | null; +}; + +function CustomNotificationList({ accountIds }: { accountIds: string[] }) { + const [notifications, setNotifications] = useState<Notification[]>([]); + + const onNotifications = useCallback((newNotifications: Notification[]) => { + setNotifications(prev => { + // Deduplicate by ID + const existingIds = new Set(prev.map(n => n.id)); + const unique = newNotifications.filter(n => !existingIds.has(n.id)); + return [...unique, ...prev]; + }); + }, []); + + useFetchNotifications({ + accountIds, + realtime: false, + onNotifications, + }); + + return ( + <ul> + {notifications.map(notification => ( + <li key={notification.id}>{notification.body}</li> + ))} + </ul> + ); +} +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `accountIds` | `string[]` | Account IDs to fetch for | +| `realtime` | `boolean` | Subscribe to real-time updates | +| `onNotifications` | `(notifications: Notification[]) => void` | Callback when notifications arrive | + +**Behavior:** + +- Fetches up to 10 most recent non-dismissed, non-expired notifications +- Uses React Query with `refetchOnMount: false` and `refetchOnWindowFocus: false` +- Calls `onNotifications` with initial data and any real-time updates + +### useDismissNotification + +Returns a function to dismiss (mark as read) a notification. + +```tsx +'use client'; + +import { useDismissNotification } from '@kit/notifications/hooks'; + +function NotificationItem({ notification }) { + const dismiss = useDismissNotification(); + + const handleDismiss = async () => { + await dismiss(notification.id); + // Update local state after dismissing + }; + + return ( + <div> + <span>{notification.body}</span> + <button onClick={handleDismiss}>Dismiss</button> + </div> + ); +} +``` + +The function updates the `dismissed` field to `true` in the database. RLS ensures users can only dismiss their own notifications. + +## Notification type + +All hooks work with this type: + +```typescript +type Notification = { + id: number; + body: string; + dismissed: boolean; + type: 'info' | 'warning' | 'error'; + created_at: string; + link: string | null; +}; +``` + +## Building a custom notification center + +Full example combining the hooks: + +```tsx +'use client'; + +import { useState, useCallback } from 'react'; +import { + useFetchNotifications, + useDismissNotification, +} from '@kit/notifications/hooks'; + +type Notification = { + id: number; + body: string; + dismissed: boolean; + type: 'info' | 'warning' | 'error'; + created_at: string; + link: string | null; +}; + +export function NotificationCenter({ accountIds }: { accountIds: string[] }) { + const [notifications, setNotifications] = useState<Notification[]>([]); + const dismiss = useDismissNotification(); + + const onNotifications = useCallback((incoming: Notification[]) => { + setNotifications(prev => { + const ids = new Set(prev.map(n => n.id)); + const newOnes = incoming.filter(n => !ids.has(n.id)); + return [...newOnes, ...prev]; + }); + }, []); + + useFetchNotifications({ + accountIds, + realtime: true, // Enable real-time + onNotifications, + }); + + const handleDismiss = async (id: number) => { + await dismiss(id); + setNotifications(prev => prev.filter(n => n.id !== id)); + }; + + if (notifications.length === 0) { + return <p>No notifications</p>; + } + + return ( + <div className="space-y-2"> + {notifications.map(notification => ( + <div + key={notification.id} + className="flex items-center justify-between p-3 border rounded" + > + <div> + <span className={`badge badge-${notification.type}`}> + {notification.type} + </span> + {notification.link ? ( + <a href={notification.link}>{notification.body}</a> + ) : ( + <span>{notification.body}</span> + )} + </div> + <button onClick={() => handleDismiss(notification.id)}> + Dismiss + </button> + </div> + ))} + </div> + ); +} +``` + +This gives you full control over styling and behavior while leveraging the built-in data fetching and real-time infrastructure. + +## Related documentation + +- [Notifications overview](/docs/next-supabase-turbo/notifications): Feature overview and architecture +- [Configuration](/docs/next-supabase-turbo/notifications/notifications-configuration): Enable/disable notifications and real-time +- [Sending notifications](/docs/next-supabase-turbo/notifications/sending-notifications): Create notifications from server code +- [Database schema](/docs/next-supabase-turbo/notifications/notifications-schema): Table structure and RLS policies diff --git a/docs/notifications/notifications-configuration.mdoc b/docs/notifications/notifications-configuration.mdoc new file mode 100644 index 000000000..c904c63a0 --- /dev/null +++ b/docs/notifications/notifications-configuration.mdoc @@ -0,0 +1,141 @@ +--- +status: "published" +title: "Configuring Notifications" +label: "Configuration" +description: "Enable or disable notifications, configure real-time updates, and understand the cost implications of Supabase Realtime subscriptions." +order: 0 +--- + +Notifications are controlled by two environment variables in your `.env` file. Both are optional with sensible defaults. + +## Environment variables + +```bash +# Enable the notifications feature (default: true) +NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true + +# Enable real-time updates via Supabase Realtime (default: false) +NEXT_PUBLIC_REALTIME_NOTIFICATIONS=false +``` + +These values are read in `apps/web/config/feature-flags.config.ts` using a helper that parses the string value: + +```typescript +const featuresFlagConfig = FeatureFlagsSchema.parse({ + enableNotifications: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS, + true, // default + ), + realtimeNotifications: getBoolean( + process.env.NEXT_PUBLIC_REALTIME_NOTIFICATIONS, + false, // default + ), +}); + +function getBoolean(value: unknown, defaultValue: boolean) { + if (typeof value === 'string') { + return value === 'true'; + } + return defaultValue; +} +``` + +## NEXT_PUBLIC_ENABLE_NOTIFICATIONS + +Controls whether the notification bell icon appears in the header. + +| Value | Behavior | +|-------|----------| +| `true` (default) | Bell icon visible, notifications functional | +| `false` | Bell icon hidden, no notification queries | + +When disabled, no database queries are made and the `NotificationsPopover` component doesn't render. + +**When to disable**: If your app doesn't need in-app notifications (e.g., you only use email notifications), set this to `false` to simplify your UI. + +## NEXT_PUBLIC_REALTIME_NOTIFICATIONS + +Controls whether the client subscribes to Supabase Realtime for instant notification delivery. + +| Value | Behavior | +|-------|----------| +| `false` (default) | Notifications load on page navigation only | +| `true` | New notifications appear instantly without refresh | + +### How real-time works + +When enabled, each connected client opens a WebSocket connection to Supabase and subscribes to `INSERT` events on the `notifications` table, filtered by the user's account IDs: + +```typescript +client.channel('notifications-channel') + .on('postgres_changes', { + event: 'INSERT', + schema: 'public', + table: 'notifications', + filter: `account_id=in.(${accountIds.join(', ')})`, + }, (payload) => { + // New notification received + }) + .subscribe(); +``` + +### Cost considerations + +Supabase Realtime connections count toward your plan limits: + +- **Free tier**: 200 concurrent connections +- **Pro tier**: 500 concurrent connections (more available as add-on) + +Each browser tab from each user maintains one connection. For an app with 100 concurrent users averaging 2 tabs each, that's 200 connections. + +**Recommendation**: Start with real-time disabled. Enable it only if instant notification delivery is a core requirement. Most users check notifications on page load anyway. + +### Without real-time + +Notifications are fetched via React Query on component mount: + +- Initial page load fetches the 10 most recent non-dismissed, non-expired notifications +- No refetch on window focus (to reduce server load) +- Users see new notifications when they navigate to a new page + +This approach works well for most SaaS applications and has zero cost impact. + +## Using feature flags in your code + +Check these flags before rendering notification-related UI: + +```typescript +import featuresFlagConfig from '~/config/feature-flags.config'; + +function AppHeader() { + return ( + <header> + {/* Other header content */} + + {featuresFlagConfig.enableNotifications && ( + <NotificationsPopover + accountIds={[accountId]} + realtime={featuresFlagConfig.realtimeNotifications} + /> + )} + </header> + ); +} +``` + +The built-in layouts already handle this check. You only need to worry about feature flags if you're building custom notification UI. + +## Testing notifications locally + +1. Ensure your local Supabase is running: `pnpm supabase:web:start` +2. Notifications are enabled by default in development +3. Use the [sending notifications API](/docs/next-supabase-turbo/notifications/sending-notifications) to create test notifications +4. If testing real-time, set `NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true` in `.env.local` + +To verify real-time is working, open two browser tabs, send a notification, and confirm it appears in both tabs without refreshing. + +## Related documentation + +- [Notifications overview](/docs/next-supabase-turbo/notifications): Feature overview and architecture +- [Sending notifications](/docs/next-supabase-turbo/notifications/sending-notifications): Server-side API for creating notifications +- [Database schema](/docs/next-supabase-turbo/notifications/notifications-schema): Table structure and RLS policies diff --git a/docs/notifications/notifications-schema.mdoc b/docs/notifications/notifications-schema.mdoc new file mode 100644 index 000000000..f2cd772c9 --- /dev/null +++ b/docs/notifications/notifications-schema.mdoc @@ -0,0 +1,246 @@ +--- +status: "published" +title: "Notifications Database Schema" +label: "Database Schema" +description: "Understand the notifications table structure, Row Level Security policies, and how to extend the schema." +order: 3 +--- + +The notifications system uses a single table with Row Level Security. This page documents the schema, security policies, and extension patterns. + +## Table structure + +```sql +create table if not exists public.notifications ( + id bigint generated always as identity primary key, + account_id uuid not null references public.accounts(id) on delete cascade, + type public.notification_type not null default 'info', + body varchar(5000) not null, + link varchar(255), + channel public.notification_channel not null default 'in_app', + dismissed boolean not null default false, + expires_at timestamptz default (now() + interval '1 month'), + created_at timestamptz not null default now() +); +``` + +### Columns + +| Column | Type | Default | Description | +|--------|------|---------|-------------| +| `id` | `bigint` | Auto-generated | Primary key | +| `account_id` | `uuid` | Required | Personal or team account ID | +| `type` | `notification_type` | `'info'` | Severity: info, warning, error | +| `body` | `varchar(5000)` | Required | Message text or translation key | +| `link` | `varchar(255)` | `null` | Optional URL for clickable notifications | +| `channel` | `notification_channel` | `'in_app'` | Delivery method | +| `dismissed` | `boolean` | `false` | Has user dismissed this notification | +| `expires_at` | `timestamptz` | Now + 1 month | Auto-expiration timestamp | +| `created_at` | `timestamptz` | Now | Creation timestamp | + +### Enums + +```sql +create type public.notification_type as enum('info', 'warning', 'error'); +create type public.notification_channel as enum('in_app', 'email'); +``` + +## Row Level Security + +Three key security constraints: + +### 1. Read policy + +Users can read notifications for their personal account or any team account they belong to: + +```sql +create policy notifications_read_self on public.notifications +for select to authenticated using ( + account_id = (select auth.uid()) + or has_role_on_account(account_id) +); +``` + +### 2. Update policy + +Users can update notifications they have access to: + +```sql +create policy notifications_update_self on public.notifications +for update to authenticated using ( + account_id = (select auth.uid()) + or has_role_on_account(account_id) +); +``` + +### 3. Update trigger (dismissed only) + +A trigger prevents updating any field except `dismissed`: + +```sql +create or replace function kit.update_notification_dismissed_status() +returns trigger set search_path to '' as $$ +begin + old.dismissed := new.dismissed; + + if (new is distinct from old) then + raise exception 'UPDATE of columns other than "dismissed" is forbidden'; + end if; + + return old; +end; +$$ language plpgsql; + +create trigger update_notification_dismissed_status +before update on public.notifications +for each row execute procedure kit.update_notification_dismissed_status(); +``` + +This ensures users cannot modify notification content, type, or other fields after creation. + +### Insert permissions + +Only `service_role` can insert notifications: + +```sql +revoke all on public.notifications from authenticated, service_role; +grant select, update on table public.notifications to authenticated, service_role; +grant insert on table public.notifications to service_role; +``` + +This is why you must use `getSupabaseServerAdminClient()` when creating notifications. + +## Indexes + +One composite index optimizes the common query pattern: + +```sql +create index idx_notifications_account_dismissed +on notifications (account_id, dismissed, expires_at); +``` + +This index supports queries that filter by account, dismissed status, and expiration. + +## Realtime + +The table is added to Supabase Realtime publication: + +```sql +alter publication supabase_realtime add table public.notifications; +``` + +This enables the real-time subscription feature when `NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true`. + +## Common modifications + +### Adding a read_at timestamp + +Track when notifications were first viewed (not just dismissed): + +```sql +alter table public.notifications +add column read_at timestamptz default null; +``` + +Update the trigger to allow `read_at` updates: + +```sql +create or replace function kit.update_notification_status() +returns trigger set search_path to '' as $$ +begin + old.dismissed := new.dismissed; + old.read_at := new.read_at; + + if (new is distinct from old) then + raise exception 'UPDATE of columns other than "dismissed" and "read_at" is forbidden'; + end if; + + return old; +end; +$$ language plpgsql; +``` + +### Adding notification categories + +Extend with a category for filtering: + +```sql +create type public.notification_category as enum( + 'system', + 'billing', + 'team', + 'content' +); + +alter table public.notifications +add column category public.notification_category default 'system'; +``` + +### Batch deletion of old notifications + +Create a function to clean up expired notifications: + +```sql +create or replace function kit.cleanup_expired_notifications() +returns integer +language plpgsql +security definer +set search_path to '' +as $$ +declare + deleted_count integer; +begin + with deleted as ( + delete from public.notifications + where expires_at < now() + returning id + ) + select count(*) into deleted_count from deleted; + + return deleted_count; +end; +$$; +``` + +Call this from a cron job, Supabase Edge Function, or pg_cron extension. + +## Testing RLS policies + +The schema includes pgTAP tests in `apps/web/supabase/tests/database/notifications.test.sql`: + +```sql +-- Users cannot insert notifications +select throws_ok( + $$ insert into notifications (account_id, body) values ('...', 'test') $$, + 'new row violates row-level security policy for table "notifications"' +); + +-- Service role can insert +set role service_role; +select lives_ok( + $$ insert into notifications (account_id, body) values ('...', 'test') $$ +); +``` + +Run tests with: + +```bash +pnpm --filter web supabase test db +``` + +Expected output on success: + +``` +# Running: notifications.test.sql +ok 1 - Users cannot insert notifications +ok 2 - Service role can insert notifications +ok 3 - Users can read their own notifications +... +``` + +## Related documentation + +- [Notifications overview](/docs/next-supabase-turbo/notifications): Feature overview and when to use notifications +- [Configuration](/docs/next-supabase-turbo/notifications/notifications-configuration): Environment variables and feature flags +- [Sending notifications](/docs/next-supabase-turbo/notifications/sending-notifications): Server-side API for creating notifications +- [UI Components](/docs/next-supabase-turbo/notifications/notifications-components): Display notifications in your app diff --git a/docs/notifications/sending-notifications.mdoc b/docs/notifications/sending-notifications.mdoc new file mode 100644 index 000000000..5a51fb83f --- /dev/null +++ b/docs/notifications/sending-notifications.mdoc @@ -0,0 +1,263 @@ +--- +status: "published" +title: "Sending Notifications" +label: "Sending Notifications" +description: "Create in-app notifications from Server Actions, API routes, and background jobs using the notifications API." +order: 1 +--- + +Notifications are created server-side using `createNotificationsApi`. This requires the Supabase admin client because only the `service_role` can insert into the notifications table. + +## Basic usage + +```typescript +import { createNotificationsApi } from '@kit/notifications/api'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +async function sendNotification(accountId: string) { + const client = getSupabaseServerAdminClient(); + const api = createNotificationsApi(client); + + await api.createNotification({ + account_id: accountId, + body: 'Your report is ready', + }); +} +``` + +The `account_id` determines who sees the notification. Pass a user's personal account ID to notify just them, or a team account ID to notify all team members. + +## Notification fields + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `account_id` | `uuid` | Yes | - | Personal or team account ID | +| `body` | `string` | Yes | - | Message text (max 5000 chars) | +| `type` | `'info' \| 'warning' \| 'error'` | No | `'info'` | Severity level | +| `link` | `string` | No | `null` | URL to navigate on click | +| `channel` | `'in_app' \| 'email'` | No | `'in_app'` | Delivery channel | +| `expires_at` | `Date` | No | 1 month | Auto-expiration timestamp | + +## Notification types + +Use types to indicate severity. Each type renders with a distinct icon color: + +```typescript +// Info (blue) - General updates +await api.createNotification({ + account_id: accountId, + body: 'New feature: Dark mode is now available', + type: 'info', +}); + +// Warning (yellow) - Attention needed +await api.createNotification({ + account_id: accountId, + body: 'Your trial expires in 3 days', + type: 'warning', + link: '/settings/billing', +}); + +// Error (red) - Action required +await api.createNotification({ + account_id: accountId, + body: 'Payment failed. Update your card to continue.', + type: 'error', + link: '/settings/billing', +}); +``` + +## Adding links + +Include a `link` to make notifications actionable. Users click the notification to navigate: + +```typescript +await api.createNotification({ + account_id: accountId, + body: 'John commented on your document', + link: '/documents/abc123#comment-456', +}); +``` + +Links should be relative paths within your app. The UI renders the body as a clickable anchor. + +## Setting expiration + +By default, notifications expire after 1 month. Set a custom expiration for time-sensitive messages: + +```typescript +// Expire in 24 hours +const tomorrow = new Date(); +tomorrow.setHours(tomorrow.getHours() + 24); + +await api.createNotification({ + account_id: accountId, + body: 'Flash sale ends tonight!', + link: '/pricing', + expires_at: tomorrow, +}); +``` + +Expired notifications are filtered out on fetch. They remain in the database but won't appear in the UI. + +## Team notifications + +Send to a team account ID to notify all members: + +```typescript +import { createNotificationsApi } from '@kit/notifications/api'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +async function notifyTeam(teamAccountId: string, newMemberName: string) { + const client = getSupabaseServerAdminClient(); + const api = createNotificationsApi(client); + + await api.createNotification({ + account_id: teamAccountId, + body: `${newMemberName} joined the team`, + link: '/settings/members', + type: 'info', + }); +} +``` + +Every user with a role on that team account will see this notification via the RLS policy. + +## Common patterns + +### Welcome notification on signup + +```typescript +// In your post-signup hook or Server Action +export async function onUserCreated(userId: string) { + const client = getSupabaseServerAdminClient(); + const api = createNotificationsApi(client); + + await api.createNotification({ + account_id: userId, + body: 'Welcome! Start by creating your first project.', + link: '/projects/new', + type: 'info', + }); +} +``` + +### Subscription renewal reminder + +```typescript +export async function sendRenewalReminder( + accountId: string, + daysRemaining: number +) { + const client = getSupabaseServerAdminClient(); + const api = createNotificationsApi(client); + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + daysRemaining); + + await api.createNotification({ + account_id: accountId, + body: `Your subscription renews in ${daysRemaining} days`, + link: '/settings/billing', + type: daysRemaining <= 3 ? 'warning' : 'info', + expires_at: expiresAt, + }); +} +``` + +### Background job completion + +```typescript +export async function onExportComplete( + accountId: string, + exportId: string +) { + const client = getSupabaseServerAdminClient(); + const api = createNotificationsApi(client); + + await api.createNotification({ + account_id: accountId, + body: 'Your data export is ready to download', + link: `/exports/${exportId}`, + type: 'info', + }); +} +``` + +### Payment failure + +```typescript +export async function onPaymentFailed(accountId: string) { + const client = getSupabaseServerAdminClient(); + const api = createNotificationsApi(client); + + await api.createNotification({ + account_id: accountId, + body: 'Payment failed. Please update your payment method.', + link: '/settings/billing', + type: 'error', + }); +} +``` + +## Using translation keys + +For internationalized apps, store translation keys instead of plain text: + +```typescript +await api.createNotification({ + account_id: accountId, + body: 'notifications.exportReady', // Translation key + link: '/exports', +}); +``` + +The UI component runs the body through `t()` from next-intl, falling back to the raw string if no translation exists. + +Add the translation to your locale files: + +```json +{ + "notifications": { + "exportReady": "Your data export is ready to download" + } +} +``` + +## Notification channels + +The `channel` field supports `'in_app'` (default) and `'email'`. Currently, only `in_app` is implemented. The `email` channel is reserved for future use where a database trigger could send email notifications. + +```typescript +// In-app only (default) +await api.createNotification({ + account_id: accountId, + body: 'New message received', + channel: 'in_app', +}); +``` + +## Error handling + +The API throws on failure. Wrap calls in try-catch for production code: + +```typescript +try { + await api.createNotification({ + account_id: accountId, + body: 'Notification message', + }); +} catch (error) { + console.error('Failed to send notification:', error); + // Don't throw - notification failure shouldn't break the main flow +} +``` + +Notifications are typically non-critical. Consider logging failures but not throwing, so the primary operation (signup, export, etc.) still succeeds. + +## Related documentation + +- [Notifications overview](/docs/next-supabase-turbo/notifications): Feature overview and when to use notifications +- [Configuration](/docs/next-supabase-turbo/notifications/notifications-configuration): Environment variables and feature flags +- [UI Components](/docs/next-supabase-turbo/notifications/notifications-components): How notifications appear in the UI +- [Database schema](/docs/next-supabase-turbo/notifications/notifications-schema): Table structure and security policies diff --git a/docs/plugins/feedback-plugin.mdoc b/docs/plugins/feedback-plugin.mdoc new file mode 100644 index 000000000..69fbc963a --- /dev/null +++ b/docs/plugins/feedback-plugin.mdoc @@ -0,0 +1,70 @@ +--- +status: "published" +title: 'Add a Feedback Widget plugin to your Next.js Supabase SaaS Starter kit' +label: 'Feedback Widget' +order: 5 +description: 'Add a Feedback Widget plugin to your Next.js Supabase SaaS Starter kit' +--- + +This plugin is a lighter version of the Roadmap plugin. It is recommended to install the Roadmap plugin if you need more features. + +The feedback plugin allows you to add a feedback widget to your app. Users can provide feedback on your app, and you can view and manage feedback submissions in the admin panel. + +### Installation + +Pull the plugin from the main repository: + +``` +npx @makerkit/cli@latest plugins add feedback +``` + +The codemod will automatically: +- Add the `@kit/feedback` dependency and install packages +- Add the feedback sidebar item to the admin panel +- Create the translation file at `apps/web/i18n/messages/en/feedback.json` +- Add the `feedback` namespace to your i18n settings +- Create the Supabase migration file for the feedback table + +### Run the migrations + +After installation, run the migration and regenerate types: + +``` +pnpm run supabase:web:reset +pnpm run supabase:web:typegen +``` + +### Import the component + +Now, you can import the component from the plugin: + +```tsx +import { FeedbackPopup } from '@kit/feedback'; +``` + +And use it in your app: + +```tsx +<FeedbackPopup> + <Button>Gimme feedback</Button> +</FeedbackPopup> +``` + +You can also import the form alone - so you can customize its appearance: + +```tsx +import {FeedbackForm} from '@kit/feedback'; +``` + +And use it in your app: + +```tsx +<FeedbackForm/> +``` + +## Admin Panel + +The admin pages and sidebar item are automatically set up by the CLI. You can find them at: + +- `apps/web/app/[locale]/admin/feedback/page.tsx` — Feedback submissions list +- `apps/web/app/[locale]/admin/feedback/[id]/page.tsx` — Submission detail page \ No newline at end of file diff --git a/docs/plugins/installing-plugins.mdoc b/docs/plugins/installing-plugins.mdoc new file mode 100644 index 000000000..cca43bc65 --- /dev/null +++ b/docs/plugins/installing-plugins.mdoc @@ -0,0 +1,27 @@ +--- +status: "published" +title: 'Installing Plugins in the Next.js Supabase SaaS Starter kit' +label: 'Installing Plugins' +order: 0 +description: 'Learn how to install plugins in the Next.js Supabase SaaS Starter kit.' +--- + +Plugins are placed into a separate repository that mirrors the original repository structure. This allows us to build the plugins using the same files and structure as the main repository. + +You may wonder why we don't include the plugins in the main repository. The reason is that plugins are optional and may not be needed by all users. + +By keeping them separate, we can keep the main repository clean and focused on the core functionality. Instead you can install the plugins you need when you need them. + +## Installing Plugins + +To install a plugin, you can use the Makerkit CLI: + +```bash +npx @makerkit/cli@latest plugins add +``` + +This command will prompt you to select a plugin to install. Once selected, the plugin will be installed in your project. + +## How Makerkit installs plugins in your project + +We use the Shadcn CLI to create a registry of plugins that are available for your variant. The Makerkit CLI uses the registry to download the plugin files and install them in your project. We also use [Codemod](https://codemod.com) to wire up the plugin in your project, so you don't have to do anything manually. \ No newline at end of file diff --git a/docs/plugins/roadmap-plugin.mdoc b/docs/plugins/roadmap-plugin.mdoc new file mode 100644 index 000000000..aaf844860 --- /dev/null +++ b/docs/plugins/roadmap-plugin.mdoc @@ -0,0 +1,75 @@ +--- +status: "published" +title: 'Roadmap Plugin in the Next.js Supabase SaaS Starter kit' +label: 'Roadmap Plugin' +order: 2 +description: 'Learn how to install the Roadmap plugin in the Next.js Supabase SaaS Starter kit.' +--- + +This plugin allows you to create a roadmap for your project and display it on your website. + +Your users can see what features are planned, in progress, and completed and suggest new features or comment on existing ones. + +## Functionality + +The plugin provides the following functionality: + +1. Display the feature requests on the website. +2. Allow users to suggest new features. +3. Allow users to comment on existing features. +4. Display the Feature Requests in the Admin panel. +5. Allow Admins to manage the Feature Requests, update their status, and delete them. +6. Allow Admins to manage the comments on the Feature Requests. + +## Installation + +To install the plugin, run the following command: + +```bash +npx @makerkit/cli plugins add +``` + +Since this plugin depends on the Kanban plugin, you need to install both. Please select the `kanban` plugin from the list of available plugins. + +Then, please select the `roadmap` plugin from the list of available plugins. + +The codemod will automatically: +- Add the `@kit/roadmap` dependency and install packages +- Create the translation file at `apps/web/i18n/messages/en/roadmap.json` +- Add the `roadmap` namespace to your i18n settings +- Add the roadmap sidebar item to the admin panel +- Create the Supabase migration file for the roadmap tables + +### Run the migrations + +After installation, run the migration and regenerate types: + +```bash +pnpm run supabase:web:reset +pnpm run supabase:web:typegen +``` + +## Displaying the Roadmap and Feature Requests + +To display the roadmap and feature requests on your website, add the following code to the `apps/web/app/[locale]/(marketing)/roadmap/page.tsx` file: + +```tsx +import { RoadmapPage } from "@kit/roadmap/server"; + +export default RoadmapPage; +``` + +Let's now add the comments GET route at `apps/web/app/[locale]/(marketing)/roadmap/comments/route.ts`: + +```tsx +import { createFetchCommentsRouteHandler } from '@kit/roadmap/route-handler'; + +export const GET = createFetchCommentsRouteHandler; +``` + +## Admin Pages + +The admin pages and sidebar item are automatically set up by the CLI. You can find them at: + +- `apps/web/app/[locale]/admin/roadmap/page.tsx` — Feature requests list +- `apps/web/app/[locale]/admin/roadmap/[id]/page.tsx` — Feature request detail page \ No newline at end of file diff --git a/docs/plugins/testimonials-plugin.mdoc b/docs/plugins/testimonials-plugin.mdoc new file mode 100644 index 000000000..69e148fe0 --- /dev/null +++ b/docs/plugins/testimonials-plugin.mdoc @@ -0,0 +1,163 @@ +--- +status: "published" +title: 'Testimonials Plugin in the Next.js Supabase SaaS Starter kit' +label: 'Testimonials Plugin' +order: 3 +description: 'Learn how to install the Testimonials plugin in the Next.js Supabase SaaS Starter kit.' +--- + +This plugin allows Makerkit users to easily collect and manage testimonials from their customers. It integrates seamlessly with the existing Makerkit structure and provides both backend and frontend components. + +## Features + +1. Testimonial submission form and manual entry +2. Admin panel for managing testimonials +3. API endpoints for CRUD operations +4. Widgets components for showing testimonials on the website + +## Installation + +To install the plugin, run the following command: + +```bash +npx @makerkit/cli plugins add testimonial +``` + +The codemod will automatically: +- Add the `@kit/testimonial` dependency and install packages +- Create the translation file at `apps/web/i18n/messages/en/testimonials.json` +- Add the `testimonials` namespace to your i18n settings +- Add the testimonials sidebar item to the admin panel +- Create the Supabase migration file for the testimonials table + +### Run the migrations + +After installation, run the migration and regenerate types: + +```bash +pnpm run supabase:web:reset +pnpm run supabase:web:typegen +``` + +The admin pages and sidebar item are automatically set up by the CLI. You can find them at: + +- `apps/web/app/[locale]/admin/testimonials/page.tsx` — Testimonials list +- `apps/web/app/[locale]/admin/testimonials/[id]/page.tsx` — Testimonial detail page + +## Displaying the Testimonial Form + +To display the testimonial form on your website, you can import the form component from the plugin and use it in your page. + +Create a new component, and import the form: + +```tsx +'use client'; + +import { useState } from 'react'; + +import { + TestimonialContainer, + TestimonialForm, + TestimonialSuccessMessage, + VideoTestimonialForm, +} from '@kit/testimonial/client'; + +export function Testimonial() { + const [success, setSuccess] = useState(false); + const onSuccess = () => setSuccess(true); + + if (success) { + return <SuccessMessage />; + } + + return ( + <TestimonialContainer + className={ + 'w-full max-w-md rounded-lg border bg-background p-8 shadow-xl' + } + welcomeMessage={<WelcomeMessage />} + enableTextReview={true} + enableVideoReview={true} + textReviewComponent={<TestimonialForm onSuccess={onSuccess} />} + videoReviewComponent={<VideoTestimonialForm onSuccess={onSuccess} />} + textButtonText="Write your thoughts" + videoButtonText="Share a video message" + backButtonText="Switch review method" + /> + ); +} + +function SuccessMessage() { + return ( + <div + className={ + 'w-full max-w-md rounded-lg border bg-background p-8 shadow-xl' + } + > + <div className="flex flex-col items-center space-y-4 text-center"> + <div className="space-y-1"> + <h1 className="text-2xl font-semibold"> + Thank you for your feedback! + </h1> + + <p className="text-muted-foreground"> + Your review has been submitted successfully. + </p> + </div> + + <div> + <TestimonialSuccessMessage /> + </div> + </div> + </div> + ); +} + +function WelcomeMessage() { + return ( + <div className="flex flex-col items-center space-y-1 text-center"> + <h1 className="text-2xl font-semibold"> + We'd love to hear your feedback! + </h1> + + <p className="text-muted-foreground"> + Your opinion helps us improve our service. + </p> + </div> + ); +} +``` + +Please customize the components as needed to fit your website's design. + +## API Endpoints + +Please add the GET and POST endpoints to fetch the testimonials at `apps/web/app/api/testimonials/route.ts`: + +```ts +import { + createTestimonialsRouteHandler, + createVideoTestimonialRouteHandler, +} from '@kit/testimonial/server'; + +export const GET = createTestimonialsRouteHandler; +export const POST = createVideoTestimonialRouteHandler; +``` + +## Widgets + +To display the testimonials on your website, you can use the following widget: + +```tsx +import { TestimonialWallWidget } from '@kit/testimonial/widgets'; + +export default function TestimonialWidgetPage() { + return ( + <div className={'flex h-full w-screen flex-1 flex-col items-center py-16'}> + <TestimonialWallWidget /> + </div> + ); +} +``` + +Done! You now have a fully functional Testimonial Collection plugin integrated with your Makerkit application. diff --git a/docs/plugins/waitlist-plugin.mdoc b/docs/plugins/waitlist-plugin.mdoc new file mode 100644 index 000000000..c7dc25482 --- /dev/null +++ b/docs/plugins/waitlist-plugin.mdoc @@ -0,0 +1,185 @@ +--- +status: "published" +title: 'Add a Waitlist to the Next.js Supabase SaaS Starter kit' +label: 'Waitlist' +order: 1 +description: 'Add a waitlist to your Next.js Supabase SaaS Starter kit to collect emails from interested users.' +--- + +In this guide, you will learn how to add a waitlist to your Next.js Supabase SaaS Starter kit to collect emails from interested users. This feature is useful for building an audience before launching your product. + +This plugin allows you to create a waitlist for your app. Users can sign up for the waitlist and receive an email when the app is ready. + +### How it works + +1. You disable sign up in your app from Supabase. This prevents any user from using the public API to sign up. +2. We create a new table in Supabase called `waitlist`. Users will sign up for the waitlist and their email will be stored in this table. +3. When you want to enable a sign up for a user, mark users as `approved` in the `waitlist` table. +4. The database trigger will create a new user in the `auth.users` table and send an email to the user with a link to set their password. +5. The user can now sign in to the app and update their password. +6. User gets removed from the waitlist as soon as the email is sent. + +### Installation + +#### Get the plugin using the CLI + +Please run the following command in your terminal: + +```bash +npx @makerkit/cli@latest plugins add waitlist +``` + +After completed, the CLI will install the plugin at `packages/plugins/waitlist`. + +The codemod will automatically: +- Add the `@kit/waitlist` dependency and install packages +- Replace the sign-up form with the waitlist form +- Add the waitlist translations to your `auth.json` locale file +- Create the Supabase migration file for the waitlist table + +#### Run the migrations + +After installation, run the migration and regenerate types: + +```bash +pnpm run supabase:web:reset +pnpm run supabase:web:typegen +``` + +#### Adding the Database Webhook to listen for new signups + +Let's extend the DB handler at `apps/web/app/api/db/webhook/route.ts`. This handler will listen for new signups and send an email to the user: + +```tsx +import { getDatabaseWebhookHandlerService } from '@kit/database-webhooks'; +import { getServerMonitoringService } from '@kit/monitoring/server'; +import { enhanceRouteHandler } from '@kit/next/routes'; + +import appConfig from '~/config/app.config'; +import pathsConfig from '~/config/paths.config'; + +/** + * @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, + async handleEvent(payload) { + if (payload.table === 'waitlist' && payload.record.approved) { + const { handleApprovedUserChange } = await import( + '@kit/waitlist/server' + ); + + const inviteToken = payload.record.invite_token; + + const redirectToUrl = new URL( + pathsConfig.auth.passwordUpdate, + appConfig.url, + ); + + if (inviteToken) { + const next = encodeURI( + pathsConfig.app.joinTeam + '?invite_token=' + inviteToken, + ); + + redirectToUrl.searchParams.append('callback', next); + } + + const redirectTo = redirectToUrl.toString(); + + await handleApprovedUserChange({ + email: payload.record.email, + redirectTo, + }); + } + }, + }); + + // 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, + }, +); +``` + +#### Adding the Trigger to the Database + +We need to add a trigger to the `waitlist` table to listen for updates and send a webhook to the app when a user is approved. + +During development, you can simply add the webhook to your seed file `apps/web/supabase/seed.sql`: + +```sql +create trigger "waitlist_approved_update" after update +on "public"."waitlist" +for each row +when (new.approved = true) +execute function "supabase_functions"."http_request"( + 'http://host.docker.internal:3000/api/db/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '5000' +); +``` + +The above creates a trigger that listens for updates to the `waitlist` table and sends a POST request to the webhook route. + +**Note**: You need to add this trigger to your production database as well. You will replace your `WEBHOOKSECRET` with the secret you set in your `.env` file and the `host.docker.internal:3000` with your production URL. +Just like you did for the other existing triggers. + +#### Approving users + +Simply update the `approved` column in the `waitlist` table to `true` to approve a user. You can do so from the Supabase dashboard or by running a query. + +Alternatively, run an update based on the created_at timestamp: + +```sql +update public.waitlist +set approved = true +where created_at < '2024-07-01'; +``` + +#### Email Templates and URL Configuration + +Please make sure to [edit the email template](https://makerkit.dev/docs/next-supabase-turbo/authentication-emails) in your Supabase account. +The default email in Supabase does not support PKCE and therefore does not work. By updating it - we replace the existing strategy with the token-based strategy - which the `confirm` route in Makerkit can support. + +Additionally, [please add the following URL to your Supabase Redirect URLS allow list](https://supabase.com/docs/guides/auth/redirect-urls): + +``` +<your-url>/password-reset +``` + +This will allow Supabase to redirect users to your app to set their password after they click the email link. + +If you don't do this - the email links will not work. + +#### Disable oAuth + +If you are using any oAuth providers, please disable them in the [Makerkit Auth configuration](../configuration/authentication-configuration#third-party-providers). Since sign-ups are disabled, users will hit errors. \ No newline at end of file diff --git a/docs/recipes/drizzle-supabase.mdoc b/docs/recipes/drizzle-supabase.mdoc new file mode 100644 index 000000000..bf94f60ab --- /dev/null +++ b/docs/recipes/drizzle-supabase.mdoc @@ -0,0 +1,495 @@ +--- +status: "published" +title: "Using Drizzle as a client for interacting with Supabase" +label: "Drizzle" +order: 6 +description: "Add Drizzle ORM to your MakerKit project for type-safe database queries while respecting Supabase Row Level Security." +--- + +Drizzle ORM is a TypeScript-first database toolkit that provides type-safe query building and automatic TypeScript type inference from your PostgreSQL database. When combined with Supabase, you get the best of both worlds: Drizzle's query builder with Supabase's Row Level Security. + +Drizzle ORM provides type-safe database queries for PostgreSQL. With MakerKit's RLS-aware client, you get full TypeScript inference while respecting Supabase Row Level Security policies. This guide shows how to add Drizzle to your project, generate types from your existing database, and query data with proper RLS enforcement. + +MakerKit uses the standard [Supabase client](/docs/next-supabase-turbo/data-fetching/supabase-clients) by default. This guide covers adding Drizzle as an alternative query layer while keeping your RLS policies intact. For more data fetching patterns, see the [data fetching overview](/docs/next-supabase-turbo/data-fetching). Tested with Drizzle ORM 0.45.x and drizzle-kit 0.31.x (January 2025). + +The RLS integration is the tricky part. Most Drizzle tutorials skip it because they assume you're either using service role (bypassing RLS) or don't need row-level permissions. For a multi-tenant SaaS, you need both: type-safe queries that still respect your security policies. + +This guide adapts the [official Drizzle + Supabase tutorial](https://orm.drizzle.team/docs/tutorials/drizzle-with-supabase) with MakerKit-specific patterns. + +## When to Use Drizzle + +**Use Drizzle when:** +- You need complex joins across multiple tables +- You want full TypeScript inference on query results +- You're writing many database queries and IDE autocomplete matters +- You prefer SQL-like syntax over the Supabase query builder + +**Stick with Supabase client when:** +- You need real-time subscriptions +- You're doing file storage operations +- You're working with auth flows +- Simple CRUD is sufficient + +## Prerequisites + +- Working MakerKit project with Supabase running locally +- Basic TypeScript knowledge +- Database with existing tables (we'll generate the schema from your DB) + +## Step 1: Install Dependencies + +Add the required packages to the `@kit/supabase` package: + +```bash +pnpm --filter "@kit/supabase" add drizzle-orm postgres jwt-decode +pnpm --filter "@kit/supabase" add -D drizzle-kit +``` + +**Package breakdown:** +- `drizzle-orm` - The ORM itself (runtime dependency) +- `postgres` - postgres.js driver, faster than node-postgres for this use case +- `jwt-decode` - Decodes Supabase JWT to extract user role for RLS +- `drizzle-kit` - CLI for schema introspection and migrations (dev only) + +## Step 2: Create Drizzle Configuration + +Create `packages/supabase/drizzle.config.js`: + +```javascript {% title="packages/supabase/drizzle.config.js" %} +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/drizzle/schema.ts', + out: './src/drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@127.0.0.1:54322/postgres', + }, + schemaFilter: ['public'], + verbose: true, + strict: true, +}); +``` + +**Configuration notes:** +- `schemaFilter: ['public']` pulls only the public schema where your application tables live. Supabase's `auth` schema tables require a separate reference (covered in Step 5). +- `schema` points to where the generated schema will be imported from. The `out` directory is where drizzle-kit writes the generated files. +- If you need to pull from multiple schemas (e.g., a custom `app` schema), add them to the array: `['public', 'app']`. + +Drizzle Kit will generate a `schema.ts` file containing TypeScript types that match your database structure. This enables full type inference on all your queries. + +## Step 3: Update package.json + +Add the scripts and exports to `packages/supabase/package.json`: + +```json {% title="packages/supabase/package.json" %} +{ + "scripts": { + "drizzle": "drizzle-kit", + "pull": "drizzle-kit pull --config drizzle.config.js" + }, + "exports": { + "./drizzle-client": "./src/clients/drizzle-client.ts", + "./drizzle-schema": "./src/drizzle/schema.ts" + } +} +``` + +The `pull` script introspects your database and generates the TypeScript schema. The exports make the Drizzle client and schema available throughout your monorepo. + +## Step 4: Create the Drizzle Client + +This is where MakerKit differs from standard Drizzle setups. We need two clients: + +1. **Admin client** - Bypasses RLS for webhooks, admin operations, and background jobs +2. **RLS client** - Sets JWT claims in a transaction to respect your security policies + +Create `packages/supabase/src/clients/drizzle-client.ts`: + +```typescript {% title="packages/supabase/src/clients/drizzle-client.ts" %} +import 'server-only'; + +import { DrizzleConfig, sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import { JwtPayload, jwtDecode } from 'jwt-decode'; +import postgres from 'postgres'; +import * as z from 'zod'; + +import * as schema from '../drizzle/schema'; +import { getSupabaseServerClient } from './server-client'; + +const SUPABASE_DATABASE_URL = z + .string({ + description: 'The URL of the Supabase database.', + required_error: 'SUPABASE_DATABASE_URL is required', + }) + .url() + .parse(process.env.SUPABASE_DATABASE_URL!); + +const config = { + casing: 'snake_case', + schema, +} satisfies DrizzleConfig<typeof schema>; + +// Admin client bypasses RLS +const adminClient = drizzle({ + client: postgres(SUPABASE_DATABASE_URL, { prepare: false }), + ...config, +}); + +// RLS protected client +const rlsClient = drizzle({ + client: postgres(SUPABASE_DATABASE_URL, { prepare: false }), + ...config, +}); + +/** + * Returns admin Drizzle client that bypasses RLS. + * Use for webhooks, admin operations, and migrations. + */ +export function getDrizzleSupabaseAdminClient() { + return adminClient; +} + +/** + * Returns RLS-aware Drizzle client. + * All queries must run inside runTransaction to respect RLS policies. + */ +export async function getDrizzleSupabaseClient() { + const client = getSupabaseServerClient(); + const { data } = await client.auth.getSession(); + const accessToken = data.session?.access_token ?? ''; + const token = decode(accessToken); + + const runTransaction = ((transaction, txConfig) => { + return rlsClient.transaction(async (tx) => { + try { + // Set Supabase auth context for RLS + await tx.execute(sql` + select set_config('request.jwt.claims', '${sql.raw( + JSON.stringify(token), + )}', TRUE); + select set_config('request.jwt.claim.sub', '${sql.raw( + token.sub ?? '', + )}', TRUE); + set local role ${sql.raw(token.role ?? 'anon')}; + `); + + return await transaction(tx); + } finally { + // Reset context + await tx.execute(sql` + select set_config('request.jwt.claims', NULL, TRUE); + select set_config('request.jwt.claim.sub', NULL, TRUE); + reset role; + `); + } + }, txConfig); + }) as typeof rlsClient.transaction; + + return { runTransaction }; +} + +function decode(accessToken: string) { + try { + return jwtDecode<JwtPayload & { role: string }>(accessToken); + } catch { + return { role: 'anon' } as JwtPayload & { role: string }; + } +} + +// Export type for external use +export type DrizzleDatabase = typeof rlsClient; +``` + +**Why `prepare: false`?** Supabase's connection pooler (Transaction mode) doesn't support prepared statements. Without this flag, you'll get "prepared statement already exists" errors in production. + +**Why transactions for RLS?** PostgreSQL's `set_config` and `SET LOCAL ROLE` only persist within a transaction. If you run queries outside a transaction, the JWT context isn't set and RLS policies see an anonymous user. + +## Step 5: Generate the Schema + +With your local Supabase running, generate the TypeScript schema: + +```bash +pnpm --filter "@kit/supabase" pull +``` + +Expected output: + +``` +Pulling from ['public'] list of schemas + +Using 'postgres' driver for database querying +[✓] 14 tables fetched +[✓] 104 columns fetched +[✓] 9 enums fetched +[✓] 18 indexes fetched +[✓] 23 foreign keys fetched +[✓] 28 policies fetched +[✓] 3 check constraints fetched +[✓] 2 views fetched + +[✓] Your schema file is ready ➜ src/drizzle/schema.ts +[✓] Your relations file is ready ➜ src/drizzle/relations.ts +``` + +### Add the Auth Schema Reference + +Some MakerKit tables reference `auth.users`. Since we only pulled `public`, add this to the top of `packages/supabase/src/drizzle/schema.ts`: + +```typescript {% title="packages/supabase/src/drizzle/schema.ts" %} +/* eslint-disable */ +import { pgSchema, uuid } from 'drizzle-orm/pg-core'; + +// Reference to auth.users for foreign key constraints +const authSchema = pgSchema('auth'); + +export const usersInAuth = authSchema.table('users', { + id: uuid('id').primaryKey(), +}); + +// ... rest of generated schema +``` + +The `/* eslint-disable */` comment prevents lint errors on the generated code. The `authSchema` reference allows foreign key relationships to work correctly. + +### Verify the Schema + +After generating, check that `packages/supabase/src/drizzle/schema.ts` contains your tables. You should see exports like `accounts`, `subscriptions`, and other tables from your database. + +## Step 6: Configure Environment Variable + +Add `SUPABASE_DATABASE_URL` to your [environment variables](/docs/next-supabase-turbo/configuration/environment-variables). For local development, add to `.env.development`: + +```bash {% title=".env.development" %} +SUPABASE_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres +``` + +{% alert type="warning" title="Keep SUPABASE_DATABASE_URL private" %} +This URL contains database credentials. Never commit it to your repository. For production, set it through your hosting provider's environment variables (Vercel, Railway, etc.). +{% /alert %} + +Find your production connection string in Supabase Dashboard → Project Settings → Database. Use the **connection pooler** URL in Transaction mode. + +## Using the Drizzle Client + +### In Server Components + +```typescript +import { getDrizzleSupabaseClient } from '@kit/supabase/drizzle-client'; +import { accounts } from '@kit/supabase/drizzle-schema'; + +async function AccountsList() { + const client = await getDrizzleSupabaseClient(); + + // All queries run inside runTransaction to respect RLS + const data = await client.runTransaction((tx) => { + return tx.select().from(accounts); + }); + + return ( + <ul> + {data.map((account) => ( + <li key={account.id}>{account.name}</li> + ))} + </ul> + ); +} +``` + +### In Server Actions + +Use with the [authActionClient utility](/docs/next-supabase-turbo/data-fetching/server-actions) for authentication and validation: + +```typescript +'use server'; + +import { getDrizzleSupabaseClient } from '@kit/supabase/drizzle-client'; +import { tasks } from '@kit/supabase/drizzle-schema'; +import { authActionClient } from '@kit/next/safe-action'; +import * as z from 'zod'; + +const CreateTaskSchema = z.object({ + title: z.string().min(1), + accountId: z.string().uuid(), +}); + +export const createTaskAction = authActionClient + .inputSchema(CreateTaskSchema) + .action(async ({ parsedInput: data }) => { + const client = await getDrizzleSupabaseClient(); + + const [task] = await client.runTransaction((tx) => { + return tx + .insert(tasks) + .values({ title: data.title, accountId: data.accountId }) + .returning(); + }); + + return { task }; + }); +``` + +### Using the Admin Client + +For operations that bypass RLS (webhooks, admin tasks, background jobs): + +```typescript +import { getDrizzleSupabaseAdminClient } from '@kit/supabase/drizzle-client'; +import { accounts } from '@kit/supabase/drizzle-schema'; +import { eq } from 'drizzle-orm'; + +async function deleteAccountAdmin(accountId: string) { + const db = getDrizzleSupabaseAdminClient(); + + // Bypasses RLS - use only for admin operations + await db.delete(accounts).where(eq(accounts.id, accountId)); +} +``` + +{% alert type="warning" title="Admin client bypasses RLS" %} +The admin client ignores all Row Level Security policies. Use it only for operations that genuinely need elevated permissions. For user-facing features, always use the RLS client with `runTransaction`. +{% /alert %} + +## Common Query Patterns + +### Queries with Filters + +```typescript +import { eq, and, gt } from 'drizzle-orm'; +import { tasks } from '@kit/supabase/drizzle-schema'; + +const client = await getDrizzleSupabaseClient(); + +const recentTasks = await client.runTransaction((tx) => { + return tx + .select() + .from(tasks) + .where( + and( + eq(tasks.accountId, accountId), + gt(tasks.createdAt, lastWeek) + ) + ); +}); +// Returns: Array<{ id: string; title: string; accountId: string; createdAt: Date; ... }> +``` + +### Joins + +```typescript +import { eq } from 'drizzle-orm'; +import { tasks, accounts } from '@kit/supabase/drizzle-schema'; + +// Assumes client from getDrizzleSupabaseClient() is in scope +const tasksWithAccounts = await client.runTransaction((tx) => { + return tx + .select({ + task: tasks, + account: accounts, + }) + .from(tasks) + .leftJoin(accounts, eq(tasks.accountId, accounts.id)); +}); +// Returns: Array<{ task: Task; account: Account | null }> +``` + +### Aggregations + +```typescript +import { count, sql, eq } from 'drizzle-orm'; +import { tasks } from '@kit/supabase/drizzle-schema'; + +// Assumes client from getDrizzleSupabaseClient() is in scope +const stats = await client.runTransaction((tx) => { + return tx + .select({ + total: count(), + completed: sql<number>`count(*) filter (where completed = true)`, + }) + .from(tasks) + .where(eq(tasks.accountId, accountId)); +}); +// Returns: [{ total: 42, completed: 18 }] +``` + +## Common Pitfalls + +**1. Running queries outside `runTransaction`** + +The RLS client doesn't expose direct query methods. You must use `runTransaction`: + +```typescript +// Wrong - this doesn't exist +const data = await client.select().from(tasks); + +// Correct +const data = await client.runTransaction((tx) => { + return tx.select().from(tasks); +}); +``` + +**2. Forgetting `prepare: false`** + +Supabase's connection pooler doesn't support prepared statements. Without this flag: + +``` +Error: prepared statement "s1" already exists +``` + +**3. Schema out of sync** + +After database changes, re-run `pnpm --filter "@kit/supabase" pull` to regenerate the schema. Remember to re-add the auth schema reference at the top of the file. + +**4. Bundling in client components** + +The Drizzle client uses `server-only`. If you accidentally import it in a client component: + +``` +Error: This module cannot be imported from a Client Component +``` + +Move your database logic to a Server Component, Server Action, or Route Handler. + +**5. Using the wrong environment variable** + +The Drizzle client expects `SUPABASE_DATABASE_URL` (your production connection pooler URL). The `drizzle.config.js` uses `DATABASE_URL` for local CLI operations like `pull`. For local development, both can point to `postgresql://postgres:postgres@127.0.0.1:54322/postgres`. In production, `SUPABASE_DATABASE_URL` should be your Supabase connection pooler URL in Transaction mode. + +## Migrations with Drizzle + +The default setup uses schema introspection (`pull`). If you want Drizzle to manage migrations instead: + +1. Move the `src/drizzle` folder to your project root +2. Update `drizzle.config.js` paths +3. Use `drizzle-kit generate` to create migrations +4. Use `drizzle-kit migrate` to apply them + +For most MakerKit projects, sticking with Supabase migrations and using Drizzle only as a query builder keeps things simpler. See the [migrations documentation](/docs/next-supabase-turbo/development/migrations) for the standard approach. + +## Server-Only Requirement + +The Drizzle client can only run on the server. Use it in: +- Server Components +- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) +- [Route Handlers](/docs/next-supabase-turbo/data-fetching/route-handlers) + +The `'server-only'` import at the top of the client file enforces this at build time. + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Does Drizzle work with Supabase RLS?", "answer": "Yes. The getDrizzleSupabaseClient function sets JWT claims inside a transaction, so your RLS policies evaluate correctly. All queries must run inside runTransaction for RLS to apply."}, + {"question": "Can I use Drizzle for migrations with Supabase?", "answer": "You can, but it's not recommended for MakerKit projects. The kit uses Supabase migrations for schema changes. Use Drizzle as a query builder and keep using Supabase CLI for migrations."}, + {"question": "Why do I need runTransaction for every query?", "answer": "PostgreSQL's set_config and SET LOCAL ROLE only persist within a transaction. Without the transaction wrapper, the JWT context isn't set and RLS policies see an anonymous user."}, + {"question": "What's the difference between admin and RLS client?", "answer": "getDrizzleSupabaseAdminClient bypasses all RLS policies - use it for webhooks, admin tasks, and background jobs. getDrizzleSupabaseClient respects RLS and should be used for all user-facing features."}, + {"question": "Why do I get 'prepared statement already exists' errors?", "answer": "Supabase's connection pooler in Transaction mode doesn't support prepared statements. Add prepare: false to your postgres client options as shown in the setup."} + ] +/%} + +## Related Documentation + +- [Data Fetching Overview](/docs/next-supabase-turbo/data-fetching) - All data fetching patterns in MakerKit +- [Supabase Clients](/docs/next-supabase-turbo/data-fetching/supabase-clients) - Understanding client types and RLS +- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) - Using authActionClient with database operations +- [Database Schema](/docs/next-supabase-turbo/development/database-schema) - MakerKit's database structure +- [Drizzle ORM Documentation](https://orm.drizzle.team/docs/overview) - Full API reference diff --git a/docs/recipes/onboarding-checkout.mdoc b/docs/recipes/onboarding-checkout.mdoc new file mode 100644 index 000000000..f5ce8ed1f --- /dev/null +++ b/docs/recipes/onboarding-checkout.mdoc @@ -0,0 +1,927 @@ +--- +status: "published" +title: 'Creating an Onboarding and Checkout flows' +label: 'Onboarding Checkout' +order: 2 +description: 'Learn how to create an onboarding and checkout flow in the Next.js Supabase Starter Kit.' +--- + +One popular request from customers is to have a way to onboard new users and guide them through the app, and have customers checkout before they can use the app. + +In this guide, we'll show you how to create an onboarding and checkout flow in the Next.js Supabase Starter Kit. + +In this guide, we will cover: + +1. Creating an onboarding flow when a user signs up. +2. Creating a multi-step form to have customers create a new Team Account +3. Creating a checkout flow to have customers pay before they can use the app. +4. Use Webhooks to update the user's record after they have paid. + +Remember: you can customize the onboarding and checkout flow to fit your app's needs. This is a starting point, and you can build on top of it. + +**Important:** Please make sure you have pulled the latest changes from the main branch before you start this guide. + +## Step 0: Adding an Onboarding Table + +Before we create the onboarding flow, let's add a new table to store the onboarding data. + +Create a new migration using the following command: + +```bash +pnpm --filter web supabase migration new onboarding +``` + +This will create a new migration file at `apps/web/supabase/migrations/<timestamp>_onboarding.sql`. Open this file and add the following SQL code: + + ```sql {% title="apps/web/supabase/migrations/<timestamp>_onboarding.sql" %} +create table if not exists public.onboarding ( + id uuid primary key default uuid_generate_v4(), + account_id uuid references public.accounts(id) not null unique, + data jsonb default '{}', + completed boolean default false, + created_at timestamp with time zone default current_timestamp, + updated_at timestamp with time zone default current_timestamp +); + +revoke all on public.onboarding from public, service_role; + +grant select, update, insert on public.onboarding to authenticated; +grant select, delete on public.onboarding to service_role; + +alter table onboarding enable row level security; + +create policy read_onboarding + on public.onboarding + for select + to authenticated + using (account_id = (select auth.uid())); + +create policy insert_onboarding + on public.onboarding + for insert + to authenticated + with check (account_id = (select auth.uid())); + +create policy update_onboarding + on public.onboarding + for update + to authenticated + using (account_id = (select auth.uid())) + with check (account_id = (select auth.uid())); +``` + +This migration creates a new `onboarding` table with the following columns: +- `id`: A unique identifier for the onboarding record. +- `account_id`: A foreign key reference to the `accounts` table. +- `data`: A JSONB column to store the onboarding data. +- `completed`: A boolean flag to indicate if the onboarding is completed. +- `created_at` and `updated_at`: Timestamps for when the record was created and updated. + +The migration also sets up row-level security policies to ensure that users can only access their own onboarding records. + +Update your DB schema by running the following command: + +```bash +pnpm run supabase:web:reset +``` + +And update your DB types by running the following command: + +```bash +pnpm run supabase:web:typegen +``` + +Now that we have the `onboarding` table set up, let's create the onboarding flow. + +## Step 1: Create the Onboarding Page + +First, let's create the main onboarding page. This will be the entry point for our onboarding flow. + +Create a new file at `apps/web/app/onboarding/page.tsx`: + + ```tsx {% title="apps/web/app/onboarding/page.tsx" %} +import { AppLogo } from '~/components/app-logo'; +import { OnboardingForm } from './_components/onboarding-form'; + +function OnboardingPage() { + return ( + <div className="flex h-screen flex-col items-center justify-center space-y-16"> + <AppLogo /> + + <div> + <OnboardingForm /> + </div> + </div> + ); +} + +export default OnboardingPage; +``` + +This page is simple. It displays your app logo and the `OnboardingForm` component, which we'll create next. + +## Step 2: Create the Onboarding Form Schema + +Before we create the form, let's define its schema. This will help us validate the form data. + +Create a new file at `apps/web/app/onboarding/_lib/onboarding-form.schema.ts`: + + ```typescript {% title="apps/web/app/onboarding/_lib/onboarding-form.schema.ts" %} +import * as z from 'zod'; + +export const OnboardingFormSchema = z.object({ + profile: z.object({ + name: z.string().min(1).max(255), + }), + team: z.object({ + name: z.string().min(1).max(255), + }), + checkout: z.object({ + planId: z.string().min(1), + productId: z.string().min(1), + }), +}); +``` + +This schema defines the structure of our onboarding form. It has three main sections: profile, team, and checkout. + +## Step 3: Create the Onboarding Form Component + +Now, let's create the main `OnboardingForm` component. This is where the magic happens! + +Create a new file at `apps/web/app/onboarding/_components/onboarding-form.tsx`: + + ```tsx {% title="apps/web/app/onboarding/_components/onboarding-form.tsx" %} +'use client'; + +import { useCallback, useRef, useState } from 'react'; + +import { createPortal } from 'react-dom'; + +import dynamic from 'next/dynamic'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { PlanPicker } from '@kit/billing-gateway/components'; +import { Button } from '@kit/ui/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@kit/ui/form'; +import { If } from '@kit/ui/if'; +import { Input } from '@kit/ui/input'; +import { + MultiStepForm, + MultiStepFormContextProvider, + MultiStepFormHeader, + MultiStepFormStep, + useMultiStepFormContext, +} from '@kit/ui/multi-step-form'; +import { Stepper } from '@kit/ui/stepper'; + +import billingConfig from '~/config/billing.config'; +import { OnboardingFormSchema } from '~/onboarding/_lib/onboarding-form.schema'; +import { submitOnboardingFormAction } from '~/onboarding/_lib/server/server-actions'; + +const EmbeddedCheckout = dynamic( + async () => { + const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout'); + + return { + default: EmbeddedCheckout, + }; + }, + { + ssr: false, + }, +); + +export function OnboardingForm() { + const [checkoutToken, setCheckoutToken] = useState<string | undefined>( + undefined, + ); + + const form = useForm({ + resolver: zodResolver(OnboardingFormSchema), + defaultValues: { + profile: { + name: '', + }, + team: { + name: '', + }, + checkout: { + planId: '', + productId: '', + }, + }, + mode: 'onBlur', + }); + + const onSubmit = useCallback( + async (data: z.infer<typeof OnboardingFormSchema>) => { + try { + const { checkoutToken } = await submitOnboardingFormAction(data); + + setCheckoutToken(checkoutToken); + } catch (error) { + console.error('Failed to submit form:', error); + } + }, + [], + ); + + const checkoutPortalRef = useRef<HTMLDivElement>(null); + + if (checkoutToken) { + return ( + <EmbeddedCheckout + checkoutToken={checkoutToken} + provider={billingConfig.provider} + onClose={() => setCheckoutToken(undefined)} + /> + ); + } + + return ( + <div + className={ + 'w-full rounded-lg p-8 shadow-sm duration-500 animate-in fade-in-90 zoom-in-95 slide-in-from-bottom-12 lg:border' + } + > + <MultiStepForm + className={'space-y-8 p-1'} + schema={OnboardingFormSchema} + form={form} + onSubmit={onSubmit} + > + <MultiStepFormHeader> + <MultiStepFormContextProvider> + {({ currentStepIndex }) => ( + <Stepper + variant={'numbers'} + steps={['Profile', 'Team', 'Complete']} + currentStep={currentStepIndex} + /> + )} + </MultiStepFormContextProvider> + </MultiStepFormHeader> + + <MultiStepFormStep name={'profile'}> + <ProfileStep /> + </MultiStepFormStep> + + <MultiStepFormStep name={'team'}> + <TeamStep /> + </MultiStepFormStep> + + <MultiStepFormStep name={'checkout'}> + <If condition={checkoutPortalRef.current}> + {(portalRef) => createPortal(<CheckoutStep />, portalRef)} + </If> + </MultiStepFormStep> + </MultiStepForm> + + <div className={'p-1'} ref={checkoutPortalRef}></div> + </div> + ); +} + +function ProfileStep() { + const { nextStep, form } = useMultiStepFormContext(); + + return ( + <Form {...form}> + <div className={'flex flex-col space-y-6'}> + <div className={'flex flex-col space-y-2'}> + <h1 className={'text-xl font-semibold'}>Welcome to Makerkit</h1> + + <p className={'text-sm text-muted-foreground'}> + Welcome to the onboarding process! Let's get started by + entering your name. + </p> + </div> + + <FormField + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Your Name</FormLabel> + + <FormControl> + <Input {...field} placeholder={'Name'} /> + </FormControl> + + <FormDescription>Enter your full name here</FormDescription> + </FormItem> + ); + }} + name={'profile.name'} + /> + + <div className={'flex justify-end'}> + <Button onClick={nextStep}>Continue</Button> + </div> + </div> + </Form> + ); +} + +function TeamStep() { + const { nextStep, prevStep, form } = useMultiStepFormContext(); + + return ( + <Form {...form}> + <div className={'flex w-full flex-col space-y-6'}> + <div className={'flex flex-col space-y-2'}> + <h1 className={'text-xl font-semibold'}>Create Your Team</h1> + + <p className={'text-sm text-muted-foreground'}> + Let's create your team. Enter your team name below. + </p> + </div> + + <FormField + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Your Team Name</FormLabel> + + <FormControl> + <Input {...field} placeholder={'Name'} /> + </FormControl> + + <FormDescription> + This is the name of your team. + </FormDescription> + </FormItem> + ); + }} + name={'team.name'} + /> + + <div className={'flex justify-end space-x-2'}> + <Button variant={'ghost'} onClick={prevStep}> + Go Back + </Button> + + <Button onClick={nextStep}>Continue</Button> + </div> + </div> + </Form> + ); +} + +function CheckoutStep() { + const { form, mutation } = useMultiStepFormContext(); + + return ( + <Form {...form}> + <div className={'flex w-full flex-col space-y-6 lg:min-w-[55rem]'}> + <div className={'flex flex-col space-y-2'}> + <PlanPicker + pending={mutation.isPending} + config={billingConfig} + onSubmit={({ planId, productId }) => { + form.setValue('checkout.planId', planId); + form.setValue('checkout.productId', productId); + + mutation.mutate(); + }} + /> + </div> + </div> + </Form> + ); +} +``` + +This component creates a multi-step form for the onboarding process. It includes steps for profile information, team creation, and plan selection. + +## Step 4: Create the Server Action + +Now, let's create the server action that will handle the form submission. + +Create a new file at `apps/web/app/onboarding/_lib/server/server-actions.ts`: + + ```typescript {% title="apps/web/app/onboarding/_lib/server/server-actions.ts" %} +'use server'; + +import { redirect } from 'next/navigation'; + +import { createBillingGatewayService } from '@kit/billing-gateway'; +import { authActionClient } from '@kit/next/safe-action'; +import { getLogger } from '@kit/shared/logger'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import appConfig from '~/config/app.config'; +import billingConfig from '~/config/billing.config'; +import pathsConfig from '~/config/paths.config'; +import { OnboardingFormSchema } from '~/onboarding/_lib/onboarding-form.schema'; + +export const submitOnboardingFormAction = authActionClient + .inputSchema(OnboardingFormSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + const logger = await getLogger(); + + logger.info({ userId: user.id }, `Submitting onboarding form...`); + + const isOnboarded = user.app_metadata.onboarded === true; + + if (isOnboarded) { + logger.info( + { userId: user.id }, + `User is already onboarded. Redirecting...`, + ); + + redirect(pathsConfig.app.home); + } + + const client = getSupabaseServerClient(); + + const createTeamResponse = await client + .from('accounts') + .insert({ + name: data.team.name, + primary_owner_user_id: user.id, + is_personal_account: false, + }) + .select('id') + .single(); + + if (createTeamResponse.error) { + logger.error( + { + error: createTeamResponse.error, + }, + `Failed to create team`, + ); + + throw createTeamResponse.error; + } else { + logger.info( + { userId: user.id, teamId: createTeamResponse.data.id }, + `Team created. Creating onboarding data...`, + ); + } + + const response = await client.from('onboarding').upsert( + { + account_id: user.id, + data: { + userName: data.profile.name, + teamAccountId: createTeamResponse.data.id, + }, + completed: true, + }, + { + onConflict: 'account_id', + }, + ); + + if (response.error) { + throw response.error; + } + + logger.info( + { userId: user.id, teamId: createTeamResponse.data.id }, + `Onboarding data created. Creating checkout session...`, + ); + + const billingService = createBillingGatewayService(billingConfig.provider); + + const { plan, product } = getPlanDetails( + data.checkout.productId, + data.checkout.planId, + ); + + const returnUrl = new URL('/onboarding/complete', appConfig.url).href; + + const checkoutSession = await billingService.createCheckoutSession({ + returnUrl, + customerEmail: user.email, + accountId: createTeamResponse.data.id, + plan, + variantQuantities: [], + enableDiscountField: product.enableDiscountField, + metadata: { + source: 'onboarding', + userId: user.id, + }, + }); + + return { + checkoutToken: checkoutSession.checkoutToken, + }; + }); + +function getPlanDetails(productId: string, planId: string) { + const product = billingConfig.products.find( + (product) => product.id === productId, + ); + + if (!product) { + throw new Error('Product not found'); + } + + const plan = product?.plans.find((plan) => plan.id === planId); + + if (!plan) { + throw new Error('Plan not found'); + } + + return { plan, product }; +} +``` + +This server action handles the form submission, inserts the onboarding data into Supabase, create a team (so we can assign it a subscription), and creates a checkout session for the selected plan. + +Once the checkout is completed, the user will be redirected to the `/onboarding/complete` page. This page will be created in the next step. + +## Step 6: Enhancing the Stripe Webhook Handler + +This change extends the functionality of the Stripe webhook handler to complete the onboarding process after a successful checkout. Here's what's happening: + +In the `handleCheckoutSessionCompleted` method, we add new logic to handle onboarding-specific actions. + +First, define the `completeOnboarding` function to process the onboarding data and create a team based on the user's input. + +**Note:** This is valid for Stripe, but you can adapt it to any other payment provider. + + ```typescript {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %} +async function completeOnboarding(accountId: string) { + const logger = await getLogger(); + const adminClient = getSupabaseServerAdminClient(); + + logger.info( + { accountId }, + `Checkout comes from onboarding. Processing onboarding data...`, + ); + + const onboarding = await adminClient + .from('onboarding') + .select('*') + .eq('account_id', accountId) + .single(); + + if (onboarding.error) { + logger.error( + { error: onboarding.error, accountId }, + `Failed to retrieve onboarding data`, + ); + + // if there's an error, we can't continue + return; + } else { + logger.info({ accountId }, `Onboarding data retrieved. Processing...`); + + const data = onboarding.data.data as { + userName: string; + teamAccountId: string; + }; + + const teamAccountId = data.teamAccountId; + + logger.info( + { userId: accountId, teamAccountId }, + `Assigning membership...`, + ); + + const assignMembershipResponse = await adminClient + .from('accounts_memberships') + .insert({ + account_id: teamAccountId, + user_id: accountId, + account_role: 'owner', + }); + + if (assignMembershipResponse.error) { + logger.error( + { + error: assignMembershipResponse.error, + }, + `Failed to assign membership`, + ); + } else { + logger.info({ accountId }, `Membership assigned. Updating account...`); + } + + const accountResponse = await adminClient + .from('accounts') + .update({ + name: data.userName, + }) + .eq('id', accountId); + + if (accountResponse.error) { + logger.error( + { + error: accountResponse.error, + }, + `Failed to update account`, + ); + } else { + logger.info( + { accountId }, + `Account updated. Cleaning up onboarding data...`, + ); + } + + // set onboarded flag on user account + const updateUserResponse = await adminClient.auth.admin.updateUserById( + accountId, + { + app_metadata: { + onboarded: true, + }, + }, + ); + + if (updateUserResponse.error) { + logger.error( + { + error: updateUserResponse.error, + }, + `Failed to update user`, + ); + } else { + logger.info({ accountId }, `User updated. Cleaning up...`); + } + + // clean up onboarding data + const deleteOnboardingResponse = await adminClient + .from('onboarding') + .delete() + .eq('account_id', accountId); + + if (deleteOnboardingResponse.error) { + logger.error( + { + error: deleteOnboardingResponse.error, + }, + `Failed to delete onboarding data`, + ); + } else { + logger.info( + { accountId }, + `Onboarding data cleaned up. Completed webhook handler.`, + ); + } + } +} +``` + +Now, we handle this function in the `handleCheckoutSessionCompleted` method, right before the `onCheckoutCompletedCallback` is called. + + ```typescript {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %} +// ... + +const subscriptionData = + await stripe.subscriptions.retrieve(subscriptionId); + +const metadata = subscriptionData.metadata as { + source: string; + userId: string; +} | undefined; + +// if the checkout comes from onboarding +// we need to complete the onboarding process +if (metadata?.source === 'onboarding') { + const userId = metadata.userId; + + await completeOnboarding(userId); +} + +return onCheckoutCompletedCallback(payload); +``` + +This enhanced webhook handler completes the onboarding process by creating a team account, updating the user's personal account, and marking the user as "onboarded" in their Supabase user metadata. + +## Step 7: Handling the Onboarding Completion Page + +The checkout will rediret to the `/onboarding/complete` page after the onboarding process is completed. This is because there is no telling when the webhook will be triggered. + +In this page, we will start fetching the user data and verify if the user has been onboarded. + +If the user has been onboarded correctly, we will redirect the user to the `/home` page. If not, we will show a loading spinner and keep checking the user's onboarded status until it is true. + + ```tsx {% title="/apps/web/app/onboarding/complete/page.tsx" %} +'use client'; + +import { useRef } from 'react'; + +import { useQuery } from '@tanstack/react-query'; + +import { useSupabase } from '@kit/supabase/hooks/use-supabase'; +import { LoadingOverlay } from '@kit/ui/loading-overlay'; + +import pathsConfig from '~/config/paths.config'; + +export default function OnboardingCompletePage() { + const { error } = useCheckUserOnboarded(); + + if (error) { + return ( + <div className={'flex flex-col items-center justify-center'}> + <p>Something went wrong...</p> + </div> + ); + } + + return <LoadingOverlay>Setting up your account...</LoadingOverlay>; +} + +/** + * @description + * This function checks if the user is onboarded + * If the user is onboarded, it redirects them to the home page + * it retries every second until the user is onboarded + */ +function useCheckUserOnboarded() { + const client = useSupabase(); + const countRef = useRef(0); + const maxCount = 10; + const error = countRef.current >= maxCount; + + useQuery({ + queryKey: ['onboarding-complete'], + refetchInterval: () => (error ? false : 1000), + queryFn: async () => { + if (error) { + return false; + } + + countRef.current++; + + const response = await client.auth.getUser(); + + if (response.error) { + throw response.error; + } + + const onboarded = response.data.user.app_metadata.onboarded; + + // if the user is onboarded, redirect them to the home page + if (onboarded) { + return window.location.assign(pathsConfig.app.home); + } + + return false; + }, + }); + + return { + error, + }; +} +``` + +This page + +## What This Means for Your Onboarding Flow + +With these changes, your onboarding process now includes these additional steps: + +1. When a user completes the checkout during onboarding, it triggers this enhanced webhook handler. +2. The handler retrieves the onboarding data that was saved earlier in the process. +3. It creates a new team account with the name provided during onboarding. +4. It updates the user's personal account with their name. +5. Finally, it marks the user as "onboarded" in their Supabase user metadata. + +This completes the onboarding process, ensuring that all the information collected during onboarding is properly saved and the user's account is fully set up. + +## Step 8: Update Your App's Routing + +To integrate this onboarding flow into your app, you'll need to update your routing logic. + +We can do this in the middleware, in the logic branch that handles the `/home` route. If the user is not logged in, we'll redirect them to the sign-in page. If the user is logged in but has not completed onboarding, we'll redirect them to the onboarding flow. + +Update the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16): + + ```typescript {% title="apps/web/proxy.ts" %} +{ + pattern: new URLPattern({ pathname: '/home/*?' }), + handler: async (req: NextRequest, res: NextResponse) => { + const { + data, + } = await getUser(req, res); + + const origin = req.nextUrl.origin; + const next = req.nextUrl.pathname; + const claims = data?.claims; + + // If user is not logged in, redirect to sign in page. + if (!claims) { + const signIn = pathsConfig.auth.signIn; + const redirectPath = `${signIn}?next=${next}`; + + return NextResponse.redirect(new URL(redirectPath, origin).href); + } + + // verify if user has completed onboarding + const isOnboarded = claims.app_metadata.onboarded; + + // If user is logged in but has not completed onboarding, + if (!isOnboarded) { + return NextResponse.redirect(new URL('/onboarding', origin).href); + } + + const supabase = createMiddlewareClient(req, res); + + const requiresMultiFactorAuthentication = + await checkRequiresMultiFactorAuthentication(supabase); + + // If user requires multi-factor authentication, redirect to MFA page. + if (requiresMultiFactorAuthentication) { + return NextResponse.redirect( + new URL(pathsConfig.auth.verifyMfa, origin).href, + ); + } + }, +} +``` + +This code checks if the user is logged in and has completed onboarding. If the user has not completed onboarding, they are redirected to the onboarding flow. + +Also add the following pattern to make sure the user is authenticated when visiting the `/onboarding` route: + +```typescript {% title="apps/web/proxy.ts" %} +{ + pattern: new URLPattern({ pathname: '/onboarding/*?' }), + handler: async (req: NextRequest, res: NextResponse) => { + const { + data, + } = await getUser(req, res); + + const claims = data?.claims; + + // If user is not logged in, redirect to sign in page. + if (!claims) { + const signIn = pathsConfig.auth.signIn; + const redirectPath = `${signIn}?next=${next}`; + + return NextResponse.redirect(new URL(redirectPath, origin).href); + } + + const supabase = createMiddlewareClient(req, res); + + const requiresMultiFactorAuthentication = + await checkRequiresMultiFactorAuthentication(supabase); + + // If user requires multi-factor authentication, redirect to MFA page. + if (requiresMultiFactorAuthentication) { + return NextResponse.redirect( + new URL(pathsConfig.auth.verifyMfa, origin).href, + ); + } + + // verify if user has completed onboarding + const isOnboarded = claims.app_metadata.onboarded; + + // If user completed onboarding, redirect to home page + if (isOnboarded) { + return NextResponse.redirect(new URL(pathsConfig.app.home, origin).href); + } + }, +} +``` + +### Marking invited users as onboarded + +When a user gets invited to a team account, you don't want to show them the onboarding flow again. You can use the `onboarded` property to mark the user as onboarded. + +Update the `packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts` server action `acceptInvitationAction` at line 125 (eg. before increasing seats): + +```tsx +// mark user as onboarded +await adminClient.auth.admin.updateUserById(user.id, { + app_metadata: { + onboarded: true, + }, +}); +``` + +In this way, the user will be redirected to the `/home` page after accepting the invite. + +### Considerations + +Remember, the user can always unsubscribe from the plan they selected during onboarding. You should handle this case in your billing system if your app always requires a plan to be active. + +## Conclusion + +That's it! You've now added an onboarding flow to your Makerkit Next.js Supabase Turbo project. + +This flow includes: + +1. A profile information step +2. A team creation step +3. A plan selection step +4. Integration with your billing system + +Remember to style your components, handle errors gracefully, and test thoroughly. Happy coding! 🚀 diff --git a/docs/recipes/personal-accounts-only.mdoc b/docs/recipes/personal-accounts-only.mdoc new file mode 100644 index 000000000..78b1f5570 --- /dev/null +++ b/docs/recipes/personal-accounts-only.mdoc @@ -0,0 +1,18 @@ +--- +status: "published" +title: 'Disabling Team Accounts in Next.js Supabase' +label: 'Disabling Team Accounts' +order: +description: 'Learn how to disable team accounts in the Next.js Supabase Turbo SaaS kit and only allow personal accounts' +--- + +Disabling team accounts and only allowing personal accounts is a common requirement for B2C SaaS applications. + +To do so, you can tweak the following environment variables: + +```bash +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=false +NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=false +``` + +That's it! Team account UI components will be hidden, and users will only be able to access their personal account. \ No newline at end of file diff --git a/docs/recipes/projects-data-model.mdoc b/docs/recipes/projects-data-model.mdoc new file mode 100644 index 000000000..ea10824fa --- /dev/null +++ b/docs/recipes/projects-data-model.mdoc @@ -0,0 +1,2759 @@ +--- +status: "published" +title: 'Adding a Projects Data Model to Your Next.js Supabase Application' +label: 'Projects Data Model' +order: 4 +description: 'Learn how to add a Projects data model to your Next.js Supabase application using Supabase' +--- + +Team Accounts allow you to manage several members in a single account. These usually share a subscription and other common attributes. However, you may need to go deeper than that, and allow team accounts to have a set of projects that they can collaborate on. + +This guide will walk you through implementing a robust project system in your Makerkit application using Supabase. We'll cover everything from setting up the necessary database tables to implementing role-based access control. + +## Overview of the Projects Data Model + +The Projects feature allows users to create and manage projects within your application. It includes: + +- Project creation and management +- Role-based access control (owner, admin, member) +- Permissions system +- Database schema and functions + +Let's dive in! + +## Database Schema and Functions + +We'll start by setting up the database schema. This involves creating tables, enums, and functions. + +### Enums + +First, let's create two enum types: + +```sql +-- Project roles +CREATE TYPE public.project_role AS ENUM ('owner', 'admin', 'member'); + +-- Project actions +CREATE TYPE public.project_action AS ENUM ( + 'view_project', + 'edit_project', + 'delete_project', + 'invite_member', + 'remove_member' +); +``` + +These enums define the possible roles and actions within a project. + +### Tables + +Now, let's create the main tables. + +We will create two tables: `projects` and `project_members`. + +The `projects` table stores project information, while the `project_members` table manages the relationship between projects and users. + +### Projects Table + +```sql +-- Projects table +CREATE TABLE IF NOT EXISTS public.projects ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +Let's dive into the `projects` table: + +- `id` is a unique identifier for each project. +- `name` is the name of the project. +- `description` is an optional description for the project. +- `account_id` is a foreign key referencing the `accounts` table. +- `created_at` and `updated_at` are timestamps for when the project was created and updated, respectively. + +### Project Members Table + +The `project_members` table stores the relationship between projects and users: + +```sql +-- Project members table +CREATE TABLE IF NOT EXISTS public.project_members ( + project_id UUID NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role public.project_role NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, user_id) +); +``` + +Let's clarify the `project_members` table: + +- The `project_id` column is a foreign key referencing the `projects` table. +- The `user_id` column is a foreign key referencing the `auth.users` table. +- The `role` column is an enum type that can have three values: `owner`, `admin`, or `member`. + +These columns ensure that each project has a unique owner, and that each member can have a role of `owner`, `admin`, or `member`. + +### Indexes + +To optimize query performance, let's add some indexes: + +```sql +CREATE INDEX projects_account_id ON public.projects (account_id); +CREATE INDEX project_members_project_id ON public.project_members (project_id); +CREATE INDEX project_members_user_id ON public.project_members (user_id); +``` + +We also need a unique index to ensure only one owner per project: + +```sql +CREATE UNIQUE INDEX projects_unique_owner ON public.project_members (project_id) WHERE role = 'owner'; +``` + +### Permissions + +Set up default permissions. This is useful to make sure we can granularly control access to the tables. + +```sql +alter table public.projects enable row level security; +alter table public.project_members enable row level security; + +-- Revoke all permissions by default +REVOKE ALL ON public.projects FROM public, service_role; +REVOKE ALL ON public.project_members FROM public, service_role; + +-- Grant access to authenticated users +GRANT SELECT, INSERT, UPDATE, DELETE ON public.projects TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.project_members TO authenticated; +``` + +## Functions + +Now, let's create several helper functions to manage projects and permissions. + +### Is Project Member + +This function checks if a user is a member of a specific project: + +```sql +-- public.is_project_member +-- this function checks if a user is a member of a specific project +create or replace function public.is_project_member(p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select exists ( + select 1 from public.project_members + where project_id = p_project_id + and account_id = (select auth.uid()) + ); +$$ language sql security definer; + +grant execute on function public.is_project_member(uuid) to authenticated; +``` + +### Is Project Admin + +This function checks if a user is an admin or owner of a specific project: + +```sql +-- public.is_project_admin +-- this function checks if a user is an admin or owner of a specific project +create or replace function public.is_project_admin(p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select exists ( + select 1 from public.project_members + where project_id = p_project_id + and account_id = (select auth.uid()) + and role in ('owner', 'admin') + ); +$$ language sql; + +grant execute on function public.is_project_admin to authenticated; +``` + +### Is Project Owner + +This function checks if a user is the owner of a project: + +```sql +-- public.is_project_owner +-- check if a user is the owner of a project +create or replace function public.is_project_owner(project_id uuid) +returns boolean +set search_path = '' +as $$ + select exists ( + select 1 from public.project_members + where project_id = $1 + and account_id = (select auth.uid()) + and role = 'owner' + ); +$$ language sql; + +grant execute on function public.is_project_owner to authenticated; +``` + +### User Has Project Permission + +This function checks if a user has the required permissions to perform a specific action on a project: + +```sql +-- public.user_has_project_permission +-- check if the current user has the required permissions to perform a specific action on a project +create or replace function public.user_has_project_permission( + p_user_auth_id uuid, + p_project_id uuid, + p_action public.project_action +) +returns boolean +set search_path = '' +as $$ +declare + v_role public.project_role; +begin + -- first, check if the user is a member of the project + select role into v_role + from public.project_members + where project_id = p_project_id and account_id = p_user_auth_id; + + if v_role is null then + return false; + end if; + + -- check permissions based on role and action + case v_role + when 'owner' then + return true; -- owners can do everything + when 'admin' then + return p_action != 'delete_project'; -- admins can do everything except delete the project + when 'member' then + return p_action in ('view_project'); + else + raise exception 'user must be a member of the project to perform this action'; + end case; +end; +$$ language plpgsql; + +grant execute on function public.user_has_project_permission to authenticated; +``` + +In the above, we use the `CASE` statement to check the role of the user and the action they are trying to perform. If the user is a member of the project and has the required permissions, we return `TRUE`. Otherwise, we raise an exception. + +Please feel free to modify the `user_has_project_permission` function to fit your specific use case and your project's requirements.For example, you may want to add more roles or actions, or restrict the permissions based on the project's settings. + +### Current User Has Project Permission + +This function is a wrapper around `user_has_project_permission` for the current user: + +```sql +-- public.current_user_has_project_permission +create or replace function public.current_user_has_project_permission( + p_project_id uuid, + p_action public.project_action +) +returns boolean +set search_path = '' +as $$ +begin + return public.user_has_project_permission((select auth.uid()), p_project_id, p_action); +end; +$$ language plpgsql; + +grant execute on function public.current_user_has_project_permission to authenticated; +``` + +### Current User Can Manage Project Member + +This function checks if the current user can manage another user in a project: + +```sql +-- public.current_user_can_manage_project_member +-- Function to check if a user can manage another user in a project +create or replace function public.current_user_can_manage_project_member( + p_target_member_role public.project_role, + p_project_id uuid +) +returns boolean +set search_path = '' +as $$ +declare + v_current_user_role public.project_role; +begin + select role into v_current_user_role + from public.project_members + where project_id = p_project_id and account_id = (select auth.uid()); + + if v_current_user_role is null or p_target_member_role is null then + raise exception 'User not found'; + end if; + + -- Check if the manager has a higher role + return (v_current_user_role = 'owner' and p_target_member_role != 'owner') or + (v_current_user_role = 'admin' and p_target_member_role = 'member'); +end; +$$ language plpgsql; + +grant execute on function public.current_user_can_manage_project_member to authenticated; +``` + +This project is extremely useful to verify that a user can perform actions that affect another user in a project. + +### Update Project Member Role + +This function updates the role of a project member: + +```sql +-- public.update_project_member_role +-- function to update the role of a project member +create or replace function public.update_project_member_role( + p_user_id uuid, + p_new_role public.project_role, + p_project_id uuid +) +returns boolean +set search_path = '' +as $$ +declare + v_current_role public.project_role; +begin + -- Get the current role of the member + select role into v_current_role + from public.project_members + where project_id = p_project_id and account_id = p_user_id; + + -- Check if the manager can manage this member + if not public.current_user_can_manage_project_member(v_current_role, p_project_id) then + raise exception 'Permission denied'; + end if; + + if p_new_role = 'owner' then + raise exception 'Owner cannot be updated to a different role'; + end if; + + -- Update the member's role + update public.project_members + set role = p_new_role + where project_id = p_project_id and account_id = p_user_id; + + return true; +end; +$$ language plpgsql; + +grant execute on function public.update_project_member_role to authenticated; +``` + +### Can Edit Project + +This function checks if a user can edit a project: + +```sql +-- public.can_edit_project +-- check if the user can edit the project +create or replace function public.can_edit_project(p_user_auth_id uuid, p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select public.user_has_project_permission(p_user_auth_id, p_project_id, 'edit_project'::public.project_action); +$$ language sql; + +grant execute on function public.can_edit_project to authenticated; +``` + +This is a simple wrapper around the `user_has_project_permission` function that checks if the current user can edit a project. + +### Can Delete Project + +This function checks if a user can delete a project: + +```sql +CREATE OR REPLACE FUNCTION public.can_delete_project(p_user_auth_id UUID, p_project_id UUID) +RETURNS BOOLEAN +SET search_path = '' +AS $$ + SELECT public.user_has_project_permission(p_user_auth_id, p_project_id, 'delete_project'::public.project_action); +$$ LANGUAGE sql; + +GRANT EXECUTE ON FUNCTION public.can_delete_project TO authenticated; +``` + +This is a simple wrapper around the `user_has_project_permission` function that checks if the current user can delete a project. + +### Can Invite Project Member + +This function checks if a user can invite a new member to the project: + +```sql +-- public.can_invite_project_member +-- check if the user can invite a new member to the project +create or replace function public.can_invite_project_member(p_user_auth_id uuid, p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select public.user_has_project_permission(p_user_auth_id, p_project_id, 'invite_member'::public.project_action); +$$ language sql; + +grant execute on function public.can_invite_project_member to authenticated; +``` + +This is also a simple wrapper around the `user_has_project_permission` function that checks if the current user can invite a new member to the project. + +## Row Level Security (RLS) Policies + +Now, let's set up RLS policies to secure our tables. RLS policies are used to control access to specific columns and rows in a table. The functions we defined earlier are used to check if a user has the required permissions to perform a specific action on a project. + +### Projects Table Policies + +```sql +/* +RLS POLICIES +*/ + +-- SELECT(public.projects) +create policy select_projects + on public.projects + for select + to authenticated + using ( + public.is_project_member(id) + ); + +-- INSERT(public.projects) +create policy insert_new_project + on public.projects + for insert + to authenticated + with check ( + public.has_role_on_account(account_id) + ); + +-- DELETE(public.projects) +create policy delete_project + on public.projects + for delete + to authenticated + using ( + public.can_delete_project((select auth.uid()), id) + ); + +-- UPDATE(public.projects) +create policy update_project + on public.projects + for update + to authenticated + using ( + public.can_edit_project((select auth.uid()), id) + ) + with check ( + public.can_edit_project((select auth.uid()), id) + ); +``` + +Alright, let's break down the policies: + +1. **Select Projects**: This policy allows authenticated users to select projects from the `projects` table. If a user is not a member of the project, they will not be able to see the project. +2. **Insert New Project**: This policy allows authenticated users to insert new projects into the `projects` table. It does so by checking if the current user is currently a member of the team account associated with the project. +3. **Delete Project**: This policy allows authenticated users to delete projects from the `projects` table. It verifies that the user can delete the project using the `can_delete_project` function. +4. **Update Project**: This policy allows authenticated users to update projects in the `projects` table. It does so by checking if the current user has the required permissions to edit the project using the `can_edit_project` function. + +### Project Members Table Policies + +```sql + +-- SELECT(public.project_members) +create policy select_project_members + on public.project_members + for select + to authenticated + using ( + public.is_project_member(project_id) + ); + +-- INSERT(public.project_members) +create policy insert_project_member + on public.project_members + for insert + to authenticated + with check ( + public.can_invite_project_member( + (select auth.uid()), + project_id + ) + ); + +-- UPDATE(public.project_members) +create policy update_project_members + on public.project_members + for update + to authenticated + using ( + public.current_user_can_manage_project_member( + role, + project_id + ) + ) + with check ( + public.current_user_can_manage_project_member( + role, + project_id + ) + ); + +-- DELETE(public.project_members) +create policy delete_project_members + on public.project_members + for delete + to authenticated + using ( + public.current_user_can_manage_project_member( + role, + project_id + ) + ); +``` + +Let's break down the policies: + +1. **Select Project Members**: This policy allows authenticated users to select project members from the `project_members` table. If a user is not a member of the project, they will not be able to see the project members. +2. **Insert Project Member**: This policy allows authenticated users to insert new project members into the `project_members` table. It does so by checking if the current user can invite a new member to the project using the `can_invite_project_member` function. +3. **Update Project Members**: This policy allows authenticated users to update project members in the `project_members` table. It does so by checking if the current user can manage the project member using the `current_user_can_manage_project_member` function. +4. **Delete Project Members**: This policy allows authenticated users to delete project members from the `project_members` table. It does so by checking if the current user can manage the project member using the `current_user_can_manage_project_member` function. + +## Additional Functions + +Let's add a few more helpful functions: + +### Add Project Owner + +This function adds the owner of the project as the first project member: + +```sql +CREATE OR REPLACE FUNCTION kit.add_project_owner() +RETURNS TRIGGER +AS $$ +BEGIN + INSERT INTO public.project_members (project_id, user_id, role) + VALUES (NEW.id, auth.uid(), 'owner'::public.project_role); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Trigger to add owner of the project creator as the first project member +CREATE TRIGGER add_project_owner_on_insert + AFTER INSERT ON public.projects + FOR EACH ROW + EXECUTE PROCEDURE kit.add_project_owner(); +``` + +### Add Project Member + +This function adds a new member to a project: + +```sql +CREATE OR REPLACE FUNCTION public.add_project_member( + p_project_id UUID, + p_user_id UUID, + p_role public.project_role DEFAULT 'member' +) RETURNS BOOLEAN +SET search_path = '' +AS $$ +DECLARE + v_account_id UUID; + v_is_personal_account boolean; +BEGIN + -- Get the account_id for the project + SELECT account_id INTO v_account_id + FROM public.projects + WHERE id = p_project_id; + + -- check if the target user account is a personal account (not a team account)Add commentMore actions + -- Use security definer context to bypass RLS for this validation + select is_personal_account into v_is_personal_account + from public.accounts + where id = p_user_id; + + if v_is_personal_account is null then + raise exception 'user account not found'; + end if; + + if not v_is_personal_account then + raise exception 'cannot invite team accounts to projects - only individual users can be invited'; + end if; + + -- check if the current user has permission to add members + if not public.is_project_admin(p_project_id) or p_role = 'owner' then + raise exception 'permission denied'; + end if; + + -- Check if the user is a member of the team account + IF NOT EXISTS ( + SELECT 1 FROM public.accounts_memberships + WHERE account_id = v_account_id AND user_id = p_user_id + ) THEN + RAISE EXCEPTION 'User is not a member of the team account'; + END IF; + + -- Add the new member (the trigger will enforce the team membership check) + INSERT INTO public.project_members (project_id, user_id, role) + VALUES (p_project_id, p_user_id, p_role) + ON CONFLICT (project_id, user_id) DO UPDATE + SET role = EXCLUDED.role; + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +GRANT EXECUTE ON FUNCTION public.add_project_member TO authenticated; +``` + +### Check Project Member in Team + +This trigger function ensures that a user being added to a project is already a member of the associated team account: + +```sql +CREATE OR REPLACE FUNCTION kit.check_project_member_in_team() +RETURNS TRIGGER +AS $$ +DECLARE + v_account_id UUID; + v_is_personal_account boolean; +BEGIN + SELECT account_id FROM public.projects + WHERE id = NEW.project_id + INTO v_account_id; + + -- check if the account being added is a personal account (not a team account)Add commentMore actions + select is_personal_account into v_is_personal_account + from public.accounts + where id = new.account_id; + + if v_is_personal_account is null then + raise exception 'account not found'; + end if; + + if not v_is_personal_account then + raise exception 'cannot add team accounts to projects - only individual user accounts can be project members'; + end if; + + IF NOT EXISTS ( + SELECT 1 FROM public.accounts_memberships + WHERE account_id = v_account_id AND user_id = NEW.user_id + ) THEN + RAISE EXCEPTION 'User must be a member of the team account to be added to the project'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Create a trigger that uses the above function +CREATE TRIGGER ensure_project_member_in_team +BEFORE INSERT OR UPDATE ON public.project_members +FOR EACH ROW EXECUTE FUNCTION kit.check_project_member_in_team(); +``` + +## Implementing the Projects Feature + +Now that we've set up all the necessary database objects, let's walk through how to use this Projects feature in your Makerkit application. + +### Creating a New Project + +To create a new project, you'll insert a row into the `public.projects` table. The `kit.add_project_owner()` trigger will automatically add the creating user as the owner. + +```sql +INSERT INTO public.projects (name, description, account_id) +VALUES ('My New Project', 'This is a description of my project', :account_id); +``` + +Replace `:account_id` with the actual account ID. + +### Adding Members to a Project + +Use the `public.add_project_member` function to add new members to a project: + +```sql +SELECT public.add_project_member(:project_id, :user_id, 'member'::public.project_role); +``` + +Replace `:project_id` and `:user_id` with actual values. The role can be 'member' or 'admin'. + +### Updating a Member's Role + +To change a member's role, use the `public.update_project_member_role` function: + +```sql +SELECT public.update_project_member_role(:user_id, 'admin'::public.project_role, :project_id); +``` + +### Querying Projects + +To get all projects a user is a member of: + +```sql +SELECT * FROM public.projects +WHERE id IN ( + SELECT project_id + FROM public.project_members + WHERE user_id = auth.uid() +); +``` + +### Checking Permissions + +You can use the various permission-checking functions in your application logic. For example: + +```sql +-- Check if the current user can edit a project +SELECT public.can_edit_project(auth.uid(), :project_id); + +-- Check if the current user can delete a project +SELECT public.can_delete_project(auth.uid(), :project_id); + +-- Check if the current user can invite members to a project +SELECT public.can_invite_project_member(auth.uid(), :project_id); +``` + +## Application-Level Logic + +Now that we've covered the database schema and functions, let's move on to the application-level logic. + +### The ProjectsService + +To provide a better API for interacting with the database, we can create a service layer (`ProjectsService`) that encapsulates all the database operations related to projects. This is a great practice as it separates concerns and makes your code more maintainable. + +The `ProjectsService` class encapsulates all the database operations related to projects. This is a great practice as it separates concerns and makes your code more maintainable. + + ```tsx {% title="apps/web/lib/server/projects/projects.service.ts" %} +import { SupabaseClient } from '@supabase/supabase-js'; + +import { Database, Tables } from '~/lib/database.types'; + +type ProjectAction = Database['public']['Enums']['project_action']; + +export function createProjectsService(client: SupabaseClient<Database>) { + return new ProjectsService(client); +} + +class ProjectsService { + constructor(private readonly client: SupabaseClient<Database>) {} + + async createProject(params: { + name: string; + description?: string; + accountId: string; + }) { + const { data, error } = await this.client + .from('projects') + .insert({ + name: params.name, + description: params.description, + account_id: params.accountId, + }) + .select('id') + .single(); + + if (error) { + throw error; + } + + return data; + } + + async getProjects(accountSlug: string) { + const { data, error } = await this.client + .from('projects') + .select('*, account: account_id ! inner (slug)') + .eq('account.slug', accountSlug); + + if (error) { + throw error; + } + + return data; + } + + async getProjectMembers(projectId: string) { + const { data, error } = await this.client + .from('project_members') + .select< + string, + Tables<'project_members'> & { + account: { + id: string; + name: string; + email: string; + }; + } + >('*, account: account_id ! inner (id, name, email)') + .eq('project_id', projectId); + + if (error) { + throw error; + } + + return data; + } + + async getProject(projectId: string) { + const { data, error } = await this.client + .from('projects') + .select('*') + .eq('id', projectId) + .single(); + + if (error) { + throw error; + } + + return data; + } + + async hasPermission(params: { projectId: string; action: ProjectAction }) { + const { data, error } = await this.client.rpc( + 'current_user_has_project_permission', + { + p_project_id: params.projectId, + p_action: params.action, + }, + ); + + if (error) { + throw error; + } + + return data; + } + + async addProjectMember(params: { + projectId: string; + userId: string; + role?: 'member' | 'admin'; + }) { + const { error } = await this.client.rpc('add_project_member', { + p_project_id: params.projectId, + p_user_id: params.userId, + p_role: params.role ?? 'member', + }); + + if (error) { + throw error; + } + + return true; + } + + async removeProjectMember(params: { projectId: string; userId: string }) { + const { error } = await this.client.from('project_members').delete().match({ + project_id: params.projectId, + account_id: params.userId, + }); + + if (error) { + throw error; + } + + return true; + } +} +``` + +Let's look at some key methods: + +1. **createProject**: This method inserts a new project into the `projects` table. +2. **getProjects**: Fetches all projects for a given account. +3. **getProjectMembers**: Retrieves all members of a specific project. +4. **getProject**: Fetches details of a single project. +5. **hasPermission**: Checks if the current user has a specific permission for a project. +6. **addProjectMember**: Adds a new member to a project. + +These methods utilize the Supabase client to interact with the database, leveraging the SQL functions and RLS policies we set up earlier. + +### Using the ProjectsService in a Next.js Page + +The `ProjectsPage` component demonstrates how to use the `ProjectsService` in a Next.js server component: + +We create an instance of `ProjectsService` using the Supabase client: + + ```typescript +const client = getSupabaseServerClient(); +const service = createProjectsService(client); +``` + +We fetch the projects for the current account: + +```typescript +const projects = use(service.getProjects(params.account)); +``` + +The `use` function is a React hook that allows us to use async data in a synchronous-looking way in server components. + +We render the projects, showing an empty state if there are no projects: + + ```tsx +<If condition={projects.length === 0}> + <EmptyState> + {/* Empty state content */} + </EmptyState> +</If> + +<div className={'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4'}> + {projects.map((project) => ( + <CardButton key={project.id} asChild> + <Link href={`/home/${params.account}/projects/${project.id}`}> + {project.name} + </Link> + </CardButton> + ))} +</div> + ``` + +Here is a page that shows a list of projects for a given account: + + ```tsx {% title="apps/web/app/[locale]/home/[account]/projects/page.tsx" %} +import { use } from 'react'; + +import Link from 'next/link'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { Button } from '@kit/ui/button'; +import { + CardButton, + CardButtonHeader, + CardButtonTitle, +} from '@kit/ui/card-button'; +import { + EmptyState, + EmptyStateButton, + EmptyStateHeading, + EmptyStateText, +} from '@kit/ui/empty-state'; +import { If } from '@kit/ui/if'; +import { PageBody, PageHeader } from '@kit/ui/page'; + +import { CreateProjectDialog } from '~/home/[account]/projects/_components/create-project-dialog'; +import { createProjectsService } from '~/lib/server/projects/projects.service'; + +interface ProjectsPageProps { + params: Promise<{ + account: string; + }>; +} + +export default function ProjectsPage({ params }: ProjectsPageProps) { + const client = getSupabaseServerClient(); + const service = createProjectsService(client); + + const { account } = use(params); + + const projects = use(service.getProjects(account)); + + return ( + <> + <PageHeader title="Projects" description={<AppBreadcrumbs />}> + <CreateProjectDialog> + <Button>New Project</Button> + </CreateProjectDialog> + </PageHeader> + + <PageBody> + <If condition={projects.length === 0}> + <EmptyState> + <EmptyStateHeading>No projects found</EmptyStateHeading> + + <EmptyStateText> + You still have not created any projects. Create your first project + now! + </EmptyStateText> + + <CreateProjectDialog> + <EmptyStateButton>Create Project</EmptyStateButton> + </CreateProjectDialog> + </EmptyState> + </If> + + <div className={'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4'}> + {projects.map((project) => ( + <CardButton key={project.id} asChild> + <Link href={`/home/${account}/projects/${project.id}`}> + <CardButtonHeader> + <CardButtonTitle>{project.name}</CardButtonTitle> + </CardButtonHeader> + </Link> + </CardButton> + ))} + </div> + </PageBody> + </> + ); +} +``` + +The page will display a list of projects for the current account, with a link to create a new project. If there are no projects, it will display an empty state with a button to create a new project, which is a link to the new project page. + +### Implementing Other Features + +Based on the `ProjectsService`, you can implement other features: + +1. **Creating a New Project**: You could create a form that calls `service.createProject()` when submitted. +2. **Project Details Page**: Use `service.getProject()` to fetch and display details of a single project. +3. **Managing Project Members**: Use `service.getProjectMembers()` to list members, and `service.addProjectMember()` to add new ones. +4. **Permission Checking**: Before performing actions, use `service.hasPermission()` to check if the user is allowed to do so. + +## Going beyond Projects + +The reason why you're adding Projects is likely because yoyu want to group more entities into a projects, so to better organizate a team's work. You can extend the Projects feature to support other entities, such as Tasks, Documents, and more. + +To do so, your project-related entities will need to be related to the Projects entity. This can be done using the `project_id` column in the related tables. + +For example, if you want to add Tasks to a Project, you can create a new table `tasks` and add a foreign key to the `project_id` column in the `projects` table. + +```sql +-- Table: public.tasks +CREATE TABLE if not exists public.tasks ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + name text NOT NULL, + description text, + project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); +``` + +With this setup, you can now create Tasks that are related to a Project. You can also use the Supabase client to interact with the database, leveraging the SQL functions and RLS policies we set up earlier. + +Remember, the database schema and functions we've created provide a solid foundation for these features. As you build out your UI, you'll be calling the methods in `ProjectsService` to interact with the database in a secure, permission-controlled way. + +This approach allows you to create a robust, scalable application while keeping your business logic cleanly separated from your UI components. + +### RLS Policies + +When it comes to RLS, you can use the permissions system we've set up to control access to your database. + +This allows you to create fine-grained access control for your users, ensuring that only those who should have access to certain data can do so. + +```sql +create policy select_projects_tasks + on public.tasks + for select + to authenticated + using ( + public.is_project_member(project_id) + ); +``` + +In short, if a user can view a Project, they can also view the Tasks related to that Project. + +You can extend the permissions system further to allow for more granular access control based on the specific requirements of your application, such as `can_edit_task` or `can_delete_task`. + +### Best Practices + +1. **Error Handling**: The service methods throw errors when something goes wrong. Make sure to catch and handle these errors appropriately in your components. +2. **Type Safety**: The service uses TypeScript, leveraging the `Database` type to ensure type safety when interacting with Supabase. +3. **Separation of Concerns**: By using a service layer, we keep our database logic separate from our UI components, making the code more maintainable and testable. +4. **Server Components**: By using Next.js server components, we can fetch data on the server, reducing the amount of JavaScript sent to the client and improving initial page load times. +5. **Reusability**: The `ProjectsService` can be used across different parts of your application, promoting code reuse. + +## Full Schema + +Below is the full schema: + +```sql +-- enums for project roles: owner, admin, member +create type public.project_role as enum ('owner', 'admin', 'member'); + +-- enums for project actions +create type public.project_action as enum ( + 'view_project', + 'edit_project', + 'delete_project', + 'invite_member', + 'remove_member' +); + +/* +# public.projects +# This table stores the projects for the account +*/ +create table if not exists public.projects ( + id uuid default gen_random_uuid() primary key, + name varchar(255) not null, + description text, + account_id uuid not null references public.accounts(id) on delete cascade, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- revoke access by default to public.projects +revoke all on public.projects from public, service_role; +-- grant access to authenticated users +grant select, insert, update, delete on public.projects to authenticated; + +-- indexes on public.projects +create index projects_account_id on public.projects (account_id); + +-- RLS policies on public.projects +alter table public.projects enable row level security; + +/* +# public.project_members +# This table stores the members of a project +*/ +create table if not exists public.project_members ( + project_id uuid not null references public.projects(id) on delete cascade, + account_id uuid not null references public.accounts(id) on delete cascade, + role public.project_role not null default 'member', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, account_id) +); + +-- make sure there is only one owner per project +create unique index projects_unique_owner on public.project_members (project_id) where role = 'owner'; + +-- indexes on public.project_members +create index project_members_project_id on public.project_members (project_id); +create index project_members_account_id on public.project_members (account_id); + +-- revoke access by default to public.project_members +revoke all on public.project_members from public, service_role; + +-- grant access to authenticated users to public.project_members +grant select, insert, update, delete on public.project_members to authenticated; + +-- RLS policies on public.project_members +alter table public.project_members enable row level security; + +-- public.is_project_member +-- this function checks if a user is a member of a specific project +create or replace function public.is_project_member(p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select exists ( + select 1 from public.project_members + where project_id = p_project_id + and account_id = (select auth.uid()) + ); +$$ language sql security definer; + +grant execute on function public.is_project_member(uuid) to authenticated; + +-- public.is_project_admin +-- this function checks if a user is an admin or owner of a specific project +create or replace function public.is_project_admin(p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select exists ( + select 1 from public.project_members + where project_id = p_project_id + and account_id = (select auth.uid()) + and role in ('owner', 'admin') + ); +$$ language sql; + +grant execute on function public.is_project_admin to authenticated; + +-- public.is_project_owner +-- check if a user is the owner of a project +create or replace function public.is_project_owner(project_id uuid) +returns boolean +set search_path = '' +as $$ + select exists ( + select 1 from public.project_members + where project_id = $1 + and account_id = (select auth.uid()) + and role = 'owner' + ); +$$ language sql; + +grant execute on function public.is_project_owner to authenticated; + +-- public.user_has_project_permission +-- check if the current user has the required permissions to perform a specific action on a project +create or replace function public.user_has_project_permission( + p_user_auth_id uuid, + p_project_id uuid, + p_action public.project_action +) +returns boolean +set search_path = '' +as $$ +declare + v_role public.project_role; +begin + -- first, check if the user is a member of the project + select role into v_role + from public.project_members + where project_id = p_project_id and account_id = p_user_auth_id; + + if v_role is null then + return false; + end if; + + -- check permissions based on role and action + case v_role + when 'owner' then + return true; -- owners can do everything + when 'admin' then + return p_action != 'delete_project'; -- admins can do everything except delete the project + when 'member' then + return p_action in ('view_project'); + else + raise exception 'user must be a member of the project to perform this action'; + end case; +end; +$$ language plpgsql; + +grant execute on function public.user_has_project_permission to authenticated; + +-- public.current_user_has_project_permission +create or replace function public.current_user_has_project_permission( + p_project_id uuid, + p_action public.project_action +) +returns boolean +set search_path = '' +as $$ +begin + return public.user_has_project_permission((select auth.uid()), p_project_id, p_action); +end; +$$ language plpgsql; + +grant execute on function public.current_user_has_project_permission to authenticated; + +-- public.current_user_can_manage_project_member +-- Function to check if a user can manage another user in a project +create or replace function public.current_user_can_manage_project_member( + p_target_member_role public.project_role, + p_project_id uuid +) +returns boolean +set search_path = '' +as $$ +declare + v_current_user_role public.project_role; +begin + select role into v_current_user_role + from public.project_members + where project_id = p_project_id and account_id = (select auth.uid()); + + if v_current_user_role is null or p_target_member_role is null then + raise exception 'User not found'; + end if; + + -- Check if the manager has a higher role + return (v_current_user_role = 'owner' and p_target_member_role != 'owner') or + (v_current_user_role = 'admin' and p_target_member_role = 'member'); +end; +$$ language plpgsql; + +grant execute on function public.current_user_can_manage_project_member to authenticated; + +-- public.update_project_member_role +-- function to update the role of a project member +create or replace function public.update_project_member_role( + p_user_id uuid, + p_new_role public.project_role, + p_project_id uuid +) +returns boolean +set search_path = '' +as $$ +declare + v_current_role public.project_role; +begin + -- Get the current role of the member + select role into v_current_role + from public.project_members + where project_id = p_project_id and account_id = p_user_id; + + -- Check if the manager can manage this member + if not public.current_user_can_manage_project_member(v_current_role, p_project_id) then + raise exception 'Permission denied'; + end if; + + if p_new_role = 'owner' then + raise exception 'Owner cannot be updated to a different role'; + end if; + + -- Update the member's role + update public.project_members + set role = p_new_role + where project_id = p_project_id and account_id = p_user_id; + + return true; +end; +$$ language plpgsql; + +grant execute on function public.update_project_member_role to authenticated; + +-- public.can_edit_project +-- check if the user can edit the project +create or replace function public.can_edit_project(p_user_auth_id uuid, p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select public.user_has_project_permission(p_user_auth_id, p_project_id, 'edit_project'::public.project_action); +$$ language sql; + +grant execute on function public.can_edit_project to authenticated; + +-- public.can_delete_project +-- check if the user can delete the project +create or replace function public.can_delete_project(p_user_auth_id uuid, p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select public.user_has_project_permission(p_user_auth_id, p_project_id, 'delete_project'::public.project_action); +$$ language sql; + +grant execute on function public.can_delete_project to authenticated; + +-- public.can_invite_project_member +-- check if the user can invite a new member to the project +create or replace function public.can_invite_project_member(p_user_auth_id uuid, p_project_id uuid) +returns boolean +set search_path = '' +as $$ + select public.user_has_project_permission(p_user_auth_id, p_project_id, 'invite_member'::public.project_action); +$$ language sql; + +grant execute on function public.can_invite_project_member to authenticated; + +/* +RLS POLICIES +*/ + +-- SELECT(public.projects) +create policy select_projects + on public.projects + for select + to authenticated + using ( + public.is_project_member(id) + ); + +-- INSERT(public.projects) +create policy insert_new_project + on public.projects + for insert + to authenticated + with check ( + public.has_role_on_account(account_id) + ); + +-- DELETE(public.projects) +create policy delete_project + on public.projects + for delete + to authenticated + using ( + public.can_delete_project((select auth.uid()), id) + ); + +-- UPDATE(public.projects) +create policy update_project + on public.projects + for update + to authenticated + using ( + public.can_edit_project((select auth.uid()), id) + ) + with check ( + public.can_edit_project((select auth.uid()), id) + ); + +-- SELECT(public.project_members) +create policy select_project_members + on public.project_members + for select + to authenticated + using ( + public.is_project_member(project_id) + ); + +-- INSERT(public.project_members) +create policy insert_project_member + on public.project_members + for insert + to authenticated + with check ( + public.can_invite_project_member( + (select auth.uid()), + project_id + ) + ); + +-- UPDATE(public.project_members) +create policy update_project_members + on public.project_members + for update + to authenticated + using ( + public.current_user_can_manage_project_member( + role, + project_id + ) + ) + with check ( + public.current_user_can_manage_project_member( + role, + project_id + ) + ); + +-- DELETE(public.project_members) +create policy delete_project_members + on public.project_members + for delete + to authenticated + using ( + public.current_user_can_manage_project_member( + role, + project_id + ) + ); + +/* +# FUNCTIONS +*/ +-- function to add owner of the project creator as the first project member +create or replace function kit.add_project_owner() +returns trigger +as $$ +begin + insert into public.project_members (project_id, account_id, role) + values (new.id, auth.uid(), 'owner'::public.project_role); + + return new; +end; +$$ language plpgsql security definer; + +-- trigger to add owner of the project creator as the first project member +create trigger add_project_owner_on_insert + after insert on public.projects + for each row + execute procedure kit.add_project_owner(); + +create or replace function public.add_project_member( + p_project_id uuid, + p_user_id uuid, + p_role public.project_role default 'member' +) returns boolean +set search_path = '' +as $$ +declare + v_account_id uuid; + v_is_personal_account boolean; +begin + -- get the account_id for the project + select account_id into v_account_id + from public.projects + where id = p_project_id; + + -- check if the target user account is a personal account (not a team account) + -- Use security definer context to bypass RLS for this validation + select is_personal_account into v_is_personal_account + from public.accounts + where id = p_user_id; + + if v_is_personal_account is null then + raise exception 'user account not found'; + end if; + + if not v_is_personal_account then + raise exception 'cannot invite team accounts to projects - only individual users can be invited'; + end if; + + -- check if the current user has permission to add members + if not public.is_project_admin(p_project_id) or p_role = 'owner' then + raise exception 'permission denied'; + end if; + + -- check if the user is a member of the team account + if not exists ( + select 1 from public.accounts_memberships + where account_id = v_account_id and user_id = p_user_id + ) then + raise exception 'user is not a member of the team account'; + end if; + + -- add the new member (the trigger will enforce the team membership check) + insert into public.project_members (project_id, account_id, role) + values (p_project_id, p_user_id, p_role) + on conflict (project_id, account_id) do update + set role = excluded.role; + + return true; +end; +$$ language plpgsql; + +grant execute on function public.add_project_member to authenticated; + +-- TRIGGERS on public.project_members +-- this trigger function ensures that a user being added to a project +-- is already a member of the associated team account and is a personal account +create or replace function kit.check_project_member_in_team() +returns trigger +as $$ +declare + v_account_id uuid; + v_is_personal_account boolean; +begin + select account_id from public.projects + where id = new.project_id + into v_account_id; + + -- check if the account being added is a personal account (not a team account) + select is_personal_account into v_is_personal_account + from public.accounts + where id = new.account_id; + + if v_is_personal_account is null then + raise exception 'account not found'; + end if; + + if not v_is_personal_account then + raise exception 'cannot add team accounts to projects - only individual user accounts can be project members'; + end if; + + if not exists ( + select 1 from public.accounts_memberships + where account_id = v_account_id and new.account_id = public.accounts_memberships.user_id + ) then + raise exception 'user must be a member of the team account to be added to the project'; + end if; + + return new; +end; +$$ language plpgsql security definer; + +-- we create a trigger that uses the above function +create trigger ensure_project_member_in_team +before insert or update on public.project_members +for each row execute function kit.check_project_member_in_team(); + +create or replace function public.get_account_members_by_query( + p_account_id uuid, + p_query text +) +returns table ( + account_id uuid, + user_id uuid, + id uuid, + name varchar(255), + email varchar(255), + picture_url varchar(1000) +) language plpgsql +set search_path = '' +as $$ +begin + return QUERY select + am.account_id, + am.user_id, + acc.id, + acc.name, + acc.email, + acc.picture_url + from + public.accounts_memberships am + join public.accounts a on a.id = am.account_id + join public.accounts acc on acc.id = am.user_id + where + am.account_id = p_account_id and + (to_tsvector(acc.name || ' ' || acc.email) @@ plainto_tsquery(p_query)) + and am.user_id != (select auth.uid()) + and acc.is_personal_account = true; -- only return personal accounts, not team accounts + +end +$$; + +grant execute on function public.get_account_members_by_query to authenticated, service_role; +``` + +And here are the tests for the above schema: + +```sql +BEGIN; + +create extension "basejump-supabase_test_helpers" version '0.0.6'; + +select + no_plan (); + +--- Create users which will automatically create personal accounts via triggers +select + tests.create_supabase_user ('primary_owner', 'project-test-owner@example.com'); + +select + tests.create_supabase_user ('admin', 'project-test-admin@example.com'); + +select + tests.create_supabase_user ('member', 'project-test-member@example.com'); + +select + tests.create_supabase_user ('custom', 'project-test-custom@example.com'); + +-- Authenticate as primary_owner to trigger the personal account creation +select makerkit.authenticate_as('primary_owner'); + +-- Wait for personal accounts to be created by the triggers +select pg_sleep(0.2); + +-- Also authenticate as each user to trigger their personal account creation +select makerkit.authenticate_as('admin'); +select pg_sleep(0.1); +select makerkit.authenticate_as('member'); +select pg_sleep(0.1); +select makerkit.authenticate_as('custom'); +select pg_sleep(0.1); + +-- Switch to elevated permissions to set up team memberships +reset role; + +-- Add all test users to the team account so they can be invited to projects +INSERT INTO public.accounts_memberships (account_id, user_id, account_role, created_at, updated_at) +VALUES + ((select makerkit.get_account_id_by_slug('makerkit')), tests.get_supabase_uid('primary_owner'), 'owner', now(), now()), + ((select makerkit.get_account_id_by_slug('makerkit')), tests.get_supabase_uid('admin'), 'member', now(), now()), + ((select makerkit.get_account_id_by_slug('makerkit')), tests.get_supabase_uid('member'), 'member', now(), now()), + ((select makerkit.get_account_id_by_slug('makerkit')), tests.get_supabase_uid('custom'), 'member', now(), now()) +ON CONFLICT (user_id, account_id) DO UPDATE SET + account_role = EXCLUDED.account_role; + +create +or replace function tests.get_project_uuid () returns uuid as $$ + -- return a valid UUID + select 'd8e789e4-8425-494b-9986-ad88ef430382'::uuid; +$$ language sql immutable; + +select + makerkit.authenticate_as ('primary_owner'); + +-- Create projects as an authenticated user so the trigger can work properly +INSERT INTO + public.projects (id, name, description, account_id) +VALUES + ( + tests.get_project_uuid (), + 'Test Project 1', + 'Description 1', + ( + select + makerkit.get_account_id_by_slug ('makerkit') + ) + ), + ( + 'd0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + 'Test Project 2', + 'Description 2', + ( + select + makerkit.get_account_id_by_slug ('makerkit') + ) + ); + +select + makerkit.authenticate_as ('primary_owner'); + +set + local role postgres; + +select + lives_ok ( + $$ INSERT INTO public.project_members (project_id, account_id, role) + VALUES + ( + tests.get_project_uuid (), + tests.get_supabase_uid ('member'), + 'member' + ); $$, + 'Inserting a user should create a personal account when personal accounts are enabled' + ); + +select + throws_ok ( + $$ INSERT INTO public.project_members (project_id, account_id, role) + values ( + tests.get_project_uuid (), + tests.get_supabase_uid ('admin'), + 'owner' + ) $$, + 'duplicate key value violates unique constraint "projects_unique_owner"', + 'A project can only have one owner' + ); + +select + lives_ok ( + $$ INSERT INTO public.project_members (project_id, account_id, role) + values ( + tests.get_project_uuid (), + tests.get_supabase_uid ('admin'), + 'admin' + ) $$, + 'A project can add an admin' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +select + is ( + ( + select + count(*) + from + public.project_members + where + project_id = tests.get_project_uuid () + )::int, + 3, + 'The primary owner should be able to see the other members' + ); + +-- Test is_project_member function +SELECT + lives_ok ( + $$ select public.is_project_member (tests.get_project_uuid ()); $$, + 'is_project_member should return true for existing member' + ); + +select + makerkit.authenticate_as ('custom'); + +SELECT + lives_ok ( + $$ select not public.is_project_member (tests.get_project_uuid ()); $$, + 'is_project_member should return false for non-member' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +-- Test is_project_admin function +SELECT + lives_ok ( + $$ select public.is_project_admin (tests.get_project_uuid ()); $$, + 'is_project_admin should return true for admin' + ); + +select + makerkit.authenticate_as ('admin'); + +select + lives_ok ( + $$ select public.is_project_admin (tests.get_project_uuid ()); $$, + 'is_project_admin should return true for owner' + ); + +select + makerkit.authenticate_as ('member'); + +SELECT + lives_ok ( + $$ + select NOT public.is_project_admin (tests.get_project_uuid ())$$, + 'is_project_admin should return false for non-admin' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +-- Test user_has_project_permission function +SELECT + lives_ok ( + $$ select public.user_has_project_permission ( + tests.get_supabase_uid ('primary_owner'), + tests.get_project_uuid (), + 'delete_project'::project_action) + $$, + 'Owner should have delete_project permission' + ); + +select + makerkit.authenticate_as ('admin'); + +SELECT + lives_ok ( + $$ select public.user_has_project_permission ( + tests.get_supabase_uid ('admin'), + tests.get_project_uuid (), + 'edit_project'::project_action) $$, + 'Admin should have edit_project permission' + ); + +select + makerkit.authenticate_as ('member'); + +SELECT + lives_ok ( + $$ select NOT public.user_has_project_permission ( + tests.get_supabase_uid ('member'), + tests.get_project_uuid (), + 'delete_project'::project_action) + $$, + 'Member should not have delete_project permission' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +select + isnt_empty ( + $$ select * from public.project_members where project_id = tests.get_project_uuid() $$, + 'The primary owner should be able to see the other members' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +-- Test update_project_member_role function +SELECT + lives_ok ( + $$ SELECT public.update_project_member_role( + tests.get_supabase_uid('member'), + 'admin'::project_role, + tests.get_project_uuid() + ) $$, + 'Owner should be able to update member role to admin' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +select + isnt_empty ( + $$ select * from public.project_members + where project_id = tests.get_project_uuid() + $$, + 'Primary owner should be able to see members' + ); + +SELECT + lives_ok ( + $$ delete from public.project_members + where account_id = tests.get_supabase_uid('member') and project_id = tests.get_project_uuid(); + $$, + 'Owner should be able to remove a member' + ); + +select + is_empty ( + $$ select * from public.project_members where project_id = tests.get_project_uuid() and account_id = tests.get_supabase_uid('member') $$, + 'The primary owner has removed the member' + ); + +-- reinsert member +insert into + public.project_members (project_id, account_id, role) +values + ( + tests.get_project_uuid (), + tests.get_supabase_uid ('member'), + 'member' + ); + +select + makerkit.authenticate_as ('admin'); + +-- Test add_project_member function +SELECT + throws_ok ( + $$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('member'), 'owner'::public.project_role)$$, + 'permission denied', + 'Should not be able to add a member with owner role' + ); + +SELECT + lives_ok ( + $$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'member'::public.project_role)$$, + 'Admin should be able to add a new member' + ); + +-- Additional tests for is_project_member function +SELECT + lives_ok ( + $$ select NOT public.is_project_member ('d0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11') $$, + 'is_project_member should return false for a project the user is not a member of' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +-- Test is_project_owner function +SELECT + lives_ok ( + $$ select public.is_project_owner (tests.get_project_uuid ())$$, + 'is_project_owner should return true for the project owner' + ); + +select + makerkit.authenticate_as ('admin'); + +SELECT + lives_ok ( + $$ select NOT public.is_project_owner (tests.get_project_uuid ())$$, + 'is_project_owner should return false for non-owner (admin)' + ); + +select + makerkit.authenticate_as ('member'); + +SELECT + lives_ok ( + $$ select NOT public.is_project_owner (tests.get_project_uuid ())$$, + 'is_project_owner should return false for non-owner (member)' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +-- Additional tests for user_has_project_permission function +SELECT + lives_ok ( + $$ select public.user_has_project_permission ( + tests.get_supabase_uid ('primary_owner'), + tests.get_project_uuid (), + 'invite_member'::project_action + ) $$, + 'Owner should have invite_member permission' + ); + +SELECT + lives_ok ( + $$ select public.user_has_project_permission ( + tests.get_supabase_uid ('primary_owner'), + tests.get_project_uuid (), + 'remove_member'::project_action + )$$, + 'Owner should have remove_member permission' + ); + +select + makerkit.authenticate_as ('admin'); + +SELECT + lives_ok ( + $$ select public.user_has_project_permission ( + tests.get_supabase_uid ('admin'), + tests.get_project_uuid (), + 'invite_member'::project_action + ) $$, + 'Admin should have invite_member permission' + ); + +SELECT + lives_ok ( + $$ select NOT public.user_has_project_permission ( + tests.get_supabase_uid ('admin'), + tests.get_project_uuid (), + 'delete_project'::project_action + )$$, + 'Admin should not have delete_project permission' + ); + +select + makerkit.authenticate_as ('member'); + +SELECT + lives_ok ( + $$ select public.user_has_project_permission ( + tests.get_supabase_uid ('member'), + tests.get_project_uuid (), + 'view_project'::project_action + )$$, + 'Member should have view_project permission' + ); + +SELECT + lives_ok ( + $$ select NOT public.user_has_project_permission ( + tests.get_supabase_uid ('member'), + tests.get_project_uuid (), + 'invite_member'::project_action + )$$, + 'Member should not have invite_member permission' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +-- Test add_project_member function +SELECT + lives_ok ( + $$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'member'::public.project_role)$$, + 'Owner should be able to add a new member' + ); + +select + makerkit.authenticate_as ('admin'); + +SELECT + lives_ok ( + $$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'member'::public.project_role)$$, + 'Admin should be able to add a new member' + ); + +SELECT + throws_ok ( + $$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'owner'::public.project_role)$$, + 'permission denied', + 'Admin should not be able to add a member with owner role' + ); + +select + makerkit.authenticate_as ('member'); + +SELECT + throws_ok ( + $$SELECT public.add_project_member(tests.get_project_uuid(), tests.get_supabase_uid('custom'), 'member'::public.project_role)$$, + 'permission denied', + 'Member should not be able to add a new member' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +-- Test RLS policies +SELECT + results_eq ( + $$SELECT count(*) FROM public.projects$$, + ARRAY[2::bigint], + 'Owner should see all projects' + ); + +select + makerkit.authenticate_as ('admin'); + +SELECT + results_eq ( + $$SELECT count(*) FROM public.projects$$, + ARRAY[1::bigint], + 'Member should only see projects they are a member of' + ); + +select + tests.create_supabase_user ('foreigner', 'foreigner@test.com'); + +select + makerkit.authenticate_as ('foreigner'); + +SELECT + results_eq ( + $$SELECT count(*) FROM public.projects$$, + ARRAY[0::bigint], + 'Non-member should not see any projects' + ); + +select + makerkit.authenticate_as ('primary_owner'); + +-- Testing direct updates for RLS policies +select + makerkit.authenticate_as ('primary_owner'); + +select + isnt_empty ( + $$ select * from public.project_members where project_id = tests.get_project_uuid() $$, + 'The primary owner should be able to see the other members' + ); + +select + makerkit.authenticate_as ('admin'); + +select + isnt_empty ( + $$ select * from public.project_members where project_id = tests.get_project_uuid() $$, + 'The owner should be able to see the other members' + ); + +select + makerkit.authenticate_as ('member'); + +select + isnt_empty ( + $$ select * from public.project_members where project_id = tests.get_project_uuid() $$, + 'The member should be able to see the other members' + ); + +select + makerkit.authenticate_as ('custom'); + +select + isnt_empty ( + $$ select * from public.project_members where project_id = tests.get_project_uuid() $$, + 'The custom user should be able to see the other members' + ); + +select + makerkit.authenticate_as ('foreigner'); + +select + is_empty ( + $$ select * from public.project_members where project_id = tests.get_project_uuid() $$, + 'The foreigner should not be able to see the other members' + ); + +-- try to update a role +select + makerkit.authenticate_as ('primary_owner'); + +select + row_eq ( + $$ select account_id, role from public.project_members where project_id = tests.get_project_uuid() $$, + row ( + tests.get_supabase_uid ('primary_owner'), + 'owner'::public.project_role + ), + 'The primary owner should be able to see the other members' + ); + +SELECT + lives_ok ( + $$ update public.project_members set role = 'admin' where project_id = tests.get_project_uuid() and account_id = tests.get_supabase_uid('member') $$, + 'Owner should be able to update a member role' + ); + +SELECT + row_eq ( + $$ select role from public.project_members where project_id = tests.get_project_uuid() and account_id = tests.get_supabase_uid('member') $$, + row ('admin'::public.project_role), + 'Owner has updated member role' + ); + +-- revert role to member +update public.project_members +set role = 'member' +where + project_id = tests.get_project_uuid () + and account_id = tests.get_supabase_uid ('member'); + +-- Test update project +select + lives_ok ( + $$ update public.projects set name = 'Updated Project' where id = tests.get_project_uuid() $$, + 'Owner should be able to update a project' + ); + +select + row_eq ( + $$ select name from public.projects where id = tests.get_project_uuid() $$, + row ('Updated Project'::varchar), + 'Owner has updated project name' + ); + +-- Test update project as member' +select + makerkit.authenticate_as ('member'); + +select + lives_ok ( + $$ update public.projects set name = 'Updated Project 2' where id = tests.get_project_uuid() $$, + 'Failing Updates are silently ignored' + ); + +select + row_eq ( + $$ select name from public.projects where id = tests.get_project_uuid() $$, + row ('Updated Project'::varchar), + 'Member has not updated project name' + ); + +-- Test cascading updates +select + makerkit.authenticate_as ('primary_owner'); + +SELECT + lives_ok ( + $$DELETE FROM public.projects WHERE id = tests.get_project_uuid()$$, + 'Owner should be able to delete a project' + ); + +SELECT + results_eq ( + $$SELECT count(*) FROM public.project_members WHERE project_id = tests.get_project_uuid()$$, + ARRAY[0::bigint], + 'Project members should be deleted when project is deleted' + ); + +-- Additional tests for account type validation +select + makerkit.authenticate_as ('primary_owner'); + +-- Create a team account for testing +reset role; + +INSERT INTO + public.accounts (id, name, slug, is_personal_account, primary_owner_user_id) +VALUES + ( + '11111111-1111-1111-1111-111111111111', + 'Test Team Account', + 'test-team', + false, + tests.get_supabase_uid ('primary_owner') + ); + +-- Note: We don't add the team account to memberships because: +-- 1. It would violate foreign key constraints (team account UUID not in auth.users) +-- 2. Team accounts should never be members of other accounts anyway +-- The function validation will catch the account type before membership validation + +-- Reset to authenticated role for testing +select + makerkit.authenticate_as ('primary_owner'); + +-- Try to add a team account to project members (should fail via trigger) +set + local role postgres; +SELECT + throws_ok ( + $$ INSERT INTO public.project_members (project_id, account_id, role) + VALUES ( + tests.get_project_uuid(), + '11111111-1111-1111-1111-111111111111', + 'member' + ) $$, + 'cannot add team accounts to projects - only individual user accounts can be project members', + 'Should not be able to add team accounts as project members via trigger' + ); + +-- Reset role for function testing +reset role; +select + makerkit.authenticate_as ('primary_owner'); + +-- Test add_project_member function with team account (should fail) +SELECT + throws_ok ( + $$ SELECT public.add_project_member( + tests.get_project_uuid(), + '11111111-1111-1111-1111-111111111111', + 'member'::public.project_role + ) $$, + 'cannot invite team accounts to projects - only individual users can be invited', + 'Should not be able to add team accounts via add_project_member function' + ); + +-- Test with non-existent account (should fail) +SELECT + throws_ok ( + $$ SELECT public.add_project_member( + tests.get_project_uuid(), + '22222222-2222-2222-2222-222222222222', + 'member'::public.project_role + ) $$, + 'user account not found', + 'Should fail when trying to add non-existent account' + ); + +-- Test get_account_members_by_query excludes team accounts +SELECT + is_empty ( + $$ SELECT * FROM public.get_account_members_by_query( + (select makerkit.get_account_id_by_slug('makerkit')), + 'Test Team Account' + ) $$, + 'get_account_members_by_query should not return team accounts' + ); + +-- Test that personal accounts appear in query results +SELECT + isnt_empty ( + $$ SELECT * FROM public.get_account_members_by_query( + (select makerkit.get_account_id_by_slug('makerkit')), + 'custom' + ) $$, + 'get_account_members_by_query should return personal accounts' + ); + +-- Test add_project_member validation for user not in team +select + tests.create_supabase_user ('outsider', 'outsider@test.com'); + +-- Authenticate as outsider to trigger personal account creation +select makerkit.authenticate_as('outsider'); + +-- Go back to primary_owner for the test +select makerkit.authenticate_as('primary_owner'); + +SELECT + throws_ilike ( + $$ SELECT public.add_project_member( + tests.get_project_uuid(), + tests.get_supabase_uid('outsider'), + 'member'::public.project_role + ) $$, + 'user account not found', + 'User is not a member of the team account' + ); + +-- Finish the tests +SELECT + * +FROM + finish (); + +ROLLBACK; +``` + +## User Interface + +Now that we have a data model, let's create the UI for managing projects. We will create a new page at `apps/web/app/[locale]/home/[account]/projects/page.tsx` that will display a list of projects for the current account. + +### Adding Translations + +Before we add the navigation link, we need to add the translation for the "Projects" route. Add the following to your translation files: + +In `apps/web/i18n/messages/en/common.json`, add to the `routes` section: + +```json +{ + "routes": { + // ... existing routes + "projects": "Projects" + } +} +``` + +### Adding the Page to the Navigation + +Let's update the navigation menu to add a new link to the projects page. Update the team account navigation configuration at `apps/web/config/team-account-navigation.config.tsx` by adding: + + ```tsx {% title="apps/web/config/team-account-navigation.config.tsx" %} {7-11} +{ + label: 'common:routes.dashboard', + path: pathsConfig.app.accountHome.replace('[account]', account), + Icon: <LayoutDashboard className={iconClasses} />, + end: true, +}, +{ + label: 'common:routes.projects', + path: `/home/${account}/projects`, + Icon: <FolderKanban className={iconClasses} />, +}, +``` + +**Note:** You need to import the `FolderKanban` component from `lucide-react` to use the icon. + +### Creating the Projects List Page + +We can now create the page at `apps/web/app/[locale]/home/[account]/projects/page.tsx` with the following content: + + ```tsx {% title="apps/web/app/[locale]/home/[account]/projects/page.tsx" %} +import { use } from 'react'; + +import Link from 'next/link'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { Button } from '@kit/ui/button'; +import { + CardButton, + CardButtonHeader, + CardButtonTitle, +} from '@kit/ui/card-button'; +import { + EmptyState, + EmptyStateButton, + EmptyStateHeading, + EmptyStateText, +} from '@kit/ui/empty-state'; +import { If } from '@kit/ui/if'; +import { PageBody, PageHeader } from '@kit/ui/page'; + +import { CreateProjectDialog } from '~/home/[account]/projects/_components/create-project-dialog'; +import { createProjectsService } from '~/lib/server/projects/projects.service'; + +interface ProjectsPageProps { + params: Promise<{ + account: string; + }>; +} + +export default function ProjectsPage({ params }: ProjectsPageProps) { + const client = getSupabaseServerClient(); + const service = createProjectsService(client); + + const { account } = use(params); + + const projects = use(service.getProjects(account)); + + return ( + <> + <PageHeader title="Projects" description={<AppBreadcrumbs />}> + <CreateProjectDialog> + <Button>New Project</Button> + </CreateProjectDialog> + </PageHeader> + + <PageBody> + <If condition={projects.length === 0}> + <EmptyState> + <EmptyStateHeading>No projects found</EmptyStateHeading> + + <EmptyStateText> + You still have not created any projects. Create your first project + now! + </EmptyStateText> + + <CreateProjectDialog> + <EmptyStateButton>Create Project</EmptyStateButton> + </CreateProjectDialog> + </EmptyState> + </If> + + <div className={'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4'}> + {projects.map((project) => ( + <CardButton key={project.id} asChild> + <Link href={`/home/${account}/projects/${project.id}`}> + <CardButtonHeader> + <CardButtonTitle>{project.name}</CardButtonTitle> + </CardButtonHeader> + </Link> + </CardButton> + ))} + </div> + </PageBody> + </> + ); +} +``` + +This page is pretty straightforward, it just displays a list of projects for the account. We can now build the `ProjectsDataTable` component that will display the list of projects. + +When we have a list of projects, we display them in the following way: + +{% img src="/assets/images/docs/projects-list.webp" width="2858" height="1920" /%} + +Instead, we can also display a message when there are no projects: + +{% img src="/assets/images/docs/projects-empty-state.webp" width="2858" height="1920" /%} + +### Creating the Schema for Project Creation + +First, we need to create a schema for the project creation form. Create a new file at `apps/web/app/[locale]/home/[account]/projects/_lib/schema/create-project-schema.ts`: + +```typescript {% title="apps/web/app/[locale]/home/[account]/projects/_lib/schema/create-project-schema.ts" %} +import * as z from 'zod'; + +export const CreateProjectSchema = z.object({ + name: z.string().min(3).max(50), + accountId: z.string().uuid(), + description: z.string().optional(), +}); + +export type CreateProjectSchemaType = z.infer<typeof CreateProjectSchema>; +``` + +### Creating the Dialog to Create a Project + +We can now create the dialog to create a project. We will create a new file at `apps/web/app/[locale]/home/[account]/projects/_components/create-project-dialog.tsx` with the following content: + + ```tsx {% title="apps/web/app/[locale]/home/[account]/projects/_components/create-project-dialog.tsx" %} +'use client'; + +import { useState, useTransition } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; + +import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace'; +import { Button } from '@kit/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@kit/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@kit/ui/form'; +import { Input } from '@kit/ui/input'; + +import { CreateProjectSchema } from '../_lib/schema/create-project-schema'; +import { createProjectAction } from '../_lib/server/server-actions'; + +export function CreateProjectDialog(props: React.PropsWithChildren) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <Dialog open={isOpen} onOpenChange={setIsOpen}> + <DialogTrigger asChild>{props.children}</DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create Project</DialogTitle> + + <DialogDescription> + Create a new project for your team. + </DialogDescription> + </DialogHeader> + + <CreateProjectDialogForm + onCancel={() => setIsOpen(false)} + onCreateProject={() => setIsOpen(false)} + /> + </DialogContent> + </Dialog> + ); +} + +function CreateProjectDialogForm(props: { + onCreateProject?: (name: string) => unknown; + onCancel?: () => unknown; +}) { + const { + account: { id: accountId }, + } = useTeamAccountWorkspace(); + + const form = useForm({ + resolver: zodResolver(CreateProjectSchema), + defaultValues: { + name: '', + accountId, + }, + }); + + const [pending, startTransition] = useTransition(); + + return ( + <Form {...form}> + <form + className={'flex flex-col space-y-4'} + onSubmit={form.handleSubmit((data) => { + startTransition(async () => { + await createProjectAction(data); + }); + })} + > + <FormField + name={'name'} + render={({ field }) => ( + <FormItem> + <FormLabel>Project Name</FormLabel> + + <FormControl> + <Input + data-test={'project-name-input'} + required + min={3} + max={50} + type={'text'} + placeholder={''} + {...field} + /> + </FormControl> + + <FormDescription>Enter a name for your project (Ex. Accounting)</FormDescription> + </FormItem> + )} + /> + + <div className={'flex justify-end space-x-2'}> + <Button variant={'outline'} type={'button'} onClick={props.onCancel}> + Cancel + </Button> + + <Button disabled={pending}>Create Project</Button> + </div> + </form> + </Form> + ); +} +``` + +We can now create the server action to create a project. We will create a new file at `apps/web/app/[locale]/home/[account]/projects/_lib/server/server-actions.ts` with the following content: + + ```tsx {% title="apps/web/app/[locale]/home/[account]/projects/_lib/server/server-actions.ts" %} +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { authActionClient } from '@kit/next/safe-action'; +import { getLogger } from '@kit/shared/logger'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { CreateProjectSchema } from '../schema/create-project-schema'; + +export const createProjectAction = authActionClient + .inputSchema(CreateProjectSchema) + .action(async ({ parsedInput: data }) => { + const client = getSupabaseServerClient(); + const logger = await getLogger(); + + logger.info( + { + accountId: data.accountId, + name: data.name, + }, + 'Creating project...', + ); + + const response = await client.from('projects').insert({ + account_id: data.accountId, + name: data.name, + }); + + if (response.error) { + logger.error( + { + accountId: data.accountId, + name: data.name, + error: response.error, + }, + 'Failed to create project', + ); + + throw response.error; + } + + logger.info( + { + accountId: data.accountId, + name: data.name, + }, + 'Project created', + ); + + revalidatePath('/home/[account]/projects', 'layout'); + }); +``` + +When the user submits the form, we call the `createProjectAction` server action. This action will insert a new project into the database. To refresh the data, we use the `revalidatePath` function to revalidate the `/home/[account]/projects` path. + +### Project Detail Page + +Now, we want to create a detail page for each project. + +In this case, we will use an outer layout to wrap the project detail page, so that we can display an inner navigation menu that will reuse the same layout. + + +We will create a new file at `apps/web/app/[locale]/home/[account]/projects/[id]/layout.tsx` with the following content: + +```tsx +import { use } from 'react'; + +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { + BorderedNavigationMenu, + BorderedNavigationMenuItem, +} from '@kit/ui/bordered-navigation-menu'; +import { PageBody, PageHeader } from '@kit/ui/page'; + +import { getProject } from './_lib/server/get-project'; + +interface ProjectDetailLayoutProps { + params: Promise<{ + id: string; + account: string; + }>; +} + +export default function ProjectDetailLayout({ + params, + children, +}: React.PropsWithChildren<ProjectDetailLayoutProps>) { + const { id, account } = use(params); + const project = use(getProject(id)); + + return ( + <> + <PageHeader + title={project.name} + description={<AppBreadcrumbs values={{ [project.id]: project.name }} />} + /> + + <PageBody className={'space-y-4'}> + <div className={'border-b px-4 pb-2.5'}> + <BorderedNavigationMenu> + <BorderedNavigationMenuItem + path={`/home/${account}/projects/${project.id}`} + label={'Documents'} + /> + + <BorderedNavigationMenuItem + path={`/home/${account}/projects/${project.id}/members`} + label={'Members'} + /> + </BorderedNavigationMenu> + </div> + + {children} + </PageBody> + </> + ); +} +``` + +#### Navigation Menu + +Just as an example, we have added a navigation menu with two links: Documents and Members. You can customize this menu to fit your needs. + +#### Using the "getProject" function + +As you may have noticed, we have a `getProject` function that will fetch the project from the database. + +We use this to make use of caching and reuse this function across all the pages in the Project layout, so we don't re-fetch the data from the database when avigating between pages. + + ```tsx {% title="apps/web/app/[locale]/home/[account]/projects/[id]/_lib/server/get-project.ts" %} +import { cache } from 'react'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { createProjectsService } from '~/lib/server/projects/projects.service'; + +export const getProject = cache(projectLoader); + +async function projectLoader(id: string) { + const client = getSupabaseServerClient(); + const service = createProjectsService(client); + + return service.getProject(id); +} +``` + +### Project Detail Page + +Now, we want to create a detail page for each project. + +We will create a new file at `apps/web/app/[locale]/home/[account]/projects/[id]/page.tsx` with the following content: + + ```tsx {% title="apps/web/app/[locale]/home/[account]/projects/[id]/page.tsx" %} +interface ProjectDetailPageProps { + params: Promise<{ + id: string; + account: string; + }>; +} + +export default function ProjectDetailPage(_: ProjectDetailPageProps) { + return <div className={'flex flex-col space-y-4'}>...</div>; +} +``` + +Generally speaking, it is up to you to decide how you want to display the project data. This is the inner part of the layout, so we can use the same layout for the detail page. + +## Conclusion + +In this tutorial, we've covered how to create a simple (yet powerful) data model for managing Projects in your Makerkit application. We've also explored how to implement this data model using Supabase and Next.js, and how to use the Supabase client to interact with the database. + +By following these steps, you can create a robust, scalable application that leverages the power of Supabase and Next.js to manage your team's projects in a secure, permission-controlled way. diff --git a/docs/recipes/stripe-billing-checkout-addons.mdoc b/docs/recipes/stripe-billing-checkout-addons.mdoc new file mode 100644 index 000000000..cdecf4011 --- /dev/null +++ b/docs/recipes/stripe-billing-checkout-addons.mdoc @@ -0,0 +1,801 @@ +--- +status: "published" +title: 'Checkout Addons with Stripe Billing' +label: 'Checkout Addons with Stripe Billing' +order: 3 +description: 'Learn how to create a subscription with addons using Stripe Billing.' +--- + +Stripe allows us to add multiple line items to a single subscription. This is useful when you want to offer additional features or services to your customers. + +This feature is not supported by default in Makerkit. However, in this guide, I will show you how to create a subscription with addons using Stripe Billing, and how to customize Makerkit to support this feature. + +Let's get started! + +## 1. Personal Account Checkout Form + +File: `apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx` + +Update your `PersonalAccountCheckoutForm` component to pass addon data to the checkout session creation process: + + ```typescript {% title="home/(user)/billing/_components/personal-account-checkout-form.tsx" %} +<CheckoutForm + // ...existing props + onSubmit={({ planId, productId, addons }) => { + startTransition(async () => { + try { + const { checkoutToken } = await createPersonalAccountCheckoutSession({ + planId, + productId, + addons, // Add this line + }); + setCheckoutToken(checkoutToken); + } catch { + setError(true); + } + }); + }} +/> +``` + +This change allows the checkout form to handle addon selections and pass them to the checkout session creation process. + +## 2. Personal Account Checkout Schema + +Let's add addon support to the personal account checkout schema. The `addons` is an array of objects, each containing a `productId` and `planId`. By default, the `addons` array is empty. + +Update your `PersonalAccountCheckoutSchema`: + + ```typescript {% title="home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts" %} +export const PersonalAccountCheckoutSchema = z.object({ + planId: z.string().min(1), + productId: z.string().min(1), + addons: z + .array( + z.object({ + productId: z.string().min(1), + planId: z.string().min(1), + }), + ) + .default([]), +}); +``` + +This schema update ensures that the addon data is properly validated before being processed. + +## 3. User Billing Service + +Update your `createCheckoutSession` method. This method is responsible for creating a checkout session with the billing gateway. We need to pass the addon data to the billing gateway: + + ```typescript {% title="home/(user)/billing/_lib/server/user-billing.service.ts" %} +async createCheckoutSession({ + planId, + productId, + addons, +}: z.infer<typeof PersonalAccountCheckoutSchema>) { + // ...existing code + + const checkoutToken = await this.billingGateway.createCheckoutSession({ + // ...existing props + addons, + }); + + // ...rest of the method +} +``` + +This change ensures that the addon information is passed to the billing gateway when creating a checkout session. + +## 4. Team Account Checkout Form + +File: `apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx` + +Make similar changes to the `TeamAccountCheckoutForm` as we did for the personal account form. + +## 5. Team Billing Schema + +File: `apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts` + +Update your `TeamCheckoutSchema` similar to the personal account schema. + +## 6. Team Billing Service + +File: `apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts` + +Update the `createCheckoutSession` method similar to the user billing service. + +## 7. Billing Configuration + +We can now add addons to our billing configuration. Update your billing configuration file to include addons: + + ```typescript {% title="apps/web/config/billing.sample.config.ts" %} +plans: [ + { + // ...existing plan config + addons: [ + { + id: 'price_1J4J9zL2c7J1J4J9zL2c7J1', + name: 'Extra Feature', + cost: 9.99, + type: 'flat' as const, + }, + ], + }, +], +``` + +**Note:** The `ID` of the addon should match the `planId` in your Stripe account. + +## 8. Localization + +Add a new translation key for translating the term "Add-ons" in your billing locale file: + + ```json {% title="apps/web/i18n/messages/en/billing.json" %} +{ + // ...existing translations + "addons": "Add-ons" +} +``` + +## 9. Billing Schema + +File: `packages/billing/core/src/create-billing-schema.ts` + +The billing schema has been updated to include addons. You don't need to change this file, but be aware that the schema now supports addons. + +## 10. Create Billing Checkout Schema + +File: `packages/billing/core/src/schema/create-billing-checkout.schema.ts` + +The checkout schema now includes addons. Again, you don't need to change this file, but your checkout process will now support addons. + +## 11. Plan Picker Component + +File: `packages/billing/gateway/src/components/plan-picker.tsx` + +This component has been significantly updated to handle addons. It now displays addons as checkboxes and manages their state. + +Here's the updated Plan Picker component: + + ```tsx {% title="packages/billing/gateway/src/components/plan-picker.tsx" %} +'use client'; + +import { useMemo } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { ArrowRight, CheckCircle } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { useLocale, useTranslations } from 'next-intl'; +import * as z from 'zod'; + +import { + BillingConfig, + type LineItemSchema, + getPlanIntervals, + getPrimaryLineItem, + getProductPlanPair, +} from '@kit/billing'; +import { formatCurrency } from '@kit/shared/utils'; +import { Badge } from '@kit/ui/badge'; +import { Button } from '@kit/ui/button'; +import { Checkbox } from '@kit/ui/checkbox'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@kit/ui/form'; +import { If } from '@kit/ui/if'; +import { Label } from '@kit/ui/label'; +import { + RadioGroup, + RadioGroupItem, + RadioGroupItemLabel, +} from '@kit/ui/radio-group'; +import { Separator } from '@kit/ui/separator'; +import { Trans } from '@kit/ui/trans'; +import { cn } from '@kit/ui/utils'; + +import { LineItemDetails } from './line-item-details'; + +const AddonSchema = z.object({ + name: z.string(), + id: z.string(), + productId: z.string(), + planId: z.string(), + cost: z.number(), +}); + +type OnSubmitData = { + planId: string; + productId: string; + addons: z.infer<typeof AddonSchema>[]; +}; + +export function PlanPicker( + props: React.PropsWithChildren<{ + config: BillingConfig; + onSubmit: (data: OnSubmitData) => void; + canStartTrial?: boolean; + pending?: boolean; + }>, +) { + const t = useTranslations(`billing`); + + const intervals = useMemo( + () => getPlanIntervals(props.config), + [props.config], + ) as string[]; + + const form = useForm({ + reValidateMode: 'onChange', + mode: 'onChange', + resolver: zodResolver( + z + .object({ + planId: z.string(), + productId: z.string(), + interval: z.string().optional(), + addons: z.array(AddonSchema).optional(), + }) + .refine( + (data) => { + try { + const { product, plan } = getProductPlanPair( + props.config, + data.planId, + ); + + return product && plan; + } catch { + return false; + } + }, + { message: t('noPlanChosen'), path: ['planId'] }, + ), + ), + defaultValues: { + interval: intervals[0], + planId: '', + productId: '', + addons: [] as z.infer<typeof AddonSchema>[], + }, + }); + + const { interval: selectedInterval } = form.watch(); + const planId = form.getValues('planId'); + + const { plan: selectedPlan, product: selectedProduct } = useMemo(() => { + try { + return getProductPlanPair(props.config, planId); + } catch { + return { + plan: null, + product: null, + }; + } + }, [props.config, planId]); + + const addons = form.watch('addons'); + + const onAddonAdded = (data: z.infer<typeof AddonSchema>) => { + form.setValue('addons', [...addons, data], { shouldValidate: true }); + }; + + const onAddonRemoved = (id: string) => { + form.setValue( + 'addons', + addons.filter((item) => item.id !== id), + { shouldValidate: true }, + ); + }; + + // display the period picker if the selected plan is recurring or if no plan is selected + const isRecurringPlan = + selectedPlan?.paymentType === 'recurring' || !selectedPlan; + + const locale = useLocale(); + + return ( + <Form {...form}> + <div + className={ + 'flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0' + } + > + <form + className={'flex w-full max-w-xl flex-col space-y-6'} + onSubmit={form.handleSubmit(props.onSubmit)} + > + <If condition={intervals.length}> + <div + className={cn('transition-all', { + ['pointer-events-none opacity-50']: !isRecurringPlan, + })} + > + <FormField + name={'interval'} + render={({ field }) => { + return ( + <FormItem className={'rounded-md border p-4'}> + <FormLabel htmlFor={'plan-picker-id'}> + <Trans i18nKey={'common.billingInterval.label'} /> + </FormLabel> + + <FormControl id={'plan-picker-id'}> + <RadioGroup name={field.name} value={field.value}> + <div className={'flex space-x-2.5'}> + {intervals.map((interval) => { + const selected = field.value === interval; + + return ( + <label + htmlFor={interval} + key={interval} + className={cn( + 'flex items-center space-x-2 rounded-md border border-transparent px-4 py-2 transition-colors', + { + ['border-primary']: selected, + ['hover:border-primary']: !selected, + }, + )} + > + <RadioGroupItem + id={interval} + value={interval} + onClick={() => { + form.setValue('interval', interval, { + shouldValidate: true, + }); + + form.setValue('addons', [], { + shouldValidate: true, + }); + + if (selectedProduct) { + const plan = selectedProduct.plans.find( + (item) => item.interval === interval, + ); + + form.setValue( + 'planId', + plan?.id ?? '', + { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }, + ); + } + }} + /> + + <span + className={cn('text-sm', { + ['cursor-pointer']: !selected, + })} + > + <Trans + i18nKey={`billing.billingInterval.${interval}`} + /> + </span> + </label> + ); + })} + </div> + </RadioGroup> + </FormControl> + + <FormMessage /> + </FormItem> + ); + }} + /> + </div> + </If> + + <FormField + name={'planId'} + render={({ field }) => ( + <FormItem> + <FormLabel> + <Trans i18nKey={'common.planPickerLabel'} /> + </FormLabel> + + <FormControl> + <RadioGroup value={field.value} name={field.name}> + {props.config.products.map((product) => { + const plan = product.plans.find((item) => { + if (item.paymentType === 'one-time') { + return true; + } + + return item.interval === selectedInterval; + }); + + if (!plan || plan.custom) { + return null; + } + + const planId = plan.id; + const selected = field.value === planId; + + const primaryLineItem = getPrimaryLineItem( + props.config, + planId, + ); + + if (!primaryLineItem) { + throw new Error(`Base line item was not found`); + } + + return ( + <RadioGroupItemLabel + selected={selected} + key={primaryLineItem.id} + > + <RadioGroupItem + data-test-plan={plan.id} + key={plan.id + selected} + id={plan.id} + value={plan.id} + onClick={() => { + if (selected) { + return; + } + + form.setValue('planId', planId, { + shouldValidate: true, + }); + + form.setValue('productId', product.id, { + shouldValidate: true, + }); + + form.setValue('addons', [], { + shouldValidate: true, + }); + }} + /> + + <div + className={ + 'flex w-full flex-col content-center space-y-2 lg:flex-row lg:items-center lg:justify-between lg:space-y-0' + } + > + <Label + htmlFor={plan.id} + className={ + 'flex flex-col justify-center space-y-2' + } + > + <div className={'flex items-center space-x-2.5'}> + <span className="font-semibold"> + <Trans + i18nKey={`billing.plans.${product.id}.name`} + defaults={product.name} + /> + </span> + + <If + condition={ + plan.trialDays && props.canStartTrial + } + > + <div> + <Badge + className={'px-1 py-0.5 text-xs'} + variant={'success'} + > + <Trans + i18nKey={`billing.trialPeriod`} + values={{ + period: plan.trialDays, + }} + /> + </Badge> + </div> + </If> + </div> + + <span className={'text-muted-foreground'}> + <Trans + i18nKey={`billing.plans.${product.id}.description`} + defaults={product.description} + /> + </span> + </Label> + + <div + className={ + 'flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-x-4 lg:space-y-0 lg:text-right' + } + > + <div> + <Price key={plan.id}> + <span> + {formatCurrency({ + currencyCode: + product.currency.toLowerCase(), + value: primaryLineItem.cost, + locale, + })} + </span> + </Price> + + <div> + <span className={'text-muted-foreground'}> + <If + condition={ + plan.paymentType === 'recurring' + } + fallback={ + <Trans i18nKey={`billing.lifetime`} /> + } + > + <Trans + i18nKey={`billing.perPeriod`} + values={{ + period: selectedInterval, + }} + /> + </If> + </span> + </div> + </div> + </div> + </div> + </RadioGroupItemLabel> + ); + })} + </RadioGroup> + </FormControl> + + <FormMessage /> + </FormItem> + )} + /> + + <If condition={selectedPlan?.addons}> + <div className={'flex flex-col space-y-2.5'}> + <span className={'text-sm font-medium'}>Addons</span> + + <div className={'flex flex-col space-y-2'}> + {selectedPlan?.addons?.map((addon) => { + return ( + <div + className={'flex items-center space-x-2 text-sm'} + key={addon.id} + > + <Checkbox + value={addon.id} + onCheckedChange={() => { + if (addons.some((item) => item.id === addon.id)) { + onAddonRemoved(addon.id); + } else { + onAddonAdded({ + productId: selectedProduct.id, + planId: selectedPlan.id, + id: addon.id, + name: addon.name, + cost: addon.cost, + }); + } + }} + /> + + <span>{addon.name}</span> + </div> + ); + })} + </div> + </div> + </If> + + <div> + <Button + data-test="checkout-submit-button" + disabled={props.pending ?? !form.formState.isValid} + > + {props.pending ? ( + t('redirectingToPayment') + ) : ( + <> + <If + condition={selectedPlan?.trialDays && props.canStartTrial} + fallback={t(`proceedToPayment`)} + > + <span>{t(`startTrial`)}</span> + </If> + + <ArrowRight className={'ml-2 h-4 w-4'} /> + </> + )} + </Button> + </div> + </form> + + {selectedPlan && selectedInterval && selectedProduct ? ( + <PlanDetails + selectedInterval={selectedInterval} + selectedPlan={selectedPlan} + selectedProduct={selectedProduct} + addons={addons} + /> + ) : null} + </div> + </Form> + ); +} + +function PlanDetails({ + selectedProduct, + selectedInterval, + selectedPlan, + addons = [], +}: { + selectedProduct: { + id: string; + name: string; + description: string; + currency: string; + features: string[]; + }; + + selectedInterval: string; + + selectedPlan: { + lineItems: z.infer<typeof LineItemSchema>[]; + paymentType: string; + }; + + addons: z.infer<typeof AddonSchema>[]; +}) { + const isRecurring = selectedPlan.paymentType === 'recurring'; + const locale = useLocale(); + + // trick to force animation on re-render + const key = Math.random(); + + return ( + <div + key={key} + className={ + 'fade-in animate-in zoom-in-95 flex w-full flex-col space-y-4 py-2 lg:px-8' + } + > + <div className={'flex flex-col space-y-0.5'}> + <span className={'text-sm font-medium'}> + <b> + <Trans + i18nKey={`billing:plans.${selectedProduct.id}.name`} + defaults={selectedProduct.name} + /> + </b>{' '} + <If condition={isRecurring}> + / <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} /> + </If> + </span> + + <p> + <span className={'text-muted-foreground text-sm'}> + <Trans + i18nKey={`billing:plans.${selectedProduct.id}.description`} + defaults={selectedProduct.description} + /> + </span> + </p> + </div> + + <If condition={selectedPlan.lineItems.length > 0}> + <Separator /> + + <div className={'flex flex-col space-y-2'}> + <span className={'text-sm font-semibold'}> + <Trans i18nKey={'billing.detailsLabel'} /> + </span> + + <LineItemDetails + lineItems={selectedPlan.lineItems ?? []} + selectedInterval={isRecurring ? selectedInterval : undefined} + currency={selectedProduct.currency} + /> + </div> + </If> + + <Separator /> + + <div className={'flex flex-col space-y-2'}> + <span className={'text-sm font-semibold'}> + <Trans i18nKey={'billing.featuresLabel'} /> + </span> + + {selectedProduct.features.map((item) => { + return ( + <div key={item} className={'flex items-center space-x-1 text-sm'}> + <CheckCircle className={'h-4 text-green-500'} /> + + <span className={'text-secondary-foreground'}> + <Trans i18nKey={item} defaults={item} /> + </span> + </div> + ); + })} + </div> + + <If condition={addons.length > 0}> + <div className={'flex flex-col space-y-2'}> + <span className={'text-sm font-semibold'}> + <Trans i18nKey={'billing.addons'} /> + </span> + + {addons.map((addon) => { + return ( + <div + key={addon.id} + className={'flex items-center space-x-1 text-sm'} + > + <CheckCircle className={'h-4 text-green-500'} /> + + <span className={'text-secondary-foreground'}> + <Trans i18nKey={addon.name} defaults={addon.name} /> + </span> + + <span>-</span> + + <span className={'text-xs font-semibold'}> + {formatCurrency({ + currencyCode: selectedProduct.currency.toLowerCase(), + value: addon.cost, + locale, + })} + </span> + </div> + ); + })} + </div> + </If> + </div> + ); +} + +function Price(props: React.PropsWithChildren) { + return ( + <span + className={ + 'animate-in slide-in-from-left-4 fade-in text-xl font-semibold tracking-tight duration-500' + } + > + {props.children} + </span> + ); +} +``` + + +## 12. Stripe Checkout Creation + +File: `packages/billing/stripe/src/services/create-stripe-checkout.ts` + +The Stripe checkout creation process now includes addons: + +```typescript +if (params.addons.length > 0) { + lineItems.push( + ...params.addons.map((addon) => ({ + price: addon.planId, + quantity: 1, + })), + ); +} +``` + +This change ensures that selected addons are included in the Stripe checkout session. + +## Conclusion + +These changes introduce a flexible addon system to Makerkit. By implementing these updates, you'll be able to offer additional features or services alongside your main subscription plans. + +Remember, while adding addons to the checkout process is now straightforward, managing them post-purchase (like allowing users to add or remove addons from an active subscription) will require additional custom development. Consider your specific use case and user needs when implementing this feature. diff --git a/docs/recipes/subscription-entitlements.mdoc b/docs/recipes/subscription-entitlements.mdoc new file mode 100644 index 000000000..0c59db0ed --- /dev/null +++ b/docs/recipes/subscription-entitlements.mdoc @@ -0,0 +1,892 @@ +--- +status: "published" +title: 'Subscription Entitlements in Next.js Supabase' +label: 'Subscription Entitlements' +order: 3 +description: 'Learn how to effectively manage access and entitlements based on subscriptions in your Next.js Supabase app.' +--- + +As your SaaS grows, the complexity of managing user entitlements increases. In this guide, we’ll build a flexible, performant, and secure entitlements system using Makerkit, Supabase, and PostgreSQL. + +This solution leverages the power of PostgreSQL functions and Supabase RPCs, enforcing business logic at the database level while integrating seamlessly with your Next.js app. + +**Note:** This is a generic solution, but you can use it as a starting point to build your own custom entitlements system. In fact, I recommend you to do so, as your needs will evolve and this solution might not cover all your requirements. + +### Why a Custom Entitlements System? + +Makerkit is built to be flexible and extensible. Instead of offering a one-size-fits-all entitlements system, Makerkit provides a foundation you can customize. + +This article will walk you through the complete process: +- **Flexibility & Extensibility:** Easily handle different entitlement types (flat or usage quotas). +- **Performance:** Offload entitlement checks to the database. +- **Consistency & Security:** Ensure rules are enforced both in your app code and via Row Level Security (RLS). + +## Step 1: Define Your Database Schema + +We start by creating two tables: one to declare the entitlements for each plan variant and another to track feature usage per account. Both tables are set up with strict security rules using RLS policies. + +### Creating the `plan_entitlements` Table + +This table stores entitlement definitions. Each row defines which features are enabled for a specific plan variant. Note the use of a unique constraint to avoid duplicate entries and strict permission controls to ensure data security. + +```sql {% title="apps/web/supabase/migrations/20250205034829_subscription-entitlements.sql" %} +-- Table to store plan entitlements +CREATE TABLE public.plan_entitlements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + variant_id VARCHAR(255) NOT NULL, + feature VARCHAR(255) NOT NULL, + entitlement JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE (variant_id, feature) +); + +revoke all on public.plan_entitlements from public; + +alter table public.plan_entitlements enable row level security; + +grant select on public.plan_entitlements to authenticated; + +create policy select_plan_entitlements + on public.plan_entitlements + for select + to authenticated + using (true); +``` + +### Creating the `feature_usage` Table + +This table tracks the usage of features for each account. We use JSONB to support flexible usage metrics and add an index for efficient lookups. + +```sql {% title="apps/web/supabase/migrations/20250205034829_subscription-entitlements.sql" %} +e to store feature usage +CREATE TABLE public.feature_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + feature VARCHAR(255) NOT NULL, + usage JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE (account_id, feature) +); + +revoke all on public.feature_usage from public; +grant select on public.feature_usage to authenticated; + +alter table public.feature_usage enable row level security; + +create policy select_feature_usage + on public.feature_usage + for select + to authenticated + using ( + public.has_role_on_account(account_id) or ((select auth.uid()) = account_id) + ); + +-- Index for faster lookups +CREATE INDEX idx_feature_usage_account_id ON public.feature_usage(account_id, feature); +``` + +### Automatically Creating a Usage Row for New Accounts + +Use a trigger to ensure that as soon as an account is created, a corresponding row in `feature_usage` is created: + +```sql {% title="apps/web/supabase/migrations/20250205034829_subscription-entitlements.sql" %} +-- Function to auto-create a feature_usage row upon account creation +CREATE OR REPLACE FUNCTION public.create_feature_usage_row() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.feature_usage (account_id, feature) + VALUES (NEW.id, ''); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Create the trigger to execute the above function after account creation +CREATE TRIGGER create_feature_usage_row +AFTER INSERT ON public.accounts +FOR EACH ROW +EXECUTE FUNCTION public.create_feature_usage_row(); +``` +--- + +## Step 2: Develop PostgreSQL Functions for Entitlements + +Encapsulate the core entitlement logic inside PostgreSQL functions. Each function is designed to be secure (using `SECURITY INVOKER` or `SECURITY DEFINER` where needed) and atomic. + +### Check if an Account Can Use a Feature + +This function determines if an account meets the criteria to use a given feature based on its subscription and plan entitlements. + +```sql + +-- Function to check if an account can use a feature +CREATE OR REPLACE FUNCTION public.can_use_feature( + p_account_id UUID, + p_feature VARCHAR +) +RETURNS BOOLEAN +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + entitlement_data JSONB; + subscription_type public.subscription_item_type; + account_member_count INTEGER; + usage_count INTEGER; + feature_usage_exists BOOLEAN; +BEGIN + -- Verify the account exists + PERFORM 1 FROM public.accounts WHERE id = p_account_id; + IF NOT FOUND THEN + RETURN FALSE; + END IF; + + -- verify the user has access to the account + if (select public.has_role_on_account(p_account_id) is false) then + return false; + end if; + + -- Simplified approach: + -- 1. Get the subscription items for the account + -- 2. Find which subscription item has a plan entitlement for the feature + -- 3. Choose the most appropriate item based on type priority + + SELECT + si.type, + pe.entitlement + INTO + subscription_type, + entitlement_data + FROM + public.subscriptions s + JOIN public.subscription_items si ON s.id = si.subscription_id + JOIN public.plan_entitlements pe ON si.variant_id = pe.variant_id + WHERE + s.account_id = p_account_id + AND s.active = true + AND pe.feature = p_feature + ORDER BY + -- Prioritize by subscription type (flat > per_seat > metered) + CASE + WHEN si.type = 'flat' THEN 1 + WHEN si.type = 'per_seat' THEN 2 + WHEN si.type = 'metered' THEN 3 + ELSE 4 + END, + -- Then by max_usage (higher limits first) + COALESCE((pe.entitlement->>'max_usage')::INTEGER, 0) DESC + LIMIT 1; + + -- If no subscription or entitlement found for the feature, return false + IF subscription_type IS NULL THEN + RETURN FALSE; + END IF; + + -- Check if the subscription type is flat + IF subscription_type = 'flat' THEN + -- If the subscription type is flat, then the account can use the feature + RETURN TRUE; + END IF; + + -- Check if the subscription type is per_seat + IF subscription_type = 'per_seat' THEN + -- Get the number of users in the account + SELECT COUNT(*) INTO account_member_count FROM public.accounts_memberships WHERE account_id = p_account_id; + + -- Check if the number of users in the account is within the allowed limit + -- Use strict less-than to prevent exceeding the limit + IF account_member_count < (entitlement_data ->> 'max_usage')::INTEGER THEN + -- If the number of users in the account is within the allowed limit, then the account can use the feature + RETURN TRUE; + ELSE + -- If the number of users in the account is not within the allowed limit, then the account cannot use the feature + RETURN FALSE; + END IF; + END IF; + + -- Check if the subscription type is metered + IF subscription_type = 'metered' THEN + -- Check if feature usage record exists + SELECT EXISTS ( + SELECT 1 FROM public.feature_usage + WHERE account_id = p_account_id AND feature = p_feature + ) INTO feature_usage_exists; + + -- If no feature usage record exists, create one with zero usage + IF NOT feature_usage_exists THEN + -- insert a new feature usage record with zero usage + INSERT INTO public.feature_usage (account_id, feature, usage) + VALUES (p_account_id, p_feature, '{"count": 0}'::jsonb); + END IF; + + -- Get the usage count from the feature usage record + SELECT (usage ->> 'count')::INTEGER INTO usage_count FROM public.feature_usage WHERE account_id = p_account_id AND feature = p_feature; + + -- Check if the feature usage is within the allowed limit + -- Use strict less-than to prevent exceeding the limit + IF usage_count < (entitlement_data ->> 'max_usage')::INTEGER THEN + -- If the feature usage is strictly less than the limit, then the account can use the feature + RETURN TRUE; + ELSE + -- If the feature usage has reached or exceeded the limit, then the account cannot use the feature + RETURN FALSE; + END IF; + END IF; + + -- If the subscription type is not flat, per_seat, or metered, then the account cannot use the feature + RETURN FALSE; +END; +$$ LANGUAGE plpgsql; +``` + +### Retrieve Entitlement Details + +The following function returns the details of an entitlement along with any usage data for the account. + +```sql {% title="apps/web/supabase/migrations/20250205034829_subscription-entitlements.sql" %} +- Function to get entitlement details +CREATE OR REPLACE FUNCTION public.get_entitlement( + p_account_id UUID, + p_feature VARCHAR +) +RETURNS TABLE(variant_id varchar(255), entitlement JSONB, type public.subscription_item_type, usage JSONB) +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + RETURN QUERY + SELECT + si.variant_id, + pe.entitlement, + si.type, + COALESCE(fu.usage, '{}'::jsonb) as usage + FROM + public.subscriptions s + JOIN public.subscription_items si ON s.id = si.subscription_id + JOIN public.plan_entitlements pe ON si.variant_id = pe.variant_id + LEFT JOIN public.feature_usage fu ON s.account_id = fu.account_id AND pe.feature = fu.feature + WHERE + s.account_id = p_account_id + AND pe.feature = p_feature + ORDER BY + -- First by active status + s.active DESC, + -- Then by subscription type (flat > per_seat > metered) + CASE + WHEN si.type = 'flat' THEN 1 + WHEN si.type = 'per_seat' THEN 2 + WHEN si.type = 'metered' THEN 3 + ELSE 4 + END, + -- Then by max_usage (higher limits first) + COALESCE((pe.entitlement->>'max_usage')::INTEGER, 0) DESC; +END; +$$ LANGUAGE plpgsql; +``` + +### Update Feature Usage + +These functions update the `feature_usage` table. The first function handles merging JSON usage data, and the second one atomically updates quota usage using an UPSERT pattern. + +```sql +-- Function to update feature usage +CREATE OR REPLACE FUNCTION public.update_feature_usage(p_account_id UUID, p_feature VARCHAR, p_usage JSONB) +RETURNS VOID +SET search_path = '' +AS $$ +BEGIN + PERFORM 1 FROM public.accounts WHERE id = p_account_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Cannot update feature usage for non-existent account'; + END IF; + + INSERT INTO public.feature_usage (account_id, feature, usage) + VALUES (p_account_id, p_feature, p_usage) + ON CONFLICT (account_id, feature) + DO UPDATE SET usage = public.feature_usage.usage || p_usage, updated_at = NOW(); +END; +$$ LANGUAGE plpgsql; + +-- Atomic update for feature quota usage using UPSERT +CREATE OR REPLACE FUNCTION public.update_feature_quota_usage( + p_account_id UUID, + p_feature VARCHAR, + p_count INTEGER +) +RETURNS VOID +SECURITY INVOKER +SET search_path = '' +AS $$ +BEGIN + -- Verify the account exists + PERFORM 1 FROM public.accounts WHERE id = p_account_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Cannot update feature usage for non-existent account'; + END IF; + + INSERT INTO public.feature_usage (account_id, feature, usage, updated_at) + VALUES ( + p_account_id, + p_feature, + jsonb_build_object('count', p_count), + NOW() + ) + ON CONFLICT (account_id, feature) DO UPDATE + SET usage = jsonb_set( + COALESCE(public.feature_usage.usage, '{}'::jsonb), + '{count}', + to_jsonb( + p_count + ) + ), + updated_at = NOW(); +END; +$$ LANGUAGE plpgsql; +``` + +Grant execute permissions securely: + +```sql +-- Grant execute permissions +GRANT EXECUTE ON FUNCTION public.can_use_feature(UUID, VARCHAR) TO authenticated, service_role; + +GRANT EXECUTE ON FUNCTION public.get_entitlement(UUID, VARCHAR) TO authenticated, service_role; + +GRANT EXECUTE ON FUNCTION public.update_feature_usage(UUID, VARCHAR, JSONB) TO service_role; + +GRANT EXECUTE ON FUNCTION public.update_feature_quota_usage(UUID, VARCHAR, INTEGER) TO service_role; +``` + +## Step 3: Create the Entitlements Service in TypeScript + +Encapsulate your entitlements logic in a TypeScript service. This service communicates with the Supabase backend via RPC and provides a clean API for your application. + +```typescript {% title="apps/web/lib/server/entitlements.service.ts" %} +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { object } from 'zod'; + +// Example API route or server action for handling an API request +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import type { Database, Json } from '~/lib/database.types'; + +export function createEntitlementsService( + client: SupabaseClient<Database>, + accountId: string, +) { + return new EntitlementsService(client, accountId); +} + +class EntitlementsService { + constructor( + private readonly client: SupabaseClient<Database>, + private readonly accountId: string, + ) {} + + async canUseFeature(feature: string) { + const { data, error } = await this.client.rpc('can_use_feature', { + p_account_id: this.accountId, + p_feature: feature, + }); + + if (error) throw error; + + return data; + } + + async getEntitlement(feature: string) { + const { data, error } = await this.client + .rpc('get_entitlement', { + p_account_id: this.accountId, + p_feature: feature, + }) + .maybeSingle(); + + if (error) { + throw error; + } + + return data; + } + + async updateFeatureUsage(feature: string, usage: Json) { + const { error } = await this.client.rpc('update_feature_usage', { + p_account_id: this.accountId, + p_feature: feature, + p_usage: usage, + }); + + if (error) throw error; + } + + async updateFeatureQuotaUsage(feature: string, count: number) { + const { error } = await this.client.rpc('update_feature_quota_usage', { + p_account_id: this.accountId, + p_feature: feature, + p_count: count, + }); + + if (error) throw error; + } +} +``` + +### How to Use the Entitlements Service + +In your API route or server component, you can use the service to check for entitlements and update usage data. For example: + +```tsx +// Example API route or server action for handling an API request +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createEntitlementsService } from '~/lib/server/entitlements.service'; + +export async function handleApiRequest(accountId: string, endpoint: string) { + const client = getSupabaseServerClient(); + const adminClient = getSupabaseServerAdminClient(); + + const entitlementsService = createEntitlementsService(client, accountId); + + const canUseAPI = await entitlementsService.canUseFeature('api_access'); + + if (!canUseAPI) { + throw new Error('No access to API'); + } + + const entitlement = await entitlementsService.getEntitlement('api_calls'); + + // Adjust processing based on entitlement type (flat, quota, etc.) + if (entitlement && entitlement.entitlement.type === 'flat') { + // NB: processApiRequest is a placeholder for your actual API request processing logic + return processApiRequest(endpoint); + } else if (entitlement && entitlement.entitlement.type === 'quota') { + const currentUsage = Number(entitlement.usage?.count ?? 0); + const limit = entitlement.entitlement.limit; + + if (currentUsage < limit) { + // create an admin service to update the feature usage + // because normal users cannot update the feature usage + const adminEntitlementsService + = createEntitlementsService(adminClient, accountId); + + // Atomically update usage count + await adminEntitlementsService.updateFeatureUsage + ('api_calls', { count: currentUsage + 1 }); + + // NB: processApiRequest is a placeholder for your actual API request processing logic + return processApiRequest(endpoint); + } else { + throw new Error('API call quota exceeded'); + } + } + throw new Error('Invalid entitlement state'); +} +``` + +## Step 4: Enforcing Entitlements in Row Level Security + +One of the major benefits of this approach is that you can enforce entitlements at the database level using RLS policies. For example, to restrict access to a table based on entitlement checks: + +```sql +-- Example RLS policy using the can_use_feature function +CREATE POLICY "users_can_access_feature" ON public.some_table + FOR SELECT + TO authenticated + USING ( + public.can_use_feature(auth.uid(), 'some_feature') + ); +``` + +This ensures that only users with the correct entitlements can access sensitive data. + +## Step 5: Integrating with Billing Webhooks + +When a billing event occurs (such as an invoice being paid), use a webhook to update entitlements accordingly. Below is an example using a Next.js API route with structured logging for observability: + +```typescript +'use server'; +import { enhanceRouteHandler } from '@kit/next/routes'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +import { createEntitlementsService } from '~/lib/server/entitlements.service'; +import { getLogger } from '@kit/shared/logger'; +import { billingConfig } from '~/config/billing'; + +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...'); + + try { + // Handle billing event using your billing event handler service... + await handleInvoicePaidEvent(request, ctx); + 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 } +); + +async function handleInvoicePaidEvent(request: Request, ctx: Record<string, unknown>) { + // Assume the request contains the account_id for which the invoice was paid + const accountId = 'extracted-account-id'; // Extract account id securely from the request payload + + const entitlementsService = createEntitlementsService(getSupabaseServerAdminClient(), accountId); + const entitlement = await entitlementsService.getEntitlement('api_calls'); + + if (!entitlement) { + ctx['error'] = `No entitlement found for "api_calls"`; + throw new Error(ctx['error']); + } + + const count = entitlement?.entitlement?.limit ?? 0; + if (!count) { + ctx['error'] = 'No limit found for "api_calls" entitlement'; + throw new Error(ctx['error']); + } + + await entitlementsService.updateFeatureUsage('api_calls', { count }); + return; +} +``` + +## PgTap tests + +Please add the following tests to your project and modify them as needed: + +```sql +begin; +create extension "basejump-supabase_test_helpers" version '0.0.6'; + +select no_plan(); + +select tests.create_supabase_user('foreigner', 'foreigner@makerkit.dev'); + +-- Create test users +select makerkit.set_identifier('primary_owner', 'test@makerkit.dev'); +select makerkit.set_identifier('member', 'member@makerkit.dev'); +select makerkit.set_identifier('foreigner', 'foreigner@makerkit.dev'); + +-- Setup test data +set local role postgres; + +-- Insert test plan entitlements +insert into public.plan_entitlements (variant_id, feature, entitlement) +values + ('basic_plan', 'api_calls', '{"limit": 1000, "period": "month"}'::jsonb), + ('pro_plan', 'api_calls', '{"limit": 10000, "period": "month"}'::jsonb), + ('basic_plan', 'storage', '{"limit": 5, "unit": "GB"}'::jsonb), + ('pro_plan', 'storage', '{"limit": 50, "unit": "GB"}'::jsonb); + +-- Create test billing customers and subscriptions +INSERT INTO public.billing_customers(account_id, provider, customer_id) +VALUES (makerkit.get_account_id_by_slug('makerkit'), 'stripe', 'cus_test'); + +-- Create a subscription with basic plan +SELECT public.upsert_subscription( + makerkit.get_account_id_by_slug('makerkit'), + 'cus_test', + 'sub_test_basic', + true, + 'active', + 'stripe', + false, + 'usd', + now(), + now() + interval '1 month', + '[{ + "id": "sub_basic", + "product_id": "prod_basic", + "variant_id": "basic_plan", + "type": "flat", + "price_amount": 1000, + "quantity": 1, + "interval": "month", + "interval_count": 1 + }]' +); + +-- Test as primary owner +select tests.authenticate_as('primary_owner'); + +-- Test reading plan entitlements +select isnt_empty( + $$ select * from plan_entitlements where variant_id = 'basic_plan' $$, + 'Primary owner can read plan entitlements' +); + +-- Test can_use_feature function +select is( + (select public.can_use_feature(makerkit.get_account_id_by_slug('makerkit'), 'api_calls')), + true, + 'Account with basic plan can use api_calls feature' +); + +-- Test get_entitlement function +select row_eq( + $$ select entitlement->>'limit' from public.get_entitlement(makerkit.get_account_id_by_slug('makerkit'), 'api_calls') $$, + row('1000'::text), + 'Get entitlement returns correct limit for api_calls' +); + +set local role service_role; + +-- Test feature usage tracking +select lives_ok( + $$ select public.update_feature_quota_usage(makerkit.get_account_id_by_slug('makerkit'), 'api_calls', 100) $$, + 'Can update feature quota usage' +); + +-- Test as primary owner +select tests.authenticate_as('primary_owner'); + +-- Verify feature usage was recorded +select row_eq( + $$ select usage->>'count' from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') and feature = 'api_calls' $$, + row('100'::text), + 'Feature usage is recorded correctly' +); + +-- Test as member +select tests.authenticate_as('member'); + +-- Members can read plan entitlements +select isnt_empty( + $$ select * from plan_entitlements $$, + 'Members can read plan entitlements' +); + +-- Members can read feature usage for their account +select isnt_empty( + $$ select * from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') $$, + 'Members can read feature usage for their account' +); + +-- Test as foreigner +select tests.authenticate_as('foreigner'); + +-- Foreigners can read plan entitlements (public info) +select isnt_empty( + $$ select * from plan_entitlements $$, + 'Foreigners can read plan entitlements' +); + +-- Foreigners cannot read feature usage for other accounts +select is_empty( + $$ select * from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') $$, + 'Foreigners cannot read feature usage for other accounts' +); + +-- Test updating to pro plan +set local role postgres; + +SELECT public.upsert_subscription( + makerkit.get_account_id_by_slug('makerkit'), + 'cus_test', + 'sub_test_basic', + true, + 'active', + 'stripe', + false, + 'usd', + now(), + now() + interval '1 month', + '[{ + "id": "sub_pro", + "product_id": "prod_pro", + "variant_id": "pro_plan", + "type": "flat", + "price_amount": 2000, + "quantity": 1, + "interval": "month", + "interval_count": 1 + }]' +); + +select tests.authenticate_as('primary_owner'); + +-- Verify pro plan entitlements +select row_eq( + $$ select entitlement->>'limit' from public.get_entitlement(makerkit.get_account_id_by_slug('makerkit'), 'api_calls') $$, + row('10000'::text), + 'Get entitlement returns updated limit for api_calls after plan upgrade' +); + +-- Test edge cases +-- Test non-existent feature +select is( + (select public.can_use_feature(makerkit.get_account_id_by_slug('makerkit'), 'non_existent_feature')), + false, + 'Cannot use non-existent feature' +); + +-- Test non-existent account +select is( + (select public.can_use_feature('12345678-1234-1234-1234-123456789012'::uuid, 'api_calls')), + false, + 'Cannot use feature for non-existent account' +); + +-- Test updating feature usage with invalid data +set local role postgres; + +select throws_ok( + $$ select public.update_feature_usage('12345678-1234-1234-1234-123456789012'::uuid, 'api_calls', '{"invalid": true}'::jsonb) $$, + 'Cannot update feature usage for non-existent account' +); + +-- Additional tests for subscription entitlements + +-------------------------------------------------------------------- +-- Additional tests for update_feature_quota_usage (storage feature) +-------------------------------------------------------------------- +set local role postgres; + +-- Create or update a subscription for storage feature if not already set +-- We'll use the basic plan for storage +SELECT public.upsert_subscription( + makerkit.get_account_id_by_slug('makerkit'), + 'cus_test', + 'sub_test_storage', + true, + 'active', + 'stripe', + false, + 'usd', + now(), + now() + interval '1 month', + '[{ + "id": "sub_storage", + "product_id": "prod_storage", + "variant_id": "basic_plan", + "type": "flat", + "price_amount": 500, + "quantity": 1, + "interval": "month", + "interval_count": 1 + }]' +); + +-- Reset storage usage by updating its quota +select lives_ok( + $$ select public.update_feature_quota_usage(makerkit.get_account_id_by_slug('makerkit'), 'storage', 5) $$, + 'Initial storage quota update sets usage to 5' +); + +select row_eq( + $$ select usage->>'count' from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') and feature = 'storage' $$, + row('5'::text), + 'Storage usage should be 5 after initial update' +); + +-- Update storage usage by adding 3 more units +select lives_ok( + $$ select public.update_feature_quota_usage(makerkit.get_account_id_by_slug('makerkit'), 'storage', 3) $$, + 'Additional storage quota update adds 3 units' +); + +select row_eq( + $$ select usage->>'count' from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') and feature = 'storage' $$, + row('8'::text), + 'Accumulated storage usage should be 8' +); + +set local role service_role; + +-- Update api_calls usage by adding an extra field +select lives_ok( + $$ select public.update_feature_usage(makerkit.get_account_id_by_slug('makerkit'), 'api_calls', '{"extra": 100}'::jsonb) $$, + 'Feature usage update concatenates new JSON data for api_calls' +); + +-- Verify that the api_calls usage JSON now contains the extra field by checking the "extra" key value directly +select is( + (select usage::json->>'extra' from feature_usage + where account_id = makerkit.get_account_id_by_slug('makerkit') + and feature = 'api_calls'), + '100', + 'Feature usage for api_calls contains extra field after update' +); + +-------------------------------------------------------------------- +-- Additional test for non-existent subscription item entitlement +-------------------------------------------------------------------- +select is_empty( + $$ select * from public.get_entitlement(makerkit.get_account_id_by_slug('makerkit'), 'nonexistent_feature') $$, + 'Get entitlement returns empty for a non-existent feature' +); + +-------------------------------------------------------------------- +-- Additional test for atomicity of updating feature usage +-------------------------------------------------------------------- +set local role postgres; + +CREATE OR REPLACE FUNCTION test_atomicity_feature_usage() RETURNS text AS $$ +DECLARE + baseline text; + current_usage text; +BEGIN + -- Capture the baseline storage usage for the 'storage' feature + SELECT usage->>'count' INTO baseline + FROM feature_usage + WHERE account_id = makerkit.get_account_id_by_slug('makerkit') + AND feature = 'storage'; + + BEGIN + -- Perform a valid update: add 10 units to storage usage + PERFORM public.update_feature_quota_usage(makerkit.get_account_id_by_slug('makerkit'), 'storage', 10); + -- Force an error by updating usage for a non-existent account + PERFORM public.update_feature_usage('00000000-0000-0000-0000-000000000000'::uuid, 'storage', '{"bad":1}'::jsonb); + -- If no error is raised, return an error message (this should not happen) + RETURN 'error not raised'; + EXCEPTION WHEN OTHERS THEN + -- Exception caught; the subtransaction should be rolled back + NULL; + END; + + -- Capture the current usage after the forced error + SELECT usage->>'count' INTO current_usage + FROM feature_usage + WHERE account_id = makerkit.get_account_id_by_slug('makerkit') AND feature = 'storage'; + + IF current_usage = baseline THEN + RETURN 'ok'; + ELSE + RETURN 'failed'; + END IF; +END; +$$ LANGUAGE plpgsql; + +select is((select test_atomicity_feature_usage()), 'ok', 'Atomicity of updating feature usage is preserved'); + +-- End of additional atomicity tests + +select * from finish(); + +rollback; +``` + +## Benefits of This Approach + +1. **Flexibility:** + Handle different entitlement types—from simple feature flags to complex usage quotas—without locking into a rigid model. +2. **Performance:** + Offload entitlement checks to PostgreSQL. This minimizes round-trips between your app and the database. +3. **Consistency & Security:** + The same functions are used in both your application code and in RLS policies, ensuring a uniform level of security. +4. **Maintainability:** + Encapsulating logic in PostgreSQL functions and a dedicated TypeScript service simplifies updates and helps prevent bugs. + +## Conclusion + +By moving entitlement logic into PostgreSQL functions and encapsulating access in a dedicated TypeScript service, you create a robust and secure system that scales with your application. This approach not only meets the complex needs of SaaS applications but also adheres to best practices for performance, security, and maintainability. + +Feel free to evolve these patterns further to suit your specific billing scenarios and business logic needs. Happy coding! \ No newline at end of file diff --git a/docs/recipes/team-account-creation-policies.mdoc b/docs/recipes/team-account-creation-policies.mdoc new file mode 100644 index 000000000..2591ddb3d --- /dev/null +++ b/docs/recipes/team-account-creation-policies.mdoc @@ -0,0 +1,355 @@ +--- +label: "Team Account Creation Policies" +title: "Guarding Team Account Creation with Policies" +description: "Learn how to restrict and validate team account creation using the policy system." +order: 8 +--- + +The Team Account Creation Policies system allows you to define custom business rules that guard when users can create new team accounts using the [Policies API](../api/policies-api). + +Common use cases include: + +- Requiring an active subscription to create team accounts +- Requiring a specific subscription plan (e.g., Pro or Enterprise) +- Limiting the number of team accounts per user + +{% sequence title="Implementation Steps" description="How to implement team account creation policies" %} + +[Understanding Policies](#understanding-policies) + +[Registering Policies](#registering-policies) + +[Common Policy Examples](#common-policy-examples) + +[Evaluating Policies](#evaluating-policies) + +{% /sequence %} + +## Understanding Policies + +Policies are defined using the `definePolicy` function and registered in the `createAccountPolicyRegistry`. Each policy: + +1. Has a unique ID +2. Specifies which stages it runs at (`preliminary` or `submission`) +3. Returns `allow()` or `deny()` with an error message + +```typescript +import { allow, definePolicy, deny } from '@kit/policies'; +import type { FeaturePolicyCreateAccountContext } from '@kit/team-accounts/server'; + +const myPolicy = definePolicy<FeaturePolicyCreateAccountContext>({ + id: 'my-policy-id', + stages: ['preliminary', 'submission'], + + async evaluate(context) { + // Return allow() to permit the action + // Return deny({ code, message, remediation }) to block it + }, +}); +``` + +### Policy Stages + +- **preliminary**: Runs before showing the create account form. Use to check if the user can attempt to create an account. +- **submission**: Runs when the form is submitted. Use to validate the account name and final checks. + +## Registering Policies + +Create a setup file and import it in your layout to register policies at app startup. + +### Step 1: Create the Registration File + +```typescript +// apps/web/lib/policies/setup-create-account-policies.ts +import 'server-only'; + +import { createAccountPolicyRegistry } from '@kit/team-accounts/server'; + +import { subscriptionRequiredPolicy } from './create-account-policies'; + +createAccountPolicyRegistry.registerPolicy(subscriptionRequiredPolicy); +``` + +### Step 2: Import in Layout + +```typescript +// apps/web/app/home/layout.tsx +import '~/lib/policies/setup-create-account-policies'; + +export default function HomeLayout({ children }) { + return <>{children}</>; +} +``` + +{% callout type="default" title="Default Behavior" %} +By default, no policies are registered and all users can create team accounts. You must register policies to enforce restrictions. +{% /callout %} + +## Common Policy Examples + +### Require Active Subscription + +Block team account creation unless the user has an active subscription on their personal account: + +```typescript +// apps/web/lib/policies/create-account-policies.ts +import 'server-only'; + +import { allow, definePolicy, deny } from '@kit/policies'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import type { FeaturePolicyCreateAccountContext } from '@kit/team-accounts/server'; + +export const subscriptionRequiredPolicy = + definePolicy<FeaturePolicyCreateAccountContext>({ + id: 'subscription-required', + stages: ['preliminary', 'submission'], + + async evaluate(context) { + const client = getSupabaseServerClient(); + + const { data: subscription, error } = await client + .from('subscriptions') + .select('id, status, active') + .eq('account_id', context.userId) + .eq('active', true) + .maybeSingle(); + + if (error) { + return deny({ + code: 'SUBSCRIPTION_CHECK_FAILED', + message: 'Failed to verify subscription status', + }); + } + + if (!subscription) { + return deny({ + code: 'SUBSCRIPTION_REQUIRED', + message: 'An active subscription is required to create team accounts', + remediation: 'Please upgrade your plan to create team accounts', + }); + } + + return allow(); + }, + }); +``` + +### Require Specific Plan (Price ID) + +Only allow users with a specific subscription plan to create team accounts: + +```typescript +export const proPlanRequiredPolicy = definePolicy< + FeaturePolicyCreateAccountContext, + { allowedPriceIds: string[] } +>({ + id: 'pro-plan-required', + stages: ['preliminary', 'submission'], + + async evaluate(context, config) { + const allowedPriceIds = config?.allowedPriceIds ?? [ + 'price_pro_monthly', + 'price_pro_yearly', + 'price_enterprise_monthly', + 'price_enterprise_yearly', + ]; + + const client = getSupabaseServerClient(); + + const { data: subscription, error } = await client + .from('subscriptions') + .select('id, active, subscription_items(price_id)') + .eq('account_id', context.userId) + .eq('active', true) + .maybeSingle(); + + if (error) { + return deny({ + code: 'SUBSCRIPTION_CHECK_FAILED', + message: 'Failed to verify subscription status', + }); + } + + if (!subscription) { + return deny({ + code: 'SUBSCRIPTION_REQUIRED', + message: 'A subscription is required to create team accounts', + remediation: 'Please subscribe to a plan to create team accounts', + }); + } + + const priceIds = + subscription.subscription_items?.map((item) => item.price_id) ?? []; + + const hasAllowedPlan = priceIds.some((priceId) => + allowedPriceIds.includes(priceId ?? '') + ); + + if (!hasAllowedPlan) { + return deny({ + code: 'PLAN_NOT_ALLOWED', + message: 'Your current plan does not include team account creation', + remediation: 'Please upgrade to a Pro or Enterprise plan', + }); + } + + return allow(); + }, +}); +``` + +### Maximum Accounts Per User + +Limit how many team accounts a user can own: + +```typescript +export const maxAccountsPolicy = definePolicy< + FeaturePolicyCreateAccountContext, + { maxAccounts: number } +>({ + id: 'max-accounts-per-user', + stages: ['preliminary', 'submission'], + + async evaluate(context, config) { + const maxAccounts = config?.maxAccounts ?? 3; + + const client = getSupabaseServerClient(); + + const { count, error } = await client + .from('accounts') + .select('*', { count: 'exact', head: true }) + .eq('primary_owner_user_id', context.userId) + .eq('is_personal_account', false); + + if (error) { + return deny({ + code: 'MAX_ACCOUNTS_CHECK_FAILED', + message: 'Failed to check account count', + }); + } + + const currentCount = count ?? 0; + + if (currentCount >= maxAccounts) { + return deny({ + code: 'MAX_ACCOUNTS_REACHED', + message: `You have reached the maximum of ${maxAccounts} team accounts`, + remediation: 'Delete an existing team account to create a new one', + }); + } + + return allow(); + }, +}); +``` + +### Rate Limiting Account Creation + +Prevent users from creating too many accounts in a short period: + +```typescript +export const rateLimitPolicy = definePolicy< + FeaturePolicyCreateAccountContext, + { maxAccountsPerDay: number } +>({ + id: 'account-creation-rate-limit', + stages: ['submission'], + + async evaluate(context, config) { + const maxAccountsPerDay = config?.maxAccountsPerDay ?? 5; + + const client = getSupabaseServerClient(); + + const oneDayAgo = new Date(); + oneDayAgo.setDate(oneDayAgo.getDate() - 1); + + const { count, error } = await client + .from('accounts') + .select('*', { count: 'exact', head: true }) + .eq('primary_owner_user_id', context.userId) + .eq('is_personal_account', false) + .gte('created_at', oneDayAgo.toISOString()); + + if (error) { + return deny({ + code: 'RATE_LIMIT_CHECK_FAILED', + message: 'Failed to check rate limit', + }); + } + + if ((count ?? 0) >= maxAccountsPerDay) { + return deny({ + code: 'RATE_LIMIT_EXCEEDED', + message: `You can only create ${maxAccountsPerDay} accounts per day`, + remediation: 'Please wait 24 hours before creating another account', + }); + } + + return allow(); + }, +}); +``` + +### Combining Multiple Policies + +Register multiple policies to enforce several rules: + +```typescript +// apps/web/lib/policies/setup-create-account-policies.ts +import 'server-only'; + +import { createAccountPolicyRegistry } from '@kit/team-accounts/server'; + +import { + maxAccountsPolicy, + proPlanRequiredPolicy, + rateLimitPolicy, +} from './create-account-policies'; + +createAccountPolicyRegistry + .registerPolicy(proPlanRequiredPolicy) + .registerPolicy(maxAccountsPolicy) + .registerPolicy(rateLimitPolicy); +``` + +## Evaluating Policies + +Use `createAccountCreationPolicyEvaluator` to check policies in your server actions: + +```typescript +import { createAccountCreationPolicyEvaluator } from '@kit/team-accounts/server'; + +async function checkCanCreateAccount(userId: string) { + const evaluator = createAccountCreationPolicyEvaluator(); + + const result = await evaluator.canCreateAccount( + { + userId, + accountName: '', + timestamp: new Date().toISOString(), + }, + 'preliminary' + ); + + return { + allowed: result.allowed, + reason: result.reasons[0] ?? null, + }; +} +``` + +### Checking if Policies Exist + +Before running evaluations, you can check if any policies are registered: + +```typescript +const evaluator = createAccountCreationPolicyEvaluator(); + +const hasPolicies = await evaluator.hasPoliciesForStage('preliminary'); + +if (hasPolicies) { + const result = await evaluator.canCreateAccount(context, 'preliminary'); + // Handle result... +} +``` diff --git a/docs/recipes/team-accounts-only.mdoc b/docs/recipes/team-accounts-only.mdoc new file mode 100644 index 000000000..20983eb27 --- /dev/null +++ b/docs/recipes/team-accounts-only.mdoc @@ -0,0 +1,339 @@ +--- +status: "published" +title: 'Disabling Personal Accounts in Next.js Supabase' +label: 'Disabling Personal Accounts' +order: 1 +description: 'Learn how to disable personal accounts in the Next.js Supabase Turbo SaaS kit and only allow team accounts' +--- + +{% alert type="warning" title="v2 Recipe" %} +This recipe applies to **v2 only**. In v3, teams-only mode is built-in as a feature flag. Simply set `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true` in your environment variables. See the [Feature Flags Configuration](/docs/next-supabase-turbo/configuration/feature-flags-configuration) for details. +{% /alert %} + +The Next.js Supabase Turbo SaaS kit is designed to allow personal accounts by default. However, you can disable the personal account view, and only allow user to access team accounts. + +Let's walk through the v2 steps to disable personal accounts in the Next.js Supabase Turbo SaaS kit: + +1. **Store team slug in cookies**: When a user logs in, store the team slug in a cookie. If none is provided, redirect the user to the team selection page. +2. **Set up Redirect**: Redirect customers to the latest selected team account +3. **Create a Team Selection Page**: Create a page where users can select the team they want to log in to. +4. **Duplicate the user settings page**: Duplicate the user settings page so they can access their own settings from within the team workspace + +## Storing the User Cookie and Redirecting to the Team Selection Page + +To make sure that users are always redirected to the team selection page, you need to store the team slug in a cookie. If the team slug is not found, redirect the user to the team selection page. We will do all of this in the middleware. + +First, let's create these functions in the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16): + + ```tsx {% title="apps/web/proxy.ts" %} +const createTeamCookie = (userId: string) => `${userId}-selected-team-slug`; + +function handleTeamAccountsOnly(request: NextRequest, userId: string) { + // always allow access to the teams page + if (request.nextUrl.pathname === '/home/teams') { + return NextResponse.next(); + } + + if (request.nextUrl.pathname === '/home') { + return redirectToTeam(request, userId); + } + + if (isTeamAccountRoute(request) && !isUserRoute(request)) { + return storeTeamSlug(request, userId); + } + + if (isUserRoute(request)) { + return redirectToTeam(request, userId); + } + + return NextResponse.next(); +} + +function isUserRoute(request: NextRequest) { + const pathName = request.nextUrl.pathname; + return ['settings', 'billing', 'members'].includes(pathName.split('/')[2]!); +} + +function isTeamAccountRoute(request: NextRequest) { + const pathName = request.nextUrl.pathname; + + return pathName.startsWith('/home/'); +} + +function storeTeamSlug(request: NextRequest, userId: string): NextResponse { + const accountSlug = request.nextUrl.pathname.split('/')[2]; + + if (!accountSlug) { + return NextResponse.next(); + } + + const cookieName = createTeamCookie(userId); + const existing = request.cookies.get(cookieName); + + if (existing?.value === accountSlug) { + return NextResponse.next(); + } + + const response = NextResponse.next(); + + response.cookies.set({ + name: createTeamCookie(userId), + value: accountSlug, + path: '/', + }); + + return response; +} + +function redirectToTeam(request: NextRequest, userId: string): NextResponse { + const cookieName = createTeamCookie(userId); + const lastTeamSlug = request.cookies.get(cookieName); + + if (lastTeamSlug) { + return NextResponse.redirect( + new URL(`/home/${lastTeamSlug.value}`, request.url), + ); + } + + return NextResponse.redirect(new URL('/home/teams', request.url)); +} +``` + + We will now add the `handleTeamAccountsOnly` function to the middleware chain in the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16). This function will check if the user is on a team account route and store the team slug in a cookie. If the user is on the home route, it will redirect them to the team selection page. + + ```tsx {% title="apps/web/proxy.ts" %} + { + pattern: new URLPattern({ pathname: '/home/*?' }), + handler: async (req: NextRequest, res: NextResponse) => { + const { data } = await getUser(req, res); + + const origin = req.nextUrl.origin; + const next = req.nextUrl.pathname; + + // If user is not logged in, redirect to sign in page. + if (!data?.claims) { + const signIn = pathsConfig.auth.signIn; + const redirectPath = `${signIn}?next=${next}`; + + return NextResponse.redirect(new URL(redirectPath, origin).href); + } + + const supabase = createMiddlewareClient(req, res); + + const requiresMultiFactorAuthentication = + await checkRequiresMultiFactorAuthentication(supabase); + + // If user requires multi-factor authentication, redirect to MFA page. + if (requiresMultiFactorAuthentication) { + return NextResponse.redirect( + new URL(pathsConfig.auth.verifyMfa, origin).href, + ); + } + + const userId = data.claims.sub; + + return handleTeamAccountsOnly(req, userId); + }, +} +``` + +In the above code snippet, we have added the `handleTeamAccountsOnly` function to the middleware chain. + +## Creating the Team Selection Page + +Next, we need to create a team selection page where users can select the team they want to log in to. We will create a new page at `apps/web/app/home/teams/page.tsx`: + + ```tsx {% title="apps/web/app/home/teams/page.tsx" %} +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +import { getTranslations } from 'next-intl/server'; + +import { HomeAccountsList } from '~/home/(user)/_components/home-accounts-list'; + +export const generateMetadata = async () => { + const t = await getTranslations('account'); + const title = t('homePage'); + + return { + title, + }; +}; + +function TeamsPage() { + return ( + <div className={'container flex flex-col flex-1 h-screen'}> + <PageHeader + title={<Trans i18nKey={'common.routes.home'} />} + description={<Trans i18nKey={'common.homeTabDescription'} />} + /> + + <PageBody> + <HomeAccountsList /> + </PageBody> + </div> + ); +} + +export default TeamsPage; +``` + +The page is extremely minimal, it just displays a list of teams that the user can select from. You can customize this page to fit your application's design. + +## Duplicating the User Settings Page + +Finally, we need to duplicate the user settings page so that users can access their settings from within the team workspace. + +We will create a new page called `user-settings.tsx` in the `apps/web/app/home/[account]` directory. + + ```tsx {% title="apps/web/app/home/[account]/user-settings/page.tsx" %} +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { PageHeader } from '@kit/ui/page'; + +import UserSettingsPage, { generateMetadata } from '../../(user)/settings/page'; + +export { generateMetadata }; + +export default function Page() { + return ( + <> + <PageHeader title={'User Settings'} description={<AppBreadcrumbs />} /> + + <UserSettingsPage /> + </> + ); +} +``` + +Feel free to customize the path or the content of the user settings page. + +### Adding the page to the Navigation Menu + +Finally, you can add the `Teams` page to the navigation menu. + +You can do this by updating the `apps/web/config/team-account-navigation.config.tsx` file: + + ```tsx {% title="apps/web/config/team-account-navigation.config.tsx" %} {26-30} +import { CreditCard, LayoutDashboard, Settings, User, Users } from 'lucide-react'; + +import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; + +import featureFlagsConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; + +const iconClasses = 'w-4'; + +const getRoutes = (account: string) => [ + { + label: 'common.routes.application', + collapsible: false, + children: [ + { + label: 'common.routes.dashboard', + path: pathsConfig.app.accountHome.replace('[account]', account), + Icon: <LayoutDashboard className={iconClasses} />, + end: true, + } + ], + }, + { + label: 'common.routes.settings', + collapsible: false, + children: [ + { + label: 'common.routes.settings', + path: createPath(pathsConfig.app.accountSettings, account), + Icon: <Settings className={iconClasses} />, + }, + { + label: 'common.routes.account', + path: createPath('/home/[account]/user-settings', account), + Icon: <User className={iconClasses} />, + }, + { + label: 'common.routes.members', + path: createPath(pathsConfig.app.accountMembers, account), + Icon: <Users className={iconClasses} />, + }, + featureFlagsConfig.enableTeamAccountBilling + ? { + label: 'common.routes.billing', + path: createPath(pathsConfig.app.accountBilling, account), + Icon: <CreditCard className={iconClasses} />, + } + : undefined, + ].filter(Boolean), + }, +]; + +export function getTeamAccountSidebarConfig(account: string) { + return NavigationConfigSchema.parse({ + routes: getRoutes(account), + style: process.env.NEXT_PUBLIC_TEAM_NAVIGATION_STYLE, + }); +} + +function createPath(path: string, account: string) { + return path.replace('[account]', account); +} +``` + +In the above code snippet, we have added the `User Settings` page to the navigation menu. + +## Removing Personal Account menu item + +To remove the personal account menu item, you can remove the personal account menu item: + + ```tsx {% title="packages/features/accounts/src/components/account-selector.tsx" %} +<CommandGroup> + <CommandItem + onSelect={() => onAccountChange(undefined)} + value={PERSONAL_ACCOUNT_SLUG} + > + <PersonalAccountAvatar /> + + <span className={'ml-2'}> + <Trans i18nKey={'teams.personalAccount'} /> + </span> + + <Icon item={PERSONAL_ACCOUNT_SLUG} /> + </CommandItem> +</CommandGroup> + +<CommandSeparator /> +``` + +Once you remove the personal account menu item, users will only see the team accounts in the navigation menu. + +## Change Redirect in Layout + +We now need to change the redirect (in case of errors) from `/home` to `/home/teams`. This is to avoid infinite redirects in case of errors. + + ```tsx {% title="apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts" %} {13} +async function workspaceLoader(accountSlug: string) { + const client = getSupabaseServerClient(); + const api = createTeamAccountsApi(client); + + const [workspace, user] = await Promise.all([ + api.getAccountWorkspace(accountSlug), + requireUserInServerComponent(), + ]); + + // we cannot find any record for the selected account + // so we redirect the user to the home page + if (!workspace.data?.account) { + return redirect('/home/teams'); + } + + return { + ...workspace.data, + user, + }; +} +``` + +## Conclusion + +By following these steps, you can disable personal accounts in the Next.js Supabase Turbo SaaS kit and only allow team accounts. + +This can help you create a more focused and collaborative environment for your users. Feel free to customize the team selection page and user settings page to fit your application's design and requirements. diff --git a/docs/security/csp.mdoc b/docs/security/csp.mdoc new file mode 100644 index 000000000..13ed30a40 --- /dev/null +++ b/docs/security/csp.mdoc @@ -0,0 +1,83 @@ +--- +label: "Content Security Policy (CSP)" +title: "Content Security Policy (CSP) in the Next.js Supabase Turbo kit" +description: "Learn how to secure your Next.js application with Content Security Policy (CSP) using the Next.js Supabase Turbo kit." +order: 4 +--- + +The Next.js Supabase Turbo kit provides a secure way to include [CSP headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) in your application. This functionality is available starting from version 2.8.0 and uses [Nosecone](https://docs.arcjet.com/nosecone/reference#headers) to generate the CSP headers. + +{% sequence title="Steps to configure CSP" description="Learn how to configure CSP in the Next.js Supabase Turbo kit." %} + +[How to enable a strict CSP](#how-to-enable-a-strict-csp) + +[Making your application compliant with CSP](#making-your-application-compliant-with-csp) + +[Adding a nonce to your application](#adding-a-nonce-to-your-application) + +[Modifying the default CSP configuration](#modifying-the-default-csp-configuration) + +{% /sequence %} + +## How to enable a strict CSP + +To enable a strict CSP, you need to set the following environment variable: + +```bash +ENABLE_STRICT_CSP=true +``` + +By default, this setting is disabled due to the overhead required for development. While this is a bit advanced, it is recommended to enable it before going to production - or at some point in your development process. + +Once enabled, the CSP headers will be automatically added to the response headers using the Next.js middleware. + +## Making your application compliant with CSP + +Enabling a strict CSP has a few consequences on your application - you will need to make sure your application is compliant with the CSP rules. + +1. You will need to add the `nonce` to any third-party script tags or stylesheets you have in your application. +2. If you make external HTTP requests, you will need to add the domains to the default list of allowed domains (by default, only requests to your Supabase project are allowed). + +## Adding a nonce to your application + +From a Server Component, you can retrieve a nonce from the `headers()` object. + +```tsx +import { headers } from "next/headers"; + +const headersStore = await headers(); +const nonce = headersStore.get("x-nonce"); +``` + +If you want to pass the nonce to a Script tag, you can do so by adding the `nonce` attribute to the script tag. + +```tsx +<script nonce={nonce} src="https://example.com/script.js"></script> +``` + +## Modifying the default CSP configuration + +In the application middleware, modify the `apps/web/lib/create-csp-response.ts` file to modify the CSP configuration. You may need to do this to allow safe external requests that your application makes. + +Please refer to the [Nosecone documentation](https://docs.arcjet.com/nosecone/reference#headers) for more information on the available options. + +```ts {% filename="apps/web/lib/create-csp-response.ts" %} +const config: NoseconeOptions = { +...noseconeConfig, +contentSecurityPolicy: { + directives: { + ...noseconeConfig.contentSecurityPolicy.directives, + connectSrc: [ + ...noseconeConfig.contentSecurityPolicy.directives.connectSrc, + ...ALLOWED_ORIGINS, + ], + imgSrc: [ + ...noseconeConfig.contentSecurityPolicy.directives.imgSrc, + ...IMG_SRC_ORIGINS, + ], + upgradeInsecureRequests: UPGRADE_INSECURE_REQUESTS, + }, +}, +crossOriginEmbedderPolicy: CROSS_ORIGIN_EMBEDDER_POLICY, +}; +``` \ No newline at end of file diff --git a/docs/security/data-validation.mdoc b/docs/security/data-validation.mdoc new file mode 100644 index 000000000..af00cb9b7 --- /dev/null +++ b/docs/security/data-validation.mdoc @@ -0,0 +1,168 @@ +--- +label: "Data Validation" +title: "Data Validation in the Next.js Supabase Turbo kit" +description: "Learn how to validate data in the Next.js Supabase Turbo kit." +order: 3 +--- + +Data Validation is a crucial aspect of building secure applications. In this section, we will look at how to validate data in the Next.js Supabase Turbo kit. + +{% sequence title="Steps to validate data" description="Learn how to validate data in the Next.js Supabase Turbo kit." %} + +[What are we validating?](#what-are-we-validating) + +[Using Zod to validate data](#using-zod-to-validate-data) + +[Validating payload data to Server Side API](#validating-payload-data-to-server-side-api) + +[Validating Cookies](#validating-cookies) + +[Validating URL parameters](#validating-url-parameters) + +{% /sequence %} + +## What are we validating? + +A general rule, is that all client-side provided data should be validated/sanitized. This includes: + +- URL parameters +- Search params +- Form data +- Cookies +- Any data provided by the user + +## Using Zod to validate data + +The Next.js Supabase Turbo kit uses [Zod](https://zod.dev/) to validate data. You can use the `z` object to validate data in your application. + +```ts +import * as z from "zod"; + +const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), +}); + +// Validate the data +const validatedData = userSchema.parse(data); +``` + +## Validating payload data to Server Side API + +We generally use two ways for sending data from a client to a server: + +1. Server Actions +2. API Route Handlers + +Let's look at how we can validate data for both of these cases. + +### Server Actions: Using authActionClient + +The `authActionClient` creates type-safe server actions with built-in Zod validation and authentication. + +```ts +'use server'; + +import { authActionClient } from "@kit/next/safe-action"; +import * as z from "zod"; + +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), +}); + +export const createUserAction = authActionClient + .inputSchema(UserSchema) + .action(async ({ parsedInput, ctx: { user } }) => { + // parsedInput is now validated against the UserSchema + + // do something with the validated data + }); +``` + +When you define an action using `authActionClient`, the `parsedInput` is validated against the `schema` automatically. The `ctx.user` provides the authenticated user. + +### API Route Handlers: Using the "enhanceRouteHandler" utility + +The `enhanceRouteHandler` hook is a utility hook that enhances Next.js API Route Handlers with Zod validation. + +```ts +import { enhanceRouteHandler } from "@kit/next/routes"; +import * as z from "zod"; + +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), +}); + +export const POST = enhanceRouteHandler(async ({ body, user, request }) => { + // body is now validated against the UserSchema + // user is the authenticated user + // request is the incoming request + + // do something with the validated data +}, { + schema: UserSchema, + auth: true, +}); +``` + +Very similar to `authActionClient`, the `enhanceRouteHandler` utility enhances the handler function with Zod validation and authentication. + +When you define an API route using `enhanceRouteHandler`, the `body` argument is validated against the `schema` option automatically. The `user` argument provides the authenticated user, and `request` is the incoming request object. + +## Validating Cookies + +Whenever you use a cookie in your application, you should validate the cookie data. + +Let's assume you receive a cookie with the name `my-cookie`, and you expect it to be a number. You can validate the cookie data as follows: + +```ts +import * as z from "zod"; +import { cookies } from "next/headers"; + +const cookieStore = await cookies(); + +const cookie = z.coerce.number() + .safeParse(cookieStore.get("my-cookie")?.value); + +if (!cookie.success) { + // handle the error or provide a default value +} +``` + +## Validating URL parameters + +Whenever you receive a URL parameter, you should validate the parameter data. Let's assume you receive a URL parameter with the name `my-param`, and you expect it to be a number. You can validate the parameter data as follows: + +```ts +interface PageProps { + searchParams: Promise<{ myParam: string }>; +} + +async function Page({ searchParams }: PageProps) { + const params = await searchParams; + + const param = z.coerce.number() + .safeParse(params.myParam); + + if (!param.success) { + // handle the error or provide a default value + } + + // render the page with the validated data +} +``` + +Going forward, remember to validate all data that you receive from the client, and never trust anything the client provides you with. Always have a default value ready to handle invalid data, which can prevent potential security issues or bugs in your application. + +## Upgrading from v2 + +{% callout title="Differences with v2" %} +In v2, server actions used `enhanceAction` from `@kit/next/actions` and Zod was imported as `import { z } from 'zod'`. In v3, server actions use `authActionClient` from `@kit/next/safe-action` and Zod is imported as `import * as z from 'zod'`. + +For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration). +{% /callout %} diff --git a/docs/security/nextjs-best-practices.mdoc b/docs/security/nextjs-best-practices.mdoc new file mode 100644 index 000000000..e416e715a --- /dev/null +++ b/docs/security/nextjs-best-practices.mdoc @@ -0,0 +1,190 @@ +--- +label: "Next.js Best Practices" +title: "Next.js Security Best Practices" +description: "Learn best practices for Next.js in the Next.js Supabase Turbo kit." +order: 1 +--- + +This section contains a list of best practices for Next.js in general, applicable to both the Next.js Supabase Turbo kit or any other Next.js application. + +{% sequence title="Best practices for Next.js in the Next.js Supabase Turbo kit" description="Learn best practices for Next.js in the Next.js Supabase Turbo kit." %} + +[Do not pass sensitive data to client components](#do-not-pass-sensitive-data-to-client-components) + +[Do not mix up client and server imports](#do-not-mix-up-client-and-server-imports) + +[Environment variables](#environment-variables) + +[Proper use of .env files](#proper-use-of-.env-files) + +{% /sequence %} + +## What goes in client components will be passed to the client + +The first and most important rule you must remember is that what goes in client components will be passed to the client. + +From this rule, you can derive the following best practices: + +- Do not pass sensitive data to client components. If you're using an API Key server-side, do not pass it to the client. +- Avoid exporting server and client code from the same file. Instead, define separate entry points for server and client code. +- Use the `import 'server-only'` package to raise errors when server code unexpectedly ends up in the client bundle. + +## Do not pass sensitive data to client components + +One common mistake made by Next.js developers is passing sensitive data to client components. This is easier than it sounds, and it can happen without you even noticing it. + +Let's look at the following example: we are using an API call in a server component, and we end up passing the API Key to the client. + +```tsx +async function ServerComponent() { + const config = { + apiKey: process.env.API_KEY, + storeId: process.env.STORE_ID, + }; + + const data = await fetchData(config); + + return <ClientComponent data={data} config={config} />; +} +``` + +In this example, the `config` object contains sensitive data, such as the API Key and the Store ID (which is a public identifier for the store, so safe to pass to the client). This data will be passed to the client, and can be accessed by the client. + +```tsx +'use client'; + +function ClientComponent({ data, config }: { data: any, config: any }) { + // ... +} +``` + +This is a problem, because the `config` object contains sensitive data, such as the API Key and the Store ID. The fact we pass it down to a `'use client'` component means that the data will be passed to the client and this means leaking sensitive data to the client, which is a security risk. + +This can happen in many other ways, and it's a good idea to be aware of this. + +A better approach is to define a service using `import 'server-only'` and use it in the server component. + +```ts +import 'server-only'; + +const config = { + apiKey: process.env.API_KEY, + storeId: process.env.STORE_ID, +}; + +export async function fetchData() { + const data = await fetchDataFromExternalApi(config); + const storeId = config.storeId; + + return { data, storeId }; +} +``` + +Now, we can use this service in the server component. + +```tsx +import { fetchData } from './fetch-data'; + +async function ServerComponent() { + const data = await fetchData(); + + return <ClientComponent data={data} />; +} +``` + +While this doesn't fully solve the problem (you can still pass the config object to the client, but it's a lot harder), it's a good start and will help you separate concerns. + +The [Taint API](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching#preventing-sensitive-data-from-being-exposed-to-the-client) will eventually help us solve this issue even better, but it's still experimental. + +## Do not mix up client and server imports + +Sometimes, you have a package that exports both client and server code. This is a problem, because it will end up in the client bundle. + +For example, we assume we have a barrel file `index.ts` from which we export a `fetchData` function and a client component `ClientComponent`. + +```ts +export * from './fetch-data'; +export * from './client-component'; +``` + +This is a problem, because it's possible that some server-side code ends up in the client bundle. + +Adding `import 'server-only'` to the barrel file will solve this issue and raise an error, however, as a best practice, you should avoid this in the first place and **use different barrel files for server and client code**; then use the `exports` field in `package.json` to re-export the server and client code from the barrel file. + +```json +{ + "exports": { + "./server": "./server.ts", + "./client": "./client.tsx" + } +} +``` + +This way, you can import the server and client code separately and you won't end up with a mix of server and client code in the client bundle. + +## Environment variables + +Environment variables are essential for configuring your application across different environments. However, they require careful management to prevent security vulnerabilities. + +### Use NEXT_PUBLIC prefix for environment variables that are available on the client + +Next.js handles environment variables uniquely, distinguishing between server-only and client-available variables: + +- Variables without the `NEXT_PUBLIC_` prefix are only available on the server +- Variables with the `NEXT_PUBLIC_` prefix are available on both server and client + +Client components can only access environment variables prefixed with `NEXT_PUBLIC_`: + +```tsx +// This is available in client components +console.log(process.env.NEXT_PUBLIC_API_URL) + +// This is undefined in client components +console.log(process.env.SECRET_API_KEY) +``` + +### Never use NEXT_PUBLIC_ variables for sensitive data + +Since `NEXT_PUBLIC_` variables are embedded in the JavaScript bundle sent to browsers, they should never contain sensitive information: + +``` +# .env +# UNSAFE: This will be exposed to the client +NEXT_PUBLIC_API_KEY=sk_live_1234567890 # NEVER DO THIS! +``` + +#### SAFE: This is only available server-side +``` +API_KEY=sk_live_1234567890 # This is correct +``` + +Remember: + +1. API keys, secrets, tokens, and passwords should never use the `NEXT_PUBLIC_` prefix +2. Use `NEXT_PUBLIC_` only for genuinely public information like public API URLs, feature flags, or public identifiers + +### Proper use of .env files + +Next.js supports various .env files for different environments: + +``` +.env # Loaded in all environments +.env.local # Loaded in all environments, ignored by git +.env.development # Only loaded in development environment +.env.production # Only loaded in production environment +.env.production.local # Only loaded in production environment, ignored by git +.env.test # Only loaded in test environment +``` + +As a general rule, **never add sensitive data to the `.env` file or any other committed file**. Instead, add it to the `.env.local` file, which by default is ignored by git (though you must check this if you're not using Makerkit). + +### Best practices: + +1. Store sensitive values in `.env.local` which should not be committed to your repository +2. Use environment-specific files for values that differ between environments +3. Use environment variables for sensitive data, not hardcoded values +4. Use `NEXT_PUBLIC_` prefix for environment variables that are available on the client +5. Never use `NEXT_PUBLIC_` variables for sensitive data +6. Use `import 'server-only'` for server-only code +7. Separate server and client code in different files and never mix them up in barrel files +8. Use the Taint API to prevent sensitive data from being exposed to the client (experimental). Makerkit will at some point adopt this API. diff --git a/docs/security/row-level-security.mdoc b/docs/security/row-level-security.mdoc new file mode 100644 index 000000000..98116fe1a --- /dev/null +++ b/docs/security/row-level-security.mdoc @@ -0,0 +1,194 @@ +--- +label: "Row Level Security" +title: "Row Level Security in the Next.js Supabase Turbo kit" +description: "Learn how to secure your Next.js application with Row Level Security (RLS) using the Next.js Supabase Turbo kit." +order: 2 +--- + +If you've opted to using the Data API in your application, you can use Row Level Security (RLS) to secure your data (which Makerkit does by default). + +{% sequence title="Steps to secure your data with RLS" description="Learn how to secure your data with RLS using the Next.js Supabase Turbo kit." %} + +[Enable RLS for your tables](#enable-rls-for-your-tables) + +[Add the RLS policy](#add-the-rls-policy) + +[Using Makerkit's Functions to write RLS policies](#using-makerkits-functions-to-write-rls-policies) + +[Testing RLS policies](#testing-rls-policies) + +{% /sequence %} + +The general rule of thumb is that you must always ensure RLS is enabled for your tables. **Failure to do so will result in a security vulnerability** because the table will be exposed to the public - and everyone will be able to read and write to it. You don't want that. + +Supabase has a great [guide on how to use RLS](https://supabase.com/docs/guides/database/postgres/row-level-security) - don't skip it! + +## Enable RLS for your tables + +When you write your table migrations, you must ensure that RLS is enabled for the table. + +First, create a table with the following command: + +```sql +create table if not exists public.documents ( + id uuid primary key default uuid_generate_v4(), + user_id uuid not null references auth.users(id), + title text not null, + content text not null, + created_at timestamp with time zone default now(), + updated_at timestamp with time zone default now() +); +``` + +### Enable RLS for the table + +Now, you need to enable RLS for the table. + +```sql +alter table public.documents enable row level security; +``` + +PS: you can also enable RLS for the table using the Supabase Studio - however I am not sure I'd recommend this approach. + +## Add the RLS policy for restricted access + +Now that we have a table with RLS enabled, **and no policies added**, you will notice that you won't be able to read or write to the table. This is good, because it means that the table is secure. However, we want to be able to read and write to the table **for the users that are authorized to do so**. + +To do this, we need to add a policy to the table. + +```sql +create policy "Users can view their own documents" + on public.documents + to authenticated + for select + using ((select auth.uid()) = user_id); +``` + +This policy will allow any **authenticated** user to read **their own documents** - e.g. the documents whose `user_id` matches the authenticated user's ID. + +If you wanted to allow all actions at once, you could use the following policy: + +```sql +create policy "Users can view their own documents" + on public.documents + to authenticated + for all + using ((select auth.uid()) = user_id); +``` + +## Using Makerkit's Functions to write RLS policies + +Makerkit comes with a set of functions that make it easier to write RLS policies. Below are some of the most common use cases: + +### Verifying a user is part of a Team Account + +If you want to verify that a user is part of a Team Account, you can use the `public.has_role_on_account` function. + +```sql +create policy "Users can view their account's documents" + on public.documents + to authenticated + for select + using (public.has_role_on_account(account_id)); +``` + +If you want to check for a specific role, you can do so by passing the role name to the function. + +```sql +create policy "Users can view their account's documents" + on public.documents + to authenticated + for select + using (public.has_role_on_account(account_id, 'admin')); +``` + +**Note:** We're expecting the `public.documents` table to have an `account_id` column that references the `public.accounts` table. + +### Verifying a user has the required permissions + +If you want to verify that a user has the required permissions, you can use the `public.has_permission` function. + +```sql +create policy "Users can view their account's documents" + on public.documents + to authenticated + for select + using (public.has_permission(auth.uid(), account_id, 'documents.read')); +``` + +### Verifying a user is the owner of a Team Account + +If you want to verify that a user is the owner of a Team Account, you can use the `public.is_owner` function. + +```sql +create policy "Users can view their account's documents" + on public.documents + to authenticated + for select + using (public.is_account_owner(account_id)); +``` + +These are just some of the most common use cases and will likely cover the vast majority of your RLS policies. + +## Testing RLS policies + +We have two different ways to test RLS policies: + +1. **Manually**: Using the Supabase Studio impersonation feature +2. **Automatically**: Using [pgTap](https://supabase.com/docs/reference/cli/supabase-test-db) tests + +### Manually testing RLS policies + +To test RLS policies manually, you can use the Supabase Studio impersonation feature. + +1. Go to the Supabase Studio +2. Navigate to the Table you want to test +3. Impersonate a user +4. Try to read, write or delete data using the UI or the SQL editor +5. Verify that the data is restricted based on the RLS policy + +### Automatically testing RLS policies + +To test RLS policies automatically, you can use the `supabase test db` command. This command uses pgTap to test the RLS policies - which is an invaluable tool for testing RLS policies in an automated and repeatable way. + +We have a set of tests in the `supabase/tests/database` folder that are designed to test the RLS policies. You can copy the same structure and add your own tests. + +Here's an example of a test: + +```sql +-- authenticate with a user +select tests.create_supabase_user('testuser', 'testuser@test.com'); +select tests.create_supabase_user('testuser2', 'testuser2@test.com'); + +-- authenticate as the first user +select tests.authenticate_as('testuser'); + +-- create a document +insert into public.documents (user_id, title, content) +values (tests.get_supabase_uid('testuser'), 'Test Document', 'This is a test document'); + +-- test the user can read their own document +select row_eq( + $$ SELECT * from public.documents $$, + row(tests.get_supabase_uid('testuser'), 'Test Document', 'This is a test document'), + 'User should be able to read their own document' +); + +-- alternatively, check the list is not empty +select not_empty( + $$ SELECT * from public.documents $$, + 'User should be able to read their own document' +); + +-- authenticate with another user +select tests.authenticate_as('testuser2'); + +-- test that the document is not visible to the other user +select is_empty( + $$ SELECT * from public.documents $$, + 'No documents should be visible to unauthenticated users' +); +``` + +This is a simple example, but you can see how you can test the RLS policies for different scenarios. + diff --git a/docs/translations/adding-translations.mdoc b/docs/translations/adding-translations.mdoc new file mode 100644 index 000000000..7ef76f7a6 --- /dev/null +++ b/docs/translations/adding-translations.mdoc @@ -0,0 +1,421 @@ +--- +status: "published" +title: "Adding new translations | Next.js Supabase SaaS Kit" +label: "Adding new translations" +description: "Learn how to add new languages, create translation files, and organize namespaces in your Next.js Supabase SaaS application." +order: 1 +--- + +This guide covers adding new languages, creating translation files, and organizing your translations into namespaces. + +{% sequence title="Steps to add new translations" description="Learn how to add new translations to your Next.js Supabase SaaS project." %} + +[Create language files](#1-create-language-files) + +[Register the language](#2-register-the-language) + +[Add custom namespaces](#3-add-custom-namespaces) + +[Translate email templates](#4-translate-email-templates) + +{% /sequence %} + +## 1. Create Language Files + +Translation files live in `apps/web/i18n/messages/{locale}/`. Each language needs its own folder with JSON files matching your namespaces. + +### Create the Language Folder + +Create a new folder using the [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes): + +```bash +mkdir apps/web/i18n/messages/es +``` + +Common language codes: +- `de` - German +- `es` - Spanish +- `fr` - French +- `it` - Italian +- `ja` - Japanese +- `pt` - Portuguese +- `zh` - Chinese + +### Regional Language Codes + +For regional variants like `es-ES` (Spanish - Spain) or `pt-BR` (Portuguese - Brazil), use lowercase with a hyphen: + +```bash +# Correct +mkdir apps/web/i18n/messages/es-es +mkdir apps/web/i18n/messages/pt-br + +# Incorrect - will not work +mkdir apps/web/i18n/messages/es-ES +``` + +The system normalizes language codes to lowercase internally. + +### Copy and Translate Files + +Copy the English files as a starting point: + +```bash +cp apps/web/i18n/messages/en/*.json apps/web/i18n/messages/es/ +``` + +Then translate each JSON file. Here's an example for `common.json`: + +```json title="apps/web/i18n/messages/es/common.json" +{ + "homeTabLabel": "Inicio", + "cancel": "Cancelar", + "clear": "Limpiar", + "goBack": "Volver", + "tryAgain": "Intentar de nuevo", + "loading": "Cargando. Por favor espere...", + "routes": { + "home": "Inicio", + "account": "Cuenta", + "billing": "Facturacion" + } +} +``` + +Keep the same key structure as the English files. Only translate the values. + +## 2. Register the Language + +Add your new language to the locales configuration: + +```tsx title="packages/i18n/src/locales.tsx" {6} +/** + * The list of supported locales. + * Add more locales here as needed. + */ +export const locales: string[] = ['en', 'es', 'de', 'fr']; +``` + +The order matters for fallback behavior: +1. First locale is the default fallback +2. When a translation is missing, the system falls back through this list + +### Verify the Registration + +After adding a language, verify it works: + +1. Restart the development server +2. Navigate to your app with the locale prefix (e.g., `/es/home`) +3. You should see your translations appear + +## 3. Add Custom Namespaces + +Namespaces organize translations by feature or domain. The default namespaces are registered in `apps/web/i18n/request.ts`: + +```tsx title="apps/web/i18n/request.ts" +const namespaces = [ + 'common', // Shared UI elements + 'auth', // Authentication flows + 'account', // Account settings + 'teams', // Team management + 'billing', // Billing and subscriptions + 'marketing', // Marketing pages +]; +``` + +### Create a New Namespace + +#### Create the JSON file for each language: + +```bash +# Create for English +touch apps/web/i18n/messages/en/projects.json + +# Create for other languages +touch apps/web/i18n/messages/es/projects.json +``` + +#### Add your translations + +```json title="apps/web/i18n/messages/en/projects.json" +{ + "title": "Projects", + "createProject": "Create Project", + "projectName": "Project Name", + "projectDescription": "Description", + "deleteProject": "Delete Project", + "confirmDelete": "Are you sure you want to delete this project?", + "status": { + "active": "Active", + "archived": "Archived", + "draft": "Draft" + } +} +``` + +#### Register the namespace: + +```tsx title="apps/web/i18n/request.ts" {8} +const namespaces = [ + 'common', + 'auth', + 'account', + 'teams', + 'billing', + 'marketing', + 'projects', // Your new namespace +]; +``` + +#### Use the namespace in your components: + +```tsx title="apps/web/app/[locale]/home/[account]/projects/page.tsx" +import { Trans } from '@kit/ui/trans'; + +function ProjectsPage() { + return ( + <div> + <h1> + <Trans i18nKey="projects.title" /> + </h1> + + <button> + <Trans i18nKey="projects.createProject" /> + </button> + </div> + ); +} +``` + +### Namespace Best Practices + +**Keep namespaces focused**: Each namespace should cover a single feature or domain. + +``` +Good: +- projects.json (project management) +- invoices.json (invoicing feature) +- notifications.json (notification system) + +Avoid: +- misc.json (too vague) +- page1.json (not semantic) +``` + +**Use consistent key naming**: + +```json +{ + "title": "Page title", + "description": "Page description", + "actions": { + "create": "Create", + "edit": "Edit", + "delete": "Delete" + }, + "status": { + "loading": "Loading...", + "error": "An error occurred", + "success": "Success!" + } +} +``` + +**Avoid duplicating common strings**: Use the `common` namespace for shared strings like "Cancel", "Save", "Loading". + +## 4. Translate Email Templates + +Email templates have their own translation system in `packages/email-templates/src/locales/`. + +### Email Translation Structure + +``` +packages/email-templates/src/locales/ +└── en/ + ├── account-delete-email.json + ├── invite-email.json + └── otp-email.json +``` + +### Add Email Translations for a New Language + +#### Create the language folder: + +```bash +mkdir packages/email-templates/src/locales/es +``` + +#### Copy and translate the email files: + +```bash +cp packages/email-templates/src/locales/en/*.json packages/email-templates/src/locales/es/ +``` + +#### Translate the content: + +```json title="packages/email-templates/src/locales/es/invite-email.json" +{ + "subject": "Has sido invitado a unirte a un equipo", + "heading": "Unete a {teamName} en {productName}", + "hello": "Hola {invitedUserEmail},", + "mainText": "<strong>{inviter}</strong> te ha invitado al equipo <strong>{teamName}</strong> en <strong>{productName}</strong>.", + "joinTeam": "Unirse a {teamName}", + "copyPasteLink": "o copia y pega esta URL en tu navegador:", + "invitationIntendedFor": "Esta invitacion es para {invitedUserEmail}." +} +``` + +Email templates support interpolation with `{variable}` syntax and basic HTML tags. + +## Organizing Large Translation Files + +For applications with many translations, consider splitting by feature: + +``` +apps/web/i18n/messages/en/ +├── common.json # 50-100 keys max +├── auth.json +├── account.json +├── billing/ +│ ├── subscriptions.json +│ ├── invoices.json +│ └── checkout.json +└── features/ + ├── projects.json + ├── analytics.json + └── integrations.json +``` + +Update your namespace registration accordingly: + +```tsx +const namespaces = [ + 'common', + 'auth', + 'account', + 'billing/subscriptions', + 'billing/invoices', + 'features/projects', +]; +``` + +## Translation Workflow Tips + +### Use Placeholders During Development + +When adding new features, start with English placeholders: + +```json +{ + "newFeature": "[TODO] New feature title", + "newFeatureDescription": "[TODO] Description of the new feature" +} +``` + +This makes untranslated strings visible and searchable. + +### Maintain Translation Parity + +Keep all language files in sync. When adding a key to one language, add it to all: + +```bash +# Check for missing keys (example script) +diff <(jq -r 'keys[]' messages/en/common.json | sort) \ + <(jq -r 'keys[]' messages/es/common.json | sort) +``` + +### Consider Translation Services + +For production applications, integrate with translation services: + +- [Crowdin](https://crowdin.com/) +- [Lokalise](https://lokalise.com/) +- [Phrase](https://phrase.com/) + +These services can: +- Sync with your JSON files via CLI or CI/CD +- Provide translator interfaces +- Handle pluralization rules per language +- Track translation coverage + +## RTL Language Support + +For right-to-left languages like Arabic (`ar`) or Hebrew (`he`): + +1. Add the language as normal to `packages/i18n/src/locales.tsx` +2. Create a client component to detect the current locale and set the `dir` attribute: + +```tsx title="apps/web/components/rtl-provider.tsx" +'use client'; + +import { useEffect } from 'react'; +import { useLocale } from 'next-intl'; + +const rtlLanguages = ['ar', 'he', 'fa', 'ur']; + +export function RtlProvider({ children }: { children: React.ReactNode }) { + const locale = useLocale(); + + useEffect(() => { + const isRtl = rtlLanguages.includes(locale); + document.documentElement.dir = isRtl ? 'rtl' : 'ltr'; + document.documentElement.lang = locale; + }, [locale]); + + return children; +} +``` + +3. Wrap your app with the provider in `RootProviders`: + +```tsx title="apps/web/components/root-providers.tsx" +import { RtlProvider } from './rtl-provider'; + +export function RootProviders({ children }: { children: React.ReactNode }) { + return ( + <RtlProvider> + {children} + </RtlProvider> + ); +} +``` + +4. Use Tailwind's RTL utilities (`rtl:` prefix) for layout adjustments: + +```tsx +<div className="ml-4 rtl:ml-0 rtl:mr-4"> + {/* Content flows correctly in both directions */} +</div> +``` + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "How do I verify my translations are working?", "answer": "Navigate to your app with the locale prefix in the URL (e.g., /es/home). If you have the Language Selector component configured, you can also use it in account settings to switch languages."}, + {"question": "Do I need to translate every single key?", "answer": "No. Missing translations fall back to the default language (usually English). During development, you can translate incrementally. For production, ensure all user-facing strings are translated."}, + {"question": "Can I use nested folders for namespaces?", "answer": "Yes. Create subfolders like billing/subscriptions.json and register them as 'billing/subscriptions' in the namespaces array in apps/web/i18n/request.ts. The resolver will load from the nested path."}, + {"question": "How do I handle pluralization in different languages?", "answer": "next-intl uses ICU message format for pluralization. Define plural rules like {count, plural, one {# item} other {# items}}. ICU format automatically handles language-specific plural rules (e.g., Russian's complex plural categories)."}, + {"question": "Should translation files be committed to git?", "answer": "Yes, translation JSON files should be version controlled. If using a translation management service, configure it to sync with your repository via pull requests."} + ] +/%} + +## Upgrading from v2 + +{% callout title="Differences with v2" %} +In v2, Makerkit used `i18next` for translations. In v3, the system uses `next-intl`. Key differences: + +- Translation files moved from `apps/web/public/locales/{locale}/` to `apps/web/i18n/messages/{locale}/` +- Language settings moved from `apps/web/lib/i18n/i18n.settings.ts` to `packages/i18n/src/locales.tsx` +- Namespace registration moved from `defaultI18nNamespaces` in `i18n.settings.ts` to `namespaces` in `apps/web/i18n/request.ts` +- Translation keys use dot notation (`namespace.key`) instead of colon notation (`namespace:key`) +- Interpolation uses single braces (`{var}`) instead of double braces (`{{var}}`) +- Pluralization uses ICU format instead of i18next `_one`/`_other` suffixes + +For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration). +{% /callout %} + +## Related Documentation + +- [Using Translations](/docs/next-supabase-turbo/translations/using-translations) - Use translations in your components +- [Language Selector](/docs/next-supabase-turbo/translations/language-selector) - Add a language switcher +- [Email Translations](/docs/next-supabase-turbo/translations/email-translations) - Translate email templates diff --git a/docs/translations/email-translations.mdoc b/docs/translations/email-translations.mdoc new file mode 100644 index 000000000..deb05bb51 --- /dev/null +++ b/docs/translations/email-translations.mdoc @@ -0,0 +1,430 @@ +--- +status: "published" +title: "Email Template Translations | Next.js Supabase SaaS Kit" +label: "Email Translations" +description: "Learn how to translate email templates for team invitations, account deletion, and OTP verification in your multi-language SaaS application." +order: 3 +--- + +Email templates in Makerkit have their own translation system, separate from the main application translations. This allows emails to be sent in the recipient's preferred language. + +## Email Translation Structure + +Email translations are stored in the `packages/email-templates` package: + +``` +packages/email-templates/src/ +├── locales/ +│ └── en/ +│ ├── account-delete-email.json +│ ├── invite-email.json +│ └── otp-email.json +└── lib/ + └── i18n.ts +``` + +## Default Email Templates + +Makerkit includes three translatable email templates: + +### Team Invitation Email + +Sent when a user is invited to join a team: + +```json title="packages/email-templates/src/locales/en/invite-email.json" +{ + "subject": "You have been invited to join a team", + "heading": "Join {teamName} on {productName}", + "hello": "Hello {invitedUserEmail},", + "mainText": "<strong>{inviter}</strong> has invited you to the <strong>{teamName}</strong> team on <strong>{productName}</strong>.", + "joinTeam": "Join {teamName}", + "copyPasteLink": "or copy and paste this URL into your browser:", + "invitationIntendedFor": "This invitation is intended for {invitedUserEmail}." +} +``` + +### Account Deletion Email + +Sent when a user requests account deletion: + +```json title="packages/email-templates/src/locales/en/account-delete-email.json" +{ + "subject": "We have deleted your {productName} account", + "previewText": "We have deleted your {productName} account", + "hello": "Hello {displayName},", + "paragraph1": "This is to confirm that we have processed your request to delete your account with {productName}.", + "paragraph2": "We're sorry to see you go. Please note that this action is irreversible, and we'll make sure to delete all of your data from our systems.", + "paragraph3": "We thank you again for using {productName}.", + "paragraph4": "The {productName} Team" +} +``` + +### OTP Verification Email + +Sent for one-time password verification: + +```json title="packages/email-templates/src/locales/en/otp-email.json" +{ + "subject": "One-time password for {productName}", + "heading": "One-time password for {productName}", + "otpText": "Your one-time password is: {otp}", + "mainText": "You're receiving this email because you need to verify your identity using a one-time password.", + "footerText": "Please enter the one-time password in the app to continue." +} +``` + +## Adding Email Translations + +### 1. Create Language Folder + +Create a new folder for your language: + +```bash +mkdir packages/email-templates/src/locales/es +``` + +### 2. Copy and Translate Files + +Copy the English templates: + +```bash +cp packages/email-templates/src/locales/en/*.json packages/email-templates/src/locales/es/ +``` + +Translate each file: + +```json title="packages/email-templates/src/locales/es/invite-email.json" +{ + "subject": "Has sido invitado a unirte a un equipo", + "heading": "Unete a {teamName} en {productName}", + "hello": "Hola {invitedUserEmail},", + "mainText": "<strong>{inviter}</strong> te ha invitado al equipo <strong>{teamName}</strong> en <strong>{productName}</strong>.", + "joinTeam": "Unirse a {teamName}", + "copyPasteLink": "o copia y pega esta URL en tu navegador:", + "invitationIntendedFor": "Esta invitacion es para {invitedUserEmail}." +} +``` + +## How Email Translations Work + +The email template system initializes i18n separately from the main application: + +```tsx title="packages/email-templates/src/lib/i18n.ts" +import type { AbstractIntlMessages } from 'next-intl'; +import { createTranslator } from 'next-intl'; + +export async function initializeEmailI18n(params: { + language: string | undefined; + namespace: string; +}) { + const language = params.language ?? 'en'; + + try { + const messages = (await import( + `../locales/${language}/${params.namespace}.json` + )) as AbstractIntlMessages; + + const translator = createTranslator({ + locale: language, + messages, + }); + + const t = translator as unknown as ( + key: string, + values?: Record<string, unknown>, + ) => string; + + return { t, language }; + } catch (error) { + console.log( + `Error loading i18n file: locales/${language}/${params.namespace}.json`, + error, + ); + + const t = (key: string) => key; + return { t, language }; + } +} +``` + +Key points: +- Each email type has its own namespace (e.g., `invite-email`, `otp-email`) +- The language can be passed when sending the email +- Falls back to `'en'` if no language specified + +## Sending Translated Emails + +When sending emails, pass the recipient's preferred language: + +```tsx title="apps/web/lib/server/send-invite-email.ts" +import { renderInviteEmail } from '@kit/email-templates'; + +export async function sendInviteEmail(params: { + invitedUserEmail: string; + inviter: string; + teamName: string; + inviteLink: string; + language?: string; // Recipient's preferred language +}) { + const { html, subject } = await renderInviteEmail({ + invitedUserEmail: params.invitedUserEmail, + inviter: params.inviter, + teamName: params.teamName, + link: params.inviteLink, + productName: 'Your App', + language: params.language, // Pass the language + }); + + await sendEmail({ + to: params.invitedUserEmail, + subject, + html, + }); +} +``` + +## Determining Recipient Language + +To send emails in the recipient's language, you need to know their preference. Common approaches: + +### From User Profile + +Store language preference in the user profile: + +```tsx +// When sending an email +const user = await getUserById(userId); +const language = user.preferredLanguage ?? 'en'; + +await sendInviteEmail({ + // ...other params + language, +}); +``` + +### From Request Context + +Use the current user's language when they trigger an action: + +```tsx title="apps/web/lib/server/invite-member.ts" +'use server'; + +import { getLocale } from 'next-intl/server'; + +export async function inviteMember(email: string) { + const currentLanguage = await getLocale(); + + await sendInviteEmail({ + invitedUserEmail: email, + // Use inviter's language as default for the invited user + language: currentLanguage, + }); +} +``` + +## Adding Custom Email Templates + +To add a new translatable email template: + +### 1. Create Translation Files + +```json title="packages/email-templates/src/locales/en/welcome-email.json" +{ + "subject": "Welcome to {productName}", + "heading": "Welcome aboard!", + "hello": "Hello {userName},", + "mainText": "Thank you for joining {productName}. We're excited to have you!", + "getStarted": "Get Started", + "helpText": "If you have any questions, feel free to reach out to our support team." +} +``` + +### 2. Create the Email Template Component + +```tsx title="packages/email-templates/src/emails/welcome.email.tsx" +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + render, +} from '@react-email/components'; + +import { initializeEmailI18n } from '../lib/i18n'; + +interface WelcomeEmailProps { + userName: string; + productName: string; + dashboardUrl: string; + language?: string; +} + +export async function renderWelcomeEmail(props: WelcomeEmailProps) { + const namespace = 'welcome-email'; + + const { t } = await initializeEmailI18n({ + language: props.language, + namespace, + }); + + const subject = t('subject', { productName: props.productName }); + + // Use render() to convert JSX to HTML string + const html = await render( + <Html> + <Head /> + <Preview>{subject}</Preview> + <Body> + <Container> + <Heading>{t('heading')}</Heading> + + <Text>{t('hello', { userName: props.userName })}</Text> + + <Text> + {t('mainText', { productName: props.productName })} + </Text> + + <Section> + <Button href={props.dashboardUrl}> + {t('getStarted')} + </Button> + </Section> + + <Text>{t('helpText')}</Text> + </Container> + </Body> + </Html> + ); + + return { html, subject }; +} +``` + +### 3. Export from Package + +```tsx title="packages/email-templates/src/index.ts" +export * from './emails/welcome.email'; +``` + +## Interpolation in Email Translations + +Email translations support the same interpolation syntax as the main application: + +### Simple Variables + +```json +{ + "hello": "Hello {userName}," +} +``` + +### HTML Tags + +You can use basic HTML for formatting: + +```json +{ + "mainText": "<strong>{inviter}</strong> has invited you to join <strong>{teamName}</strong>." +} +``` + +The email template must render this content appropriately: + +```tsx +<Text dangerouslySetInnerHTML={{ __html: t('mainText', { inviter, teamName }) }} /> +``` + +{% callout type="warning" title="Security Warning" %} +When using `dangerouslySetInnerHTML`, ensure all interpolated values come from trusted sources (your database, not user input). Never interpolate raw user input into HTML translations without sanitization. For user-provided content, use plain text translations instead. +{% /callout %} + +## Testing Email Translations + +### Preview in Development + +Use the email preview feature to test translations: + +```bash +# Start the email preview server +cd packages/email-templates +pnpm dev +``` + +Then open `http://localhost:3001` to preview email templates. + +### Test with Inbucket + +When running Supabase locally, emails are captured by Inbucket: + +1. Start Supabase: `pnpm supabase:web:start` +2. Open Inbucket: `http://localhost:54324` +3. Trigger an action that sends an email +4. Check Inbucket for the translated email + +### Verify All Languages + +Create a test script to verify translations exist for all configured languages: + +```bash +# Check that all email translation files exist +for lang in en es de fr; do + for file in invite-email account-delete-email otp-email; do + path="packages/email-templates/src/locales/${lang}/${file}.json" + if [ -f "$path" ]; then + echo "OK: $path" + else + echo "MISSING: $path" + fi + done +done +``` + +## Troubleshooting + +### Email Shows English Instead of User's Language + +Check that you're passing the language parameter when rendering the email: + +```tsx +const { html, subject } = await renderInviteEmail({ + // ... + language: user.preferredLanguage, // Must be passed explicitly +}); +``` + +### Translation File Not Found Error + +Verify the file exists at the expected path: + +``` +packages/email-templates/src/locales/[language]/[namespace].json +``` + +The namespace must match the email template name (e.g., `invite-email`, not `invite`). + +### HTML Not Rendering in Email + +Email clients have limited HTML support. Stick to basic tags (`<strong>`, `<em>`, `<br>`) and avoid complex CSS. Test with multiple email clients (Gmail, Outlook, Apple Mail). + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "How do I preview email translations locally?", "answer": "Run 'pnpm dev' in the packages/email-templates directory to start the email preview server at localhost:3001. You can switch languages in the preview to test different translations."}, + {"question": "Can I use the same translations for app and email?", "answer": "Email templates use a separate translation system in packages/email-templates/src/locales. This separation allows emails to be rendered without the full app context and keeps email-specific strings isolated."}, + {"question": "How do I add a new email template with translations?", "answer": "Create the translation JSON files in packages/email-templates/src/locales/[lang]/[template-name].json, then create the React Email component that calls initializeEmailI18n with the matching namespace."}, + {"question": "Do email translations support pluralization?", "answer": "Yes, email translations use next-intl's createTranslator which supports ICU message format for pluralization."}, + {"question": "How do I comply with email regulations (CAN-SPAM, GDPR)?", "answer": "Include an unsubscribe link in marketing emails, add your physical address, and honor unsubscribe requests within 10 days. For GDPR, ensure you have consent before sending and document it. These requirements apply regardless of language."} + ] +/%} + +## Related Documentation + +- [Using Translations](/docs/next-supabase-turbo/translations/using-translations) - Translation basics +- [Adding Translations](/docs/next-supabase-turbo/translations/adding-translations) - Add new languages +- [Language Selector](/docs/next-supabase-turbo/translations/language-selector) - Let users change language +- [Sending Emails](/docs/next-supabase-turbo/emails/sending-emails) - Email sending configuration diff --git a/docs/translations/language-selector.mdoc b/docs/translations/language-selector.mdoc new file mode 100644 index 000000000..4c4154448 --- /dev/null +++ b/docs/translations/language-selector.mdoc @@ -0,0 +1,363 @@ +--- +status: "published" +title: "Language Selector Component | Next.js Supabase SaaS Kit" +label: "Language Selector" +description: "Learn how to add and customize the language selector component to let users switch languages in your application." +order: 2 +--- + +The `LanguageSelector` component lets users switch between available languages. It automatically displays all languages registered in your i18n settings. + +## Using the Language Selector + +Import and render the component anywhere in your application: + +```tsx +import { LanguageSelector } from '@kit/ui/language-selector'; + +export function SettingsPage() { + return ( + <div> + <h2>Language Settings</h2> + <LanguageSelector /> + </div> + ); +} +``` + +The component: +- Reads available languages from your i18n configuration +- Displays language names in the user's current language using `Intl.DisplayNames` +- Navigates to the equivalent URL with the new locale prefix +- The new locale is persisted via URL-based routing + +## Default Placement + +The language selector is already included in the personal account settings page when more than one language is configured. You'll find it at: + +``` +/home/settings → Account Settings → Language +``` + +If only one locale is registered in `packages/i18n/src/locales.tsx`, the selector is hidden automatically. + +## Adding to Other Locations + +### Marketing Header + +Add the selector to your marketing site header: + +```tsx title="apps/web/app/[locale]/(marketing)/_components/site-header.tsx" +import { LanguageSelector } from '@kit/ui/language-selector'; + +import { routing } from '@kit/i18n/routing'; + +export function SiteHeader() { + const showLanguageSelector = routing.locales.length > 1; + + return ( + <header> + <nav> + {/* Navigation items */} + </nav> + + {showLanguageSelector && ( + <LanguageSelector /> + )} + </header> + ); +} +``` + +### Footer + +Add language selection to your footer: + +```tsx title="apps/web/app/[locale]/(marketing)/_components/site-footer.tsx" +import { LanguageSelector } from '@kit/ui/language-selector'; + +import { routing } from '@kit/i18n/routing'; + +export function SiteFooter() { + return ( + <footer> + <div> + {/* Footer content */} + </div> + + {routing.locales.length > 1 && ( + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground">Language:</span> + <LanguageSelector /> + </div> + )} + </footer> + ); +} +``` + +### Dashboard Sidebar + +Include in the application sidebar: + +```tsx title="apps/web/components/sidebar.tsx" +import { LanguageSelector } from '@kit/ui/language-selector'; + +import { routing } from '@kit/i18n/routing'; + +export function Sidebar() { + return ( + <aside> + {/* Sidebar navigation */} + + <div className="mt-auto p-4"> + {routing.locales.length > 1 && ( + <LanguageSelector /> + )} + </div> + </aside> + ); +} +``` + +## Handling Language Changes + +The `onChange` prop lets you run custom logic when the language changes: + +```tsx +import { LanguageSelector } from '@kit/ui/language-selector'; + +export function LanguageSettings() { + const handleLanguageChange = (locale: string) => { + // Track analytics + analytics.track('language_changed', { locale }); + + // Update user preferences in database + updateUserPreferences({ language: locale }); + }; + + return ( + <LanguageSelector onChange={handleLanguageChange} /> + ); +} +``` + +The `onChange` callback fires before navigation, so keep it synchronous or use a fire-and-forget pattern for async operations. + +## How Language Detection Works + +The system uses URL-based locale routing powered by `next-intl` middleware. + +### 1. URL Prefix + +The locale is determined by the URL path prefix: + +``` +/en/home → English +/es/home → Spanish +/de/home → German +``` + +### 2. Browser Preference (New Visitors) + +When a user visits the root URL (`/`), the middleware checks the browser's `Accept-Language` header and redirects to the matching locale: + +``` +User visits / → Accept-Language: es → Redirect to /es/ +``` + +### 3. Default Fallback + +If no matching locale is found, the system redirects to `NEXT_PUBLIC_DEFAULT_LOCALE`: + +```bash title=".env" +NEXT_PUBLIC_DEFAULT_LOCALE=en +``` + +## Configuration Options + +### Adding Locales + +Register supported locales in your configuration: + +```tsx title="packages/i18n/src/locales.tsx" +export const locales: string[] = ['en', 'es', 'de', 'fr']; +``` + +When only one locale is registered, the language selector is hidden automatically. + +## Styling the Selector + +The `LanguageSelector` uses Shadcn UI's `Select` component. Customize it through your Tailwind configuration or by wrapping it: + +```tsx title="components/custom-language-selector.tsx" +import { LanguageSelector } from '@kit/ui/language-selector'; + +export function CustomLanguageSelector() { + return ( + <div className="[&_button]:w-[180px] [&_button]:bg-muted"> + <LanguageSelector /> + </div> + ); +} +``` + +For deeper customization, you can create your own selector using `next-intl` navigation utilities: + +```tsx title="components/language-dropdown.tsx" +'use client'; + +import { useCallback, useMemo } from 'react'; + +import { useLocale } from 'next-intl'; +import { useRouter, usePathname } from '@kit/i18n/navigation'; +import { Globe } from 'lucide-react'; + +import { routing } from '@kit/i18n/routing'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; +import { Button } from '@kit/ui/button'; + +export function LanguageDropdown() { + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + + const languageNames = useMemo(() => { + return new Intl.DisplayNames([locale], { type: 'language' }); + }, [locale]); + + const handleLanguageChange = useCallback( + (newLocale: string) => { + router.replace(pathname, { locale: newLocale }); + }, + [router, pathname], + ); + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <Globe className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + + <DropdownMenuContent align="end"> + {routing.locales.map((loc) => { + const label = languageNames.of(loc) ?? loc; + const isActive = loc === locale; + + return ( + <DropdownMenuItem + key={loc} + onClick={() => handleLanguageChange(loc)} + className={isActive ? 'bg-accent' : ''} + > + {label} + </DropdownMenuItem> + ); + })} + </DropdownMenuContent> + </DropdownMenu> + ); +} +``` + +## SEO Considerations + +With URL-based locale routing, each language variant has its own URL, which is optimal for SEO. The `next-intl` middleware automatically handles `hreflang` alternate links. + +For additional control, you can add explicit alternates in your metadata: + +```tsx title="apps/web/app/[locale]/layout.tsx" +import { routing } from '@kit/i18n/routing'; + +export function generateMetadata() { + const baseUrl = 'https://yoursite.com'; + + return { + alternates: { + languages: Object.fromEntries( + routing.locales.map((lang) => [lang, `${baseUrl}/${lang}`]) + ), + }, + }; +} +``` + +## Testing Language Switching + +To test language switching during development: + +1. **URL method**: + - Navigate directly to a URL with the locale prefix (e.g., `/es/home`) + - Verify translations appear correctly +2. **Component method**: + - Navigate to account settings or wherever you placed the selector + - Select a different language + - Verify the URL updates with the new locale prefix and translations change + +## Accessibility Considerations + +The default `LanguageSelector` uses Shadcn UI's `Select` component which provides: + +- Keyboard navigation (arrow keys, Enter, Escape) +- Screen reader announcements +- Focus management + +When creating custom language selectors, ensure you include: + +```tsx +<DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon" + aria-label="Change language" + aria-haspopup="listbox" + > + <Globe className="h-4 w-4" /> + <span className="sr-only"> + Current language: {languageNames.of(locale)} + </span> + </Button> + </DropdownMenuTrigger> + {/* ... */} +</DropdownMenu> +``` + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "Why does the page navigate when I change the language?", "answer": "Language switching works via URL-based routing. When you select a new language, the app navigates to the equivalent URL with the new locale prefix (e.g., /en/home to /es/home). This ensures all components render with the correct translations."}, + {"question": "Can I change the language without navigation?", "answer": "URL-based locale routing requires navigation since the locale is part of the URL. This is the recommended approach as it provides better SEO, shareable URLs per language, and proper server-side rendering of translations."}, + {"question": "How do I hide the language selector for single-language apps?", "answer": "The selector automatically hides when only one locale is in the locales array. You can also conditionally render it: {routing.locales.length > 1 && <LanguageSelector />}"}, + {"question": "Can I save language preference to the user's profile?", "answer": "Yes. Use the onChange prop to save to your database when the language changes. On future visits, you can redirect users to their preferred locale server-side in middleware."}, + {"question": "Does the language selector work with URL-based routing?", "answer": "Yes, v3 uses URL-based locale routing natively via next-intl middleware. Each locale has its own URL prefix (e.g., /en/about, /es/about). The language selector navigates between these URLs automatically."} + ] +/%} + +## Upgrading from v2 + +{% callout title="Differences with v2" %} +In v2, language switching was cookie-based — changing language set a `lang` cookie and reloaded the page. In v3, language switching uses URL-based routing via `next-intl` middleware. Key differences: + +- Locale is determined by URL prefix (`/en/`, `/es/`) instead of a `lang` cookie +- Language change navigates to a new URL instead of `i18n.changeLanguage()` + reload +- `languages` from `~/lib/i18n/i18n.settings` is now `routing.locales` from `@kit/i18n/routing` +- `useTranslation` from `react-i18next` is now `useTranslations`/`useLocale` from `next-intl` +- No custom middleware needed — `next-intl` provides URL-based routing natively + +For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration). +{% /callout %} + +## Related Documentation + +- [Using Translations](/docs/next-supabase-turbo/translations/using-translations) - Learn how to use translations +- [Adding Translations](/docs/next-supabase-turbo/translations/adding-translations) - Add new languages +- [Email Translations](/docs/next-supabase-turbo/translations/email-translations) - Translate email templates diff --git a/docs/translations/using-translations.mdoc b/docs/translations/using-translations.mdoc new file mode 100644 index 000000000..68ccd99b0 --- /dev/null +++ b/docs/translations/using-translations.mdoc @@ -0,0 +1,386 @@ +--- +status: "published" +title: "Using translations in your Next.js Supabase project" +label: "Using translations" +description: "Learn how to use translations in Server Components, Client Components, and Server Actions with Makerkit's next-intl-based translation system." +order: 0 +--- + +Makerkit uses `next-intl` for internationalization, abstracted behind the `@kit/i18n` package. This abstraction ensures future changes to the translation library won't break your code. + +{% sequence title="Steps to use translations" description="Learn how to use translations in your Next.js Supabase project." %} + +[Understand the translation architecture](#translation-architecture) + +[Use translations in Server Components](#using-translations-in-server-components) + +[Use translations in Client Components](#using-translations-in-client-components) + +[Work with translation keys and namespaces](#working-with-translation-keys) + +{% /sequence %} + +## Translation Architecture + +The translation system supports: + +1. **Server Components (RSC)** - Access translations via `getTranslations` from `next-intl/server` +2. **Client Components** - Access translations via `useTranslations` from `next-intl` +3. **URL-based locale routing** - Locale is determined by the URL prefix (e.g., `/en/home`, `/es/home`) + +Translation files are stored in `apps/web/i18n/messages/{locale}/`. The default structure includes: + +``` +apps/web/i18n/messages/ +└── en/ + ├── common.json # Shared UI strings + ├── auth.json # Authentication flows + ├── account.json # Account settings + ├── teams.json # Team management + ├── billing.json # Billing and subscriptions + └── marketing.json # Marketing pages +``` + +## Using Translations in Server Components + +Server Components can access translations directly using `getTranslations` from `next-intl/server`. + +### Using getTranslations + +```tsx title="apps/web/app/[locale]/home/page.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function HomePage() { + const t = await getTranslations('common'); + + return ( + <div> + <h1>{t('homeTabLabel')}</h1> + <p>{t('homeTabDescription')}</p> + </div> + ); +} +``` + +### Using the Trans Component + +The `Trans` component renders translated strings directly in JSX: + +```tsx title="apps/web/app/[locale]/home/page.tsx" +import { Trans } from '@kit/ui/trans'; + +export default function HomePage() { + return ( + <div> + <h1> + <Trans i18nKey="common.homeTabLabel" /> + </h1> + + <p> + <Trans i18nKey="common.homeTabDescription" /> + </p> + </div> + ); +} +``` + +**Import the `Trans` component from `@kit/ui/trans`** - the Makerkit wrapper handles server/client differences. + +### Using Translations in Metadata + +For page metadata, use `getTranslations` directly: + +```tsx title="apps/web/app/[locale]/home/page.tsx" +import { getTranslations } from 'next-intl/server'; +import { Trans } from '@kit/ui/trans'; + +export async function generateMetadata() { + const t = await getTranslations('common'); + + return { + title: t('homeTabLabel'), + }; +} + +export default function HomePage() { + return ( + <Trans i18nKey="common.homeTabLabel" /> + ); +} +``` + +## Using Translations in Client Components + +Client Components receive translations through the `NextIntlClientProvider` in the root layout. + +### Using the useTranslations Hook + +The `useTranslations` hook provides access to the translation function: + +```tsx title="components/my-component.tsx" +'use client'; + +import { useTranslations } from 'next-intl'; + +export function MyComponent() { + const t = useTranslations(); + + return ( + <button onClick={() => alert(t('common.cancel'))}> + {t('common.cancel')} + </button> + ); +} +``` + +### Specifying Namespaces + +Load specific namespaces for scoped access: + +```tsx title="components/billing-component.tsx" +'use client'; + +import { useTranslations } from 'next-intl'; + +export function BillingComponent() { + const t = useTranslations('billing'); + + // Keys without namespace prefix + return <span>{t('subscriptionSettingsTabLabel')}</span>; +} +``` + +### Using Trans in Client Components + +The `Trans` component also works in Client Components: + +```tsx title="components/welcome-message.tsx" +'use client'; + +import { Trans } from '@kit/ui/trans'; + +export function WelcomeMessage() { + return ( + <p> + <Trans i18nKey="common.signedInAs" /> + </p> + ); +} +``` + +## Working with Translation Keys + +### Key Format + +Translation keys use dot notation `namespace.keyPath`: + +```tsx +// Simple key +<Trans i18nKey="common.cancel" /> + +// Nested key +<Trans i18nKey="common.routes.home" /> + +// With namespace in useTranslations +const t = useTranslations('auth'); +t('signIn'); // Equivalent to 'auth.signIn' +``` + +### Interpolation + +Pass dynamic values to translations using single braces: + +```json title="apps/web/i18n/messages/en/common.json" +{ + "pageOfPages": "Page {page} of {total}", + "showingRecordCount": "Showing {pageSize} of {totalCount} rows" +} +``` + +```tsx +import { Trans } from '@kit/ui/trans'; + +// Using Trans component +<Trans + i18nKey="common.pageOfPages" + values={{ page: 1, total: 10 }} +/> + +// Using t function +const t = useTranslations(); +t('common.showingRecordCount', { pageSize: 25, totalCount: 100 }); +``` + +### Nested Translations + +Access nested objects with dot notation: + +```json title="apps/web/i18n/messages/en/common.json" +{ + "routes": { + "home": "Home", + "account": "Account", + "billing": "Billing" + }, + "roles": { + "owner": { + "label": "Owner" + }, + "member": { + "label": "Member" + } + } +} +``` + +```tsx +<Trans i18nKey="common.routes.home" /> +<Trans i18nKey="common.roles.owner.label" /> +``` + +### HTML in Translations + +For translations containing HTML, use the `Trans` component with components prop: + +```json title="apps/web/i18n/messages/en/auth.json" +{ + "clickToAcceptAs": "Click the button below to accept the invite as <b>{email}</b>" +} +``` + +```tsx +<Trans + i18nKey="auth.clickToAcceptAs" + values={{ email: user.email }} + components={{ b: <strong /> }} +/> +``` + +## Common Patterns + +### Conditional Translations + +```tsx +import { useTranslations, useLocale } from 'next-intl'; + +const t = useTranslations(); +const locale = useLocale(); + +// Check current language +if (locale === 'en') { + // English-specific logic +} + +// Translate with values +const label = t('optional.key', { name: 'World' }); +``` + +### Pluralization + +next-intl uses ICU message format for pluralization: + +```json title="apps/web/i18n/messages/en/common.json" +{ + "itemCount": "{count, plural, one {# item} other {# items}}" +} +``` + +```tsx +t('common.itemCount', { count: 1 }); // "1 item" +t('common.itemCount', { count: 5 }); // "5 items" +``` + +### Date and Number Formatting + +Use the standard `Intl` APIs alongside translations: + +```tsx +const locale = useLocale(); + +const formattedDate = new Intl.DateTimeFormat(locale).format(date); +const formattedNumber = new Intl.NumberFormat(locale).format(1234.56); +``` + +## Server Actions + +For Server Actions, use `getTranslations` from `next-intl/server`: + +```tsx title="apps/web/lib/server/actions.ts" +'use server'; + +import { getTranslations } from 'next-intl/server'; + +export async function myServerAction() { + const t = await getTranslations('common'); + + // Use translations + const message = t('genericServerError'); + + return { error: message }; +} +``` + +## Environment Variables + +Configure language behavior with these environment variables: + +```bash title=".env" +# Default language (fallback when user preference unavailable) +NEXT_PUBLIC_DEFAULT_LOCALE=en +``` + +The locale is determined by the URL prefix (e.g., `/en/`, `/es/`). When a user visits the root URL, they are redirected to their preferred locale based on: + +1. The browser's `Accept-Language` header +2. Falls back to `NEXT_PUBLIC_DEFAULT_LOCALE` + +## Troubleshooting + +### Missing Translation Warning + +If you see a missing translation warning, check: + +1. The key exists in your translation file +2. All interpolation values are provided +3. The namespace is registered in `apps/web/i18n/request.ts` + +### Translations Not Updating + +If translations don't update after editing JSON files: + +1. Restart the development server +2. Clear browser cache +3. Check for JSON syntax errors in translation files + +{% faq + title="Frequently Asked Questions" + items=[ + {"question": "How do I switch languages programmatically?", "answer": "Use router.replace() with the new locale from @kit/i18n/navigation. The locale is part of the URL path (e.g., /en/ to /es/), so changing language means navigating to the equivalent URL with a different locale prefix."}, + {"question": "Why are my translations not showing?", "answer": "Check that the namespace is registered in the namespaces array in apps/web/i18n/request.ts, the JSON file exists in apps/web/i18n/messages/{locale}/, and verify the key uses dot notation (namespace.key not namespace:key)."}, + {"question": "Can I use translations in Server Actions?", "answer": "Yes, import getTranslations from next-intl/server and call it at the start of your server action. Then use the returned t() function for translations."}, + {"question": "What's the difference between Trans component and useTranslations hook?", "answer": "Trans is a React component that renders translated strings directly in JSX, supporting interpolation and HTML. useTranslations is a hook that returns a t() function for programmatic access to translations, useful for attributes, conditionals, or non-JSX contexts."}, + {"question": "How do I handle missing translations during development?", "answer": "Missing translations log warnings to the console. Use [TODO] prefixes in your JSON values to make untranslated strings searchable. The system falls back to the key name if no translation is found."} + ] +/%} + +## Upgrading from v2 + +{% callout title="Differences with v2" %} +In v2, Makerkit used `i18next` and `react-i18next` for internationalization. In v3, the system uses `next-intl`. Key differences: + +- Translation keys use dot notation (`namespace.key`) instead of colon notation (`namespace:key`) +- Interpolation uses single braces (`{var}`) instead of double braces (`{{var}}`) +- Server components use `getTranslations` from `next-intl/server` instead of `withI18n` HOC and `createI18nServerInstance` +- Client components use `useTranslations` from `next-intl` instead of `useTranslation` from `react-i18next` +- Translation files are in `apps/web/i18n/messages/{locale}/` instead of `apps/web/public/locales/{locale}/` +- Pluralization uses ICU format (`{count, plural, one {# item} other {# items}}`) instead of i18next `_one`/`_other` suffixes +- Locale is determined by URL prefix, not cookies + +For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration). +{% /callout %} + +## Related Documentation + +- [Adding Translations](/docs/next-supabase-turbo/translations/adding-translations) - Add new languages and namespaces +- [Language Selector](/docs/next-supabase-turbo/translations/language-selector) - Let users change their language +- [Email Translations](/docs/next-supabase-turbo/translations/email-translations) - Translate email templates diff --git a/docs/troubleshooting/troubleshooting-authentication.mdoc b/docs/troubleshooting/troubleshooting-authentication.mdoc new file mode 100644 index 000000000..d65f6d56b --- /dev/null +++ b/docs/troubleshooting/troubleshooting-authentication.mdoc @@ -0,0 +1,24 @@ +--- +status: "published" +title: 'Troubleshooting authentication issues in the Next.js Supabase kit' +label: 'Authentication' +order: 2 +description: 'Troubleshoot issues related to authentication in the Next.js Supabase SaaS kit' +--- + + +## Supabase redirects to localhost instead of my website URL + +This is most likely an issue related to not having set up the Authentication URL in the Supabase settings as describe in [the going to production guide](../going-to-production/supabase); + +## Cannot Receive Emails from Supabase + +This issue may arise if you have not setup an SMTP provider from within Supabase. + +Supabase's own SMTP provider has low limits and low deliverability, therefore you must set up your own - and only use the default Supabase's one for testing purposes. + +## Cannot sign in with oAuth provider + +This is most likely a settings issues from within the Supabase dashboard or in the provider's settings. + +Please [read Supabase's documentation](https://supabase.com/docs/guides/auth/social-login) on how to set up third-party providers. diff --git a/docs/troubleshooting/troubleshooting-billing.mdoc b/docs/troubleshooting/troubleshooting-billing.mdoc new file mode 100644 index 000000000..9e5b31aa9 --- /dev/null +++ b/docs/troubleshooting/troubleshooting-billing.mdoc @@ -0,0 +1,43 @@ +--- +status: "published" +title: 'Troubleshooting billing issues in the Next.js Supabase kit' +label: 'Billing' +order: 3 +description: 'Troubleshoot issues related to billing in the Next.js Supabase SaaS kit' +--- + +## Cannot create a Checkout + +This happen in the following cases: + +1. **The environment variables are not set correctly**: Please make sure you have set the following environment variables in your `.env` file if locally - or in your hosting provider's dashboard if in production +2. **The plan IDs used are incorrect**: Make sure to use the exact plan IDs as they are in the payment provider's dashboard. + +## The Database is not updated after subscribing to a plan + +This may happen if the webhook is not set up correctly. Please make sure you have set up the webhook in the payment provider's dashboard and that the URL is correct. + +Common issues include: +1. **The webhook is not set up correctly**: Please make sure you have set up the webhook in the payment provider's dashboard and that the URL is correct. +2. **The plan IDs used are incorrect**: Make sure to use the exact plan IDs as they are in the payment provider's dashboard. +3. **The environment variables are not set correctly**: Please make sure you have set the following environment variables in your `.env` file if locally - or in your hosting provider's dashboard if in production + +### Locally + +If working locally, make sure that: + +1. **Stripe**: If using Stripe, that the Stripe CLI is up and running +2. **Lemon Squeezy**: If using Lemon Squeezy, that the webhook set in Lemon Squeezy is correct and that the server is running. Additionally, make sure the proxy is set up correctly if you are testing locally (see the Lemon Squeezy documentation for more information). + +### Production + +If working in production, make sure that: + +- The Stripe/Lemon Squeezy webhooks are set up correctly and are being sent to the correct URL. The URL must be publicly accessible. If the URL is not publicly accessible, you will not be able to receive webhooks from Stripe. +- If the webhooks are being sent, please read the logs in your hosting provider to see if there are any errors related to webhooks. +- If after reading the logs, the webhooks are not being sent, please contact support by posting relevant information in the Support Ticket. + +When opening a support ticket, please make sure to include: +1. The logs being sent from Stripe in the Stripe Dashboard +2. The logs in your hosting provider +3. Any error messages you may have received \ No newline at end of file diff --git a/docs/troubleshooting/troubleshooting-deployment.mdoc b/docs/troubleshooting/troubleshooting-deployment.mdoc new file mode 100644 index 000000000..3d791dd3a --- /dev/null +++ b/docs/troubleshooting/troubleshooting-deployment.mdoc @@ -0,0 +1,15 @@ +--- +status: "published" +title: 'Troubleshooting deployment issues in the Next.js Supabase kit' +label: 'Deployment' +order: 4 +description: 'Troubleshoot issues related to deploying the application in the Next.js Supabase SaaS kit' +--- + +## The deployment build fails + +This is most likely an issue related to the environment variables not being set correctly in the deployment environment. Please analyse the logs of the deployment provider to see what is the issue. + +The kit is very defensive about incorrect environment variables, and will throw an error if any of the required environment variables are not set. In this way - the build will fail if the environment variables are not set correctly - instead of deploying a broken application. + +If you are deploying to Vercel, [please follow this guide](../going-to-production/vercel). \ No newline at end of file diff --git a/docs/troubleshooting/troubleshooting-emails.mdoc b/docs/troubleshooting/troubleshooting-emails.mdoc new file mode 100644 index 000000000..36294e035 --- /dev/null +++ b/docs/troubleshooting/troubleshooting-emails.mdoc @@ -0,0 +1,17 @@ +--- +status: "published" +title: 'Troubleshooting emails issues in the Next.js Supabase kit' +label: 'Emails' +order: 5 +description: 'Troubleshoot issues related to emails in the Next.js Supabase SaaS kit' +--- + +To troubleshoot emails issues in the Next.js Supabase kit, please make sure to: + +1. **Correct Credentials**: please make sure you have setup the correct credentials in your environment variables. Refer to the [emails guide](../emails/email-configuration) for more information. +2. **Your domain is verified**: make sure your domain is verified in your Email provider +3. **The Database Webhooks are configured**: make sure you have configured the Database Webhooks in your Supabase project. Refer to the [Supabase deployment guide](../going-to-production/supabase) for more information. Make sure to read Webhooks are pointing to the correct URL and use the correct shared secret. +4. **The Webhooks use a public URL**: make sure the webhooks use a public URL and not behind authentication (such as a Vercel Preview URL). +5. **Read the Logs**: please verify your server logs in your hosting provider to see if there are any errors related to emails. + +If you have done all the above and still having issues, please open a Support Ticket in Discord. \ No newline at end of file diff --git a/docs/troubleshooting/troubleshooting-installation.mdoc b/docs/troubleshooting/troubleshooting-installation.mdoc new file mode 100644 index 000000000..29c5457f6 --- /dev/null +++ b/docs/troubleshooting/troubleshooting-installation.mdoc @@ -0,0 +1,37 @@ +--- +status: "published" + +title: 'Troubleshooting installation issues in the Next.js Supabase SaaS kit' +label: 'Installation' +order: 1 +description: 'Troubleshoot issues related to installing the Next.js Supabase SaaS kit' +--- + + +## Cannot clone the repository + +Issues related to cloning the repository are usually related to a Git misconfiguration in your local machine. The commands displayed in this guide using SSH: these will work only if you have setup your SSH keys in Github. + +If you run into issues, [please make sure you follow this guide to set up your SSH key in Github](https://docs.github.com/en/authentication/connecting-to-github-with-ssh). + +If this also fails, please use HTTPS instead. You will be able to see the commands in the repository's Github page under the "Clone" dropdown. + +Please also make sure that the account that accepted the invite to Makerkit, and the locally connected account are the same. + +## The Next.js dev server does not start + +This may happen due to some issues in the packages. Try to clean the workspace and reinstall everything again: + +```bash +pnpm run clean:workspaces +pnpm run clean +pnpm i +``` + +You can now retry running the dev server. + +## Supabase does not start + +If you cannot run the Supabase local development environment, it's likely you have not started Docker locally. Supabase requires Docker to be installed and running. + +Please make sure you have installed Docker (or compatible software such as Colima, Orbstack) and that is running on your local machine. diff --git a/docs/troubleshooting/troubleshooting-license.mdoc b/docs/troubleshooting/troubleshooting-license.mdoc new file mode 100644 index 000000000..9c801695e --- /dev/null +++ b/docs/troubleshooting/troubleshooting-license.mdoc @@ -0,0 +1,60 @@ +--- +status: "published" +title: 'Troubleshooting license issues in the Next.js Supabase kit' +label: 'License' +order: 6 +description: 'Troubleshoot issues related to license in the Next.js Supabase SaaS kit' +--- + +## Email from GitHub not received + +If you haven't received the GitHub invitation email after purchasing a Makerkit license, this is almost always due to GitHub's email delivery behavior. + +### Understanding GitHub's Email Behavior + +**Important:** GitHub sends repository invitations to your **primary email address** associated with your GitHub account, not the email you used for your Makerkit purchase or payment or where the invitation was sent. + +### How to Find Your GitHub Primary Email + +1. Go to [GitHub Email Settings](https://github.com/settings/emails) +2. Look for the email marked as "Primary" +3. Check the inbox of that primary email address for the Makerkit repository invitation + +### Common Scenarios + +#### Scenario 1: Different Email for Purchase vs. GitHub + +If you purchased Makerkit with `work@company.com` but your GitHub primary email is `personal@gmail.com`, the invitation will be sent to `personal@gmail.com`. + +#### Scenario 2: Multiple GitHub Email Addresses + +If you have multiple email addresses in your GitHub account but only one is set as "Primary", only that primary email will receive the invitation. + +### Steps to Resolve + +1. **Check Your GitHub Primary Email** + - Visit [GitHub Email Settings](https://github.com/settings/emails) and make sure your primary email is set correctly + - Check the inbox (including spam/junk folders) of your primary email address +2. **Change Your Primary Email (if needed)** + - If you want invitations sent to a different email: + - Add the desired email to your GitHub account if not already added + - Verify the email address + - Set it as your primary email + - Contact Makerkit support to resend the invitation +3. **Check Spam/Junk Folders** + - GitHub invitation emails sometimes end up in spam + - Search for emails from `notifications@github.com` +4. **Check GitHub Organizations** + - Visit [GitHub Notifications](https://github.com/settings/organizations) + - Organizations may also appear there + +### Still Can't Find the Invitation? + +If you've checked your primary email and still haven't received the invitation: + +1. Contact Makerkit support with: + - Your GitHub username + - Your GitHub primary email address + - Confirmation that you've checked spam folders + +Support will resend the invitation to ensure you receive it. \ No newline at end of file diff --git a/docs/troubleshooting/troubleshooting-module-not-found.mdoc b/docs/troubleshooting/troubleshooting-module-not-found.mdoc new file mode 100644 index 000000000..4e96110b7 --- /dev/null +++ b/docs/troubleshooting/troubleshooting-module-not-found.mdoc @@ -0,0 +1,62 @@ +--- +status: "published" +title: 'Troubleshooting module not found issues in the Next.js Supabase kit' +label: 'Modules not found' +order: 6 +description: 'Troubleshoot issues related to modules not found in the Next.js Supabase SaaS kit' +--- + +Let's walk through common "Module not found" errors and how to fix them in your Makerkit project. + +This issue is mostly related to either dependency installed in the wrong package or issues with the file system. + +### 1. Windows Paths issues + +Windows has a limitation for the maximum path length. If you're running into issues with paths, you can try the following: + +1. Move the project closer to the root of your drive +2. Use a shorter name for the project's folder name +3. Please Please Please use WSL2 instead of Windows + +### 2. Windows OneDrive Conflicts + +OneDrive can cause file system issues with Node.js projects. If you're using Windows with OneDrive: + +1. Move your project outside of OneDrive-synced folders +2. Or disable OneDrive sync for your development folder + + +### 3. Dependency Installation Issues + +The most common cause is incorrect dependency installation. Here's how to fix it: + +```bash +# First, clean your workspace +pnpm run clean:workspaces +pnpm run clean + +# Reinstall dependencies +pnpm install +``` + +If you're adding new dependencies, make sure to install them in the correct package: + +```bash +# For main app dependencies +pnpm install my-package --filter web + +# For a specific package +pnpm install my-package --filter @kit/ui +``` + +For example, if you're using the dependency in the `@kit/ui` package, you should install it in the `@kit/ui` package: + +```bash +pnpm add my-package --filter "@kit/ui" +``` + +If it's in the main app, you should install it in the main app: + +```bash +pnpm add my-package --filter web +``` \ No newline at end of file diff --git a/package.json b/package.json index 7043f0213..f72a8a738 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,12 @@ { "name": "next-supabase-saas-kit-turbo", - "version": "2.24.1", + "version": "3.0.0", "private": true, - "sideEffects": false, - "engines": { - "node": ">=20.10.0" - }, "author": { - "url": "https://makerkit.dev", - "name": "MakerKit" + "name": "MakerKit", + "url": "https://makerkit.dev" }, + "sideEffects": false, "scripts": { "turbo": "turbo", "preinstall": "pnpm run --filter scripts requirements", @@ -18,10 +15,11 @@ "clean": "git clean -xdf node_modules dist .next", "clean:workspaces": "turbo clean", "dev": "cross-env FORCE_COLOR=1 turbo dev --parallel", - "format": "turbo format --cache-dir=.turbo --continue -- --cache --cache-location=\"node_modules/.cache/.prettiercache\" --ignore-path=\"../../.gitignore\"", - "format:fix": "turbo format --cache-dir=.turbo --continue -- --write --cache --cache-location=\"node_modules/.cache/.prettiercache\" --ignore-path=\"../../.gitignore\"", - "lint": "turbo lint --cache-dir=.turbo --affected --continue -- --cache --cache-location \"node_modules/.cache/.eslintcache\" && manypkg check", - "lint:fix": "turbo lint --cache-dir=.turbo --affected --continue -- --fix --cache --cache-location \"node_modules/.cache/.eslintcache\" && manypkg fix", + "format": "oxfmt --check", + "format:fix": "oxfmt", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "healthcheck": "oxlint --fix && oxfmt && pnpm run typecheck && manypkg fix", "typecheck": "turbo typecheck --affected --cache-dir=.turbo", "test": "turbo test --cache-dir=.turbo", "update": "pnpm update -r", @@ -36,19 +34,19 @@ "env:generate": "turbo gen env", "env:validate": "turbo gen validate-env" }, - "prettier": "@kit/prettier-config", - "packageManager": "pnpm@10.19.0", - "pnpm": { - "overrides": { - "zod": "3.25.76" - } - }, "devDependencies": { - "@manypkg/cli": "^0.25.1", - "@turbo/gen": "^2.8.11", - "cross-env": "^10.0.0", - "prettier": "^3.8.1", - "turbo": "2.8.11", - "typescript": "^5.9.3" - } + "@manypkg/cli": "catalog:", + "@turbo/gen": "catalog:", + "@types/node": "catalog:", + "cross-env": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "server-only": "catalog:", + "turbo": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=20.10.0" + }, + "packageManager": "pnpm@10.32.1" } diff --git a/packages/analytics/AGENTS.md b/packages/analytics/AGENTS.md index 9044d1189..59c80ec4b 100644 --- a/packages/analytics/AGENTS.md +++ b/packages/analytics/AGENTS.md @@ -1,105 +1,12 @@ -# @kit/analytics Package +# @kit/analytics -Analytics package providing a unified interface for tracking events, page views, and user identification across multiple analytics providers. +## Non-Negotiables -## Architecture +1. Client: `import { analytics } from '@kit/analytics'` / Server: `import { analytics } from '@kit/analytics/server'` +2. NEVER track PII (emails, names, IPs) in event properties +3. NEVER manually call `trackPageView` or `identify` — the analytics provider plugin handles these automatically +4. NEVER create custom providers without implementing the full `AnalyticsService` interface -- **AnalyticsManager**: Central manager orchestrating multiple analytics providers -- **AnalyticsService**: Interface defining analytics operations (track, identify, pageView) -- **Provider System**: Pluggable providers (currently includes NullAnalyticsService) -- **Client/Server Split**: Separate entry points for client and server-side usage +## Exemplar -## Usage - -### Basic Import - -```typescript -// Client-side -import { analytics } from '@kit/analytics'; - -// Server-side -import { analytics } from '@kit/analytics/server'; -``` - -### Core Methods - -```typescript -// Track events -await analytics.trackEvent('button_clicked', { - button_id: 'signup', - page: 'homepage' -}); - -// Track page views -await analytics.trackPageView('/dashboard'); - -// Identify users -await analytics.identify('user123', { - email: 'user@example.com', - plan: 'premium' -}); -``` - -Page views and user identification are handled by the plugin by default. - -## Creating Custom Providers - -Implement the `AnalyticsService` interface: - -```typescript -import { AnalyticsService } from '@kit/analytics'; - -class CustomAnalyticsService implements AnalyticsService { - async initialize(): Promise<void> { - // Initialize your analytics service - } - - async trackEvent(name: string, properties?: Record<string, string | string[]>): Promise<void> { - // Track event implementation - } - - async trackPageView(path: string): Promise<void> { - // Track page view implementation - } - - async identify(userId: string, traits?: Record<string, string>): Promise<void> { - // Identify user implementation - } -} -``` - -## Default Behavior - -- Uses `NullAnalyticsService` when no providers are active -- All methods return Promises that resolve to arrays of provider results -- Console debug logging when no active services or using null service -- Graceful error handling with console warnings for missing providers - -## Server-Side Analytics - -When using PostHog, you can track events server-side for better reliability and privacy: - -```typescript -import { analytics } from '@kit/analytics/server'; - -// Server-side event tracking (e.g., in API routes) -export async function POST(request: Request) { - // ... handle request - - // Track server-side events - await analytics.trackEvent('api_call', { - endpoint: '/api/users', - method: 'POST', - user_id: userId, - }); - - return Response.json({ success: true }); -} - -// Track user registration server-side -await analytics.identify(user.id, { - email: user.email, - created_at: user.created_at, - plan: user.plan, -}); -``` \ No newline at end of file +- `apps/web/components/analytics-provider.tsx` — provider setup with plugin registration diff --git a/packages/analytics/eslint.config.mjs b/packages/analytics/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/analytics/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/analytics/package.json b/packages/analytics/package.json index d12dc6631..2eb498cb2 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,29 +1,24 @@ { "name": "@kit/analytics", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts", - "./server": "./src/server.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/node": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./server": "./src/server.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/tsconfig": "workspace:*", + "@types/node": "catalog:" } } diff --git a/packages/analytics/src/server.ts b/packages/analytics/src/server.ts index 4cb6326ec..8e7c0630d 100644 --- a/packages/analytics/src/server.ts +++ b/packages/analytics/src/server.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { createAnalyticsManager } from './analytics-manager'; import { NullAnalyticsService } from './null-analytics-service'; import type { AnalyticsManager } from './types'; diff --git a/packages/billing/core/eslint.config.mjs b/packages/billing/core/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/billing/core/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/billing/core/package.json b/packages/billing/core/package.json index f4864e61e..6e22c53b1 100644 --- a/packages/billing/core/package.json +++ b/packages/billing/core/package.json @@ -1,33 +1,28 @@ { "name": "@kit/billing", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts", - "./components/*": "./src/components/*", - "./schema": "./src/schema/index.ts", - "./types": "./src/types/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/supabase": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@kit/ui": "workspace:*", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*", + "./schema": "./src/schema/index.ts", + "./types": "./src/types/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:*", + "zod": "catalog:" } } diff --git a/packages/billing/core/src/create-billing-schema.ts b/packages/billing/core/src/create-billing-schema.ts index 8f33c1fe9..45e74eb61 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export enum LineItemType { Flat = 'flat', @@ -19,42 +19,13 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']); export const LineItemSchema = z .object({ - id: z - .string({ - description: - 'Unique identifier for the line item. Defined by the Provider.', - }) - .min(1), - name: z - .string({ - description: 'Name of the line item. Displayed to the user.', - }) - .min(1), - description: z - .string({ - description: - 'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' + - ' from the line item. This is useful if you want to provide a more detailed description to the user.', - }) - .optional(), - cost: z - .number({ - description: 'Cost of the line item. Displayed to the user.', - }) - .min(0), + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + cost: z.number().min(0), type: LineItemTypeSchema, - unit: z - .string({ - description: - 'Unit of the line item. Displayed to the user. Example "seat" or "GB"', - }) - .optional(), - setupFee: z - .number({ - description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`, - }) - .positive() - .optional(), + unit: z.string().optional(), + setupFee: z.number().positive().optional(), tiers: z .array( z.object({ @@ -90,16 +61,8 @@ export const LineItemSchema = z export const PlanSchema = z .object({ - id: z - .string({ - description: 'Unique identifier for the plan. Defined by yourself.', - }) - .min(1), - name: z - .string({ - description: 'Name of the plan. Displayed to the user.', - }) - .min(1), + id: z.string().min(1), + name: z.string().min(1), interval: BillingIntervalSchema.optional(), custom: z.boolean().default(false).optional(), label: z.string().min(1).optional(), @@ -122,13 +85,7 @@ export const PlanSchema = z path: ['lineItems'], }, ), - trialDays: z - .number({ - description: - 'Number of days for the trial period. Leave empty for no trial.', - }) - .positive() - .optional(), + trialDays: z.number().positive().optional(), paymentType: PaymentTypeSchema, }) .refine( @@ -207,56 +164,15 @@ export const PlanSchema = z const ProductSchema = z .object({ - id: z - .string({ - description: - 'Unique identifier for the product. Defined by th Provider.', - }) - .min(1), - name: z - .string({ - description: 'Name of the product. Displayed to the user.', - }) - .min(1), - description: z - .string({ - description: 'Description of the product. Displayed to the user.', - }) - .min(1), - currency: z - .string({ - description: 'Currency code for the product. Displayed to the user.', - }) - .min(3) - .max(3), - badge: z - .string({ - description: - 'Badge for the product. Displayed to the user. Example: "Popular"', - }) - .optional(), - features: z - .array( - z.string({ - description: 'Features of the product. Displayed to the user.', - }), - ) - .nonempty(), - enableDiscountField: z - .boolean({ - description: 'Enable discount field for the product in the checkout.', - }) - .optional(), - highlighted: z - .boolean({ - description: 'Highlight this product. Displayed to the user.', - }) - .optional(), - hidden: z - .boolean({ - description: 'Hide this product from being displayed to users.', - }) - .optional(), + id: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1), + currency: z.string().min(3).max(3), + badge: z.string().optional(), + features: z.array(z.string()).nonempty(), + enableDiscountField: z.boolean().optional(), + highlighted: z.boolean().optional(), + hidden: z.boolean().optional(), plans: z.array(PlanSchema), }) .refine((data) => data.plans.length > 0, { @@ -337,14 +253,14 @@ const BillingSchema = z }, ); -export function createBillingSchema(config: z.infer<typeof BillingSchema>) { +export function createBillingSchema(config: z.output<typeof BillingSchema>) { return BillingSchema.parse(config); } -export type BillingConfig = z.infer<typeof BillingSchema>; -export type ProductSchema = z.infer<typeof ProductSchema>; +export type BillingConfig = z.output<typeof BillingSchema>; +export type ProductSchema = z.output<typeof ProductSchema>; -export function getPlanIntervals(config: z.infer<typeof BillingSchema>) { +export function getPlanIntervals(config: z.output<typeof BillingSchema>) { const intervals = config.products .flatMap((product) => product.plans.map((plan) => plan.interval)) .filter(Boolean); @@ -363,7 +279,7 @@ export function getPlanIntervals(config: z.infer<typeof BillingSchema>) { * @param planId */ export function getPrimaryLineItem( - config: z.infer<typeof BillingSchema>, + config: z.output<typeof BillingSchema>, planId: string, ) { for (const product of config.products) { @@ -391,7 +307,7 @@ export function getPrimaryLineItem( } export function getProductPlanPair( - config: z.infer<typeof BillingSchema>, + config: z.output<typeof BillingSchema>, planId: string, ) { for (const product of config.products) { @@ -406,7 +322,7 @@ export function getProductPlanPair( } export function getProductPlanPairByVariantId( - config: z.infer<typeof BillingSchema>, + config: z.output<typeof BillingSchema>, planId: string, ) { for (const product of config.products) { @@ -422,7 +338,7 @@ export function getProductPlanPairByVariantId( throw new Error('Plan not found'); } -export type PlanTypeMap = Map<string, z.infer<typeof LineItemTypeSchema>>; +export type PlanTypeMap = Map<string, z.output<typeof LineItemTypeSchema>>; /** * @name getPlanTypesMap @@ -430,7 +346,7 @@ export type PlanTypeMap = Map<string, z.infer<typeof LineItemTypeSchema>>; * @param config */ export function getPlanTypesMap( - config: z.infer<typeof BillingSchema>, + config: z.output<typeof BillingSchema>, ): PlanTypeMap { const planTypes: PlanTypeMap = new Map(); diff --git a/packages/billing/core/src/schema/cancel-subscription-params.schema.ts b/packages/billing/core/src/schema/cancel-subscription-params.schema.ts index b0e6ef48d..6cc29dd5b 100644 --- a/packages/billing/core/src/schema/cancel-subscription-params.schema.ts +++ b/packages/billing/core/src/schema/cancel-subscription-params.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const CancelSubscriptionParamsSchema = z.object({ subscriptionId: z.string(), diff --git a/packages/billing/core/src/schema/create-biling-portal-session.schema.ts b/packages/billing/core/src/schema/create-biling-portal-session.schema.ts index 75affe124..86306f0bd 100644 --- a/packages/billing/core/src/schema/create-biling-portal-session.schema.ts +++ b/packages/billing/core/src/schema/create-biling-portal-session.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const CreateBillingPortalSessionSchema = z.object({ returnUrl: z.string().url(), diff --git a/packages/billing/core/src/schema/create-billing-checkout.schema.ts b/packages/billing/core/src/schema/create-billing-checkout.schema.ts index 6194beda4..50d6fa936 100644 --- a/packages/billing/core/src/schema/create-billing-checkout.schema.ts +++ b/packages/billing/core/src/schema/create-billing-checkout.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { PlanSchema } from '../create-billing-schema'; @@ -15,5 +15,5 @@ export const CreateBillingCheckoutSchema = z.object({ quantity: z.number(), }), ), - metadata: z.record(z.string()).optional(), + metadata: z.record(z.string(), z.string()).optional(), }); diff --git a/packages/billing/core/src/schema/query-billing-usage.schema.ts b/packages/billing/core/src/schema/query-billing-usage.schema.ts index 384a760dd..ae823b76c 100644 --- a/packages/billing/core/src/schema/query-billing-usage.schema.ts +++ b/packages/billing/core/src/schema/query-billing-usage.schema.ts @@ -1,32 +1,17 @@ -import { z } from 'zod'; +import * as z from 'zod'; -const TimeFilter = z.object( - { - startTime: z.number(), - endTime: z.number(), - }, - { - description: `The time range to filter the usage records. Used for Stripe`, - }, -); +const TimeFilter = z.object({ + startTime: z.number(), + endTime: z.number(), +}); -const PageFilter = z.object( - { - page: z.number(), - size: z.number(), - }, - { - description: `The page and size to filter the usage records. Used for LS`, - }, -); +const PageFilter = z.object({ + page: z.number(), + size: z.number(), +}); export const QueryBillingUsageSchema = z.object({ - id: z.string({ - description: - 'The id of the usage record. For Stripe a meter ID, for LS a subscription item ID.', - }), - customerId: z.string({ - description: 'The id of the customer in the billing system', - }), + id: z.string(), + customerId: z.string(), filter: z.union([TimeFilter, PageFilter]), }); diff --git a/packages/billing/core/src/schema/report-billing-usage.schema.ts b/packages/billing/core/src/schema/report-billing-usage.schema.ts index fc3a91f7d..6469ae4ef 100644 --- a/packages/billing/core/src/schema/report-billing-usage.schema.ts +++ b/packages/billing/core/src/schema/report-billing-usage.schema.ts @@ -1,15 +1,8 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const ReportBillingUsageSchema = z.object({ - id: z.string({ - description: - 'The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.', - }), - eventName: z - .string({ - description: 'The name of the event that triggered the usage', - }) - .optional(), + id: z.string(), + eventName: z.string().optional(), usage: z.object({ quantity: z.number(), action: z.enum(['increment', 'set']).optional(), diff --git a/packages/billing/core/src/schema/retrieve-checkout-session.schema.ts b/packages/billing/core/src/schema/retrieve-checkout-session.schema.ts index 4be18b3cf..7bece34d1 100644 --- a/packages/billing/core/src/schema/retrieve-checkout-session.schema.ts +++ b/packages/billing/core/src/schema/retrieve-checkout-session.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const RetrieveCheckoutSessionSchema = z.object({ sessionId: z.string(), diff --git a/packages/billing/core/src/schema/update-subscription-params.schema.ts b/packages/billing/core/src/schema/update-subscription-params.schema.ts index ac3844420..43cf11c9c 100644 --- a/packages/billing/core/src/schema/update-subscription-params.schema.ts +++ b/packages/billing/core/src/schema/update-subscription-params.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const UpdateSubscriptionParamsSchema = z.object({ subscriptionId: z.string().min(1), diff --git a/packages/billing/core/src/services/billing-strategy-provider.service.ts b/packages/billing/core/src/services/billing-strategy-provider.service.ts index 662c99cfd..23ca1d3b6 100644 --- a/packages/billing/core/src/services/billing-strategy-provider.service.ts +++ b/packages/billing/core/src/services/billing-strategy-provider.service.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { CancelSubscriptionParamsSchema, @@ -13,13 +13,13 @@ import { UpsertSubscriptionParams } from '../types'; export abstract class BillingStrategyProviderService { abstract createBillingPortalSession( - params: z.infer<typeof CreateBillingPortalSessionSchema>, + params: z.output<typeof CreateBillingPortalSessionSchema>, ): Promise<{ url: string; }>; abstract retrieveCheckoutSession( - params: z.infer<typeof RetrieveCheckoutSessionSchema>, + params: z.output<typeof RetrieveCheckoutSessionSchema>, ): Promise<{ checkoutToken: string | null; status: 'complete' | 'expired' | 'open'; @@ -31,31 +31,31 @@ export abstract class BillingStrategyProviderService { }>; abstract createCheckoutSession( - params: z.infer<typeof CreateBillingCheckoutSchema>, + params: z.output<typeof CreateBillingCheckoutSchema>, ): Promise<{ checkoutToken: string; }>; abstract cancelSubscription( - params: z.infer<typeof CancelSubscriptionParamsSchema>, + params: z.output<typeof CancelSubscriptionParamsSchema>, ): Promise<{ success: boolean; }>; abstract reportUsage( - params: z.infer<typeof ReportBillingUsageSchema>, + params: z.output<typeof ReportBillingUsageSchema>, ): Promise<{ success: boolean; }>; abstract queryUsage( - params: z.infer<typeof QueryBillingUsageSchema>, + params: z.output<typeof QueryBillingUsageSchema>, ): Promise<{ value: number; }>; abstract updateSubscriptionItem( - params: z.infer<typeof UpdateSubscriptionParamsSchema>, + params: z.output<typeof UpdateSubscriptionParamsSchema>, ): Promise<{ success: boolean; }>; diff --git a/packages/billing/gateway/eslint.config.mjs b/packages/billing/gateway/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/billing/gateway/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/billing/gateway/package.json b/packages/billing/gateway/package.json index 6d49e5179..6a686bff3 100644 --- a/packages/billing/gateway/package.json +++ b/packages/billing/gateway/package.json @@ -1,26 +1,28 @@ { "name": "@kit/billing-gateway", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" + "private": true, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } }, - "prettier": "@kit/prettier-config", "exports": { ".": "./src/index.ts", "./components": "./src/components/index.ts", "./checkout": "./src/components/embedded-checkout.tsx", "./marketing": "./src/components/marketing.tsx" }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, "devDependencies": { - "@hookform/resolvers": "^5.2.2", + "@hookform/resolvers": "catalog:", "@kit/billing": "workspace:*", - "@kit/eslint-config": "workspace:*", "@kit/lemon-squeezy": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/shared": "workspace:*", "@kit/stripe": "workspace:*", "@kit/supabase": "workspace:*", @@ -28,19 +30,12 @@ "@kit/ui": "workspace:*", "@supabase/supabase-js": "catalog:", "@types/react": "catalog:", - "date-fns": "^4.1.0", + "date-fns": "catalog:", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", "react": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", "zod": "catalog:" - }, - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } } } diff --git a/packages/billing/gateway/src/components/billing-portal-card.tsx b/packages/billing/gateway/src/components/billing-portal-card.tsx index 64fe61f82..85e229481 100644 --- a/packages/billing/gateway/src/components/billing-portal-card.tsx +++ b/packages/billing/gateway/src/components/billing-portal-card.tsx @@ -17,19 +17,19 @@ export function BillingPortalCard() { <Card> <CardHeader> <CardTitle> - <Trans i18nKey="billing:billingPortalCardTitle" /> + <Trans i18nKey="billing.billingPortalCardTitle" /> </CardTitle> <CardDescription> - <Trans i18nKey="billing:billingPortalCardDescription" /> + <Trans i18nKey="billing.billingPortalCardDescription" /> </CardDescription> </CardHeader> <CardContent className={'space-y-2'}> <div> - <Button data-test={'manage-billing-redirect-button'}> + <Button type="submit" data-test={'manage-billing-redirect-button'}> <span> - <Trans i18nKey="billing:billingPortalCardButton" /> + <Trans i18nKey="billing.billingPortalCardButton" /> </span> <ArrowUpRight className={'h-4'} /> diff --git a/packages/billing/gateway/src/components/billing-session-status.tsx b/packages/billing/gateway/src/components/billing-session-status.tsx index 7f62f8bca..3e7dae4e2 100644 --- a/packages/billing/gateway/src/components/billing-session-status.tsx +++ b/packages/billing/gateway/src/components/billing-session-status.tsx @@ -41,7 +41,7 @@ export function BillingSessionStatus({ <Heading level={3}> <span className={'mr-4 font-semibold'}> - <Trans i18nKey={'billing:checkoutSuccessTitle'} /> + <Trans i18nKey={'billing.checkoutSuccessTitle'} /> </span> 🎉 </Heading> @@ -49,22 +49,26 @@ export function BillingSessionStatus({ <div className={'text-muted-foreground flex flex-col space-y-4'}> <p> <Trans - i18nKey={'billing:checkoutSuccessDescription'} + i18nKey={'billing.checkoutSuccessDescription'} values={{ customerEmail }} /> </p> </div> <div> - <Button data-test={'checkout-success-back-link'} asChild> - <Link href={redirectPath}> - <span> - <Trans i18nKey={'billing:checkoutSuccessBackButton'} /> - </span> + <Button + nativeButton={false} + data-test={'checkout-success-back-link'} + render={ + <Link href={redirectPath}> + <span> + <Trans i18nKey={'billing.checkoutSuccessBackButton'} /> + </span> - <ChevronRight className={'h-4'} /> - </Link> - </Button> + <ChevronRight className={'h-4'} /> + </Link> + } + /> </div> </div> </section> diff --git a/packages/billing/gateway/src/components/current-lifetime-order-card.tsx b/packages/billing/gateway/src/components/current-lifetime-order-card.tsx index 6156e8ae3..184a3d035 100644 --- a/packages/billing/gateway/src/components/current-lifetime-order-card.tsx +++ b/packages/billing/gateway/src/components/current-lifetime-order-card.tsx @@ -44,11 +44,11 @@ export function CurrentLifetimeOrderCard({ <Card> <CardHeader> <CardTitle> - <Trans i18nKey="billing:planCardTitle" /> + <Trans i18nKey="billing.planCardTitle" /> </CardTitle> <CardDescription> - <Trans i18nKey="billing:planCardDescription" /> + <Trans i18nKey="billing.planCardDescription" /> </CardDescription> </CardHeader> @@ -70,7 +70,7 @@ export function CurrentLifetimeOrderCard({ <div> <div className="flex flex-col gap-y-1"> <span className="font-semibold"> - <Trans i18nKey="billing:detailsLabel" /> + <Trans i18nKey="billing.detailsLabel" /> </span> <LineItemDetails diff --git a/packages/billing/gateway/src/components/current-plan-alert.tsx b/packages/billing/gateway/src/components/current-plan-alert.tsx index 9eeca42c9..4eb142e5d 100644 --- a/packages/billing/gateway/src/components/current-plan-alert.tsx +++ b/packages/billing/gateway/src/components/current-plan-alert.tsx @@ -21,7 +21,7 @@ export function CurrentPlanAlert( status: Enums<'subscription_status'>; }>, ) { - const prefix = 'billing:status'; + const prefix = 'billing.status'; const text = `${prefix}.${props.status}.description`; const title = `${prefix}.${props.status}.heading`; diff --git a/packages/billing/gateway/src/components/current-plan-badge.tsx b/packages/billing/gateway/src/components/current-plan-badge.tsx index 23f7ab2e6..3162942b0 100644 --- a/packages/billing/gateway/src/components/current-plan-badge.tsx +++ b/packages/billing/gateway/src/components/current-plan-badge.tsx @@ -23,7 +23,7 @@ export function CurrentPlanBadge( status: Status; }>, ) { - const text = `billing:status.${props.status}.badge`; + const text = `billing.status.${props.status}.badge`; const variant = statusBadgeMap[props.status]; return ( diff --git a/packages/billing/gateway/src/components/current-subscription-card.tsx b/packages/billing/gateway/src/components/current-subscription-card.tsx index e2cca8872..fab3d54a6 100644 --- a/packages/billing/gateway/src/components/current-subscription-card.tsx +++ b/packages/billing/gateway/src/components/current-subscription-card.tsx @@ -48,11 +48,11 @@ export function CurrentSubscriptionCard({ <Card> <CardHeader> <CardTitle> - <Trans i18nKey="billing:planCardTitle" /> + <Trans i18nKey="billing.planCardTitle" /> </CardTitle> <CardDescription> - <Trans i18nKey="billing:planCardDescription" /> + <Trans i18nKey="billing.planCardDescription" /> </CardDescription> </CardHeader> @@ -94,7 +94,7 @@ export function CurrentSubscriptionCard({ <div className="flex flex-col gap-y-1 border-y border-dashed py-4"> <span className="font-semibold"> - <Trans i18nKey="billing:detailsLabel" /> + <Trans i18nKey="billing.detailsLabel" /> </span> <LineItemDetails @@ -110,12 +110,12 @@ export function CurrentSubscriptionCard({ <InfoIcon className={'h-4 w-4'} /> <AlertTitle> - <Trans i18nKey="billing:trialAlertTitle" /> + <Trans i18nKey="billing.trialAlertTitle" /> </AlertTitle> <AlertDescription> <Trans - i18nKey="billing:trialAlertDescription" + i18nKey="billing.trialAlertDescription" values={{ date: formatDate( subscription.trial_ends_at ?? '', @@ -134,12 +134,12 @@ export function CurrentSubscriptionCard({ <MessageCircleWarning className={'h-4 w-4'} /> <AlertTitle> - <Trans i18nKey="billing:subscriptionCancelled" /> + <Trans i18nKey="billing.subscriptionCancelled" /> </AlertTitle> <AlertDescription> <Trans - i18nKey="billing:cancelSubscriptionDate" + i18nKey="billing.cancelSubscriptionDate" values={{ date: formatDate( subscription.period_ends_at ?? '', diff --git a/packages/billing/gateway/src/components/embedded-checkout.tsx b/packages/billing/gateway/src/components/embedded-checkout.tsx index 442aa2888..79eace940 100644 --- a/packages/billing/gateway/src/components/embedded-checkout.tsx +++ b/packages/billing/gateway/src/components/embedded-checkout.tsx @@ -38,8 +38,6 @@ export function EmbeddedCheckout( checkoutToken={props.checkoutToken} /> </Suspense> - - <BlurryBackdrop /> </> ); } @@ -71,14 +69,3 @@ function CheckoutSelector( throw new Error(`Unsupported provider: ${props.provider as string}`); } } - -function BlurryBackdrop() { - return ( - <div - className={ - 'bg-background/30 fixed top-0 left-0 w-full backdrop-blur-sm' + - ' !m-0 h-full' - } - /> - ); -} diff --git a/packages/billing/gateway/src/components/line-item-details.tsx b/packages/billing/gateway/src/components/line-item-details.tsx index 906838133..706162d7e 100644 --- a/packages/billing/gateway/src/components/line-item-details.tsx +++ b/packages/billing/gateway/src/components/line-item-details.tsx @@ -1,8 +1,8 @@ 'use client'; import { PlusSquare } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import { useLocale, useTranslations } from 'next-intl'; +import * as z from 'zod'; import type { LineItemSchema } from '@kit/billing'; import { formatCurrency } from '@kit/shared/utils'; @@ -14,14 +14,14 @@ const className = 'flex text-secondary-foreground items-center text-sm'; export function LineItemDetails( props: React.PropsWithChildren<{ - lineItems: z.infer<typeof LineItemSchema>[]; + lineItems: z.output<typeof LineItemSchema>[]; currency: string; selectedInterval?: string | undefined; alwaysDisplayMonthlyPrice?: boolean; }>, ) { - const { t, i18n } = useTranslation(); - const locale = i18n.language; + const t = useTranslations('billing'); + const locale = useLocale(); const currencyCode = props?.currency.toLowerCase(); const shouldDisplayMonthlyPrice = @@ -32,16 +32,16 @@ export function LineItemDetails( return ''; } - const i18nKey = `billing:units.${unit}`; + const i18nKey = `units.${unit}` as never; - if (!i18n.exists(i18nKey)) { + if (!t.has(i18nKey)) { return unit; } return t(i18nKey, { count, defaultValue: unit, - }); + } as never); }; const getDisplayCost = (cost: number, hasTiers: boolean) => { @@ -82,7 +82,7 @@ export function LineItemDetails( <span> <Trans - i18nKey={'billing:setupFee'} + i18nKey={'billing.setupFee'} values={{ setupFee: formatCurrency({ currencyCode, @@ -111,18 +111,18 @@ export function LineItemDetails( <PlusSquare className={'w-3'} /> <span> - <Trans i18nKey={'billing:basePlan'} /> + <Trans i18nKey={'billing.basePlan'} /> </span> </span> <span> <If condition={props.selectedInterval} - fallback={<Trans i18nKey={'billing:lifetime'} />} + fallback={<Trans i18nKey={'billing.lifetime'} />} > ( <Trans - i18nKey={`billing:billingInterval.${props.selectedInterval}`} + i18nKey={`billing.billingInterval.${props.selectedInterval}`} /> ) </If> @@ -149,7 +149,7 @@ export function LineItemDetails( <span className={'flex gap-x-2 text-sm'}> <span> <Trans - i18nKey={'billing:perUnit'} + i18nKey={'billing.perUnit'} values={{ unit: getUnitLabel(unit, 1), }} @@ -172,10 +172,10 @@ export function LineItemDetails( <span> <If condition={Boolean(unit) && !isDefaultSeatUnit} - fallback={<Trans i18nKey={'billing:perTeamMember'} />} + fallback={<Trans i18nKey={'billing.perTeamMember'} />} > <Trans - i18nKey={'billing:perUnitShort'} + i18nKey={'billing.perUnitShort'} values={{ unit: getUnitLabel(unit, 1), }} @@ -215,7 +215,7 @@ export function LineItemDetails( <span className={'flex space-x-1'}> <span> <Trans - i18nKey={'billing:perUnit'} + i18nKey={'billing.perUnit'} values={{ unit: getUnitLabel(unit, 1), }} @@ -268,11 +268,11 @@ function Tiers({ unit, }: { currency: string; - item: z.infer<typeof LineItemSchema>; unit?: string; + item: z.output<typeof LineItemSchema>; }) { - const { t, i18n } = useTranslation(); - const locale = i18n.language; + const t = useTranslations('billing'); + const locale = useLocale(); // Helper to safely convert tier values to numbers for pluralization // Falls back to plural form (2) for 'unlimited' values @@ -285,10 +285,13 @@ function Tiers({ const getUnitLabel = (count: number) => { if (!unit) return ''; - return t(`billing:units.${unit}`, { - count, - defaultValue: unit, - }); + return t( + `units.${unit}` as never, + { + count, + defaultValue: unit, + } as never, + ); }; const tiers = item.tiers?.map((tier, index) => { @@ -327,7 +330,7 @@ function Tiers({ <If condition={tiersLength > 1}> <span> <Trans - i18nKey={'billing:andAbove'} + i18nKey={'billing.andAbove'} values={{ unit: getUnitLabel(getSafeCount(previousTierFrom) - 1), previousTier: getSafeCount(previousTierFrom) - 1, @@ -338,7 +341,7 @@ function Tiers({ <If condition={tiersLength === 1}> <span> <Trans - i18nKey={'billing:forEveryUnit'} + i18nKey={'billing.forEveryUnit'} values={{ unit: getUnitLabel(1), }} @@ -350,7 +353,7 @@ function Tiers({ <If condition={isIncluded}> <span> <Trans - i18nKey={'billing:includedUpTo'} + i18nKey={'billing.includedUpTo'} values={{ unit: getUnitLabel(getSafeCount(upTo)), upTo, @@ -368,7 +371,7 @@ function Tiers({ </span>{' '} <span> <Trans - i18nKey={'billing:fromPreviousTierUpTo'} + i18nKey={'billing.fromPreviousTierUpTo'} values={{ previousTierFrom, unit: getUnitLabel(1), diff --git a/packages/billing/gateway/src/components/plan-cost-display.tsx b/packages/billing/gateway/src/components/plan-cost-display.tsx index 35995d314..c48825773 100644 --- a/packages/billing/gateway/src/components/plan-cost-display.tsx +++ b/packages/billing/gateway/src/components/plan-cost-display.tsx @@ -2,15 +2,15 @@ import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import { useLocale } from 'next-intl'; +import * as z from 'zod'; import type { LineItemSchema } from '@kit/billing'; import { formatCurrency } from '@kit/shared/utils'; import { Trans } from '@kit/ui/trans'; type PlanCostDisplayProps = { - primaryLineItem: z.infer<typeof LineItemSchema>; + primaryLineItem: z.output<typeof LineItemSchema>; currencyCode: string; interval?: string; alwaysDisplayMonthlyPrice?: boolean; @@ -30,7 +30,7 @@ export function PlanCostDisplay({ alwaysDisplayMonthlyPrice = true, className, }: PlanCostDisplayProps) { - const { i18n } = useTranslation(); + const locale = useLocale(); const { shouldDisplayTier, lowestTier, tierTranslationKey, displayCost } = useMemo(() => { @@ -62,8 +62,8 @@ export function PlanCostDisplay({ isMultiTier, lowestTier, tierTranslationKey: isMultiTier - ? 'billing:startingAtPriceUnit' - : 'billing:priceUnit', + ? 'billing.startingAtPriceUnit' + : 'billing.priceUnit', displayCost: cost, }; }, [primaryLineItem, interval, alwaysDisplayMonthlyPrice]); @@ -72,7 +72,7 @@ export function PlanCostDisplay({ const formattedCost = formatCurrency({ currencyCode: currencyCode.toLowerCase(), value: lowestTier?.cost ?? 0, - locale: i18n.language, + locale: locale, }); return ( @@ -91,7 +91,7 @@ export function PlanCostDisplay({ const formattedCost = formatCurrency({ currencyCode: currencyCode.toLowerCase(), value: displayCost, - locale: i18n.language, + locale: locale, }); return <span className={className}>{formattedCost}</span>; diff --git a/packages/billing/gateway/src/components/plan-picker.tsx b/packages/billing/gateway/src/components/plan-picker.tsx index 6653cd84f..cda9a2b8d 100644 --- a/packages/billing/gateway/src/components/plan-picker.tsx +++ b/packages/billing/gateway/src/components/plan-picker.tsx @@ -4,9 +4,9 @@ import { useMemo } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowRight, CheckCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import * as z from 'zod'; import { BillingConfig, @@ -25,7 +25,6 @@ import { FormMessage, } from '@kit/ui/form'; import { If } from '@kit/ui/if'; -import { Label } from '@kit/ui/label'; import { RadioGroup, RadioGroupItem, @@ -50,7 +49,7 @@ export function PlanPicker( }; }>, ) { - const { t } = useTranslation(`billing`); + const t = useTranslations('billing'); const intervals = useMemo( () => getPlanIntervals(props.config), @@ -137,7 +136,7 @@ export function PlanPicker( render={({ field }) => { return ( <FormItem className={'flex flex-col gap-4'}> - <FormControl id={'plan-picker-id'}> + <FormControl> <RadioGroup name={field.name} value={field.value}> <div className={'flex space-x-1'}> {intervals.map((interval) => { @@ -147,6 +146,23 @@ export function PlanPicker( <label htmlFor={interval} key={interval} + onClick={() => { + form.setValue('interval', interval, { + shouldValidate: true, + }); + + if (selectedProduct) { + const plan = selectedProduct.plans.find( + (item) => item.interval === interval, + ); + + form.setValue('planId', plan?.id ?? '', { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + } + }} className={cn( 'focus-within:border-primary flex items-center gap-x-2.5 rounded-md px-2.5 py-2 transition-colors', { @@ -158,27 +174,6 @@ export function PlanPicker( <RadioGroupItem id={interval} value={interval} - onClick={() => { - form.setValue('interval', interval, { - shouldValidate: true, - }); - - if (selectedProduct) { - const plan = selectedProduct.plans.find( - (item) => item.interval === interval, - ); - - form.setValue( - 'planId', - plan?.id ?? '', - { - shouldValidate: true, - shouldDirty: true, - shouldTouch: true, - }, - ); - } - }} /> <span @@ -187,7 +182,7 @@ export function PlanPicker( })} > <Trans - i18nKey={`billing:billingInterval.${interval}`} + i18nKey={`billing.billingInterval.${interval}`} /> </span> </label> @@ -244,15 +239,28 @@ export function PlanPicker( <RadioGroupItemLabel selected={selected} key={primaryLineItem.id} + htmlFor={primaryLineItem.id} className="rounded-md !border-transparent" + onClick={() => { + if (selected) { + return; + } + + form.setValue('planId', planId, { + shouldValidate: true, + }); + + form.setValue('productId', product.id, { + shouldValidate: true, + }); + }} > <div className={ 'flex w-full flex-col content-center gap-y-3 lg:flex-row lg:items-center lg:justify-between lg:space-y-0' } > - <Label - htmlFor={plan.id} + <div className={ 'flex flex-col justify-center space-y-2.5' } @@ -263,24 +271,11 @@ export function PlanPicker( key={plan.id + selected} id={plan.id} value={plan.id} - onClick={() => { - if (selected) { - return; - } - - form.setValue('planId', planId, { - shouldValidate: true, - }); - - form.setValue('productId', product.id, { - shouldValidate: true, - }); - }} /> <span className="font-semibold"> <Trans - i18nKey={`billing:plans.${product.id}.name`} + i18nKey={`billing.plans.${product.id}.name`} defaults={product.name} /> </span> @@ -296,7 +291,7 @@ export function PlanPicker( variant={'success'} > <Trans - i18nKey={`billing:trialPeriod`} + i18nKey={`billing.trialPeriod`} values={{ period: plan.trialDays, }} @@ -308,11 +303,11 @@ export function PlanPicker( <span className={'text-muted-foreground'}> <Trans - i18nKey={`billing:plans.${product.id}.description`} + i18nKey={`billing.plans.${product.id}.description`} defaults={product.description} /> </span> - </Label> + </div> <div className={ @@ -336,10 +331,10 @@ export function PlanPicker( plan.paymentType === 'recurring' } fallback={ - <Trans i18nKey={`billing:lifetime`} /> + <Trans i18nKey={`billing.lifetime`} /> } > - <Trans i18nKey={`billing:perMonth`} /> + <Trans i18nKey={`billing.perMonth`} /> </If> </span> </div> @@ -367,6 +362,7 @@ export function PlanPicker( <div> <Button + type="submit" data-test="checkout-submit-button" disabled={props.pending ?? !form.formState.isValid} > @@ -408,7 +404,7 @@ function PlanDetails({ selectedInterval: string; selectedPlan: { - lineItems: z.infer<typeof LineItemSchema>[]; + lineItems: z.output<typeof LineItemSchema>[]; paymentType: string; }; }) { diff --git a/packages/billing/gateway/src/components/pricing-table.tsx b/packages/billing/gateway/src/components/pricing-table.tsx index ba9ab771b..c82d6b45f 100644 --- a/packages/billing/gateway/src/components/pricing-table.tsx +++ b/packages/billing/gateway/src/components/pricing-table.tsx @@ -5,8 +5,8 @@ import { useState } from 'react'; import Link from 'next/link'; import { ArrowRight, CheckCircle } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import { useTranslations } from 'next-intl'; +import * as z from 'zod'; import { BillingConfig, @@ -122,14 +122,14 @@ function PricingItem( selectable: boolean; - primaryLineItem: z.infer<typeof LineItemSchema> | undefined; + primaryLineItem: z.output<typeof LineItemSchema> | undefined; redirectToCheckout?: boolean; alwaysDisplayMonthlyPrice?: boolean; plan: { id: string; - lineItems: z.infer<typeof LineItemSchema>[]; + lineItems: z.output<typeof LineItemSchema>[]; interval?: Interval; name?: string; href?: string; @@ -154,19 +154,19 @@ function PricingItem( }; }>, ) { - const { t, i18n } = useTranslation(); + const t = useTranslations(); const highlighted = props.product.highlighted ?? false; const lineItem = props.primaryLineItem!; const isCustom = props.plan.custom ?? false; - const i18nKey = `billing:units.${lineItem.unit}`; + const i18nKey = `billing.units.${lineItem.unit}` as never; const unitLabel = lineItem?.unit - ? i18n.exists(i18nKey) + ? t.has(i18nKey) ? t(i18nKey, { count: 1, defaultValue: lineItem.unit, - }) + } as never) : lineItem.unit : ''; @@ -260,10 +260,10 @@ function PricingItem( <span> <If condition={props.plan.interval} - fallback={<Trans i18nKey={'billing:lifetime'} />} + fallback={<Trans i18nKey={'billing.lifetime'} />} > {(interval) => ( - <Trans i18nKey={`billing:billingInterval.${interval}`} /> + <Trans i18nKey={`billing.billingInterval.${interval}`} /> )} </If> </span> @@ -279,10 +279,10 @@ function PricingItem( <If condition={lineItem?.type === 'per_seat'}> <If condition={Boolean(lineItem?.unit) && !isDefaultSeatUnit} - fallback={<Trans i18nKey={'billing:perTeamMember'} />} + fallback={<Trans i18nKey={'billing.perTeamMember'} />} > <Trans - i18nKey={'billing:perUnitShort'} + i18nKey={'billing.perUnitShort'} values={{ unit: unitLabel, }} @@ -294,7 +294,7 @@ function PricingItem( condition={lineItem?.type !== 'per_seat' && lineItem?.unit} > <Trans - i18nKey={'billing:perUnit'} + i18nKey={'billing.perUnit'} values={{ unit: lineItem?.unit, }} @@ -343,7 +343,7 @@ function PricingItem( <div className={'flex flex-col space-y-2'}> <h6 className={'text-sm font-semibold'}> - <Trans i18nKey={'billing:detailsLabel'} /> + <Trans i18nKey={'billing.detailsLabel'} /> </h6> <LineItemDetails @@ -402,7 +402,7 @@ function Price({ <span className={'text-muted-foreground text-sm leading-loose'}> <span>/</span> - <Trans i18nKey={'billing:perMonth'} /> + <Trans i18nKey={'billing.perMonth'} /> </span> </If> </div> @@ -446,41 +446,41 @@ function PlanIntervalSwitcher( return ( <div className={ - 'hover:border-border flex gap-x-1 rounded-full border border-transparent transition-colors' + 'hover:border-border border-border/50 flex gap-x-0 rounded-full border' } > {props.intervals.map((plan, index) => { const selected = plan === props.interval; const className = cn( - 'animate-in fade-in rounded-full !outline-hidden transition-all focus:!ring-0', + 'animate-in fade-in rounded-full transition-all focus:!ring-0', { 'border-r-transparent': index === 0, ['hover:text-primary text-muted-foreground']: !selected, - ['cursor-default font-semibold']: selected, - ['hover:bg-initial']: !selected, + ['cursor-default']: selected, }, ); return ( <Button - key={plan} size={'sm'} - variant={selected ? 'secondary' : 'ghost'} + key={plan} + variant={selected ? 'secondary' : 'custom'} className={className} onClick={() => props.setInterval(plan)} > <span className={'flex items-center'}> <CheckCircle - className={cn('animate-in fade-in zoom-in-95 h-3', { - hidden: !selected, - 'slide-in-from-left-4': index === 0, - 'slide-in-from-right-4': index === props.intervals.length - 1, - })} + className={cn( + 'animate-in fade-in zoom-in-50 mr-1 size-3 duration-200', + { + hidden: !selected, + }, + )} /> - <span className={'capitalize'}> - <Trans i18nKey={`common:billingInterval.${plan}`} /> + <span className={'text-xs capitalize'}> + <Trans i18nKey={`billing.billingInterval.${plan}`} /> </span> </span> </Button> @@ -509,7 +509,7 @@ function DefaultCheckoutButton( highlighted?: boolean; }>, ) { - const { t } = useTranslation('billing'); + const t = useTranslations('billing'); const signUpPath = props.paths.signUp; @@ -522,7 +522,7 @@ function DefaultCheckoutButton( const linkHref = props.plan.href ?? `${signUpPath}?${searchParams.toString()}`; - const label = props.plan.buttonLabel ?? 'common:getStartedWithPlan'; + const label = props.plan.buttonLabel ?? 'common.getStartedWithPlan'; return ( <Link className={'w-full'} href={linkHref}> @@ -536,9 +536,9 @@ function DefaultCheckoutButton( i18nKey={label} defaults={label} values={{ - plan: t(props.product.name, { - defaultValue: props.product.name, - }), + plan: t.has(props.product.name as never) + ? t(props.product.name as never) + : props.product.name, }} /> </span> diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts index d3e8069af..a858ef215 100644 --- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts +++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts @@ -1,6 +1,5 @@ import 'server-only'; - -import { z } from 'zod'; +import * as z from 'zod'; import { type BillingProviderSchema, @@ -20,7 +19,7 @@ export function createBillingEventHandlerFactoryService( // Create a registry for billing webhook handlers const billingWebhookHandlerRegistry = createRegistry< BillingWebhookHandlerService, - z.infer<typeof BillingProviderSchema> + z.output<typeof BillingProviderSchema> >(); // Register the Stripe webhook handler diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-provider.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-provider.ts index 47cb1d115..d44fce671 100644 --- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-provider.ts +++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-provider.ts @@ -1,5 +1,4 @@ import 'server-only'; - import type { SupabaseClient } from '@supabase/supabase-js'; import type { PlanTypeMap } from '@kit/billing'; diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts index b5df88089..58e7696f6 100644 --- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts +++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { BillingWebhookHandlerService } from '@kit/billing'; diff --git a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-provider-factory.ts b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-provider-factory.ts index 3f8dd4793..21a6bbddd 100644 --- a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-provider-factory.ts +++ b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-provider-factory.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '@kit/supabase/database'; diff --git a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts index 01231145f..c7989fd71 100644 --- a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts +++ b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts @@ -1,6 +1,5 @@ import 'server-only'; - -import { z } from 'zod'; +import * as z from 'zod'; import { type BillingProviderSchema, @@ -11,7 +10,7 @@ import { createRegistry } from '@kit/shared/registry'; // Create a registry for billing strategy providers export const billingStrategyRegistry = createRegistry< BillingStrategyProviderService, - z.infer<typeof BillingProviderSchema> + z.output<typeof BillingProviderSchema> >(); // Register the Stripe billing strategy diff --git a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts index 0a68a4eba..c7f07a613 100644 --- a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts +++ b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import type { BillingProviderSchema } from '@kit/billing'; import { @@ -14,7 +14,7 @@ import { import { billingStrategyRegistry } from './billing-gateway-registry'; export function createBillingGatewayService( - provider: z.infer<typeof BillingProviderSchema>, + provider: z.output<typeof BillingProviderSchema>, ) { return new BillingGatewayService(provider); } @@ -30,7 +30,7 @@ export function createBillingGatewayService( */ class BillingGatewayService { constructor( - private readonly provider: z.infer<typeof BillingProviderSchema>, + private readonly provider: z.output<typeof BillingProviderSchema>, ) {} /** @@ -40,7 +40,7 @@ class BillingGatewayService { * */ async createCheckoutSession( - params: z.infer<typeof CreateBillingCheckoutSchema>, + params: z.output<typeof CreateBillingCheckoutSchema>, ) { const strategy = await this.getStrategy(); const payload = CreateBillingCheckoutSchema.parse(params); @@ -54,7 +54,7 @@ class BillingGatewayService { * @param {RetrieveCheckoutSessionSchema} params - The parameters to retrieve the checkout session. */ async retrieveCheckoutSession( - params: z.infer<typeof RetrieveCheckoutSessionSchema>, + params: z.output<typeof RetrieveCheckoutSessionSchema>, ) { const strategy = await this.getStrategy(); const payload = RetrieveCheckoutSessionSchema.parse(params); @@ -68,7 +68,7 @@ class BillingGatewayService { * @param {CreateBillingPortalSessionSchema} params - The parameters to create the billing portal session. */ async createBillingPortalSession( - params: z.infer<typeof CreateBillingPortalSessionSchema>, + params: z.output<typeof CreateBillingPortalSessionSchema>, ) { const strategy = await this.getStrategy(); const payload = CreateBillingPortalSessionSchema.parse(params); @@ -82,7 +82,7 @@ class BillingGatewayService { * @param {CancelSubscriptionParamsSchema} params - The parameters for cancelling the subscription. */ async cancelSubscription( - params: z.infer<typeof CancelSubscriptionParamsSchema>, + params: z.output<typeof CancelSubscriptionParamsSchema>, ) { const strategy = await this.getStrategy(); const payload = CancelSubscriptionParamsSchema.parse(params); @@ -95,7 +95,7 @@ class BillingGatewayService { * @description This is used to report the usage of the billing to the provider. * @param params */ - async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) { + async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) { const strategy = await this.getStrategy(); const payload = ReportBillingUsageSchema.parse(params); @@ -107,7 +107,7 @@ class BillingGatewayService { * @description Queries the usage of the metered billing. * @param params */ - async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) { + async queryUsage(params: z.output<typeof QueryBillingUsageSchema>) { const strategy = await this.getStrategy(); const payload = QueryBillingUsageSchema.parse(params); @@ -129,7 +129,7 @@ class BillingGatewayService { * @param params */ async updateSubscriptionItem( - params: z.infer<typeof UpdateSubscriptionParamsSchema>, + params: z.output<typeof UpdateSubscriptionParamsSchema>, ) { const strategy = await this.getStrategy(); const payload = UpdateSubscriptionParamsSchema.parse(params); diff --git a/packages/billing/gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts b/packages/billing/gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts index 6bf8a18d0..bf9cf1957 100644 --- a/packages/billing/gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts +++ b/packages/billing/gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { Tables } from '@kit/supabase/database'; import { createBillingGatewayService } from '../billing-gateway/billing-gateway.service'; diff --git a/packages/billing/gateway/src/server/utils/resolve-product-plan.ts b/packages/billing/gateway/src/server/utils/resolve-product-plan.ts index 76127d5e2..8177b2669 100644 --- a/packages/billing/gateway/src/server/utils/resolve-product-plan.ts +++ b/packages/billing/gateway/src/server/utils/resolve-product-plan.ts @@ -1,6 +1,5 @@ import 'server-only'; - -import { z } from 'zod'; +import * as z from 'zod'; import { BillingConfig, @@ -24,7 +23,7 @@ export async function resolveProductPlan( currency: string, ): Promise<{ product: ProductSchema; - plan: z.infer<typeof PlanSchema>; + plan: z.output<typeof PlanSchema>; }> { // we can't always guarantee that the plan will be present in the local config // so we need to fallback to fetching the plan details from the billing provider diff --git a/packages/billing/lemon-squeezy/eslint.config.mjs b/packages/billing/lemon-squeezy/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/billing/lemon-squeezy/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/billing/lemon-squeezy/package.json b/packages/billing/lemon-squeezy/package.json index f11e203d0..8802dc676 100644 --- a/packages/billing/lemon-squeezy/package.json +++ b/packages/billing/lemon-squeezy/package.json @@ -1,25 +1,27 @@ { "name": "@kit/lemon-squeezy", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" + "private": true, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } }, - "prettier": "@kit/prettier-config", "exports": { ".": "./src/index.ts", "./components": "./src/components/index.ts" }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, "dependencies": { - "@lemonsqueezy/lemonsqueezy.js": "4.0.0" + "@lemonsqueezy/lemonsqueezy.js": "catalog:" }, "devDependencies": { "@kit/billing": "workspace:*", - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/shared": "workspace:*", "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", @@ -28,12 +30,5 @@ "next": "catalog:", "react": "catalog:", "zod": "catalog:" - }, - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } } } diff --git a/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts b/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts index 4cbdeea3d..67c69fea5 100644 --- a/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts +++ b/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * @name getLemonSqueezyEnv @@ -10,18 +10,18 @@ export const getLemonSqueezyEnv = () => .object({ secretKey: z .string({ - description: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`, + error: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`, }) .min(1), webhooksSecret: z .string({ - description: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`, + error: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`, }) .min(1) .max(40), storeId: z .string({ - description: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`, + error: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`, }) .min(1), }) diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts index 90ba1ae92..f85a83ec0 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts @@ -1,5 +1,5 @@ import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js'; -import { z } from 'zod'; +import * as z from 'zod'; import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema'; @@ -11,7 +11,7 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk'; * @param {object} params - The parameters required to create the billing portal session. */ export async function createLemonSqueezyBillingPortalSession( - params: z.infer<typeof CreateBillingPortalSessionSchema>, + params: z.output<typeof CreateBillingPortalSessionSchema>, ) { await initializeLemonSqueezyClient(); diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts index dc0462c51..518cdc685 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts @@ -3,7 +3,7 @@ import { createCheckout, getCustomer, } from '@lemonsqueezy/lemonsqueezy.js'; -import { z } from 'zod'; +import * as z from 'zod'; import type { CreateBillingCheckoutSchema } from '@kit/billing/schema'; @@ -14,7 +14,7 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk'; * Creates a checkout for a Lemon Squeezy product. */ export async function createLemonSqueezyCheckout( - params: z.infer<typeof CreateBillingCheckoutSchema>, + params: z.output<typeof CreateBillingCheckoutSchema>, ) { await initializeLemonSqueezyClient(); diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts index e042d261f..3f1c781e8 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { cancelSubscription, createUsageRecord, @@ -9,7 +8,7 @@ import { listUsageRecords, updateSubscriptionItem, } from '@lemonsqueezy/lemonsqueezy.js'; -import { z } from 'zod'; +import * as z from 'zod'; import { BillingStrategyProviderService } from '@kit/billing'; import type { @@ -40,7 +39,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async createCheckoutSession( - params: z.infer<typeof CreateBillingCheckoutSchema>, + params: z.output<typeof CreateBillingCheckoutSchema>, ) { const logger = await getLogger(); @@ -78,7 +77,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async createBillingPortalSession( - params: z.infer<typeof CreateBillingPortalSessionSchema>, + params: z.output<typeof CreateBillingPortalSessionSchema>, ) { const logger = await getLogger(); @@ -117,7 +116,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async cancelSubscription( - params: z.infer<typeof CancelSubscriptionParamsSchema>, + params: z.output<typeof CancelSubscriptionParamsSchema>, ) { const logger = await getLogger(); @@ -165,7 +164,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async retrieveCheckoutSession( - params: z.infer<typeof RetrieveCheckoutSessionSchema>, + params: z.output<typeof RetrieveCheckoutSessionSchema>, ) { const logger = await getLogger(); @@ -209,7 +208,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @description Reports the usage of the billing * @param params */ - async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) { + async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) { const logger = await getLogger(); const ctx = { @@ -248,7 +247,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async queryUsage( - params: z.infer<typeof QueryBillingUsageSchema>, + params: z.output<typeof QueryBillingUsageSchema>, ): Promise<{ value: number }> { const logger = await getLogger(); @@ -312,7 +311,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async updateSubscriptionItem( - params: z.infer<typeof UpdateSubscriptionParamsSchema>, + params: z.output<typeof UpdateSubscriptionParamsSchema>, ) { const logger = await getLogger(); diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-sdk.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-sdk.ts index 5ed20968e..9a74e9b57 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-sdk.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-sdk.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { getLogger } from '@kit/shared/logger'; import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema'; diff --git a/packages/billing/stripe/eslint.config.mjs b/packages/billing/stripe/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/billing/stripe/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/billing/stripe/package.json b/packages/billing/stripe/package.json index 0296465e9..ed8c680e4 100644 --- a/packages/billing/stripe/package.json +++ b/packages/billing/stripe/package.json @@ -1,19 +1,23 @@ { "name": "@kit/stripe", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit", - "start": "docker run --rm -it --name=stripe -v ~/.config/stripe:/root/.config/stripe stripe/stripe-cli:latest listen --forward-to http://host.docker.internal:3000/api/billing/webhook" + "private": true, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } }, - "prettier": "@kit/prettier-config", "exports": { ".": "./src/index.ts", "./components": "./src/components/index.ts" }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit", + "start": "docker run --rm -it --name=stripe -v ~/.config/stripe:/root/.config/stripe stripe/stripe-cli:latest listen --forward-to http://host.docker.internal:3000/api/billing/webhook" + }, "dependencies": { "@stripe/react-stripe-js": "catalog:", "@stripe/stripe-js": "catalog:", @@ -21,23 +25,14 @@ }, "devDependencies": { "@kit/billing": "workspace:*", - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/shared": "workspace:*", "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", "@types/react": "catalog:", - "date-fns": "^4.1.0", + "date-fns": "catalog:", "next": "catalog:", "react": "catalog:", "zod": "catalog:" - }, - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } } } diff --git a/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx b/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx index 19f828bfd..09fa2efb9 100644 --- a/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx +++ b/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx @@ -44,12 +44,13 @@ function EmbeddedCheckoutPopup({ onClose?: () => void; }>) { const [open, setOpen] = useState(true); - const className = `bg-white p-4 overflow-y-auto shadow-transparent border`; + const className = `bg-white p-4 overflow-y-auto shadow-transparent border w-full min-w-md max-w-4xl`; return ( <Dialog defaultOpen open={open} + disablePointerDismissal onOpenChange={(open) => { if (!open && onClose) { onClose(); @@ -63,9 +64,6 @@ function EmbeddedCheckoutPopup({ maxHeight: '98vh', }} className={className} - onOpenAutoFocus={(e) => e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} > <DialogTitle className={'hidden'}>Checkout</DialogTitle> <div>{children}</div> diff --git a/packages/billing/stripe/src/schema/stripe-client-env.schema.ts b/packages/billing/stripe/src/schema/stripe-client-env.schema.ts index 5cb12ee39..22d657b53 100644 --- a/packages/billing/stripe/src/schema/stripe-client-env.schema.ts +++ b/packages/billing/stripe/src/schema/stripe-client-env.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const StripeClientEnvSchema = z .object({ diff --git a/packages/billing/stripe/src/schema/stripe-server-env.schema.ts b/packages/billing/stripe/src/schema/stripe-server-env.schema.ts index 9c9847ec3..70032df89 100644 --- a/packages/billing/stripe/src/schema/stripe-server-env.schema.ts +++ b/packages/billing/stripe/src/schema/stripe-server-env.schema.ts @@ -1,15 +1,15 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const StripeServerEnvSchema = z .object({ secretKey: z .string({ - required_error: `Please provide the variable STRIPE_SECRET_KEY`, + error: `Please provide the variable STRIPE_SECRET_KEY`, }) .min(1), webhooksSecret: z .string({ - required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`, + error: `Please provide the variable STRIPE_WEBHOOK_SECRET`, }) .min(1), }) diff --git a/packages/billing/stripe/src/services/create-stripe-billing-portal-session.ts b/packages/billing/stripe/src/services/create-stripe-billing-portal-session.ts index e8de54d55..55eab72fd 100644 --- a/packages/billing/stripe/src/services/create-stripe-billing-portal-session.ts +++ b/packages/billing/stripe/src/services/create-stripe-billing-portal-session.ts @@ -1,5 +1,5 @@ import type { Stripe } from 'stripe'; -import { z } from 'zod'; +import * as z from 'zod'; import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema'; @@ -9,7 +9,7 @@ import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema'; */ export async function createStripeBillingPortalSession( stripe: Stripe, - params: z.infer<typeof CreateBillingPortalSessionSchema>, + params: z.output<typeof CreateBillingPortalSessionSchema>, ) { return stripe.billingPortal.sessions.create({ customer: params.customerId, diff --git a/packages/billing/stripe/src/services/create-stripe-checkout.ts b/packages/billing/stripe/src/services/create-stripe-checkout.ts index da46e1626..39a045650 100644 --- a/packages/billing/stripe/src/services/create-stripe-checkout.ts +++ b/packages/billing/stripe/src/services/create-stripe-checkout.ts @@ -1,5 +1,5 @@ import type { Stripe } from 'stripe'; -import { z } from 'zod'; +import * as z from 'zod'; import type { CreateBillingCheckoutSchema } from '@kit/billing/schema'; @@ -17,7 +17,7 @@ const enableTrialWithoutCreditCard = */ export async function createStripeCheckout( stripe: Stripe, - params: z.infer<typeof CreateBillingCheckoutSchema>, + params: z.output<typeof CreateBillingCheckoutSchema>, ) { // in MakerKit, a subscription belongs to an organization, // rather than to a user diff --git a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts index c95d96c65..c85977eb2 100644 --- a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts +++ b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts @@ -1,7 +1,6 @@ import 'server-only'; - import type { Stripe } from 'stripe'; -import { z } from 'zod'; +import * as z from 'zod'; import { BillingStrategyProviderService } from '@kit/billing'; import type { @@ -35,7 +34,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async createCheckoutSession( - params: z.infer<typeof CreateBillingCheckoutSchema>, + params: z.output<typeof CreateBillingCheckoutSchema>, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -67,7 +66,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async createBillingPortalSession( - params: z.infer<typeof CreateBillingPortalSessionSchema>, + params: z.output<typeof CreateBillingPortalSessionSchema>, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -96,7 +95,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async cancelSubscription( - params: z.infer<typeof CancelSubscriptionParamsSchema>, + params: z.output<typeof CancelSubscriptionParamsSchema>, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -139,7 +138,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async retrieveCheckoutSession( - params: z.infer<typeof RetrieveCheckoutSessionSchema>, + params: z.output<typeof RetrieveCheckoutSessionSchema>, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -183,7 +182,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @description Reports usage for a subscription with the Metrics API * @param params */ - async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) { + async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -230,7 +229,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @name queryUsage * @description Reports the total usage for a subscription with the Metrics API */ - async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) { + async queryUsage(params: z.output<typeof QueryBillingUsageSchema>) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -287,7 +286,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async updateSubscriptionItem( - params: z.infer<typeof UpdateSubscriptionParamsSchema>, + params: z.output<typeof UpdateSubscriptionParamsSchema>, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); diff --git a/packages/billing/stripe/src/services/stripe-sdk.ts b/packages/billing/stripe/src/services/stripe-sdk.ts index 0fb6aea60..da06618c4 100644 --- a/packages/billing/stripe/src/services/stripe-sdk.ts +++ b/packages/billing/stripe/src/services/stripe-sdk.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema'; const STRIPE_API_VERSION = '2026-02-25.clover'; diff --git a/packages/cms/core/eslint.config.mjs b/packages/cms/core/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/cms/core/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/cms/core/package.json b/packages/cms/core/package.json index a74edfc33..4310b46ea 100644 --- a/packages/cms/core/package.json +++ b/packages/cms/core/package.json @@ -1,32 +1,27 @@ { "name": "@kit/cms", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts" - }, - "devDependencies": { - "@kit/cms-types": "workspace:*", - "@kit/eslint-config": "workspace:*", - "@kit/keystatic": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/shared": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@kit/wordpress": "workspace:*", - "@types/node": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/cms-types": "workspace:*", + "@kit/keystatic": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/wordpress": "workspace:*", + "@types/node": "catalog:" } } diff --git a/packages/cms/keystatic/eslint.config.mjs b/packages/cms/keystatic/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/cms/keystatic/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/cms/keystatic/package.json b/packages/cms/keystatic/package.json index 59efb1c50..e55c70494 100644 --- a/packages/cms/keystatic/package.json +++ b/packages/cms/keystatic/package.json @@ -1,41 +1,35 @@ { "name": "@kit/keystatic", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts", - "./renderer": "./src/content-renderer.tsx", - "./admin": "./src/keystatic-admin.tsx", - "./route-handler": "./src/keystatic-route-handler.ts" - }, - "dependencies": { - "@keystatic/core": "0.5.48", - "@keystatic/next": "^5.0.4", - "@markdoc/markdoc": "^0.5.4" - }, - "devDependencies": { - "@kit/cms-types": "workspace:*", - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@kit/ui": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:", - "react": "catalog:", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./renderer": "./src/content-renderer.tsx", + "./admin": "./src/keystatic-admin.tsx", + "./route-handler": "./src/keystatic-route-handler.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@keystatic/core": "catalog:", + "@keystatic/next": "catalog:", + "@markdoc/markdoc": "catalog:" + }, + "devDependencies": { + "@kit/cms-types": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:*", + "@types/react": "catalog:", + "react": "catalog:", + "zod": "catalog:" } } diff --git a/packages/cms/keystatic/src/create-reader.ts b/packages/cms/keystatic/src/create-reader.ts index 29ce9ad15..8777679e6 100644 --- a/packages/cms/keystatic/src/create-reader.ts +++ b/packages/cms/keystatic/src/create-reader.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { KeystaticStorage } from './keystatic-storage'; import { keyStaticConfig } from './keystatic.config'; @@ -51,7 +51,7 @@ function getKeystaticGithubConfiguration() { return z .object({ token: z.string({ - description: + error: 'The GitHub token to use for authentication. Please provide the value through the "KEYSTATIC_GITHUB_TOKEN" environment variable.', }), repo: z.custom<`${string}/${string}`>(), diff --git a/packages/cms/keystatic/src/keystatic-storage.ts b/packages/cms/keystatic/src/keystatic-storage.ts index f27871bbe..432d77db7 100644 --- a/packages/cms/keystatic/src/keystatic-storage.ts +++ b/packages/cms/keystatic/src/keystatic-storage.ts @@ -1,7 +1,5 @@ import { CloudConfig, GitHubConfig, LocalConfig } from '@keystatic/core'; -import { z } from 'zod'; - -type ZodOutputFor<T> = z.ZodType<T, z.ZodTypeDef, unknown>; +import * as z from 'zod'; /** * @name STORAGE_KIND @@ -37,7 +35,7 @@ const PROJECT = process.env.KEYSTATIC_STORAGE_PROJECT; */ const local = z.object({ kind: z.literal('local'), -}) satisfies ZodOutputFor<LocalConfig['storage']>; +}) satisfies z.ZodType<LocalConfig['storage']>; /** * @name cloud @@ -47,12 +45,12 @@ const cloud = z.object({ kind: z.literal('cloud'), project: z .string({ - description: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`, + error: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`, }) .min(1), branchPrefix: z.string().optional(), pathPrefix: z.string().optional(), -}) satisfies ZodOutputFor<CloudConfig['storage']>; +}) satisfies z.ZodType<CloudConfig['storage']>; /** * @name github @@ -63,7 +61,7 @@ const github = z.object({ repo: z.custom<`${string}/${string}`>(), branchPrefix: z.string().optional(), pathPrefix: z.string().optional(), -}) satisfies ZodOutputFor<GitHubConfig['storage']>; +}) satisfies z.ZodType<GitHubConfig['storage']>; /** * @name KeystaticStorage diff --git a/packages/cms/types/eslint.config.mjs b/packages/cms/types/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/cms/types/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/cms/types/package.json b/packages/cms/types/package.json index 2dac6316d..aec33a410 100644 --- a/packages/cms/types/package.json +++ b/packages/cms/types/package.json @@ -1,27 +1,22 @@ { "name": "@kit/cms-types", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/tsconfig": "workspace:*" } } diff --git a/packages/cms/wordpress/docker-compose.yml b/packages/cms/wordpress/docker-compose.yml index ebfbe68ca..15bf0d9bd 100644 --- a/packages/cms/wordpress/docker-compose.yml +++ b/packages/cms/wordpress/docker-compose.yml @@ -31,18 +31,18 @@ services: - WORDPRESS_DB_NAME=wordpress - WORDPRESS_DEBUG=1 - WORDPRESS_CONFIG_EXTRA = | - define('FS_METHOD', 'direct'); - /** disable wp core auto update */ - define('WP_AUTO_UPDATE_CORE', false); - - /** local environment settings */ - define('WP_CACHE', false); - define('ENVIRONMENT', 'local'); - - /** force site home url */ - if(!defined('WP_HOME')) { - define('WP_HOME', 'http://localhost'); - define('WP_SITEURL', WP_HOME); - } + define('FS_METHOD', 'direct'); + /** disable wp core auto update */ + define('WP_AUTO_UPDATE_CORE', false); + + /** local environment settings */ + define('WP_CACHE', false); + define('ENVIRONMENT', 'local'); + + /** force site home url */ + if(!defined('WP_HOME')) { + define('WP_HOME', 'http://localhost'); + define('WP_SITEURL', WP_HOME); + } volumes: - db_data: \ No newline at end of file + db_data: diff --git a/packages/cms/wordpress/eslint.config.mjs b/packages/cms/wordpress/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/cms/wordpress/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/cms/wordpress/package.json b/packages/cms/wordpress/package.json index b799ed23f..0dd85c993 100644 --- a/packages/cms/wordpress/package.json +++ b/packages/cms/wordpress/package.json @@ -1,34 +1,28 @@ { "name": "@kit/wordpress", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit", - "start": "docker compose up" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts", - "./renderer": "./src/content-renderer.tsx" - }, - "devDependencies": { - "@kit/cms-types": "workspace:*", - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@kit/ui": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:", - "wp-types": "^4.69.0" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./renderer": "./src/content-renderer.tsx" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit", + "start": "docker compose up" + }, + "devDependencies": { + "@kit/cms-types": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:*", + "@types/react": "catalog:", + "wp-types": "catalog:" } } diff --git a/packages/database-webhooks/eslint.config.mjs b/packages/database-webhooks/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/database-webhooks/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/database-webhooks/package.json b/packages/database-webhooks/package.json index 3dd9a97c3..c6b8d79e7 100644 --- a/packages/database-webhooks/package.json +++ b/packages/database-webhooks/package.json @@ -1,34 +1,29 @@ { "name": "@kit/database-webhooks", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts" - }, - "devDependencies": { - "@kit/billing": "workspace:*", - "@kit/billing-gateway": "workspace:*", - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/shared": "workspace:*", - "@kit/stripe": "workspace:*", - "@kit/supabase": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@supabase/supabase-js": "catalog:", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/billing": "workspace:*", + "@kit/billing-gateway": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/stripe": "workspace:*", + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@supabase/supabase-js": "catalog:", + "zod": "catalog:" } } diff --git a/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts b/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts index 42ae4b01d..7984adc71 100644 --- a/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts +++ b/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; diff --git a/packages/database-webhooks/src/server/services/verifier/postgres-database-webhook-verifier.service.ts b/packages/database-webhooks/src/server/services/verifier/postgres-database-webhook-verifier.service.ts index dd28e00d3..174d9ce9b 100644 --- a/packages/database-webhooks/src/server/services/verifier/postgres-database-webhook-verifier.service.ts +++ b/packages/database-webhooks/src/server/services/verifier/postgres-database-webhook-verifier.service.ts @@ -1,11 +1,10 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { DatabaseWebhookVerifierService } from './database-webhook-verifier.service'; const webhooksSecret = z .string({ - description: `The secret used to verify the webhook signature`, - required_error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`, + error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`, }) .min(1) .parse(process.env.SUPABASE_DB_WEBHOOK_SECRET); diff --git a/packages/email-templates/AGENTS.md b/packages/email-templates/AGENTS.md index 357ae3641..42d9e30f5 100644 --- a/packages/email-templates/AGENTS.md +++ b/packages/email-templates/AGENTS.md @@ -4,7 +4,8 @@ This package owns transactional email templates and renderers using React Email. ## Non-negotiables -1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss it. +1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss + it. 2. New email renderer must be exported from `src/index.ts`. 3. Renderer contract: async function returning `{ html, subject }`. 4. i18n namespace must match locale filename in `src/locales/<lang>/<namespace>.json`. @@ -19,4 +20,5 @@ This package owns transactional email templates and renderers using React Email. 3. Export template renderer from `src/index.ts`. 4. Add renderer to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`). -`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering will miss it. +`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering +will miss it. diff --git a/packages/email-templates/eslint.config.mjs b/packages/email-templates/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/email-templates/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/email-templates/package.json b/packages/email-templates/package.json index 2bc2b00f1..f6330f97c 100644 --- a/packages/email-templates/package.json +++ b/packages/email-templates/package.json @@ -1,36 +1,30 @@ { "name": "@kit/email-templates", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts", - "./registry": "./src/registry.ts" - }, - "dependencies": { - "@react-email/components": "catalog:" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/i18n": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:", - "react": "catalog:", - "react-dom": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./registry": "./src/registry.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@react-email/components": "catalog:" + }, + "devDependencies": { + "@kit/tsconfig": "workspace:*", + "@types/react": "catalog:", + "next-intl": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" } } diff --git a/packages/email-templates/src/emails/account-delete.email.tsx b/packages/email-templates/src/emails/account-delete.email.tsx index 749295645..fb239705c 100644 --- a/packages/email-templates/src/emails/account-delete.email.tsx +++ b/packages/email-templates/src/emails/account-delete.email.tsx @@ -29,11 +29,11 @@ export async function renderAccountDeleteEmail(props: Props) { namespace, }); - const previewText = t(`${namespace}:previewText`, { + const previewText = t(`previewText`, { productName: props.productName, }); - const subject = t(`${namespace}:subject`, { + const subject = t(`subject`, { productName: props.productName, }); @@ -54,27 +54,27 @@ export async function renderAccountDeleteEmail(props: Props) { <EmailContent> <Text className="text-[16px] leading-[24px] text-[#242424]"> - {t(`${namespace}:hello`)} + {t(`hello`)} </Text> <Text className="text-[16px] leading-[24px] text-[#242424]"> - {t(`${namespace}:paragraph1`, { + {t(`paragraph1`, { productName: props.productName, })} </Text> <Text className="text-[16px] leading-[24px] text-[#242424]"> - {t(`${namespace}:paragraph2`)} + {t(`paragraph2`)} </Text> <Text className="text-[16px] leading-[24px] text-[#242424]"> - {t(`${namespace}:paragraph3`, { + {t(`paragraph3`, { productName: props.productName, })} </Text> <Text className="text-[16px] leading-[24px] text-[#242424]"> - {t(`${namespace}:paragraph4`, { + {t(`paragraph4`, { productName: props.productName, })} </Text> diff --git a/packages/email-templates/src/emails/invite.email.tsx b/packages/email-templates/src/emails/invite.email.tsx index a55fcf4d7..0ed9785e0 100644 --- a/packages/email-templates/src/emails/invite.email.tsx +++ b/packages/email-templates/src/emails/invite.email.tsx @@ -42,24 +42,25 @@ export async function renderInviteEmail(props: Props) { }); const previewText = `Join ${props.invitedUserEmail} on ${props.productName}`; - const subject = t(`${namespace}:subject`); + const subject = t(`subject`); - const heading = t(`${namespace}:heading`, { + const heading = t(`heading`, { teamName: props.teamName, productName: props.productName, }); - const hello = t(`${namespace}:hello`, { + const hello = t(`hello`, { invitedUserEmail: props.invitedUserEmail, }); - const mainText = t(`${namespace}:mainText`, { + const mainText = t(`mainText`, { inviter: props.inviter, teamName: props.teamName, productName: props.productName, + strong: (chunks: string) => `<strong>${chunks}</strong>`, }); - const joinTeam = t(`${namespace}:joinTeam`, { + const joinTeam = t(`joinTeam`, { teamName: props.teamName, }); @@ -108,7 +109,7 @@ export async function renderInviteEmail(props: Props) { </Section> <Text className="text-[16px] leading-[24px] text-[#242424]"> - {t(`${namespace}:copyPasteLink`)}{' '} + {t(`copyPasteLink`)}{' '} <Link href={props.link} className="text-blue-600 no-underline"> {props.link} </Link> @@ -117,7 +118,7 @@ export async function renderInviteEmail(props: Props) { <Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" /> <Text className="text-[12px] leading-[24px] text-[#666666]"> - {t(`${namespace}:invitationIntendedFor`, { + {t(`invitationIntendedFor`, { invitedUserEmail: props.invitedUserEmail, })} </Text> diff --git a/packages/email-templates/src/emails/otp.email.tsx b/packages/email-templates/src/emails/otp.email.tsx index 534b6ce3b..28011be13 100644 --- a/packages/email-templates/src/emails/otp.email.tsx +++ b/packages/email-templates/src/emails/otp.email.tsx @@ -32,22 +32,22 @@ export async function renderOtpEmail(props: Props) { namespace, }); - const subject = t(`${namespace}:subject`, { + const subject = t(`subject`, { productName: props.productName, }); const previewText = subject; - const heading = t(`${namespace}:heading`, { + const heading = t(`heading`, { productName: props.productName, }); - const otpText = t(`${namespace}:otpText`, { + const otpText = t(`otpText`, { otp: props.otp, }); - const mainText = t(`${namespace}:mainText`); - const footerText = t(`${namespace}:footerText`); + const mainText = t(`mainText`); + const footerText = t(`footerText`); const html = await render( <Html> diff --git a/packages/email-templates/src/lib/i18n.ts b/packages/email-templates/src/lib/i18n.ts index 0ea0c9428..d1cb18d1e 100644 --- a/packages/email-templates/src/lib/i18n.ts +++ b/packages/email-templates/src/lib/i18n.ts @@ -1,32 +1,47 @@ -import { createI18nSettings } from '@kit/i18n'; -import { initializeServerI18n } from '@kit/i18n/server'; +import type { AbstractIntlMessages } from 'next-intl'; +import { createTranslator } from 'next-intl'; -export function initializeEmailI18n(params: { +export async function initializeEmailI18n(params: { language: string | undefined; namespace: string; }) { - const language = - params.language ?? process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en'; + const language = params.language ?? 'en'; - return initializeServerI18n( - createI18nSettings({ + try { + // Load the translation messages for the specified namespace + const messages = (await import( + `../locales/${language}/${params.namespace}.json` + )) as AbstractIntlMessages; + + // Create a translator function with the messages + const translator = createTranslator({ + locale: language, + messages, + }); + + // Type-cast to make it compatible with the i18next API + const t = translator as unknown as ( + key: string, + values?: Record<string, unknown>, + ) => string; + + // Return an object compatible with the i18next API + return { + t, language, - languages: [language], - namespaces: params.namespace, - }), - async (language, namespace) => { - try { - const data = await import(`../locales/${language}/${namespace}.json`); + }; + } catch (error) { + console.log( + `Error loading i18n file: locales/${language}/${params.namespace}.json`, + error, + ); - return data as Record<string, string>; - } catch (error) { - console.log( - `Error loading i18n file: locales/${language}/${namespace}.json`, - error, - ); + // Return a fallback translator that returns the key as-is + const t = (key: string) => key; - return {}; - } - }, - ); + return { + t, + language, + }; + } } diff --git a/packages/email-templates/src/locales/en/account-delete-email.json b/packages/email-templates/src/locales/en/account-delete-email.json index 1b71932e5..2055c7cd1 100644 --- a/packages/email-templates/src/locales/en/account-delete-email.json +++ b/packages/email-templates/src/locales/en/account-delete-email.json @@ -1,9 +1,9 @@ { - "subject": "We have deleted your {{productName}} account", - "previewText": "We have deleted your {{productName}} account", - "hello": "Hello {{displayName}},", - "paragraph1": "This is to confirm that we have processed your request to delete your account with {{productName}}.", + "subject": "We have deleted your {productName} account", + "previewText": "We have deleted your {productName} account", + "hello": "Hello {displayName},", + "paragraph1": "This is to confirm that we have processed your request to delete your account with {productName}.", "paragraph2": "We're sorry to see you go. Please note that this action is irreversible, and we'll make sure to delete all of your data from our systems.", - "paragraph3": "We thank you again for using {{productName}}.", - "paragraph4": "The {{productName}} Team" -} \ No newline at end of file + "paragraph3": "We thank you again for using {productName}.", + "paragraph4": "The {productName} Team" +} diff --git a/packages/email-templates/src/locales/en/invite-email.json b/packages/email-templates/src/locales/en/invite-email.json index da06d64e9..6e1a2ec60 100644 --- a/packages/email-templates/src/locales/en/invite-email.json +++ b/packages/email-templates/src/locales/en/invite-email.json @@ -1,9 +1,9 @@ { "subject": "You have been invited to join a team", - "heading": "Join {{teamName}} on {{productName}}", - "hello": "Hello {{invitedUserEmail}},", - "mainText": "<strong>{{inviter}}</strong> has invited you to the <strong>{{teamName}}</strong> team on <strong>{{productName}}</strong>.", - "joinTeam": "Join {{teamName}}", + "heading": "Join {teamName} on {productName}", + "hello": "Hello {invitedUserEmail},", + "mainText": "<strong>{inviter}</strong> has invited you to the <strong>{teamName}</strong> team on <strong>{productName}</strong>.", + "joinTeam": "Join {teamName}", "copyPasteLink": "or copy and paste this URL into your browser:", - "invitationIntendedFor": "This invitation is intended for {{invitedUserEmail}}." -} \ No newline at end of file + "invitationIntendedFor": "This invitation is intended for {invitedUserEmail}." +} diff --git a/packages/email-templates/src/locales/en/otp-email.json b/packages/email-templates/src/locales/en/otp-email.json index 9439b35eb..ae8ac81b2 100644 --- a/packages/email-templates/src/locales/en/otp-email.json +++ b/packages/email-templates/src/locales/en/otp-email.json @@ -1,7 +1,7 @@ { - "subject": "One-time password for {{productName}}", - "heading": "One-time password for {{productName}}", - "otpText": "Your one-time password is: {{otp}}", + "subject": "One-time password for {productName}", + "heading": "One-time password for {productName}", + "otpText": "Your one-time password is: {otp}", "footerText": "Please enter the one-time password in the app to continue.", "mainText": "You're receiving this email because you need to verify your identity using a one-time password." } diff --git a/packages/features/AGENTS.md b/packages/features/AGENTS.md index 7b4cc3b67..278801aff 100644 --- a/packages/features/AGENTS.md +++ b/packages/features/AGENTS.md @@ -1,289 +1,35 @@ -# Feature Packages Instructions +# Feature Packages -This file contains instructions for working with feature packages including accounts, teams, billing, auth, and notifications. +## Packages -## Feature Package Structure +- `accounts/` — Personal account management +- `admin/` — Super admin functionality +- `auth/` — Authentication features +- `notifications/` — Notification system +- `team-accounts/` — Team account management -- `accounts/` - Personal account management -- `admin/` - Super admin functionality -- `auth/` - Authentication features -- `notifications/` - Notification system -- `team-accounts/` - Team account management +## Non-Negotiables -## Account Services +1. ALWAYS use `createAccountsApi(client)` / `createTeamAccountsApi(client)` factories — NEVER query tables directly if methods exist +2. NEVER import `useUserWorkspace` outside `app/home/(user)` routes +3. NEVER import `useTeamAccountWorkspace` outside `app/home/[account]` routes +4. NEVER call admin operations without `isSuperAdmin()` check first +5. ALWAYS wrap admin pages with `AdminGuard` +6. ALWAYS use `getLogger()` from `@kit/shared/logger` for structured logging — NEVER `console.log` in production code +7. NEVER bypass permission checks when permissions exist — use `api.hasPermission({ accountId, userId, permission })` -### Personal Accounts API +## Key Imports -Located at: `packages/features/accounts/src/server/api.ts` +| API | Import | +| ----------------- | ----------------------------------------------------- | +| Personal accounts | `createAccountsApi` from `@kit/accounts/api` | +| Team accounts | `createTeamAccountsApi` from `@kit/team-accounts/api` | +| Admin check | `isSuperAdmin` from `@kit/admin` | +| Admin guard | `AdminGuard` from `@kit/admin/components/admin-guard` | +| Logger | `getLogger` from `@kit/shared/logger` | -```typescript -import { createAccountsApi } from '@kit/accounts/api'; -import { getSupabaseServerClient } from '@kit/supabase/server-client'; +## Exemplars -const client = getSupabaseServerClient(); -const api = createAccountsApi(client); - -// Get account data -const account = await api.getAccount(accountId); - -// Get account workspace -const workspace = await api.getAccountWorkspace(); - -// Load user accounts -const accounts = await api.loadUserAccounts(); - -// Get subscription -const subscription = await api.getSubscription(accountId); - -// Get customer ID -const customerId = await api.getCustomerId(accountId); -``` - -### Team Accounts API - -Located at: `packages/features/team-accounts/src/server/api.ts` - -```typescript -import { createTeamAccountsApi } from '@kit/team-accounts/api'; - -const api = createTeamAccountsApi(client); - -// Get team account by slug -const account = await api.getTeamAccount(slug); - -// Get account workspace -const workspace = await api.getAccountWorkspace(slug); - -// Check permissions -const hasPermission = await api.hasPermission({ - accountId, - userId, - permission: 'billing.manage' -}); - -// Get members count -const count = await api.getMembersCount(accountId); - -// Get invitation -const invitation = await api.getInvitation(adminClient, token); -``` - -## Workspace Contexts - -### Personal Account Context - -Use in `apps/web/app/home/(user)` routes: - -```tsx -import { useUserWorkspace } from 'kit/accounts/hooks/use-user-workspace'; - -function PersonalComponent() { - const { user, account } = useUserWorkspace(); - - // user: authenticated user data - // account: personal account data - - return <div>Welcome {user.name}</div>; -} -``` - -Context provider: `packages/features/accounts/src/components/user-workspace-context-provider.tsx` - -### Team Account Context - -Use in `apps/web/app/home/[account]` routes: - -```tsx -import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace'; - -function TeamComponent() { - const { account, user, accounts } = useTeamAccountWorkspace(); - - // account: current team account data - // user: authenticated user data - // accounts: all accounts user has access to - - return <div>Team: {account.name}</div>; -} -``` - -Context provider: `packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx` - -## Billing Services - -### Personal Billing - -Located at: `apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts` - -```typescript -// Personal billing operations -// - Manage individual user subscriptions -// - Handle personal account payments -// - Process individual billing changes -``` - -### Team Billing - -Located at: `apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts` - -```typescript -// Team billing operations -// - Manage team subscriptions -// - Handle team payments -// - Process team billing changes -``` - -### Per-Seat Billing Service - -Located at: `packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts` - -```typescript -import { createAccountPerSeatBillingService } from '@kit/team-accounts/billing'; - -const billingService = createAccountPerSeatBillingService(client); - -// Increase seats when adding team members -await billingService.increaseSeats(accountId); - -// Decrease seats when removing team members -await billingService.decreaseSeats(accountId); - -// Get per-seat subscription item -const subscription = await billingService.getPerSeatSubscriptionItem(accountId); -``` - -## Authentication Features - -### OTP for Sensitive Operations - -Use one-time tokens from `packages/otp/src/api/index.ts`: - -```tsx -import { VerifyOtpForm } from '@kit/otp/components'; - -<VerifyOtpForm - purpose="account-deletion" - email={user.email} - onSuccess={(otp) => { - // Proceed with verified operation - handleSensitiveOperation(otp); - }} - CancelButton={<Button variant="outline">Cancel</Button>} -/> -``` - -## Admin Features - -### Super Admin Protection - -For admin routes, use `AdminGuard`: - -```tsx -import { AdminGuard } from '@kit/admin/components/admin-guard'; - -function AdminPage() { - return ( - <div> - <h1>Admin Dashboard</h1> - {/* Admin content */} - </div> - ); -} - -// Wrap the page component -export default AdminGuard(AdminPage); -``` - -### Admin Service - -Located at: `packages/features/admin/src/lib/server/services/admin.service.ts` - -```typescript -// Admin service operations -// - Manage all accounts -// - Handle admin-level operations -// - Access system-wide data -``` - -### Checking Admin Status - -```typescript -import { isSuperAdmin } from '@kit/admin'; - -function criticalAdminFeature() { - const isAdmin = await isSuperAdmin(client); - - if (!isAdmin) { - throw new Error('Access denied: Admin privileges required'); - } - - // ... -} -``` - -## Error Handling & Logging - -### Structured Logging - -Use logger from `packages/shared/src/logger/logger.ts`: - -```typescript -import { getLogger } from '@kit/shared/logger'; - -async function featureOperation() { - const logger = await getLogger(); - - const ctx = { - name: 'feature-operation', - userId: user.id, - accountId: account.id - }; - - try { - logger.info(ctx, 'Starting feature operation'); - - // Perform operation - const result = await performOperation(); - - logger.info({ ...ctx, result }, 'Feature operation completed'); - return result; - } catch (error) { - logger.error({ ...ctx, error }, 'Feature operation failed'); - throw error; - } -} -``` - -## Permission Patterns - -### Team Permissions - -```typescript -import { createTeamAccountsApi } from '@kit/team-accounts/api'; - -const api = createTeamAccountsApi(client); - -// Check if user has specific permission on account -const canManageBilling = await api.hasPermission({ - accountId, - userId, - permission: 'billing.manage' -}); - -if (!canManageBilling) { - throw new Error('Insufficient permissions'); -} -``` - -### Account Ownership - -```typescript -// Check if user is account owner (works for both personal and team accounts) -const isOwner = await client.rpc('is_account_owner', { - account_id: accountId -}); - -if (!isOwner) { - throw new Error('Only account owners can perform this action'); -} -``` \ No newline at end of file +- Server actions: `packages/features/accounts/src/server/personal-accounts-server-actions.ts` +- Workspace loading: `apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts` +- Team policies: `packages/features/team-accounts/src/server/policies/policies.ts` diff --git a/packages/features/accounts/eslint.config.mjs b/packages/features/accounts/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/features/accounts/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/features/accounts/package.json b/packages/features/accounts/package.json index 3173e844d..7d0d07511 100644 --- a/packages/features/accounts/package.json +++ b/packages/features/accounts/package.json @@ -1,12 +1,13 @@ { "name": "@kit/accounts", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" + "private": true, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } }, "exports": { "./personal-account-dropdown": "./src/components/personal-account-dropdown.tsx", @@ -16,43 +17,38 @@ "./hooks/*": "./src/hooks/*.ts", "./api": "./src/server/api.ts" }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, "dependencies": { - "nanoid": "^5.1.6" + "nanoid": "catalog:" }, "devDependencies": { - "@hookform/resolvers": "^5.2.2", + "@hookform/resolvers": "catalog:", "@kit/billing-gateway": "workspace:*", "@kit/email-templates": "workspace:*", - "@kit/eslint-config": "workspace:*", + "@kit/i18n": "workspace:*", "@kit/mailers": "workspace:*", "@kit/monitoring": "workspace:*", "@kit/next": "workspace:*", "@kit/otp": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/shared": "workspace:*", "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", - "@radix-ui/react-icons": "^1.3.2", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "lucide-react": "catalog:", "next": "catalog:", - "next-themes": "0.4.6", + "next-intl": "catalog:", + "next-safe-action": "catalog:", + "next-themes": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", "zod": "catalog:" - }, - "prettier": "@kit/prettier-config", - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } } } diff --git a/packages/features/accounts/src/components/account-selector.tsx b/packages/features/accounts/src/components/account-selector.tsx index 349db393d..408f14f16 100644 --- a/packages/features/accounts/src/components/account-selector.tsx +++ b/packages/features/accounts/src/components/account-selector.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; -import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons'; -import { CheckCircle, Plus } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { ChevronsUpDown, Plus, User } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar'; import { Button } from '@kit/ui/button'; @@ -40,7 +39,7 @@ interface AccountSelectorProps { selectedAccount?: string; collapsed?: boolean; className?: string; - collisionPadding?: number; + showPersonalAccount?: boolean; onAccountChange: (value: string | undefined) => void; } @@ -57,16 +56,14 @@ export function AccountSelector({ enableTeamCreation: true, }, collapsed = false, - collisionPadding = 20, + showPersonalAccount = true, }: React.PropsWithChildren<AccountSelectorProps>) { const [open, setOpen] = useState<boolean>(false); const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const personalData = usePersonalAccountData(userId); - const value = useMemo(() => { - return selectedAccount ?? PERSONAL_ACCOUNT_SLUG; - }, [selectedAccount]); + const value = selectedAccount ?? PERSONAL_ACCOUNT_SLUG; const selected = accounts.find((account) => account.value === value); const pictureUrl = personalData.data?.picture_url; @@ -74,128 +71,134 @@ export function AccountSelector({ return ( <> <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <Button - data-test={'account-selector-trigger'} - size={collapsed ? 'icon' : 'default'} - variant="ghost" - role="combobox" - aria-expanded={open} - className={cn( - 'dark:shadow-primary/10 group mr-1 w-full min-w-0 px-2 lg:w-auto lg:max-w-fit', - { - 'justify-start': !collapsed, - 'm-auto justify-center px-2 lg:w-full': collapsed, - }, - className, - )} - > - <If - condition={selected} - fallback={ - <span - className={cn('flex max-w-full items-center', { - 'justify-center gap-x-0': collapsed, - 'gap-x-2': !collapsed, - })} - > - <PersonalAccountAvatar pictureUrl={pictureUrl} /> - - <span - className={cn('truncate', { - hidden: collapsed, - })} - > - <Trans i18nKey={'teams:personalAccount'} /> - </span> - </span> - } - > - {(account) => ( - <span - className={cn('flex max-w-full items-center', { - 'justify-center gap-x-0': collapsed, - 'gap-x-2': !collapsed, - })} - > - <Avatar className={'h-6 w-6 rounded-xs'}> - <AvatarImage src={account.image ?? undefined} /> - - <AvatarFallback - className={'group-hover:bg-background rounded-xs'} - > - {account.label ? account.label[0] : ''} - </AvatarFallback> - </Avatar> - - <span - className={cn('truncate', { - hidden: collapsed, - })} - > - {account.label} - </span> - </span> + <PopoverTrigger + render={ + <Button + data-test={'account-selector-trigger'} + size={collapsed ? 'icon' : 'default'} + variant="ghost" + role="combobox" + aria-expanded={open} + className={cn( + 'dark:shadow-primary/10 group w-full min-w-0 px-1 lg:w-auto', + { + 'justify-start': !collapsed, + 'm-auto justify-center lg:w-full': collapsed, + }, + className, )} - </If> - - <CaretSortIcon - className={cn('ml-1 h-4 w-4 shrink-0 opacity-50', { - hidden: collapsed, - })} /> - </Button> + } + > + <If + condition={selected} + fallback={ + <span + className={cn('flex max-w-full items-center', { + 'justify-center gap-x-0': collapsed, + 'gap-x-2': !collapsed, + })} + > + <PersonalAccountAvatar pictureUrl={pictureUrl} /> + + <span + className={cn('truncate', { + hidden: collapsed, + })} + > + <Trans i18nKey={'teams.personalAccount'} /> + </span> + </span> + } + > + {(account) => ( + <span + className={cn('flex max-w-full items-center', { + 'justify-center gap-x-0': collapsed, + 'gap-x-2': !collapsed, + })} + > + <Avatar className={'h-6 w-6'}> + <AvatarImage src={account.image ?? undefined} /> + + <AvatarFallback> + {account.label ? account.label[0] : ''} + </AvatarFallback> + </Avatar> + + <span + className={cn('truncate lg:max-w-[130px]', { + hidden: collapsed, + })} + > + {account.label} + </span> + </span> + )} + </If> + + <ChevronsUpDown + className={cn('h-4 w-4 shrink-0 opacity-50', { + hidden: collapsed, + })} + /> </PopoverTrigger> <PopoverContent data-test={'account-selector-content'} - className="w-full p-0" - collisionPadding={collisionPadding} + className="w-full gap-0 p-0" > - <Command> + <Command value={value}> <CommandInput placeholder={t('searchAccount')} className="h-9" /> <CommandList> - <CommandGroup> - <CommandItem - className="shadow-none" - onSelect={() => onAccountChange(undefined)} - value={PERSONAL_ACCOUNT_SLUG} - > - <PersonalAccountAvatar /> + {showPersonalAccount && ( + <> + <CommandGroup> + <CommandItem + tabIndex={0} + value={PERSONAL_ACCOUNT_SLUG} + onSelect={() => onAccountChange(undefined)} + className={cn('', { + 'bg-muted': value === PERSONAL_ACCOUNT_SLUG, + 'data-selected:hover:bg-muted/50 data-selected:bg-transparent': + value !== PERSONAL_ACCOUNT_SLUG, + })} + > + <PersonalAccountAvatar /> - <span className={'ml-2'}> - <Trans i18nKey={'teams:personalAccount'} /> - </span> + <span className={'ml-2'}> + <Trans i18nKey={'teams.personalAccount'} /> + </span> + </CommandItem> + </CommandGroup> - <Icon selected={value === PERSONAL_ACCOUNT_SLUG} /> - </CommandItem> - </CommandGroup> - - <CommandSeparator /> + <CommandSeparator /> + </> + )} <If condition={accounts.length > 0}> <CommandGroup heading={ <Trans - i18nKey={'teams:yourTeams'} + i18nKey={'teams.yourTeams'} values={{ teamsCount: accounts.length }} /> } > {(accounts ?? []).map((account) => ( <CommandItem + className={cn('', { + 'bg-muted': value === account.value, + 'data-selected:hover:bg-muted/50 data-selected:bg-transparent': + value !== account.value, + })} + tabIndex={0} data-test={'account-selector-team'} data-name={account.label} data-slug={account.value} - className={cn( - 'group my-1 flex justify-between shadow-none transition-colors', - { - ['bg-muted']: value === account.value, - }, - )} key={account.value} - value={account.value ?? ''} + value={account.value ?? undefined} onSelect={(currentValue) => { setOpen(false); @@ -204,13 +207,12 @@ export function AccountSelector({ } }} > - <div className={'flex items-center'}> - <Avatar className={'mr-2 h-6 w-6 rounded-xs'}> + <div className={'flex w-full items-center'}> + <Avatar className={'mr-2 h-6 w-6'}> <AvatarImage src={account.image ?? undefined} /> <AvatarFallback - className={cn('rounded-xs', { - ['bg-background']: value === account.value, + className={cn({ ['group-hover:bg-background']: value !== account.value, })} @@ -219,12 +221,10 @@ export function AccountSelector({ </AvatarFallback> </Avatar> - <span className={'mr-2 max-w-[165px] truncate'}> + <span className={'max-w-[165px] truncate'}> {account.label} </span> </div> - - <Icon selected={(account.value ?? '') === value} /> </CommandItem> ))} </CommandGroup> @@ -232,26 +232,27 @@ export function AccountSelector({ </CommandList> </Command> - <Separator /> - <If condition={features.enableTeamCreation}> - <div className={'p-1'}> - <Button - data-test={'create-team-account-trigger'} - variant="ghost" - size={'sm'} - className="w-full justify-start text-sm font-normal" - onClick={() => { - setIsCreatingAccount(true); - setOpen(false); - }} - > - <Plus className="mr-3 h-4 w-4" /> + <div className="px-1"> + <Separator /> - <span> - <Trans i18nKey={'teams:createTeam'} /> - </span> - </Button> + <div className="py-1"> + <Button + data-test={'create-team-account-trigger'} + variant="ghost" + className="w-full justify-start text-sm font-normal" + onClick={() => { + setIsCreatingAccount(true); + setOpen(false); + }} + > + <Plus className="mr-3 h-4 w-4" /> + + <span> + <Trans i18nKey={'teams.createTeam'} /> + </span> + </Button> + </div> </div> </If> </PopoverContent> @@ -275,18 +276,10 @@ function UserAvatar(props: { pictureUrl?: string }) { ); } -function Icon({ selected }: { selected: boolean }) { - return ( - <CheckCircle - className={cn('ml-auto h-4 w-4', selected ? 'opacity-100' : 'opacity-0')} - /> - ); -} - function PersonalAccountAvatar({ pictureUrl }: { pictureUrl?: string | null }) { return pictureUrl ? ( <UserAvatar pictureUrl={pictureUrl} /> ) : ( - <PersonIcon className="h-5 w-5" /> + <User className="h-5 w-5" /> ); } diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx index 6dc1acd01..893714ba7 100644 --- a/packages/features/accounts/src/components/personal-account-dropdown.tsx +++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx @@ -87,20 +87,19 @@ export function PersonalAccountDropdown({ aria-label="Open your profile menu" data-test={'account-dropdown-trigger'} className={cn( - 'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[minimized=true]/sidebar:px-0', + 'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[collapsible=icon]:px-0', className ?? '', { - ['active:bg-secondary/50 items-center gap-4 rounded-md' + - ' hover:bg-secondary border border-dashed p-2 transition-colors']: + ['active:bg-secondary/50 group-data-[collapsible=none]:hover:bg-secondary items-center gap-4 rounded-md border-dashed p-2 transition-colors group-data-[collapsible=none]:border']: showProfileName, }, )} > <ProfileAvatar className={ - 'group-hover/trigger:border-background/50 rounded-md border border-transparent transition-colors' + 'group-hover/trigger:border-background/50 border border-transparent transition-colors' } - fallbackClassName={'rounded-md border'} + fallbackClassName={'border'} displayName={displayName ?? user?.email ?? ''} pictureUrl={personalAccountData?.picture_url} /> @@ -108,7 +107,7 @@ export function PersonalAccountDropdown({ <If condition={showProfileName}> <div className={ - 'fade-in flex w-full flex-col truncate text-left group-data-[minimized=true]/sidebar:hidden' + 'fade-in flex w-full flex-col truncate text-left group-data-[collapsible=icon]:hidden' } > <span @@ -128,19 +127,25 @@ export function PersonalAccountDropdown({ <ChevronsUpDown className={ - 'text-muted-foreground mr-1 h-8 group-data-[minimized=true]/sidebar:hidden' + 'text-muted-foreground mr-1 h-8 group-data-[collapsible=icon]:hidden' } /> </If> </DropdownMenuTrigger> <DropdownMenuContent className={'xl:min-w-[15rem]!'}> - <DropdownMenuItem className={'h-10! rounded-none'}> + <DropdownMenuItem + className={'group/item h-10! data-[highlighted]:bg-transparent'} + > <div className={'flex flex-col justify-start truncate text-left text-xs'} > - <div className={'text-muted-foreground'}> - <Trans i18nKey={'common:signedInAs'} /> + <div + className={ + 'text-muted-foreground group-hover/item:text-muted-foreground!' + } + > + <Trans i18nKey={'common.signedInAs'} /> </div> <div> @@ -151,48 +156,48 @@ export function PersonalAccountDropdown({ <DropdownMenuSeparator /> - <DropdownMenuItem asChild> - <Link - className={'s-full flex cursor-pointer items-center space-x-2'} - href={paths.home} - > - <Home className={'h-5'} /> + <DropdownMenuItem + render={ + <Link className={'flex items-center gap-x-2'} href={paths.home} /> + } + > + <Home className={'h-4 w-4'} /> - <span> - <Trans i18nKey={'common:routes.home'} /> - </span> - </Link> + <span> + <Trans i18nKey={'common.routes.home'} /> + </span> </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem asChild> - <Link - className={'s-full flex cursor-pointer items-center space-x-2'} - href={'/docs'} - > - <MessageCircleQuestion className={'h-5'} /> + <DropdownMenuItem + render={ + <Link className={'flex items-center gap-x-2'} href={'/docs'} /> + } + > + <MessageCircleQuestion className={'h-4 w-4'} /> - <span> - <Trans i18nKey={'common:documentation'} /> - </span> - </Link> + <span> + <Trans i18nKey={'common.documentation'} /> + </span> </DropdownMenuItem> <If condition={isSuperAdmin}> <DropdownMenuSeparator /> - <DropdownMenuItem asChild> - <Link - className={ - 's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500' - } - href={'/admin'} - > - <Shield className={'h-5'} /> + <DropdownMenuItem + render={ + <Link + className={ + 'flex items-center gap-x-2 text-yellow-700 dark:text-yellow-500' + } + href={'/admin'} + /> + } + > + <Shield className={'h-4 w-4'} /> - <span>Super Admin</span> - </Link> + <span>Super Admin</span> </DropdownMenuItem> </If> @@ -210,11 +215,11 @@ export function PersonalAccountDropdown({ className={'cursor-pointer'} onClick={signOutRequested} > - <span className={'flex w-full items-center space-x-2'}> - <LogOut className={'h-5'} /> + <span className={'flex w-full items-center gap-x-2'}> + <LogOut className={'h-4 w-4'} /> <span> - <Trans i18nKey={'auth:signOut'} /> + <Trans i18nKey={'auth.signOut'} /> </span> </span> </DropdownMenuItem> diff --git a/packages/features/accounts/src/components/personal-account-settings/account-danger-zone.tsx b/packages/features/accounts/src/components/personal-account-settings/account-danger-zone.tsx index 38b945edf..bfd68ec76 100644 --- a/packages/features/accounts/src/components/personal-account-settings/account-danger-zone.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/account-danger-zone.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useFormStatus } from 'react-dom'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; import { useForm, useWatch } from 'react-hook-form'; import { ErrorBoundary } from '@kit/monitoring/components'; @@ -31,11 +30,11 @@ export function AccountDangerZone() { <div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-1'}> <span className={'text-sm font-medium'}> - <Trans i18nKey={'account:deleteAccount'} /> + <Trans i18nKey={'account.deleteAccount'} /> </span> <p className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'account:deleteAccountDescription'} /> + <Trans i18nKey={'account.deleteAccountDescription'} /> </p> </div> @@ -55,16 +54,18 @@ function DeleteAccountModal() { return ( <AlertDialog> - <AlertDialogTrigger asChild> - <Button data-test={'delete-account-button'} variant={'destructive'}> - <Trans i18nKey={'account:deleteAccount'} /> - </Button> - </AlertDialogTrigger> + <AlertDialogTrigger + render={ + <Button data-test={'delete-account-button'} variant={'destructive'}> + <Trans i18nKey={'account.deleteAccount'} /> + </Button> + } + /> - <AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}> + <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey={'account:deleteAccount'} /> + <Trans i18nKey={'account.deleteAccount'} /> </AlertDialogTitle> </AlertDialogHeader> @@ -77,6 +78,8 @@ function DeleteAccountModal() { } function DeleteAccountForm(props: { email: string }) { + const { execute, isPending } = useAction(deletePersonalAccountAction); + const form = useForm({ resolver: zodResolver(DeletePersonalAccountSchema), defaultValues: { @@ -94,7 +97,7 @@ function DeleteAccountForm(props: { email: string }) { onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })} CancelButton={ <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> } /> @@ -105,11 +108,12 @@ function DeleteAccountForm(props: { email: string }) { <Form {...form}> <form data-test={'delete-account-form'} - action={deletePersonalAccountAction} + onSubmit={(e) => { + e.preventDefault(); + execute({ otp }); + }} className={'flex flex-col space-y-4'} > - <input type="hidden" name="otp" value={otp} /> - <div className={'flex flex-col space-y-6'}> <div className={ @@ -118,11 +122,11 @@ function DeleteAccountForm(props: { email: string }) { > <div className={'flex flex-col space-y-2'}> <div> - <Trans i18nKey={'account:deleteAccountDescription'} /> + <Trans i18nKey={'account.deleteAccountDescription'} /> </div> <div> - <Trans i18nKey={'common:modalConfirmationQuestion'} /> + <Trans i18nKey={'common.modalConfirmationQuestion'} /> </div> </div> </div> @@ -130,36 +134,28 @@ function DeleteAccountForm(props: { email: string }) { <AlertDialogFooter> <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> - <DeleteAccountSubmitButton disabled={!form.formState.isValid} /> + <Button + data-test={'confirm-delete-account-button'} + type={'submit'} + disabled={isPending || !form.formState.isValid} + name={'action'} + variant={'destructive'} + > + {isPending ? ( + <Trans i18nKey={'account.deletingAccount'} /> + ) : ( + <Trans i18nKey={'account.deleteAccount'} /> + )} + </Button> </AlertDialogFooter> </form> </Form> ); } -function DeleteAccountSubmitButton(props: { disabled: boolean }) { - const { pending } = useFormStatus(); - - return ( - <Button - data-test={'confirm-delete-account-button'} - type={'submit'} - disabled={pending || props.disabled} - name={'action'} - variant={'destructive'} - > - {pending ? ( - <Trans i18nKey={'account:deletingAccount'} /> - ) : ( - <Trans i18nKey={'account:deleteAccount'} /> - )} - </Button> - ); -} - function DeleteAccountErrorContainer() { return ( <div className="flex flex-col gap-y-4"> @@ -167,7 +163,7 @@ function DeleteAccountErrorContainer() { <div> <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> </div> </div> @@ -177,14 +173,14 @@ function DeleteAccountErrorContainer() { function DeleteAccountErrorAlert() { return ( <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'account:deleteAccountErrorHeading'} /> + <Trans i18nKey={'account.deleteAccountErrorHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'common:genericError'} /> + <Trans i18nKey={'common.genericError'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx b/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx index 052abbeec..524d43685 100644 --- a/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx @@ -2,8 +2,7 @@ import type { Provider } from '@supabase/supabase-js'; -import { useTranslation } from 'react-i18next'; - +import { routing } from '@kit/i18n'; import { Card, CardContent, @@ -55,11 +54,11 @@ export function PersonalAccountSettingsContainer( <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'account:accountImage'} /> + <Trans i18nKey={'account.accountImage'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'account:accountImageDescription'} /> + <Trans i18nKey={'account.accountImageDescription'} /> </CardDescription> </CardHeader> @@ -76,11 +75,11 @@ export function PersonalAccountSettingsContainer( <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'account:name'} /> + <Trans i18nKey={'account.name'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'account:nameDescription'} /> + <Trans i18nKey={'account.nameDescription'} /> </CardDescription> </CardHeader> @@ -93,16 +92,16 @@ export function PersonalAccountSettingsContainer( <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'account:language'} /> + <Trans i18nKey={'account.language'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'account:languageDescription'} /> + <Trans i18nKey={'account.languageDescription'} /> </CardDescription> </CardHeader> <CardContent> - <LanguageSelector /> + <LanguageSelector locales={routing.locales} /> </CardContent> </Card> </If> @@ -110,11 +109,11 @@ export function PersonalAccountSettingsContainer( <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'account:updateEmailCardTitle'} /> + <Trans i18nKey={'account.updateEmailCardTitle'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'account:updateEmailCardDescription'} /> + <Trans i18nKey={'account.updateEmailCardDescription'} /> </CardDescription> </CardHeader> @@ -127,11 +126,11 @@ export function PersonalAccountSettingsContainer( <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'account:updatePasswordCardTitle'} /> + <Trans i18nKey={'account.updatePasswordCardTitle'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'account:updatePasswordCardDescription'} /> + <Trans i18nKey={'account.updatePasswordCardDescription'} /> </CardDescription> </CardHeader> @@ -144,11 +143,11 @@ export function PersonalAccountSettingsContainer( <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'account:multiFactorAuth'} /> + <Trans i18nKey={'account.multiFactorAuth'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'account:multiFactorAuthDescription'} /> + <Trans i18nKey={'account.multiFactorAuthDescription'} /> </CardDescription> </CardHeader> @@ -160,11 +159,11 @@ export function PersonalAccountSettingsContainer( <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'account:linkedAccounts'} /> + <Trans i18nKey={'account.linkedAccounts'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'account:linkedAccountsDescription'} /> + <Trans i18nKey={'account.linkedAccountsDescription'} /> </CardDescription> </CardHeader> @@ -183,11 +182,11 @@ export function PersonalAccountSettingsContainer( <Card className={'border-destructive'}> <CardHeader> <CardTitle> - <Trans i18nKey={'account:dangerZone'} /> + <Trans i18nKey={'account.dangerZone'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'account:dangerZoneDescription'} /> + <Trans i18nKey={'account.dangerZoneDescription'} /> </CardDescription> </CardHeader> @@ -201,10 +200,7 @@ export function PersonalAccountSettingsContainer( } function useSupportMultiLanguage() { - const { i18n } = useTranslation(); - const langs = (i18n?.options?.supportedLngs as string[]) ?? []; + const { locales } = routing; - const supportedLangs = langs.filter((lang) => lang !== 'cimode'); - - return supportedLangs.length > 1; + return locales.length > 1; } diff --git a/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx b/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx index f8fa94942..beff0b131 100644 --- a/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx @@ -1,10 +1,9 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { CheckIcon } from '@radix-ui/react-icons'; -import { Mail } from 'lucide-react'; +import { Check, Mail } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -62,7 +61,7 @@ export function UpdateEmailForm({ callbackPath: string; onSuccess?: () => void; }) { - const { t } = useTranslation('account'); + const t = useTranslations('account'); const updateUserMutation = useUpdateUser(); const isSettingEmail = !email; @@ -108,14 +107,14 @@ export function UpdateEmailForm({ > <If condition={updateUserMutation.data}> <Alert variant={'success'}> - <CheckIcon className={'h-4'} /> + <Check className={'h-4'} /> <AlertTitle> <Trans i18nKey={ isSettingEmail - ? 'account:setEmailSuccess' - : 'account:updateEmailSuccess' + ? 'account.setEmailSuccess' + : 'account.updateEmailSuccess' } /> </AlertTitle> @@ -124,8 +123,8 @@ export function UpdateEmailForm({ <Trans i18nKey={ isSettingEmail - ? 'account:setEmailSuccessMessage' - : 'account:updateEmailSuccessMessage' + ? 'account.setEmailSuccessMessage' + : 'account.updateEmailSuccessMessage' } /> </AlertDescription> @@ -148,9 +147,7 @@ export function UpdateEmailForm({ required type={'email'} placeholder={t( - isSettingEmail - ? 'account:emailAddress' - : 'account:newEmail', + isSettingEmail ? 'emailAddress' : 'newEmail', )} {...field} /> @@ -177,7 +174,7 @@ export function UpdateEmailForm({ data-test={'account-email-form-repeat-email-input'} required type={'email'} - placeholder={t('account:repeatEmail')} + placeholder={t('repeatEmail')} /> </InputGroup> </FormControl> @@ -190,12 +187,12 @@ export function UpdateEmailForm({ </div> <div> - <Button disabled={updateUserMutation.isPending}> + <Button type="submit" disabled={updateUserMutation.isPending}> <Trans i18nKey={ isSettingEmail - ? 'account:setEmailAddress' - : 'account:updateEmailSubmitLabel' + ? 'account.setEmailAddress' + : 'account.updateEmailSubmitLabel' } /> </Button> diff --git a/packages/features/accounts/src/components/personal-account-settings/link-accounts/link-accounts-list.tsx b/packages/features/accounts/src/components/personal-account-settings/link-accounts/link-accounts-list.tsx index 492a99212..1e277065d 100644 --- a/packages/features/accounts/src/components/personal-account-settings/link-accounts/link-accounts-list.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/link-accounts/link-accounts-list.tsx @@ -112,9 +112,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) { const promise = unlinkMutation.mutateAsync(identity); toast.promise(promise, { - loading: <Trans i18nKey={'account:unlinkingAccount'} />, - success: <Trans i18nKey={'account:accountUnlinked'} />, - error: <Trans i18nKey={'account:unlinkAccountError'} />, + loading: <Trans i18nKey={'account.unlinkingAccount'} />, + success: <Trans i18nKey={'account.accountUnlinked'} />, + error: <Trans i18nKey={'account.unlinkAccountError'} />, }); }; @@ -129,9 +129,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) { }); toast.promise(promise, { - loading: <Trans i18nKey={'account:linkingAccount'} />, - success: <Trans i18nKey={'account:accountLinked'} />, - error: <Trans i18nKey={'account:linkAccountError'} />, + loading: <Trans i18nKey={'account.linkingAccount'} />, + success: <Trans i18nKey={'account.accountLinked'} />, + error: <Trans i18nKey={'account.linkAccountError'} />, }); }; @@ -149,11 +149,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) { <div className="space-y-2.5"> <div> <h3 className="text-foreground text-sm font-medium"> - <Trans i18nKey={'account:linkedMethods'} /> + <Trans i18nKey={'account.linkedMethods'} /> </h3> <p className="text-muted-foreground text-xs"> - <Trans i18nKey={'account:alreadyLinkedMethodsDescription'} /> + <Trans i18nKey={'account.alreadyLinkedMethodsDescription'} /> </p> </div> @@ -185,28 +185,30 @@ export function LinkAccountsList(props: LinkAccountsListProps) { <ItemActions> <If condition={hasMultipleIdentities}> <AlertDialog> - <AlertDialogTrigger asChild> - <Button - variant="outline" - size="sm" - disabled={unlinkMutation.isPending} - > - <If condition={unlinkMutation.isPending}> - <Spinner className="mr-2 h-3 w-3" /> - </If> - <Trans i18nKey={'account:unlinkAccount'} /> - </Button> - </AlertDialogTrigger> + <AlertDialogTrigger + render={ + <Button + variant="outline" + size="sm" + disabled={unlinkMutation.isPending} + > + <If condition={unlinkMutation.isPending}> + <Spinner className="mr-2 h-3 w-3" /> + </If> + <Trans i18nKey={'account.unlinkAccount'} /> + </Button> + } + /> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey={'account:confirmUnlinkAccount'} /> + <Trans i18nKey={'account.confirmUnlinkAccount'} /> </AlertDialogTitle> <AlertDialogDescription> <Trans - i18nKey={'account:unlinkAccountConfirmation'} + i18nKey={'account.unlinkAccountConfirmation'} values={{ provider: identity.provider }} /> </AlertDialogDescription> @@ -214,14 +216,14 @@ export function LinkAccountsList(props: LinkAccountsListProps) { <AlertDialogFooter> <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> <AlertDialogAction onClick={() => handleUnlinkAccount(identity)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - <Trans i18nKey={'account:unlinkAccount'} /> + <Trans i18nKey={'account.unlinkAccount'} /> </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> @@ -243,11 +245,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) { <div className="space-y-2.5"> <div> <h3 className="text-foreground text-sm font-medium"> - <Trans i18nKey={'account:availableMethods'} /> + <Trans i18nKey={'account.availableMethods'} /> </h3> <p className="text-muted-foreground text-xs"> - <Trans i18nKey={'account:availableMethodsDescription'} /> + <Trans i18nKey={'account.availableMethodsDescription'} /> </p> </div> @@ -281,7 +283,7 @@ export function LinkAccountsList(props: LinkAccountsListProps) { <ItemDescription> <Trans - i18nKey={'account:linkAccountDescription'} + i18nKey={'account.linkAccountDescription'} values={{ provider }} /> </ItemDescription> @@ -299,7 +301,7 @@ function NoAccountsAvailable() { return ( <div> <span className="text-muted-foreground text-xs"> - <Trans i18nKey={'account:noAccountsAvailable'} /> + <Trans i18nKey={'account.noAccountsAvailable'} /> </span> </div> ); @@ -310,38 +312,41 @@ function UpdateEmailDialog(props: { redirectTo: string }) { return ( <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Item variant="outline" role="button" className="hover:bg-muted/50"> - <ItemMedia> - <div className="text-muted-foreground flex h-5 w-5 items-center justify-center"> - <OauthProviderLogoImage providerId={'email'} /> - </div> - </ItemMedia> - - <ItemContent> - <ItemHeader> - <div className="flex flex-col"> - <ItemTitle className="text-sm font-medium"> - <Trans i18nKey={'account:setEmailAddress'} /> - </ItemTitle> - - <ItemDescription> - <Trans i18nKey={'account:setEmailDescription'} /> - </ItemDescription> + <DialogTrigger + nativeButton={false} + render={ + <Item variant="outline" role="button" className="hover:bg-muted/50"> + <ItemMedia> + <div className="text-muted-foreground flex h-5 w-5 items-center justify-center"> + <OauthProviderLogoImage providerId={'email'} /> </div> - </ItemHeader> - </ItemContent> - </Item> - </DialogTrigger> + </ItemMedia> + + <ItemContent> + <ItemHeader> + <div className="flex flex-col"> + <ItemTitle className="text-sm font-medium"> + <Trans i18nKey={'account.setEmailAddress'} /> + </ItemTitle> + + <ItemDescription> + <Trans i18nKey={'account.setEmailDescription'} /> + </ItemDescription> + </div> + </ItemHeader> + </ItemContent> + </Item> + } + /> <DialogContent> <DialogHeader> <DialogTitle> - <Trans i18nKey={'account:setEmailAddress'} /> + <Trans i18nKey={'account.setEmailAddress'} /> </DialogTitle> <DialogDescription> - <Trans i18nKey={'account:setEmailDescription'} /> + <Trans i18nKey={'account.setEmailDescription'} /> </DialogDescription> </DialogHeader> @@ -373,34 +378,38 @@ function UpdatePasswordDialog(props: { return ( <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild data-test="open-password-dialog-trigger"> - <Item variant="outline" role="button" className="hover:bg-muted/50"> - <ItemMedia> - <div className="text-muted-foreground flex h-5 w-5 items-center justify-center"> - <OauthProviderLogoImage providerId={'password'} /> - </div> - </ItemMedia> - - <ItemContent> - <ItemHeader> - <div className="flex flex-col"> - <ItemTitle className="text-sm font-medium"> - <Trans i18nKey={'account:linkEmailPassword'} /> - </ItemTitle> - - <ItemDescription> - <Trans i18nKey={'account:updatePasswordDescription'} /> - </ItemDescription> + <DialogTrigger + nativeButton={false} + data-test="open-password-dialog-trigger" + render={ + <Item variant="outline" role="button" className="hover:bg-muted/50"> + <ItemMedia> + <div className="text-muted-foreground flex h-5 w-5 items-center justify-center"> + <OauthProviderLogoImage providerId={'password'} /> </div> - </ItemHeader> - </ItemContent> - </Item> - </DialogTrigger> + </ItemMedia> + + <ItemContent> + <ItemHeader> + <div className="flex flex-col"> + <ItemTitle className="text-sm font-medium"> + <Trans i18nKey={'account.linkEmailPassword'} /> + </ItemTitle> + + <ItemDescription> + <Trans i18nKey={'account.updatePasswordDescription'} /> + </ItemDescription> + </div> + </ItemHeader> + </ItemContent> + </Item> + } + /> <DialogContent> <DialogHeader> <DialogTitle> - <Trans i18nKey={'account:linkEmailPassword'} /> + <Trans i18nKey={'account.linkEmailPassword'} /> </DialogTitle> </DialogHeader> diff --git a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx index 3c74c0e38..2713633b5 100644 --- a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx @@ -4,10 +4,9 @@ import { useCallback, useState } from 'react'; import type { Factor } from '@supabase/supabase-js'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ShieldCheck, X } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { ShieldCheck, TriangleAlert, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; @@ -78,7 +77,7 @@ function FactorsTableContainer(props: { userId: string }) { <Spinner /> <div> - <Trans i18nKey={'account:loadingFactors'} /> + <Trans i18nKey={'account.loadingFactors'} /> </div> </div> ); @@ -88,14 +87,14 @@ function FactorsTableContainer(props: { userId: string }) { return ( <div> <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'account:factorsListError'} /> + <Trans i18nKey={'account.factorsListError'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'account:factorsListErrorDescription'} /> + <Trans i18nKey={'account.factorsListErrorDescription'} /> </AlertDescription> </Alert> </div> @@ -114,11 +113,11 @@ function FactorsTableContainer(props: { userId: string }) { <ItemContent> <ItemTitle> - <Trans i18nKey={'account:multiFactorAuthHeading'} /> + <Trans i18nKey={'account.multiFactorAuthHeading'} /> </ItemTitle> <ItemDescription> - <Trans i18nKey={'account:multiFactorAuthDescription'} /> + <Trans i18nKey={'account.multiFactorAuthDescription'} /> </ItemDescription> </ItemContent> </Item> @@ -136,7 +135,7 @@ function ConfirmUnenrollFactorModal( setIsModalOpen: (isOpen: boolean) => void; }>, ) { - const { t } = useTranslation(); + const t = useTranslations(); const unEnroll = useUnenrollFactor(props.userId); const onUnenrollRequested = useCallback( @@ -149,15 +148,18 @@ function ConfirmUnenrollFactorModal( if (!response.success) { const errorCode = response.data; - throw t(`auth:errors.${errorCode}`, { - defaultValue: t(`account:unenrollFactorError`), - }); + throw t( + `auth.errors.${errorCode}` as never, + { + defaultValue: t(`account.unenrollFactorError` as never), + } as never, + ); } }); toast.promise(promise, { - loading: t(`account:unenrollingFactor`), - success: t(`account:unenrollFactorSuccess`), + loading: t(`account.unenrollingFactor` as never), + success: t(`account.unenrollFactorSuccess` as never), error: (error: string) => { return error; }, @@ -171,17 +173,17 @@ function ConfirmUnenrollFactorModal( <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey={'account:unenrollFactorModalHeading'} /> + <Trans i18nKey={'account.unenrollFactorModalHeading'} /> </AlertDialogTitle> <AlertDialogDescription> - <Trans i18nKey={'account:unenrollFactorModalDescription'} /> + <Trans i18nKey={'account.unenrollFactorModalDescription'} /> </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> <AlertDialogAction @@ -189,7 +191,7 @@ function ConfirmUnenrollFactorModal( disabled={unEnroll.isPending} onClick={() => onUnenrollRequested(props.factorId)} > - <Trans i18nKey={'account:unenrollFactorModalButtonLabel'} /> + <Trans i18nKey={'account.unenrollFactorModalButtonLabel'} /> </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> @@ -212,13 +214,13 @@ function FactorsTable({ <TableHeader> <TableRow> <TableHead> - <Trans i18nKey={'account:factorName'} /> + <Trans i18nKey={'account.factorName'} /> </TableHead> <TableHead> - <Trans i18nKey={'account:factorType'} /> + <Trans i18nKey={'account.factorType'} /> </TableHead> <TableHead> - <Trans i18nKey={'account:factorStatus'} /> + <Trans i18nKey={'account.factorStatus'} /> </TableHead> <TableHead /> @@ -250,18 +252,20 @@ function FactorsTable({ <td className={'flex justify-end'}> <TooltipProvider> <Tooltip> - <TooltipTrigger asChild> - <Button - variant={'ghost'} - size={'icon'} - onClick={() => setUnenrolling(factor.id)} - > - <X className={'h-4'} /> - </Button> - </TooltipTrigger> + <TooltipTrigger + render={ + <Button + variant={'ghost'} + size={'icon'} + onClick={() => setUnenrolling(factor.id)} + > + <X className={'h-4'} /> + </Button> + } + /> <TooltipContent> - <Trans i18nKey={'account:unenrollTooltip'} /> + <Trans i18nKey={'account.unenrollTooltip'} /> </TooltipContent> </Tooltip> </TooltipProvider> diff --git a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx index bd2b58709..630f9a16b 100644 --- a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx @@ -3,12 +3,11 @@ import { useCallback, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeftIcon } from 'lucide-react'; +import { ArrowLeftIcon, TriangleAlert } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import * as z from 'zod'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key'; @@ -31,6 +30,7 @@ import { FormLabel, FormMessage, } from '@kit/ui/form'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; import { @@ -45,41 +45,43 @@ import { Trans } from '@kit/ui/trans'; import { refreshAuthSession } from '../../../server/personal-accounts-server-actions'; export function MultiFactorAuthSetupDialog(props: { userId: string }) { - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); + const t = useTranslations(); + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog(); const onEnrollSuccess = useCallback(() => { - setIsOpen(false); + setIsPending(false); + setOpen(false); - return toast.success(t(`account:multiFactorSetupSuccess`)); - }, [t]); + return toast.success(t(`account.multiFactorSetupSuccess` as never)); + }, [t, setIsPending, setOpen]); return ( - <Dialog open={isOpen} onOpenChange={setIsOpen}> - <DialogTrigger asChild> - <Button> - <Trans i18nKey={'account:setupMfaButtonLabel'} /> - </Button> - </DialogTrigger> + <Dialog {...dialogProps}> + <DialogTrigger + render={ + <Button> + <Trans i18nKey={'account.setupMfaButtonLabel'} /> + </Button> + } + /> - <DialogContent - onInteractOutside={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > + <DialogContent showCloseButton={!isPending}> <DialogHeader> <DialogTitle> - <Trans i18nKey={'account:setupMfaButtonLabel'} /> + <Trans i18nKey={'account.setupMfaButtonLabel'} /> </DialogTitle> <DialogDescription> - <Trans i18nKey={'account:multiFactorAuthDescription'} /> + <Trans i18nKey={'account.multiFactorAuthDescription'} /> </DialogDescription> </DialogHeader> <div> <MultiFactorAuthSetupForm userId={props.userId} - onCancel={() => setIsOpen(false)} + isPending={isPending} + setIsPending={setIsPending} + onCancel={() => setOpen(false)} onEnrolled={onEnrollSuccess} /> </div> @@ -92,10 +94,14 @@ function MultiFactorAuthSetupForm({ onEnrolled, onCancel, userId, + isPending, + setIsPending, }: React.PropsWithChildren<{ userId: string; onCancel: () => void; onEnrolled: () => void; + isPending: boolean; + setIsPending: (pending: boolean) => void; }>) { const verifyCodeMutation = useVerifyCodeMutation(userId); @@ -112,10 +118,7 @@ function MultiFactorAuthSetupForm({ }, }); - const [state, setState] = useState({ - loading: false, - error: '', - }); + const [error, setError] = useState(''); const factorId = useWatch({ name: 'factorId', @@ -130,10 +133,8 @@ function MultiFactorAuthSetupForm({ verificationCode: string; factorId: string; }) => { - setState({ - loading: true, - error: '', - }); + setIsPending(true); + setError(''); try { await verifyCodeMutation.mutateAsync({ @@ -143,25 +144,18 @@ function MultiFactorAuthSetupForm({ await refreshAuthSession(); - setState({ - loading: false, - error: '', - }); - onEnrolled(); } catch (error) { const message = (error as Error).message || `Unknown error`; - setState({ - loading: false, - error: message, - }); + setIsPending(false); + setError(message); } }, - [onEnrolled, verifyCodeMutation], + [onEnrolled, verifyCodeMutation, setIsPending], ); - if (state.error) { + if (error) { return <ErrorAlert />; } @@ -170,6 +164,7 @@ function MultiFactorAuthSetupForm({ <div className={'flex justify-center'}> <FactorQrCode userId={userId} + isPending={isPending} onCancel={onCancel} onSetFactorId={(factorId) => verificationCodeForm.setValue('factorId', factorId) @@ -210,7 +205,7 @@ function MultiFactorAuthSetupForm({ <FormDescription> <Trans - i18nKey={'account:verifyActivationCodeDescription'} + i18nKey={'account.verifyActivationCodeDescription'} /> </FormDescription> @@ -222,20 +217,25 @@ function MultiFactorAuthSetupForm({ /> <div className={'flex justify-end space-x-2'}> - <Button type={'button'} variant={'ghost'} onClick={onCancel}> - <Trans i18nKey={'common:cancel'} /> + <Button + type={'button'} + variant={'ghost'} + disabled={isPending} + onClick={onCancel} + > + <Trans i18nKey={'common.cancel'} /> </Button> <Button disabled={ - !verificationCodeForm.formState.isValid || state.loading + !verificationCodeForm.formState.isValid || isPending } type={'submit'} > - {state.loading ? ( - <Trans i18nKey={'account:verifyingCode'} /> + {isPending ? ( + <Trans i18nKey={'account.verifyingCode'} /> ) : ( - <Trans i18nKey={'account:enableMfaFactor'} /> + <Trans i18nKey={'account.enableMfaFactor'} /> )} </Button> </div> @@ -251,13 +251,15 @@ function FactorQrCode({ onSetFactorId, onCancel, userId, + isPending, }: React.PropsWithChildren<{ userId: string; + isPending: boolean; onCancel: () => void; onSetFactorId: (factorId: string) => void; }>) { const enrollFactorMutation = useEnrollFactor(userId); - const { t } = useTranslation(); + const t = useTranslations(); const [error, setError] = useState<string>(''); const form = useForm({ @@ -279,16 +281,16 @@ function FactorQrCode({ return ( <div className={'flex w-full flex-col space-y-2'}> <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'account:qrCodeErrorHeading'} /> + <Trans i18nKey={'account.qrCodeErrorHeading'} /> </AlertTitle> <AlertDescription> <Trans - i18nKey={`auth:errors.${error}`} - defaults={t('account:qrCodeErrorDescription')} + i18nKey={`auth.errors.${error}`} + defaults={t('account.qrCodeErrorDescription')} /> </AlertDescription> </Alert> @@ -296,7 +298,7 @@ function FactorQrCode({ <div> <Button variant={'outline'} onClick={onCancel}> <ArrowLeftIcon className={'h-4'} /> - <Trans i18nKey={`common:retry`} /> + <Trans i18nKey={`common.retry`} /> </Button> </div> </div> @@ -306,6 +308,7 @@ function FactorQrCode({ if (!factorName) { return ( <FactorNameForm + isPending={isPending} onCancel={onCancel} onSetFactorName={async (name) => { const response = await enrollFactorMutation.mutateAsync(name); @@ -336,7 +339,7 @@ function FactorQrCode({ > <p> <span className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'account:multiFactorModalHeading'} /> + <Trans i18nKey={'account.multiFactorModalHeading'} /> </span> </p> @@ -349,6 +352,7 @@ function FactorQrCode({ function FactorNameForm( props: React.PropsWithChildren<{ + isPending: boolean; onSetFactorName: (name: string) => void; onCancel: () => void; }>, @@ -379,7 +383,7 @@ function FactorNameForm( return ( <FormItem> <FormLabel> - <Trans i18nKey={'account:factorNameLabel'} /> + <Trans i18nKey={'account.factorNameLabel'} /> </FormLabel> <FormControl> @@ -387,7 +391,7 @@ function FactorNameForm( </FormControl> <FormDescription> - <Trans i18nKey={'account:factorNameHint'} /> + <Trans i18nKey={'account.factorNameHint'} /> </FormDescription> <FormMessage /> @@ -397,12 +401,17 @@ function FactorNameForm( /> <div className={'flex justify-end space-x-2'}> - <Button type={'button'} variant={'ghost'} onClick={props.onCancel}> - <Trans i18nKey={'common:cancel'} /> + <Button + type={'button'} + variant={'ghost'} + disabled={props.isPending} + onClick={props.onCancel} + > + <Trans i18nKey={'common.cancel'} /> </Button> - <Button type={'submit'}> - <Trans i18nKey={'account:factorNameSubmitLabel'} /> + <Button type={'submit'} disabled={props.isPending}> + <Trans i18nKey={'account.factorNameSubmitLabel'} /> </Button> </div> </div> @@ -501,14 +510,14 @@ function useVerifyCodeMutation(userId: string) { function ErrorAlert() { return ( <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'account:multiFactorSetupErrorHeading'} /> + <Trans i18nKey={'account.multiFactorSetupErrorHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'account:multiFactorSetupErrorDescription'} /> + <Trans i18nKey={'account.multiFactorSetupErrorDescription'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx b/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx index 09d680bd8..d638c1d10 100644 --- a/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx @@ -5,10 +5,9 @@ import { useState } from 'react'; import type { PostgrestError } from '@supabase/supabase-js'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { Check, Lock, XIcon } from 'lucide-react'; +import { Check, Lock, TriangleAlert, XIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -41,7 +40,7 @@ export const UpdatePasswordForm = ({ callbackPath: string; onSuccess?: () => void; }) => { - const { t } = useTranslation('account'); + const t = useTranslations('account'); const updateUserMutation = useUpdateUser(); const [needsReauthentication, setNeedsReauthentication] = useState(false); @@ -131,7 +130,7 @@ export const UpdatePasswordForm = ({ autoComplete={'new-password'} required type={'password'} - placeholder={t('account:newPassword')} + placeholder={t('newPassword')} {...field} /> </InputGroup> @@ -160,14 +159,14 @@ export const UpdatePasswordForm = ({ } required type={'password'} - placeholder={t('account:repeatPassword')} + placeholder={t('repeatPassword')} {...field} /> </InputGroup> </FormControl> <FormDescription> - <Trans i18nKey={'account:repeatPasswordDescription'} /> + <Trans i18nKey={'account.repeatPasswordDescription'} /> </FormDescription> <FormMessage /> @@ -179,10 +178,11 @@ export const UpdatePasswordForm = ({ <div> <Button + type="submit" disabled={updateUserMutation.isPending} data-test="identity-form-submit" > - <Trans i18nKey={'account:updatePasswordSubmitLabel'} /> + <Trans i18nKey={'account.updatePasswordSubmitLabel'} /> </Button> </div> </div> @@ -192,20 +192,20 @@ export const UpdatePasswordForm = ({ }; function ErrorAlert({ error }: { error: { code: string } }) { - const { t } = useTranslation(); + const t = useTranslations(); return ( <Alert variant={'destructive'}> <XIcon className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'account:updatePasswordError'} /> + <Trans i18nKey={'account.updatePasswordError'} /> </AlertTitle> <AlertDescription> <Trans - i18nKey={`auth:errors.${error.code}`} - defaults={t('auth:resetPasswordError')} + i18nKey={`auth.errors.${error.code}`} + defaults={t('auth.resetPasswordError')} /> </AlertDescription> </Alert> @@ -218,11 +218,11 @@ function SuccessAlert() { <Check className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'account:updatePasswordSuccess'} /> + <Trans i18nKey={'account.updatePasswordSuccess'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'account:updatePasswordSuccessMessage'} /> + <Trans i18nKey={'account.updatePasswordSuccessMessage'} /> </AlertDescription> </Alert> ); @@ -231,14 +231,14 @@ function SuccessAlert() { function NeedsReauthenticationAlert() { return ( <Alert variant={'warning'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'account:needsReauthentication'} /> + <Trans i18nKey={'account.needsReauthentication'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'account:needsReauthenticationDescription'} /> + <Trans i18nKey={'account.needsReauthenticationDescription'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/accounts/src/components/personal-account-settings/update-account-details-form.tsx b/packages/features/accounts/src/components/personal-account-settings/update-account-details-form.tsx index b0cba9c60..8a29aa79a 100644 --- a/packages/features/accounts/src/components/personal-account-settings/update-account-details-form.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/update-account-details-form.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { User } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Database } from '@kit/supabase/database'; import { Button } from '@kit/ui/button'; @@ -35,7 +35,7 @@ export function UpdateAccountDetailsForm({ onUpdate: (user: Partial<UpdateUserDataParams>) => void; }) { const updateAccountMutation = useUpdateAccountData(userId); - const { t } = useTranslation('account'); + const t = useTranslations('account'); const form = useForm({ resolver: zodResolver(AccountDetailsSchema), @@ -79,7 +79,7 @@ export function UpdateAccountDetailsForm({ <InputGroupInput data-test={'account-display-name'} minLength={2} - placeholder={t('account:name')} + placeholder={t('name')} maxLength={100} {...field} /> @@ -92,8 +92,8 @@ export function UpdateAccountDetailsForm({ /> <div> - <Button disabled={updateAccountMutation.isPending}> - <Trans i18nKey={'account:updateProfileSubmitLabel'} /> + <Button type="submit" disabled={updateAccountMutation.isPending}> + <Trans i18nKey={'account.updateProfileSubmitLabel'} /> </Button> </div> </form> diff --git a/packages/features/accounts/src/components/personal-account-settings/update-account-image-container.tsx b/packages/features/accounts/src/components/personal-account-settings/update-account-image-container.tsx index c087d89e5..a36eaa014 100644 --- a/packages/features/accounts/src/components/personal-account-settings/update-account-image-container.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/update-account-image-container.tsx @@ -4,7 +4,7 @@ import { useCallback } from 'react'; import type { SupabaseClient } from '@supabase/supabase-js'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { Database } from '@kit/supabase/database'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; @@ -41,7 +41,7 @@ function UploadProfileAvatarForm(props: { onAvatarUpdated: () => void; }) { const client = useSupabase(); - const { t } = useTranslation('account'); + const t = useTranslations('account'); const createToaster = useCallback( (promise: () => Promise<unknown>) => { @@ -111,11 +111,11 @@ function UploadProfileAvatarForm(props: { <ImageUploader value={props.pictureUrl} onValueChange={onValueChange}> <div className={'flex flex-col space-y-1'}> <span className={'text-sm'}> - <Trans i18nKey={'account:profilePictureHeading'} /> + <Trans i18nKey={'account.profilePictureHeading'} /> </span> <span className={'text-xs'}> - <Trans i18nKey={'account:profilePictureSubheading'} /> + <Trans i18nKey={'account.profilePictureSubheading'} /> </span> </div> </ImageUploader> diff --git a/packages/features/accounts/src/schema/account-details.schema.ts b/packages/features/accounts/src/schema/account-details.schema.ts index ce54094ee..a8f06e38d 100644 --- a/packages/features/accounts/src/schema/account-details.schema.ts +++ b/packages/features/accounts/src/schema/account-details.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const AccountDetailsSchema = z.object({ displayName: z.string().min(2).max(100), diff --git a/packages/features/accounts/src/schema/delete-personal-account.schema.ts b/packages/features/accounts/src/schema/delete-personal-account.schema.ts index 48220850b..2c131cbd5 100644 --- a/packages/features/accounts/src/schema/delete-personal-account.schema.ts +++ b/packages/features/accounts/src/schema/delete-personal-account.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const DeletePersonalAccountSchema = z.object({ otp: z.string().min(6), diff --git a/packages/features/accounts/src/schema/link-email-password.schema.ts b/packages/features/accounts/src/schema/link-email-password.schema.ts index 90933e736..6dd9eb4bb 100644 --- a/packages/features/accounts/src/schema/link-email-password.schema.ts +++ b/packages/features/accounts/src/schema/link-email-password.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const LinkEmailPasswordSchema = z .object({ @@ -8,5 +8,5 @@ export const LinkEmailPasswordSchema = z }) .refine((values) => values.password === values.repeatPassword, { path: ['repeatPassword'], - message: `account:passwordNotMatching`, + message: `account.passwordNotMatching`, }); diff --git a/packages/features/accounts/src/schema/update-email.schema.ts b/packages/features/accounts/src/schema/update-email.schema.ts index 58f45d0f3..c514cf601 100644 --- a/packages/features/accounts/src/schema/update-email.schema.ts +++ b/packages/features/accounts/src/schema/update-email.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const UpdateEmailSchema = { withTranslation: (errorMessage: string) => { diff --git a/packages/features/accounts/src/schema/update-password.schema.ts b/packages/features/accounts/src/schema/update-password.schema.ts index ae0308573..88b59d482 100644 --- a/packages/features/accounts/src/schema/update-password.schema.ts +++ b/packages/features/accounts/src/schema/update-password.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const PasswordUpdateSchema = { withTranslation: (errorMessage: string) => { diff --git a/packages/features/accounts/src/server/personal-accounts-server-actions.ts b/packages/features/accounts/src/server/personal-accounts-server-actions.ts index 10f74ed16..714221cf4 100644 --- a/packages/features/accounts/src/server/personal-accounts-server-actions.ts +++ b/packages/features/accounts/src/server/personal-accounts-server-actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -23,25 +23,17 @@ export async function refreshAuthSession() { return {}; } -export const deletePersonalAccountAction = enhanceAction( - async (formData: FormData, user) => { +export const deletePersonalAccountAction = authActionClient + .inputSchema(DeletePersonalAccountSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { const logger = await getLogger(); - // validate the form data - const { success } = DeletePersonalAccountSchema.safeParse( - Object.fromEntries(formData.entries()), - ); - - if (!success) { - throw new Error('Invalid form data'); - } - const ctx = { name: 'account.delete', userId: user.id, }; - const otp = formData.get('otp') as string; + const otp = data.otp; if (!otp) { throw new Error('OTP is required'); @@ -101,6 +93,4 @@ export const deletePersonalAccountAction = enhanceAction( // redirect to the home page redirect('/'); - }, - {}, -); + }); diff --git a/packages/features/accounts/src/server/services/delete-personal-account.service.ts b/packages/features/accounts/src/server/services/delete-personal-account.service.ts index f0a8c489c..f8eb09016 100644 --- a/packages/features/accounts/src/server/services/delete-personal-account.service.ts +++ b/packages/features/accounts/src/server/services/delete-personal-account.service.ts @@ -1,8 +1,7 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -133,12 +132,12 @@ class DeletePersonalAccountService { .object({ productName: z .string({ - required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required', + error: 'NEXT_PUBLIC_PRODUCT_NAME is required', }) .min(1), fromEmail: z .string({ - required_error: 'EMAIL_SENDER is required', + error: 'EMAIL_SENDER is required', }) .min(1), }) diff --git a/packages/features/admin/eslint.config.mjs b/packages/features/admin/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/features/admin/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/features/admin/package.json b/packages/features/admin/package.json index 39c568eb4..96392bd29 100644 --- a/packages/features/admin/package.json +++ b/packages/features/admin/package.json @@ -1,45 +1,41 @@ { "name": "@kit/admin", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "devDependencies": { - "@hookform/resolvers": "^5.2.2", - "@kit/eslint-config": "workspace:*", - "@kit/next": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/shared": "workspace:*", - "@kit/supabase": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@kit/ui": "workspace:*", - "@makerkit/data-loader-supabase-core": "^0.0.10", - "@makerkit/data-loader-supabase-nextjs": "^1.2.5", - "@supabase/supabase-js": "catalog:", - "@tanstack/react-query": "catalog:", - "@tanstack/react-table": "^8.21.3", - "@types/react": "catalog:", - "lucide-react": "catalog:", - "next": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "react-hook-form": "catalog:", - "zod": "catalog:" - }, - "exports": { - ".": "./src/index.ts", - "./components/*": "./src/components/*.tsx" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "catalog:", + "@kit/next": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:*", + "@makerkit/data-loader-supabase-core": "catalog:", + "@makerkit/data-loader-supabase-nextjs": "catalog:", + "@supabase/supabase-js": "catalog:", + "@tanstack/react-query": "catalog:", + "@tanstack/react-table": "catalog:", + "@types/react": "catalog:", + "lucide-react": "catalog:", + "next": "catalog:", + "next-safe-action": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-hook-form": "catalog:", + "zod": "catalog:" } } diff --git a/packages/features/admin/src/components/admin-account-page.tsx b/packages/features/admin/src/components/admin-account-page.tsx index d8565d744..34376de6e 100644 --- a/packages/features/admin/src/components/admin-account-page.tsx +++ b/packages/features/admin/src/components/admin-account-page.tsx @@ -60,9 +60,8 @@ async function PersonalAccountPage(props: { account: Account }) { userResult.data.user.banned_until !== 'none'; return ( - <> + <PageBody className="gap-y-4"> <PageHeader - className="border-b" description={ <AppBreadcrumbs values={{ @@ -123,41 +122,39 @@ async function PersonalAccountPage(props: { account: Account }) { </div> </PageHeader> - <PageBody className={'space-y-6 py-4'}> - <div className={'flex items-center justify-between'}> - <div className={'flex items-center gap-x-4'}> - <div className={'flex items-center gap-x-2.5'}> - <ProfileAvatar - pictureUrl={props.account.picture_url} - displayName={props.account.name} - /> + <div className={'flex items-center justify-between'}> + <div className={'flex items-center gap-x-4'}> + <div className={'flex items-center gap-x-2.5'}> + <ProfileAvatar + pictureUrl={props.account.picture_url} + displayName={props.account.name} + /> - <span className={'text-sm font-semibold capitalize'}> - {props.account.name} - </span> - </div> + <span className={'text-sm font-semibold capitalize'}> + {props.account.name} + </span> + </div> - <Badge variant={'outline'}>Personal Account</Badge> + <Badge variant={'outline'}>Personal Account</Badge> - <If condition={isBanned}> - <Badge variant={'destructive'}>Banned</Badge> - </If> + <If condition={isBanned}> + <Badge variant={'destructive'}>Banned</Badge> + </If> + </div> + </div> + + <div className={'flex flex-col gap-y-8'}> + <SubscriptionsTable accountId={props.account.id} /> + + <div className={'divider-divider-x flex flex-col gap-y-2.5'}> + <Heading level={6}>Teams</Heading> + + <div className={'rounded-lg border p-2'}> + <AdminMembershipsTable memberships={memberships} /> </div> </div> - - <div className={'flex flex-col gap-y-8'}> - <SubscriptionsTable accountId={props.account.id} /> - - <div className={'divider-divider-x flex flex-col gap-y-2.5'}> - <Heading level={6}>Teams</Heading> - - <div className={'rounded-lg border p-2'}> - <AdminMembershipsTable memberships={memberships} /> - </div> - </div> - </div> - </PageBody> - </> + </div> + </PageBody> ); } @@ -167,9 +164,8 @@ async function TeamAccountPage(props: { const members = await getMembers(props.account.slug ?? ''); return ( - <> + <PageBody className={'gap-y-6'}> <PageHeader - className="border-b" description={ <AppBreadcrumbs values={{ @@ -191,39 +187,37 @@ async function TeamAccountPage(props: { </AdminDeleteAccountDialog> </PageHeader> - <PageBody className={'space-y-6 py-4'}> - <div className={'flex justify-between'}> - <div className={'flex items-center gap-x-4'}> - <div className={'flex items-center gap-x-2.5'}> - <ProfileAvatar - pictureUrl={props.account.picture_url} - displayName={props.account.name} - /> + <div className={'flex justify-between'}> + <div className={'flex items-center gap-x-4'}> + <div className={'flex items-center gap-x-2.5'}> + <ProfileAvatar + pictureUrl={props.account.picture_url} + displayName={props.account.name} + /> - <span className={'text-sm font-semibold capitalize'}> - {props.account.name} - </span> - </div> - - <Badge variant={'outline'}>Team Account</Badge> + <span className={'text-sm font-semibold capitalize'}> + {props.account.name} + </span> </div> + + <Badge variant={'outline'}>Team Account</Badge> </div> + </div> - <div> - <div className={'flex flex-col gap-y-8'}> - <SubscriptionsTable accountId={props.account.id} /> + <div> + <div className={'flex flex-col gap-y-8'}> + <SubscriptionsTable accountId={props.account.id} /> - <div className={'flex flex-col gap-y-2.5'}> - <Heading level={6}>Team Members</Heading> + <div className={'flex flex-col gap-y-2.5'}> + <Heading level={6}>Team Members</Heading> - <div className={'rounded-lg border p-2'}> - <AdminMembersTable members={members} /> - </div> + <div className={'rounded-lg border p-2'}> + <AdminMembersTable members={members} /> </div> </div> </div> - </PageBody> - </> + </div> + </PageBody> ); } diff --git a/packages/features/admin/src/components/admin-accounts-table.tsx b/packages/features/admin/src/components/admin-accounts-table.tsx index 05d8448fb..544089f94 100644 --- a/packages/features/admin/src/components/admin-accounts-table.tsx +++ b/packages/features/admin/src/components/admin-accounts-table.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useState } from 'react'; + import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; @@ -7,7 +9,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ColumnDef } from '@tanstack/react-table'; import { EllipsisVertical } from 'lucide-react'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Tables } from '@kit/supabase/database'; import { Button } from '@kit/ui/button'; @@ -21,7 +23,6 @@ import { } from '@kit/ui/dropdown-menu'; import { DataTable } from '@kit/ui/enhanced-data-table'; import { Form, FormControl, FormField, FormItem } from '@kit/ui/form'; -import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; import { Select, @@ -77,7 +78,7 @@ export function AdminAccountsTable( } function AccountsTableFilters(props: { - filters: z.infer<typeof FiltersSchema>; + filters: z.output<typeof FiltersSchema>; }) { const form = useForm({ resolver: zodResolver(FiltersSchema), @@ -92,7 +93,7 @@ function AccountsTableFilters(props: { const router = useRouter(); const pathName = usePathname(); - const onSubmit = ({ type, query }: z.infer<typeof FiltersSchema>) => { + const onSubmit = ({ type, query }: z.output<typeof FiltersSchema>) => { const params = new URLSearchParams({ account_type: type, query: query ?? '', @@ -105,6 +106,12 @@ function AccountsTableFilters(props: { const type = useWatch({ control: form.control, name: 'type' }); + const options = { + all: 'All Accounts', + team: 'Team', + personal: 'Personal', + }; + return ( <Form {...form}> <form @@ -116,7 +123,7 @@ function AccountsTableFilters(props: { onValueChange={(value) => { form.setValue( 'type', - value as z.infer<typeof FiltersSchema>['type'], + value as z.output<typeof FiltersSchema>['type'], { shouldValidate: true, shouldDirty: true, @@ -128,16 +135,20 @@ function AccountsTableFilters(props: { }} > <SelectTrigger> - <SelectValue placeholder={'Account Type'} /> + <SelectValue placeholder={'Account Type'}> + {(value: keyof typeof options) => options[value]} + </SelectValue> </SelectTrigger> <SelectContent> <SelectGroup> <SelectLabel>Account Type</SelectLabel> - <SelectItem value={'all'}>All accounts</SelectItem> - <SelectItem value={'team'}>Team</SelectItem> - <SelectItem value={'personal'}>Personal</SelectItem> + {Object.entries(options).map(([key, value]) => ( + <SelectItem key={key} value={key}> + {value} + </SelectItem> + ))} </SelectGroup> </SelectContent> </Select> @@ -157,6 +168,8 @@ function AccountsTableFilters(props: { </FormItem> )} /> + + <button type="submit" hidden /> </form> </Form> ); @@ -194,75 +207,143 @@ function getColumns(): ColumnDef<Account>[] { { id: 'created_at', header: 'Created At', - accessorKey: 'created_at', + cell: ({ row }) => { + return new Date(row.original.created_at!).toLocaleDateString( + undefined, + { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }, + ); + }, }, { id: 'updated_at', header: 'Updated At', - accessorKey: 'updated_at', + cell: ({ row }) => { + return row.original.updated_at + ? new Date(row.original.updated_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : '-'; + }, }, { id: 'actions', header: '', - cell: ({ row }) => { - const isPersonalAccount = row.original.is_personal_account; - const userId = row.original.id; - - return ( - <div className={'flex justify-end'}> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant={'outline'} size={'icon'}> - <EllipsisVertical className={'h-4'} /> - </Button> - </DropdownMenuTrigger> - - <DropdownMenuContent align={'end'}> - <DropdownMenuGroup> - <DropdownMenuLabel>Actions</DropdownMenuLabel> - - <DropdownMenuItem> - <Link - className={'h-full w-full'} - href={`/admin/accounts/${userId}`} - > - View - </Link> - </DropdownMenuItem> - - <If condition={isPersonalAccount}> - <AdminResetPasswordDialog userId={userId}> - <DropdownMenuItem onSelect={(e) => e.preventDefault()}> - Send Reset Password link - </DropdownMenuItem> - </AdminResetPasswordDialog> - - <AdminImpersonateUserDialog userId={userId}> - <DropdownMenuItem onSelect={(e) => e.preventDefault()}> - Impersonate User - </DropdownMenuItem> - </AdminImpersonateUserDialog> - - <AdminDeleteUserDialog userId={userId}> - <DropdownMenuItem onSelect={(e) => e.preventDefault()}> - Delete Personal Account - </DropdownMenuItem> - </AdminDeleteUserDialog> - </If> - - <If condition={!isPersonalAccount}> - <AdminDeleteAccountDialog accountId={row.original.id}> - <DropdownMenuItem onSelect={(e) => e.preventDefault()}> - Delete Team Account - </DropdownMenuItem> - </AdminDeleteAccountDialog> - </If> - </DropdownMenuGroup> - </DropdownMenuContent> - </DropdownMenu> - </div> - ); - }, + cell: ({ row }) => <ActionsCell account={row.original} />, }, ]; } + +type ActiveDialog = + | 'reset-password' + | 'impersonate' + | 'delete-user' + | 'delete-account' + | null; + +function ActionsCell({ account }: { account: Account }) { + const [activeDialog, setActiveDialog] = useState<ActiveDialog>(null); + const isPersonalAccount = account.is_personal_account; + + return ( + <div className={'flex justify-end'}> + <DropdownMenu> + <DropdownMenuTrigger + render={ + <Button variant={'outline'} size={'icon'}> + <EllipsisVertical className={'h-4'} /> + </Button> + } + /> + + <DropdownMenuContent className="min-w-52"> + <DropdownMenuGroup> + <DropdownMenuLabel>Actions</DropdownMenuLabel> + + <DropdownMenuItem + render={ + <Link + className={'h-full w-full'} + href={`/admin/accounts/${account.id}`} + > + View + </Link> + } + /> + + {isPersonalAccount && ( + <> + <DropdownMenuItem + onClick={() => setActiveDialog('reset-password')} + > + Send Reset Password link + </DropdownMenuItem> + + <DropdownMenuItem + onClick={() => setActiveDialog('impersonate')} + > + Impersonate User + </DropdownMenuItem> + + <DropdownMenuItem + variant="destructive" + onClick={() => setActiveDialog('delete-user')} + > + Delete Personal Account + </DropdownMenuItem> + </> + )} + + {!isPersonalAccount && ( + <DropdownMenuItem + variant="destructive" + onClick={() => setActiveDialog('delete-account')} + > + Delete Team Account + </DropdownMenuItem> + )} + </DropdownMenuGroup> + </DropdownMenuContent> + </DropdownMenu> + + {isPersonalAccount && ( + <> + <AdminResetPasswordDialog + userId={account.id} + open={activeDialog === 'reset-password'} + onOpenChange={(open) => !open && setActiveDialog(null)} + /> + + <AdminImpersonateUserDialog + userId={account.id} + open={activeDialog === 'impersonate'} + onOpenChange={(open) => !open && setActiveDialog(null)} + /> + + <AdminDeleteUserDialog + userId={account.id} + open={activeDialog === 'delete-user'} + onOpenChange={(open) => !open && setActiveDialog(null)} + /> + </> + )} + + {!isPersonalAccount && ( + <AdminDeleteAccountDialog + accountId={account.id} + open={activeDialog === 'delete-account'} + onOpenChange={(open) => !open && setActiveDialog(null)} + /> + )} + </div> + ); +} diff --git a/packages/features/admin/src/components/admin-ban-user-dialog.tsx b/packages/features/admin/src/components/admin-ban-user-dialog.tsx index a0cdfee65..a2b6e809e 100644 --- a/packages/features/admin/src/components/admin-ban-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-ban-user-dialog.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -26,6 +25,7 @@ import { FormLabel, FormMessage, } from '@kit/ui/form'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; @@ -37,11 +37,14 @@ export function AdminBanUserDialog( userId: string; }>, ) { - const [open, setOpen] = useState(false); + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog(); return ( - <AlertDialog open={open} onOpenChange={setOpen}> - <AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger> + <AlertDialog + open={dialogProps.open} + onOpenChange={dialogProps.onOpenChange} + > + <AlertDialogTrigger render={props.children as React.ReactElement} /> <AlertDialogContent> <AlertDialogHeader> @@ -53,15 +56,31 @@ export function AdminBanUserDialog( </AlertDialogDescription> </AlertDialogHeader> - <BanUserForm userId={props.userId} onSuccess={() => setOpen(false)} /> + <BanUserForm + userId={props.userId} + isPending={isPending} + setIsPending={setIsPending} + onSuccess={() => { + setIsPending(false); + setOpen(false); + }} + /> </AlertDialogContent> </AlertDialog> ); } -function BanUserForm(props: { userId: string; onSuccess: () => void }) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(false); +function BanUserForm(props: { + userId: string; + isPending: boolean; + setIsPending: (pending: boolean) => void; + onSuccess: () => void; +}) { + const { execute, hasErrored } = useAction(banUserAction, { + onExecute: () => props.setIsPending(true), + onSuccess: () => props.onSuccess(), + onSettled: () => props.setIsPending(false), + }); const form = useForm({ resolver: zodResolver(BanUserSchema), @@ -76,18 +95,9 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) { <form data-test={'admin-ban-user-form'} className={'flex flex-col space-y-8'} - onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - try { - await banUserAction(data); - props.onSuccess(); - } catch { - setError(true); - } - }); - })} + onSubmit={form.handleSubmit((data) => execute(data))} > - <If condition={error}> + <If condition={hasErrored}> <Alert variant={'destructive'}> <AlertTitle>Error</AlertTitle> @@ -125,10 +135,16 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) { /> <AlertDialogFooter> - <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel> + <AlertDialogCancel disabled={props.isPending}> + Cancel + </AlertDialogCancel> - <Button disabled={pending} type={'submit'} variant={'destructive'}> - {pending ? 'Banning...' : 'Ban User'} + <Button + disabled={props.isPending} + type={'submit'} + variant={'destructive'} + > + {props.isPending ? 'Banning...' : 'Ban User'} </Button> </AlertDialogFooter> </form> diff --git a/packages/features/admin/src/components/admin-create-user-dialog.tsx b/packages/features/admin/src/components/admin-create-user-dialog.tsx index 1b64f1b4c..cc34eb78b 100644 --- a/packages/features/admin/src/components/admin-create-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-create-user-dialog.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -27,6 +26,7 @@ import { FormLabel, FormMessage, } from '@kit/ui/form'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; import { toast } from '@kit/ui/sonner'; @@ -38,9 +38,7 @@ import { } from '../lib/server/schema/create-user.schema'; export function AdminCreateUserDialog(props: React.PropsWithChildren) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - const [open, setOpen] = useState(false); + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog(); const form = useForm({ resolver: zodResolver(CreateUserSchema), @@ -52,28 +50,25 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) { mode: 'onChange', }); - const onSubmit = (data: CreateUserSchemaType) => { - startTransition(async () => { - try { - const result = await createUserAction(data); + const { execute, result } = useAction(createUserAction, { + onExecute: () => setIsPending(true), + onSuccess: () => { + toast.success('User created successfully'); + form.reset(); + setIsPending(false); + setOpen(false); + }, + onSettled: () => setIsPending(false), + }); - if (result.success) { - toast.success('User creates successfully'); - form.reset(); - - setOpen(false); - } - - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Error'); - } - }); - }; + const error = result.serverError; return ( - <AlertDialog open={open} onOpenChange={setOpen}> - <AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger> + <AlertDialog + open={dialogProps.open} + onOpenChange={dialogProps.onOpenChange} + > + <AlertDialogTrigger render={props.children as React.ReactElement} /> <AlertDialogContent> <AlertDialogHeader> @@ -88,7 +83,9 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) { <form data-test={'admin-create-user-form'} className={'flex flex-col space-y-4'} - onSubmit={form.handleSubmit(onSubmit)} + onSubmit={form.handleSubmit((data: CreateUserSchemaType) => + execute(data), + )} > <If condition={!!error}> <Alert variant={'destructive'}> @@ -164,10 +161,10 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) { /> <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel> - <Button disabled={pending} type={'submit'}> - {pending ? 'Creating...' : 'Create User'} + <Button disabled={isPending} type={'submit'}> + {isPending ? 'Creating...' : 'Create User'} </Button> </AlertDialogFooter> </form> diff --git a/packages/features/admin/src/components/admin-delete-account-dialog.tsx b/packages/features/admin/src/components/admin-delete-account-dialog.tsx index 0fa6d8d02..f3354231f 100644 --- a/packages/features/admin/src/components/admin-delete-account-dialog.tsx +++ b/packages/features/admin/src/components/admin-delete-account-dialog.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -35,22 +34,15 @@ import { DeleteAccountSchema } from '../lib/server/schema/admin-actions.schema'; export function AdminDeleteAccountDialog( props: React.PropsWithChildren<{ accountId: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; }>, ) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(false); - - const form = useForm({ - resolver: zodResolver(DeleteAccountSchema), - defaultValues: { - accountId: props.accountId, - confirmation: '', - }, - }); - return ( - <AlertDialog> - <AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger> + <AlertDialog open={props.open} onOpenChange={props.onOpenChange}> + <If condition={props.children}> + <AlertDialogTrigger render={props.children as React.ReactElement} /> + </If> <AlertDialogContent> <AlertDialogHeader> @@ -63,73 +55,75 @@ export function AdminDeleteAccountDialog( </AlertDialogDescription> </AlertDialogHeader> - <Form {...form}> - <form - data-form={'admin-delete-account-form'} - className={'flex flex-col space-y-8'} - onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - try { - await deleteAccountAction(data); - setError(false); - } catch { - setError(true); - } - }); - })} - > - <If condition={error}> - <Alert variant={'destructive'}> - <AlertTitle>Error</AlertTitle> - - <AlertDescription> - There was an error deleting the account. Please check the - server logs to see what went wrong. - </AlertDescription> - </Alert> - </If> - - <FormField - name={'confirmation'} - render={({ field }) => ( - <FormItem> - <FormLabel> - Type <b>CONFIRM</b> to confirm - </FormLabel> - - <FormControl> - <Input - pattern={'CONFIRM'} - required - placeholder={'Type CONFIRM to confirm'} - {...field} - /> - </FormControl> - - <FormDescription> - Are you sure you want to do this? This action cannot be - undone. - </FormDescription> - - <FormMessage /> - </FormItem> - )} - /> - - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - - <Button - disabled={pending} - type={'submit'} - variant={'destructive'} - > - {pending ? 'Deleting...' : 'Delete'} - </Button> - </AlertDialogFooter> - </form> - </Form> + <DeleteAccountForm accountId={props.accountId} /> </AlertDialogContent> </AlertDialog> ); } + +function DeleteAccountForm(props: { accountId: string }) { + const { execute, isPending, hasErrored } = useAction(deleteAccountAction); + + const form = useForm({ + resolver: zodResolver(DeleteAccountSchema), + defaultValues: { + accountId: props.accountId, + confirmation: '', + }, + }); + + return ( + <Form {...form}> + <form + data-test={'admin-delete-account-form'} + className={'flex flex-col space-y-8'} + onSubmit={form.handleSubmit((data) => execute(data))} + > + <If condition={hasErrored}> + <Alert variant={'destructive'}> + <AlertTitle>Error</AlertTitle> + + <AlertDescription> + There was an error deleting the account. Please check the server + logs to see what went wrong. + </AlertDescription> + </Alert> + </If> + + <FormField + name={'confirmation'} + render={({ field }) => ( + <FormItem> + <FormLabel> + Type <b>CONFIRM</b> to confirm + </FormLabel> + + <FormControl> + <Input + pattern={'CONFIRM'} + required + placeholder={'Type CONFIRM to confirm'} + {...field} + /> + </FormControl> + + <FormDescription> + Are you sure you want to do this? This action cannot be undone. + </FormDescription> + + <FormMessage /> + </FormItem> + )} + /> + + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + + <Button disabled={isPending} type={'submit'} variant={'destructive'}> + {isPending ? 'Deleting...' : 'Delete'} + </Button> + </AlertDialogFooter> + </form> + </Form> + ); +} diff --git a/packages/features/admin/src/components/admin-delete-user-dialog.tsx b/packages/features/admin/src/components/admin-delete-user-dialog.tsx index 7390e45fc..e3d696ebd 100644 --- a/packages/features/admin/src/components/admin-delete-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-delete-user-dialog.tsx @@ -1,10 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - -import { isRedirectError } from 'next/dist/client/components/redirect-error'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -37,22 +34,15 @@ import { DeleteUserSchema } from '../lib/server/schema/admin-actions.schema'; export function AdminDeleteUserDialog( props: React.PropsWithChildren<{ userId: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; }>, ) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(false); - - const form = useForm({ - resolver: zodResolver(DeleteUserSchema), - defaultValues: { - userId: props.userId, - confirmation: '', - }, - }); - return ( - <AlertDialog> - <AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger> + <AlertDialog open={props.open} onOpenChange={props.onOpenChange}> + <If condition={props.children}> + <AlertDialogTrigger render={props.children as React.ReactElement} /> + </If> <AlertDialogContent> <AlertDialogHeader> @@ -65,78 +55,75 @@ export function AdminDeleteUserDialog( </AlertDialogDescription> </AlertDialogHeader> - <Form {...form}> - <form - data-test={'admin-delete-user-form'} - className={'flex flex-col space-y-8'} - onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - try { - await deleteUserAction(data); - - setError(false); - } catch { - if (isRedirectError(error)) { - // Do nothing - } else { - setError(true); - } - } - }); - })} - > - <If condition={error}> - <Alert variant={'destructive'}> - <AlertTitle>Error</AlertTitle> - - <AlertDescription> - There was an error deleting the user. Please check the server - logs to see what went wrong. - </AlertDescription> - </Alert> - </If> - - <FormField - name={'confirmation'} - render={({ field }) => ( - <FormItem> - <FormLabel> - Type <b>CONFIRM</b> to confirm - </FormLabel> - - <FormControl> - <Input - required - pattern={'CONFIRM'} - placeholder={'Type CONFIRM to confirm'} - {...field} - /> - </FormControl> - - <FormDescription> - Are you sure you want to do this? This action cannot be - undone. - </FormDescription> - - <FormMessage /> - </FormItem> - )} - /> - - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - - <Button - disabled={pending} - type={'submit'} - variant={'destructive'} - > - {pending ? 'Deleting...' : 'Delete'} - </Button> - </AlertDialogFooter> - </form> - </Form> + <DeleteUserForm userId={props.userId} /> </AlertDialogContent> </AlertDialog> ); } + +function DeleteUserForm(props: { userId: string }) { + const { execute, isPending, hasErrored } = useAction(deleteUserAction); + + const form = useForm({ + resolver: zodResolver(DeleteUserSchema), + defaultValues: { + userId: props.userId, + confirmation: '', + }, + }); + + return ( + <Form {...form}> + <form + data-test={'admin-delete-user-form'} + className={'flex flex-col space-y-8'} + onSubmit={form.handleSubmit((data) => execute(data))} + > + <If condition={hasErrored}> + <Alert variant={'destructive'}> + <AlertTitle>Error</AlertTitle> + + <AlertDescription> + There was an error deleting the user. Please check the server logs + to see what went wrong. + </AlertDescription> + </Alert> + </If> + + <FormField + name={'confirmation'} + render={({ field }) => ( + <FormItem> + <FormLabel> + Type <b>CONFIRM</b> to confirm + </FormLabel> + + <FormControl> + <Input + required + pattern={'CONFIRM'} + placeholder={'Type CONFIRM to confirm'} + {...field} + /> + </FormControl> + + <FormDescription> + Are you sure you want to do this? This action cannot be undone. + </FormDescription> + + <FormMessage /> + </FormItem> + )} + /> + + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + + <Button disabled={isPending} type={'submit'} variant={'destructive'}> + {isPending ? 'Deleting...' : 'Delete'} + </Button> + </AlertDialogFooter> + </form> + </Form> + ); +} diff --git a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx index d97e6af10..2b449bd83 100644 --- a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery } from '@tanstack/react-query'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; @@ -35,40 +36,34 @@ import { LoadingOverlay } from '@kit/ui/loading-overlay'; import { impersonateUserAction } from '../lib/server/admin-server-actions'; import { ImpersonateUserSchema } from '../lib/server/schema/admin-actions.schema'; +type Tokens = { + accessToken: string; + refreshToken: string; +}; + export function AdminImpersonateUserDialog( props: React.PropsWithChildren<{ userId: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; }>, ) { - const form = useForm({ - resolver: zodResolver(ImpersonateUserSchema), - defaultValues: { - userId: props.userId, - confirmation: '', - }, - }); - - const [tokens, setTokens] = useState<{ - accessToken: string; - refreshToken: string; - }>(); - - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState<boolean | null>(null); + const [tokens, setTokens] = useState<Tokens>(); if (tokens) { - return ( - <> - <ImpersonateUserAuthSetter tokens={tokens} /> - - <LoadingOverlay>Setting up your session...</LoadingOverlay> - </> - ); + return <ImpersonateUserAuthSetter tokens={tokens} />; } return ( - <AlertDialog> - <AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger> + <AlertDialog + open={props.open} + onOpenChange={(open) => { + props.onOpenChange?.(open); + }} + > + <If condition={props.children}> + <AlertDialogTrigger render={props.children as React.ReactElement} /> + </If> <AlertDialogContent> <AlertDialogHeader> @@ -87,73 +82,88 @@ export function AdminImpersonateUserDialog( </AlertDialogDescription> </AlertDialogHeader> - <Form {...form}> - <form - data-test={'admin-impersonate-user-form'} - className={'flex flex-col space-y-8'} - onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - try { - const result = await impersonateUserAction(data); - - setTokens(result); - } catch { - setError(true); - } - }); - })} - > - <If condition={error}> - <Alert variant={'destructive'}> - <AlertTitle>Error</AlertTitle> - - <AlertDescription> - Failed to impersonate user. Please check the logs to - understand what went wrong. - </AlertDescription> - </Alert> - </If> - - <FormField - name={'confirmation'} - render={({ field }) => ( - <FormItem> - <FormLabel> - Type <b>CONFIRM</b> to confirm - </FormLabel> - - <FormControl> - <Input - required - pattern={'CONFIRM'} - placeholder={'Type CONFIRM to confirm'} - {...field} - /> - </FormControl> - - <FormDescription> - Are you sure you want to impersonate this user? - </FormDescription> - - <FormMessage /> - </FormItem> - )} - /> - - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - - <Button disabled={isPending} type={'submit'}> - {isPending ? 'Impersonating...' : 'Impersonate User'} - </Button> - </AlertDialogFooter> - </form> - </Form> + <AdminImpersonateUserForm userId={props.userId} onSuccess={setTokens} /> </AlertDialogContent> </AlertDialog> ); } +function AdminImpersonateUserForm(props: { + userId: string; + onSuccess: (data: Tokens) => void; +}) { + const form = useForm({ + resolver: zodResolver(ImpersonateUserSchema), + defaultValues: { + userId: props.userId, + confirmation: '', + }, + }); + + const { execute, isPending, hasErrored } = useAction(impersonateUserAction, { + onSuccess: ({ data }) => { + if (data) { + props.onSuccess(data); + } + }, + }); + + return ( + <Form {...form}> + <form + data-test={'admin-impersonate-user-form'} + className={'flex flex-col space-y-8'} + onSubmit={form.handleSubmit((data) => execute(data))} + > + <If condition={hasErrored}> + <Alert variant={'destructive'}> + <AlertTitle>Error</AlertTitle> + + <AlertDescription> + Failed to impersonate user. Please check the logs to understand + what went wrong. + </AlertDescription> + </Alert> + </If> + + <FormField + name={'confirmation'} + render={({ field }) => ( + <FormItem> + <FormLabel> + Type <b>CONFIRM</b> to confirm + </FormLabel> + + <FormControl> + <Input + required + pattern={'CONFIRM'} + placeholder={'Type CONFIRM to confirm'} + {...field} + /> + </FormControl> + + <FormDescription> + Are you sure you want to impersonate this user? + </FormDescription> + + <FormMessage /> + </FormItem> + )} + /> + + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + + <Button disabled={isPending} type={'submit'}> + {isPending ? 'Impersonating...' : 'Impersonate User'} + </Button> + </AlertDialogFooter> + </form> + </Form> + ); +} + function ImpersonateUserAuthSetter({ tokens, }: React.PropsWithChildren<{ diff --git a/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx b/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx index e8a9f14d7..ca8e0dc7e 100644 --- a/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -26,6 +25,7 @@ import { FormLabel, FormMessage, } from '@kit/ui/form'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; @@ -37,11 +37,14 @@ export function AdminReactivateUserDialog( userId: string; }>, ) { - const [open, setOpen] = useState(false); + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog(); return ( - <AlertDialog open={open} onOpenChange={setOpen}> - <AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger> + <AlertDialog + open={dialogProps.open} + onOpenChange={dialogProps.onOpenChange} + > + <AlertDialogTrigger render={props.children as React.ReactElement} /> <AlertDialogContent> <AlertDialogHeader> @@ -54,16 +57,29 @@ export function AdminReactivateUserDialog( <ReactivateUserForm userId={props.userId} - onSuccess={() => setOpen(false)} + isPending={isPending} + setIsPending={setIsPending} + onSuccess={() => { + setIsPending(false); + setOpen(false); + }} /> </AlertDialogContent> </AlertDialog> ); } -function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(false); +function ReactivateUserForm(props: { + userId: string; + isPending: boolean; + setIsPending: (pending: boolean) => void; + onSuccess: () => void; +}) { + const { execute, hasErrored } = useAction(reactivateUserAction, { + onExecute: () => props.setIsPending(true), + onSuccess: () => props.onSuccess(), + onSettled: () => props.setIsPending(false), + }); const form = useForm({ resolver: zodResolver(ReactivateUserSchema), @@ -78,18 +94,9 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) { <form data-test={'admin-reactivate-user-form'} className={'flex flex-col space-y-8'} - onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - try { - await reactivateUserAction(data); - props.onSuccess(); - } catch { - setError(true); - } - }); - })} + onSubmit={form.handleSubmit((data) => execute(data))} > - <If condition={error}> + <If condition={hasErrored}> <Alert variant={'destructive'}> <AlertTitle>Error</AlertTitle> @@ -127,10 +134,12 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) { /> <AlertDialogFooter> - <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel> + <AlertDialogCancel disabled={props.isPending}> + Cancel + </AlertDialogCancel> - <Button disabled={pending} type={'submit'}> - {pending ? 'Reactivating...' : 'Reactivate User'} + <Button disabled={props.isPending} type={'submit'}> + {props.isPending ? 'Reactivating...' : 'Reactivate User'} </Button> </AlertDialogFooter> </form> diff --git a/packages/features/admin/src/components/admin-reset-password-dialog.tsx b/packages/features/admin/src/components/admin-reset-password-dialog.tsx index 0755c96a1..2ad1afabf 100644 --- a/packages/features/admin/src/components/admin-reset-password-dialog.tsx +++ b/packages/features/admin/src/components/admin-reset-password-dialog.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -34,50 +33,21 @@ import { toast } from '@kit/ui/sonner'; import { resetPasswordAction } from '../lib/server/admin-server-actions'; const FormSchema = z.object({ - userId: z.string().uuid(), + userId: z.uuid(), confirmation: z.custom<string>((value) => value === 'CONFIRM'), }); -export function AdminResetPasswordDialog( - props: React.PropsWithChildren<{ - userId: string; - }>, -) { - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - userId: props.userId, - confirmation: '', - }, - }); - - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - const [success, setSuccess] = useState(false); - - const onSubmit = form.handleSubmit((data) => { - setError(null); - setSuccess(false); - - startTransition(async () => { - try { - await resetPasswordAction(data); - - setSuccess(true); - form.reset({ userId: props.userId, confirmation: '' }); - - toast.success('Password reset email successfully sent'); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - - toast.error('We hit an error. Please read the logs.'); - } - }); - }); - +export function AdminResetPasswordDialog(props: { + userId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + children?: React.ReactNode; +}) { return ( - <AlertDialog> - <AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger> + <AlertDialog open={props.open} onOpenChange={props.onOpenChange}> + {props.children && ( + <AlertDialogTrigger render={props.children as React.ReactElement} /> + )} <AlertDialogContent> <AlertDialogHeader> @@ -89,75 +59,102 @@ export function AdminResetPasswordDialog( </AlertDialogHeader> <div className="relative"> - <Form {...form}> - <form onSubmit={onSubmit} className="space-y-4"> - <FormField - control={form.control} - name="confirmation" - render={({ field }) => ( - <FormItem> - <FormLabel>Confirmation</FormLabel> - - <FormDescription> - Type CONFIRM to execute this request. - </FormDescription> - - <FormControl> - <Input - placeholder="CONFIRM" - {...field} - autoComplete="off" - /> - </FormControl> - - <FormMessage /> - </FormItem> - )} - /> - - <If condition={!!error}> - <Alert variant="destructive"> - <AlertTitle> - We encountered an error while sending the email - </AlertTitle> - - <AlertDescription> - Please check the server logs for more details. - </AlertDescription> - </Alert> - </If> - - <If condition={success}> - <Alert> - <AlertTitle> - Password reset email sent successfully - </AlertTitle> - - <AlertDescription> - The password reset email has been sent to the user. - </AlertDescription> - </Alert> - </If> - - <input type="hidden" name="userId" value={props.userId} /> - - <AlertDialogFooter> - <AlertDialogCancel disabled={isPending}> - Cancel - </AlertDialogCancel> - - <Button - type="submit" - disabled={isPending} - variant="destructive" - > - {isPending ? 'Sending...' : 'Send Reset Email'} - </Button> - </AlertDialogFooter> - </form> - </Form> + <AdminResetPasswordForm + userId={props.userId} + onSuccess={() => props.onOpenChange(false)} + /> </div> </AlertDialogContent> </AlertDialog> ); } + +function AdminResetPasswordForm({ + userId, + onSuccess, +}: { + userId: string; + onSuccess: () => void; +}) { + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + userId, + confirmation: '', + }, + }); + + const { execute, isPending, hasErrored, hasSucceeded } = useAction( + resetPasswordAction, + { + onSuccess: () => { + toast.success('Password reset email successfully sent'); + onSuccess(); + }, + onError: () => { + toast.error('We hit an error. Please read the logs.'); + }, + }, + ); + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit((data) => execute(data))} + className="space-y-4" + > + <FormField + control={form.control} + name="confirmation" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirmation</FormLabel> + + <FormDescription> + Type CONFIRM to execute this request. + </FormDescription> + + <FormControl> + <Input placeholder="CONFIRM" {...field} autoComplete="off" /> + </FormControl> + + <FormMessage /> + </FormItem> + )} + /> + + <If condition={hasErrored}> + <Alert variant="destructive"> + <AlertTitle> + We encountered an error while sending the email + </AlertTitle> + + <AlertDescription> + Please check the server logs for more details. + </AlertDescription> + </Alert> + </If> + + <If condition={hasSucceeded}> + <Alert> + <AlertTitle>Password reset email sent successfully</AlertTitle> + + <AlertDescription> + The password reset email has been sent to the user. + </AlertDescription> + </Alert> + </If> + + <input type="hidden" name="userId" value={userId} /> + + <AlertDialogFooter> + <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel> + + <Button type="submit" disabled={isPending} variant="destructive"> + {isPending ? 'Sending...' : 'Send Reset Email'} + </Button> + </AlertDialogFooter> + </form> + </Form> + ); +} diff --git a/packages/features/admin/src/lib/server/admin-server-actions.ts b/packages/features/admin/src/lib/server/admin-server-actions.ts index c030fd17e..8aeada217 100644 --- a/packages/features/admin/src/lib/server/admin-server-actions.ts +++ b/packages/features/admin/src/lib/server/admin-server-actions.ts @@ -3,7 +3,6 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; @@ -19,212 +18,168 @@ import { CreateUserSchema } from './schema/create-user.schema'; import { ResetPasswordSchema } from './schema/reset-password.schema'; import { createAdminAccountsService } from './services/admin-accounts.service'; import { createAdminAuthUserService } from './services/admin-auth-user.service'; -import { adminAction } from './utils/admin-action'; +import { adminActionClient } from './utils/admin-action-client'; /** * @name banUserAction * @description Ban a user from the system. */ -export const banUserAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const banUserAction = adminActionClient + .inputSchema(BanUserSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is banning user...`); + logger.info({ userId }, `Super Admin is banning user...`); - const { error } = await service.banUser(userId); + const { error } = await service.banUser(userId); - if (error) { - logger.error({ error }, `Error banning user`); + if (error) { + logger.error({ error }, `Error banning user`); + throw new Error('Error banning user'); + } - return { - success: false, - }; - } + revalidateAdmin(); - revalidateAdmin(); - - logger.info({ userId }, `Super Admin has successfully banned user`); - }, - { - schema: BanUserSchema, - }, - ), -); + logger.info({ userId }, `Super Admin has successfully banned user`); + }); /** * @name reactivateUserAction * @description Reactivate a user in the system. */ -export const reactivateUserAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const reactivateUserAction = adminActionClient + .inputSchema(ReactivateUserSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is reactivating user...`); + logger.info({ userId }, `Super Admin is reactivating user...`); - const { error } = await service.reactivateUser(userId); + const { error } = await service.reactivateUser(userId); - if (error) { - logger.error({ error }, `Error reactivating user`); + if (error) { + logger.error({ error }, `Error reactivating user`); + throw new Error('Error reactivating user'); + } - return { - success: false, - }; - } + revalidateAdmin(); - revalidateAdmin(); - - logger.info({ userId }, `Super Admin has successfully reactivated user`); - }, - { - schema: ReactivateUserSchema, - }, - ), -); + logger.info({ userId }, `Super Admin has successfully reactivated user`); + }); /** * @name impersonateUserAction * @description Impersonate a user in the system. */ -export const impersonateUserAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const impersonateUserAction = adminActionClient + .inputSchema(ImpersonateUserSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is impersonating user...`); + logger.info({ userId }, `Super Admin is impersonating user...`); - return await service.impersonateUser(userId); - }, - { - schema: ImpersonateUserSchema, - }, - ), -); + return await service.impersonateUser(userId); + }); /** * @name deleteUserAction * @description Delete a user from the system. */ -export const deleteUserAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const deleteUserAction = adminActionClient + .inputSchema(DeleteUserSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is deleting user...`); + logger.info({ userId }, `Super Admin is deleting user...`); - await service.deleteUser(userId); + await service.deleteUser(userId); - logger.info({ userId }, `Super Admin has successfully deleted user`); + logger.info({ userId }, `Super Admin has successfully deleted user`); - return redirect('/admin/accounts'); - }, - { - schema: DeleteUserSchema, - }, - ), -); + redirect('/admin/accounts'); + }); /** * @name deleteAccountAction * @description Delete an account from the system. */ -export const deleteAccountAction = adminAction( - enhanceAction( - async ({ accountId }) => { - const service = getAdminAccountsService(); - const logger = await getLogger(); +export const deleteAccountAction = adminActionClient + .inputSchema(DeleteAccountSchema) + .action(async ({ parsedInput: { accountId } }) => { + const service = getAdminAccountsService(); + const logger = await getLogger(); - logger.info({ accountId }, `Super Admin is deleting account...`); + logger.info({ accountId }, `Super Admin is deleting account...`); - await service.deleteAccount(accountId); + await service.deleteAccount(accountId); - revalidateAdmin(); + revalidateAdmin(); - logger.info( - { accountId }, - `Super Admin has successfully deleted account`, - ); + logger.info({ accountId }, `Super Admin has successfully deleted account`); - return redirect('/admin/accounts'); - }, - { - schema: DeleteAccountSchema, - }, - ), -); + redirect('/admin/accounts'); + }); /** * @name createUserAction * @description Create a new user in the system. */ -export const createUserAction = adminAction( - enhanceAction( - async ({ email, password, emailConfirm }) => { - const adminClient = getSupabaseServerAdminClient(); - const logger = await getLogger(); +export const createUserAction = adminActionClient + .inputSchema(CreateUserSchema) + .action(async ({ parsedInput: { email, password, emailConfirm } }) => { + const adminClient = getSupabaseServerAdminClient(); + const logger = await getLogger(); - logger.info({ email }, `Super Admin is creating a new user...`); + logger.info({ email }, `Super Admin is creating a new user...`); - const { data, error } = await adminClient.auth.admin.createUser({ - email, - password, - email_confirm: emailConfirm, - }); + const { data, error } = await adminClient.auth.admin.createUser({ + email, + password, + email_confirm: emailConfirm, + }); - if (error) { - logger.error({ error }, `Error creating user`); - throw new Error(`Error creating user: ${error.message}`); - } + if (error) { + logger.error({ error }, `Error creating user`); + throw new Error(`Error creating user: ${error.message}`); + } - logger.info( - { userId: data.user.id }, - `Super Admin has successfully created a new user`, - ); + logger.info( + { userId: data.user.id }, + `Super Admin has successfully created a new user`, + ); - revalidatePath(`/admin/accounts`); + revalidatePath(`/admin/accounts`); - return { - success: true, - user: data.user, - }; - }, - { - schema: CreateUserSchema, - }, - ), -); + return { + success: true, + user: data.user, + }; + }); /** * @name resetPasswordAction * @description Reset a user's password by sending a password reset email. */ -export const resetPasswordAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const resetPasswordAction = adminActionClient + .inputSchema(ResetPasswordSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is resetting user password...`); + logger.info({ userId }, `Super Admin is resetting user password...`); - const result = await service.resetPassword(userId); + const result = await service.resetPassword(userId); - logger.info( - { userId }, - `Super Admin has successfully sent password reset email`, - ); + logger.info( + { userId }, + `Super Admin has successfully sent password reset email`, + ); - return result; - }, - { - schema: ResetPasswordSchema, - }, - ), -); + return result; + }); function revalidateAdmin() { revalidatePath(`/admin/accounts/[id]`, 'page'); diff --git a/packages/features/admin/src/lib/server/loaders/admin-dashboard.loader.ts b/packages/features/admin/src/lib/server/loaders/admin-dashboard.loader.ts index fa0757882..f576e8e6c 100644 --- a/packages/features/admin/src/lib/server/loaders/admin-dashboard.loader.ts +++ b/packages/features/admin/src/lib/server/loaders/admin-dashboard.loader.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { cache } from 'react'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; diff --git a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts index 9506012a6..a9b5f0c6d 100644 --- a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts +++ b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const ConfirmationSchema = z.object({ confirmation: z.custom<string>((value) => value === 'CONFIRM'), diff --git a/packages/features/admin/src/lib/server/schema/create-user.schema.ts b/packages/features/admin/src/lib/server/schema/create-user.schema.ts index 586474f81..7553871f6 100644 --- a/packages/features/admin/src/lib/server/schema/create-user.schema.ts +++ b/packages/features/admin/src/lib/server/schema/create-user.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const CreateUserSchema = z.object({ email: z.string().email({ message: 'Please enter a valid email address' }), @@ -8,4 +8,4 @@ export const CreateUserSchema = z.object({ emailConfirm: z.boolean().default(false).optional(), }); -export type CreateUserSchemaType = z.infer<typeof CreateUserSchema>; +export type CreateUserSchemaType = z.output<typeof CreateUserSchema>; diff --git a/packages/features/admin/src/lib/server/schema/reset-password.schema.ts b/packages/features/admin/src/lib/server/schema/reset-password.schema.ts index 45ada893b..c5bd43657 100644 --- a/packages/features/admin/src/lib/server/schema/reset-password.schema.ts +++ b/packages/features/admin/src/lib/server/schema/reset-password.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * Schema for resetting a user's password diff --git a/packages/features/admin/src/lib/server/services/admin-accounts.service.ts b/packages/features/admin/src/lib/server/services/admin-accounts.service.ts index 35815e0dd..40a35badd 100644 --- a/packages/features/admin/src/lib/server/services/admin-accounts.service.ts +++ b/packages/features/admin/src/lib/server/services/admin-accounts.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '@kit/supabase/database'; diff --git a/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts b/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts index 99dc89138..c701845b3 100644 --- a/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts +++ b/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts @@ -1,8 +1,7 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { Database } from '@kit/supabase/database'; diff --git a/packages/features/admin/src/lib/server/utils/admin-action-client.ts b/packages/features/admin/src/lib/server/utils/admin-action-client.ts new file mode 100644 index 000000000..27a68a0ca --- /dev/null +++ b/packages/features/admin/src/lib/server/utils/admin-action-client.ts @@ -0,0 +1,20 @@ +import 'server-only'; +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { isSuperAdmin } from './is-super-admin'; + +/** + * @name adminActionClient + * @description Safe action client for admin-only actions. + * Extends authActionClient with super admin verification. + */ +export const adminActionClient = authActionClient.use(async ({ next, ctx }) => { + const isAdmin = await isSuperAdmin(getSupabaseServerClient()); + + if (!isAdmin) { + throw new Error('Unauthorized'); + } + + return next({ ctx }); +}); diff --git a/packages/features/admin/tsconfig.json b/packages/features/admin/tsconfig.json index 7383acd45..c4697e934 100644 --- a/packages/features/admin/tsconfig.json +++ b/packages/features/admin/tsconfig.json @@ -4,7 +4,5 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, "include": ["*.ts", "src"], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/packages/features/auth/eslint.config.mjs b/packages/features/auth/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/features/auth/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/features/auth/package.json b/packages/features/auth/package.json index 3313e3ee3..0b9e8fb32 100644 --- a/packages/features/auth/package.json +++ b/packages/features/auth/package.json @@ -1,12 +1,13 @@ { "name": "@kit/auth", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" + "private": true, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } }, "exports": { "./sign-in": "./src/sign-in.ts", @@ -19,33 +20,25 @@ "./resend-email-link": "./src/components/resend-auth-link-form.tsx", "./oauth-provider-logo-image": "./src/components/oauth-provider-logo-image.tsx" }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, "devDependencies": { - "@hookform/resolvers": "^5.2.2", - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", + "@hookform/resolvers": "catalog:", "@kit/shared": "workspace:*", "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", "@marsidev/react-turnstile": "catalog:", - "@radix-ui/react-icons": "^1.3.2", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", - "@types/node": "catalog:", "@types/react": "catalog:", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", - "sonner": "^2.0.7", + "sonner": "catalog:", "zod": "catalog:" - }, - "prettier": "@kit/prettier-config", - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } } } diff --git a/packages/features/auth/src/components/auth-error-alert.tsx b/packages/features/auth/src/components/auth-error-alert.tsx index e4cda20d8..bc387bcd4 100644 --- a/packages/features/auth/src/components/auth-error-alert.tsx +++ b/packages/features/auth/src/components/auth-error-alert.tsx @@ -1,4 +1,4 @@ -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; import { WeakPasswordError, @@ -33,23 +33,25 @@ export function AuthErrorAlert({ return <WeakPasswordErrorAlert reasons={error.reasons} />; } - const DefaultError = <Trans i18nKey="auth:errors.default" />; - const errorCode = error instanceof Error ? error.message : error; + const DefaultError = <Trans i18nKey="auth.errors.default" />; + + const errorCode = + error instanceof Error + ? 'code' in error && typeof error.code === 'string' + ? error.code + : error.message + : error; return ( <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'w-4'} /> + <TriangleAlert className={'w-4'} /> <AlertTitle> - <Trans i18nKey={`auth:errorAlertHeading`} /> + <Trans i18nKey={`auth.errorAlertHeading`} /> </AlertTitle> <AlertDescription data-test={'auth-error-message'}> - <Trans - i18nKey={`auth:errors.${errorCode}`} - defaults={'<DefaultError />'} - components={{ DefaultError }} - /> + <Trans i18nKey={`auth.errors.${errorCode}`} defaults={DefaultError} /> </AlertDescription> </Alert> ); @@ -62,21 +64,21 @@ function WeakPasswordErrorAlert({ }) { return ( <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'w-4'} /> + <TriangleAlert className={'w-4'} /> <AlertTitle> - <Trans i18nKey={'auth:errors.weakPassword.title'} /> + <Trans i18nKey={'auth.errors.weakPassword.title'} /> </AlertTitle> <AlertDescription data-test={'auth-error-message'}> - <Trans i18nKey={'auth:errors.weakPassword.description'} /> + <Trans i18nKey={'auth.errors.weakPassword.description'} /> {reasons.length > 0 && ( <ul className="mt-2 list-inside list-disc space-y-1 text-xs"> {reasons.map((reason) => ( <li key={reason}> <Trans - i18nKey={`auth:errors.weakPassword.reasons.${reason}`} + i18nKey={`auth.errors.weakPassword.reasons.${reason}`} defaults={reason} /> </li> diff --git a/packages/features/auth/src/components/email-input.tsx b/packages/features/auth/src/components/email-input.tsx index 7440dd33b..8cebe6941 100644 --- a/packages/features/auth/src/components/email-input.tsx +++ b/packages/features/auth/src/components/email-input.tsx @@ -1,7 +1,7 @@ 'use client'; import { Mail } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { InputGroup, @@ -10,7 +10,7 @@ import { } from '@kit/ui/input-group'; export function EmailInput(props: React.ComponentProps<'input'>) { - const { t } = useTranslation('auth'); + const t = useTranslations('auth'); return ( <InputGroup className="dark:bg-background"> diff --git a/packages/features/auth/src/components/existing-account-hint.tsx b/packages/features/auth/src/components/existing-account-hint.tsx index 24672be78..87c9d6974 100644 --- a/packages/features/auth/src/components/existing-account-hint.tsx +++ b/packages/features/auth/src/components/existing-account-hint.tsx @@ -7,7 +7,7 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { UserCheck } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { Alert, AlertDescription } from '@kit/ui/alert'; import { If } from '@kit/ui/if'; @@ -36,7 +36,7 @@ export function ExistingAccountHintImpl({ useLastAuthMethod(); const params = useSearchParams(); - const { t } = useTranslation(); + const t = useTranslations(); const isInvite = params.get('invite_token'); @@ -53,13 +53,13 @@ export function ExistingAccountHintImpl({ switch (methodType) { case 'password': - return 'auth:methodPassword'; + return 'auth.methodPassword'; case 'otp': - return 'auth:methodOtp'; + return 'auth.methodOtp'; case 'magic_link': - return 'auth:methodMagicLink'; + return 'auth.methodMagicLink'; default: - return 'auth:methodDefault'; + return 'auth.methodDefault'; } }, [methodType, isOAuth, providerName]); @@ -73,10 +73,10 @@ export function ExistingAccountHintImpl({ <Alert data-test={'existing-account-hint'} className={className}> <UserCheck className="h-4 w-4" /> - <AlertDescription> + <AlertDescription className={'text-xs'}> <Trans - i18nKey="auth:existingAccountHint" - values={{ method: t(methodDescription) }} + i18nKey="auth.existingAccountHint" + values={{ methodName: t(methodDescription) }} components={{ method: <span className="font-medium" />, signInLink: ( diff --git a/packages/features/auth/src/components/last-auth-method-hint.tsx b/packages/features/auth/src/components/last-auth-method-hint.tsx index 1b6a9595d..9842e74c5 100644 --- a/packages/features/auth/src/components/last-auth-method-hint.tsx +++ b/packages/features/auth/src/components/last-auth-method-hint.tsx @@ -32,13 +32,13 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) { const methodKey = useMemo(() => { switch (methodType) { case 'password': - return 'auth:methodPassword'; + return 'auth.methodPassword'; case 'otp': - return 'auth:methodOtp'; + return 'auth.methodOtp'; case 'magic_link': - return 'auth:methodMagicLink'; + return 'auth.methodMagicLink'; case 'oauth': - return 'auth:methodOauth'; + return 'auth.methodOauth'; default: return null; } @@ -61,10 +61,10 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) { <Lightbulb className="h-3 w-3" /> <span> - <Trans i18nKey="auth:lastUsedMethodPrefix" />{' '} + <Trans i18nKey="auth.lastUsedMethodPrefix" />{' '} <If condition={isOAuth && Boolean(providerName)}> <Trans - i18nKey="auth:methodOauthWithProvider" + i18nKey="auth.methodOauthWithProvider" values={{ provider: providerName }} components={{ provider: <span className="text-muted-foreground font-medium" />, diff --git a/packages/features/auth/src/components/magic-link-auth-container.tsx b/packages/features/auth/src/components/magic-link-auth-container.tsx index e82bba939..16c2d87e0 100644 --- a/packages/features/auth/src/components/magic-link-auth-container.tsx +++ b/packages/features/auth/src/components/magic-link-auth-container.tsx @@ -1,10 +1,10 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Check, TriangleAlert } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import * as z from 'zod'; import { useAppEvents } from '@kit/shared/events'; import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp'; @@ -44,7 +44,7 @@ export function MagicLinkAuthContainer({ }; }) { const captcha = useCaptcha({ siteKey: captchaSiteKey }); - const { t } = useTranslation(); + const t = useTranslations(); const signInWithOtpMutation = useSignInWithOtp(); const appEvents = useAppEvents(); const { recordAuthMethod } = useLastAuthMethod(); @@ -90,9 +90,9 @@ export function MagicLinkAuthContainer({ }; toast.promise(promise, { - loading: t('auth:sendingEmailLink'), - success: t(`auth:sendLinkSuccessToast`), - error: t(`auth:errors.linkTitle`), + loading: t('auth.sendingEmailLink'), + success: t(`auth.sendLinkSuccessToast`), + error: t(`auth.errors.linkTitle`), }); captcha.reset(); @@ -116,7 +116,7 @@ export function MagicLinkAuthContainer({ render={({ field }) => ( <FormItem> <FormLabel> - <Trans i18nKey={'common:emailAddress'} /> + <Trans i18nKey={'common.emailAddress'} /> </FormLabel> <FormControl> @@ -133,17 +133,20 @@ export function MagicLinkAuthContainer({ <TermsAndConditionsFormField /> </If> - <Button disabled={signInWithOtpMutation.isPending || captchaLoading}> + <Button + type="submit" + disabled={signInWithOtpMutation.isPending || captchaLoading} + > <If condition={captchaLoading}> - <Trans i18nKey={'auth:verifyingCaptcha'} /> + <Trans i18nKey={'auth.verifyingCaptcha'} /> </If> <If condition={signInWithOtpMutation.isPending && !captchaLoading}> - <Trans i18nKey={'auth:sendingEmailLink'} /> + <Trans i18nKey={'auth.sendingEmailLink'} /> </If> <If condition={!signInWithOtpMutation.isPending && !captchaLoading}> - <Trans i18nKey={'auth:sendEmailLink'} /> + <Trans i18nKey={'auth.sendEmailLink'} /> </If> </Button> </div> @@ -155,14 +158,14 @@ export function MagicLinkAuthContainer({ function SuccessAlert() { return ( <Alert variant={'success'}> - <CheckIcon className={'h-4'} /> + <Check className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'auth:sendLinkSuccess'} /> + <Trans i18nKey={'auth.sendLinkSuccess'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'auth:sendLinkSuccessDescription'} /> + <Trans i18nKey={'auth.sendLinkSuccessDescription'} /> </AlertDescription> </Alert> ); @@ -171,14 +174,14 @@ function SuccessAlert() { function ErrorAlert() { return ( <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'auth:errors.linkTitle'} /> + <Trans i18nKey={'auth.errors.linkTitle'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'auth:errors.linkDescription'} /> + <Trans i18nKey={'auth.errors.linkDescription'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/auth/src/components/multi-factor-challenge-container.tsx b/packages/features/auth/src/components/multi-factor-challenge-container.tsx index e3625a41e..c99c5f938 100644 --- a/packages/features/auth/src/components/multi-factor-challenge-container.tsx +++ b/packages/features/auth/src/components/multi-factor-challenge-container.tsx @@ -5,10 +5,10 @@ import { useEffect, useEffectEvent } from 'react'; import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useMutation } from '@tanstack/react-query'; +import { TriangleAlert } from 'lucide-react'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; @@ -94,7 +94,7 @@ export function MultiFactorChallengeContainer({ <div className={'flex flex-col items-center gap-y-6'}> <div className="flex flex-col items-center gap-y-4"> <Heading level={5}> - <Trans i18nKey={'auth:verifyCodeHeading'} /> + <Trans i18nKey={'auth.verifyCodeHeading'} /> </Heading> </div> @@ -102,15 +102,15 @@ export function MultiFactorChallengeContainer({ <div className={'flex flex-col gap-y-4'}> <If condition={verifyMFAChallenge.error}> <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-5'} /> + <TriangleAlert className={'h-5'} /> <AlertTitle> - <Trans i18nKey={'account:invalidVerificationCodeHeading'} /> + <Trans i18nKey={'account.invalidVerificationCodeHeading'} /> </AlertTitle> <AlertDescription> <Trans - i18nKey={'account:invalidVerificationCodeDescription'} + i18nKey={'account.invalidVerificationCodeDescription'} /> </AlertDescription> </Alert> @@ -143,7 +143,7 @@ export function MultiFactorChallengeContainer({ <FormDescription className="text-center"> <Trans - i18nKey={'account:verifyActivationCodeDescription'} + i18nKey={'account.verifyActivationCodeDescription'} /> </FormDescription> @@ -156,6 +156,7 @@ export function MultiFactorChallengeContainer({ </div> <Button + type="submit" className="w-full" data-test={'submit-mfa-button'} disabled={ @@ -166,13 +167,13 @@ export function MultiFactorChallengeContainer({ > <If condition={verifyMFAChallenge.isPending}> <span className={'animate-in fade-in slide-in-from-bottom-24'}> - <Trans i18nKey={'account:verifyingCode'} /> + <Trans i18nKey={'account.verifyingCode'} /> </span> </If> <If condition={verifyMFAChallenge.isSuccess}> <span className={'animate-in fade-in slide-in-from-bottom-24'}> - <Trans i18nKey={'auth:redirecting'} /> + <Trans i18nKey={'auth.redirecting'} /> </span> </If> @@ -181,7 +182,7 @@ export function MultiFactorChallengeContainer({ !verifyMFAChallenge.isPending && !verifyMFAChallenge.isSuccess } > - <Trans i18nKey={'account:submitVerificationCode'} /> + <Trans i18nKey={'account.submitVerificationCode'} /> </If> </Button> </div> @@ -255,7 +256,7 @@ function FactorsListContainer({ <Spinner /> <div className={'text-sm'}> - <Trans i18nKey={'account:loadingFactors'} /> + <Trans i18nKey={'account.loadingFactors'} /> </div> </div> ); @@ -265,14 +266,14 @@ function FactorsListContainer({ return ( <div className={'w-full'}> <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'h-4'} /> + <TriangleAlert className={'h-4'} /> <AlertTitle> - <Trans i18nKey={'account:factorsListError'} /> + <Trans i18nKey={'account.factorsListError'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'account:factorsListErrorDescription'} /> + <Trans i18nKey={'account.factorsListErrorDescription'} /> </AlertDescription> </Alert> </div> @@ -285,7 +286,7 @@ function FactorsListContainer({ <div className={'animate-in fade-in flex flex-col space-y-4 duration-500'}> <div> <span className={'font-medium'}> - <Trans i18nKey={'account:selectFactor'} /> + <Trans i18nKey={'account.selectFactor'} /> </span> </div> diff --git a/packages/features/auth/src/components/oauth-providers.tsx b/packages/features/auth/src/components/oauth-providers.tsx index f97055d3c..0f2d4cbda 100644 --- a/packages/features/auth/src/components/oauth-providers.tsx +++ b/packages/features/auth/src/components/oauth-providers.tsx @@ -114,7 +114,7 @@ export const OauthProviders: React.FC<{ }} > <Trans - i18nKey={'auth:signInWithProvider'} + i18nKey={'auth.signInWithProvider'} values={{ provider: getProviderName(provider), }} diff --git a/packages/features/auth/src/components/otp-sign-in-container.tsx b/packages/features/auth/src/components/otp-sign-in-container.tsx index ea9de3a3f..a28f8c334 100644 --- a/packages/features/auth/src/components/otp-sign-in-container.tsx +++ b/packages/features/auth/src/components/otp-sign-in-container.tsx @@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp'; import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp'; @@ -132,7 +132,7 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) { </FormControl> <FormDescription> - <Trans i18nKey="common:otp.enterCodeFromEmail" /> + <Trans i18nKey="common.otp.enterCodeFromEmail" /> </FormDescription> <FormMessage /> @@ -149,10 +149,10 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) { {verifyMutation.isPending ? ( <> <Spinner className="mr-2 h-4 w-4" /> - <Trans i18nKey="common:otp.verifying" /> + <Trans i18nKey="common.otp.verifying" /> </> ) : ( - <Trans i18nKey="common:otp.verifyCode" /> + <Trans i18nKey="common.otp.verifyCode" /> )} </Button> @@ -166,7 +166,7 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) { }); }} > - <Trans i18nKey="common:otp.requestNewCode" /> + <Trans i18nKey="common.otp.requestNewCode" /> </Button> </div> </form> @@ -191,7 +191,7 @@ function OtpEmailForm({ defaultValues: { email: '' }, }); - const handleSendOtp = async ({ email }: z.infer<typeof EmailSchema>) => { + const handleSendOtp = async ({ email }: z.output<typeof EmailSchema>) => { await signInMutation.mutateAsync({ email, options: { captchaToken: captcha.token, shouldCreateUser }, @@ -230,10 +230,10 @@ function OtpEmailForm({ {signInMutation.isPending ? ( <> <Spinner className="mr-2 h-4 w-4" /> - <Trans i18nKey="common:otp.sendingCode" /> + <Trans i18nKey="common.otp.sendingCode" /> </> ) : ( - <Trans i18nKey="common:otp.sendVerificationCode" /> + <Trans i18nKey="common.otp.sendVerificationCode" /> )} </Button> </form> diff --git a/packages/features/auth/src/components/password-reset-request-container.tsx b/packages/features/auth/src/components/password-reset-request-container.tsx index 6f5d86488..660e85cdf 100644 --- a/packages/features/auth/src/components/password-reset-request-container.tsx +++ b/packages/features/auth/src/components/password-reset-request-container.tsx @@ -1,9 +1,9 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import * as z from 'zod'; import { useRequestResetPassword } from '@kit/supabase/hooks/use-request-reset-password'; import { Alert, AlertDescription } from '@kit/ui/alert'; @@ -31,7 +31,7 @@ export function PasswordResetRequestContainer(params: { redirectPath: string; captchaSiteKey?: string; }) { - const { t } = useTranslation('auth'); + const t = useTranslations('auth'); const resetPasswordMutation = useRequestResetPassword(); const captcha = useCaptcha({ siteKey: params.captchaSiteKey }); const captchaLoading = !captcha.isReady; @@ -51,7 +51,7 @@ export function PasswordResetRequestContainer(params: { <If condition={success}> <Alert variant={'success'}> <AlertDescription> - <Trans i18nKey={'auth:passwordResetSuccessMessage'} /> + <Trans i18nKey={'auth.passwordResetSuccessMessage'} /> </AlertDescription> </Alert> </If> @@ -85,7 +85,7 @@ export function PasswordResetRequestContainer(params: { render={({ field }) => ( <FormItem> <FormLabel> - <Trans i18nKey={'common:emailAddress'} /> + <Trans i18nKey={'common.emailAddress'} /> </FormLabel> <FormControl> @@ -111,15 +111,15 @@ export function PasswordResetRequestContainer(params: { !resetPasswordMutation.isPending && !captchaLoading } > - <Trans i18nKey={'auth:passwordResetLabel'} /> + <Trans i18nKey={'auth.passwordResetLabel'} /> </If> <If condition={resetPasswordMutation.isPending}> - <Trans i18nKey={'auth:passwordResetLabel'} /> + <Trans i18nKey={'auth.passwordResetLabel'} /> </If> <If condition={captchaLoading}> - <Trans i18nKey={'auth:verifyingCaptcha'} /> + <Trans i18nKey={'auth.verifyingCaptcha'} /> </If> </Button> </div> diff --git a/packages/features/auth/src/components/password-sign-in-container.tsx b/packages/features/auth/src/components/password-sign-in-container.tsx index 4ca46ba66..cb49bfc8f 100644 --- a/packages/features/auth/src/components/password-sign-in-container.tsx +++ b/packages/features/auth/src/components/password-sign-in-container.tsx @@ -27,7 +27,7 @@ export function PasswordSignInContainer({ const captchaLoading = !captcha.isReady; const onSubmit = useCallback( - async (credentials: z.infer<typeof PasswordSignInSchema>) => { + async (credentials: z.output<typeof PasswordSignInSchema>) => { try { const data = await signInMutation.mutateAsync({ ...credentials, diff --git a/packages/features/auth/src/components/password-sign-in-form.tsx b/packages/features/auth/src/components/password-sign-in-form.tsx index 84c0eefc8..6f04643a3 100644 --- a/packages/features/auth/src/components/password-sign-in-form.tsx +++ b/packages/features/auth/src/components/password-sign-in-form.tsx @@ -4,8 +4,8 @@ import Link from 'next/link'; import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowRight, Mail } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import type { z } from 'zod'; import { Button } from '@kit/ui/button'; @@ -33,12 +33,12 @@ export function PasswordSignInForm({ loading = false, redirecting = false, }: { - onSubmit: (params: z.infer<typeof PasswordSignInSchema>) => unknown; + onSubmit: (params: z.output<typeof PasswordSignInSchema>) => unknown; captchaLoading: boolean; loading: boolean; redirecting: boolean; }) { - const { t } = useTranslation('auth'); + const t = useTranslations('auth'); const form = useForm({ resolver: zodResolver(PasswordSignInSchema), @@ -94,15 +94,14 @@ export function PasswordSignInForm({ <div> <Button - asChild + nativeButton={false} + render={<Link href={'/auth/password-reset'} />} type={'button'} size={'sm'} variant={'link'} className={'text-xs'} > - <Link href={'/auth/password-reset'}> - <Trans i18nKey={'auth:passwordForgottenQuestion'} /> - </Link> + <Trans i18nKey={'auth.passwordForgottenQuestion'} /> </Button> </div> </FormItem> @@ -118,19 +117,19 @@ export function PasswordSignInForm({ > <If condition={redirecting}> <span className={'animate-in fade-in slide-in-from-bottom-24'}> - <Trans i18nKey={'auth:redirecting'} /> + <Trans i18nKey={'auth.redirecting'} /> </span> </If> <If condition={loading}> <span className={'animate-in fade-in slide-in-from-bottom-24'}> - <Trans i18nKey={'auth:signingIn'} /> + <Trans i18nKey={'auth.signingIn'} /> </span> </If> <If condition={captchaLoading}> <span className={'animate-in fade-in slide-in-from-bottom-24'}> - <Trans i18nKey={'auth:verifyingCaptcha'} /> + <Trans i18nKey={'auth.verifyingCaptcha'} /> </span> </If> @@ -140,7 +139,7 @@ export function PasswordSignInForm({ 'animate-in fade-in slide-in-from-bottom-24 flex items-center' } > - <Trans i18nKey={'auth:signInWithEmail'} /> + <Trans i18nKey={'auth.signInWithEmail'} /> <ArrowRight className={ diff --git a/packages/features/auth/src/components/password-sign-up-container.tsx b/packages/features/auth/src/components/password-sign-up-container.tsx index a0c93564a..121e3225f 100644 --- a/packages/features/auth/src/components/password-sign-up-container.tsx +++ b/packages/features/auth/src/components/password-sign-up-container.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CheckCircledIcon } from '@radix-ui/react-icons'; +import { CheckCircle } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { If } from '@kit/ui/if'; @@ -71,14 +71,14 @@ export function EmailPasswordSignUpContainer({ function SuccessAlert() { return ( <Alert variant={'success'}> - <CheckCircledIcon className={'w-4'} /> + <CheckCircle className={'w-4'} /> <AlertTitle> - <Trans i18nKey={'auth:emailConfirmationAlertHeading'} /> + <Trans i18nKey={'auth.emailConfirmationAlertHeading'} /> </AlertTitle> <AlertDescription data-test={'email-confirmation-alert'}> - <Trans i18nKey={'auth:emailConfirmationAlertBody'} /> + <Trans i18nKey={'auth.emailConfirmationAlertBody'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/auth/src/components/password-sign-up-form.tsx b/packages/features/auth/src/components/password-sign-up-form.tsx index 23fe51b09..8be9dacdc 100644 --- a/packages/features/auth/src/components/password-sign-up-form.tsx +++ b/packages/features/auth/src/components/password-sign-up-form.tsx @@ -102,7 +102,7 @@ export function PasswordSignUpForm({ </FormControl> <FormDescription> - <Trans i18nKey={'auth:repeatPasswordDescription'} /> + <Trans i18nKey={'auth.repeatPasswordDescription'} /> </FormDescription> <FormMessage /> @@ -123,13 +123,13 @@ export function PasswordSignUpForm({ > <If condition={captchaLoading}> <span className={'animate-in fade-in slide-in-from-bottom-24'}> - <Trans i18nKey={'auth:verifyingCaptcha'} /> + <Trans i18nKey={'auth.verifyingCaptcha'} /> </span> </If> <If condition={loading && !captchaLoading}> <span className={'animate-in fade-in slide-in-from-bottom-24'}> - <Trans i18nKey={'auth:signingUp'} /> + <Trans i18nKey={'auth.signingUp'} /> </span> </If> @@ -139,7 +139,7 @@ export function PasswordSignUpForm({ 'animate-in fade-in slide-in-from-bottom-24 flex items-center' } > - <Trans i18nKey={'auth:signUpWithEmail'} /> + <Trans i18nKey={'auth.signUpWithEmail'} /> <ArrowRight className={ diff --git a/packages/features/auth/src/components/resend-auth-link-form.tsx b/packages/features/auth/src/components/resend-auth-link-form.tsx index c7fa19d56..5d736dfc1 100644 --- a/packages/features/auth/src/components/resend-auth-link-form.tsx +++ b/packages/features/auth/src/components/resend-auth-link-form.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -40,12 +40,12 @@ export function ResendAuthLinkForm(props: { return ( <Alert variant={'success'}> <AlertTitle> - <Trans i18nKey={'auth:resendLinkSuccess'} /> + <Trans i18nKey={'auth.resendLinkSuccess'} /> </AlertTitle> <AlertDescription> <Trans - i18nKey={'auth:resendLinkSuccessDescription'} + i18nKey={'auth.resendLinkSuccessDescription'} defaults={'Success!'} /> </AlertDescription> @@ -85,17 +85,17 @@ export function ResendAuthLinkForm(props: { }} /> - <Button disabled={resendLink.isPending || captchaLoading}> + <Button type="submit" disabled={resendLink.isPending || captchaLoading}> <If condition={captchaLoading}> - <Trans i18nKey={'auth:verifyingCaptcha'} /> + <Trans i18nKey={'auth.verifyingCaptcha'} /> </If> <If condition={resendLink.isPending && !captchaLoading}> - <Trans i18nKey={'auth:resendingLink'} /> + <Trans i18nKey={'auth.resendingLink'} /> </If> <If condition={!resendLink.isPending && !captchaLoading}> - <Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} /> + <Trans i18nKey={'auth.resendLink'} defaults={'Resend Link'} /> </If> </Button> </form> diff --git a/packages/features/auth/src/components/sign-in-methods-container.tsx b/packages/features/auth/src/components/sign-in-methods-container.tsx index b738e9be3..19dbebf31 100644 --- a/packages/features/auth/src/components/sign-in-methods-container.tsx +++ b/packages/features/auth/src/components/sign-in-methods-container.tsx @@ -86,7 +86,7 @@ export function SignInMethodsContainer(props: { <div className="relative flex justify-center text-xs uppercase"> <span className="bg-background text-muted-foreground px-2"> - <Trans i18nKey="auth:orContinueWith" /> + <Trans i18nKey="auth.orContinueWith" /> </span> </div> </div> diff --git a/packages/features/auth/src/components/sign-up-methods-container.tsx b/packages/features/auth/src/components/sign-up-methods-container.tsx index 41fc73566..68a57421a 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -78,7 +78,7 @@ export function SignUpMethodsContainer(props: { <div className="relative flex justify-center text-xs uppercase"> <span className="bg-background text-muted-foreground px-2"> - <Trans i18nKey="auth:orContinueWith" /> + <Trans i18nKey="auth.orContinueWith" /> </span> </div> </div> diff --git a/packages/features/auth/src/components/terms-and-conditions-form-field.tsx b/packages/features/auth/src/components/terms-and-conditions-form-field.tsx index 7c4a19085..e8600da7b 100644 --- a/packages/features/auth/src/components/terms-and-conditions-form-field.tsx +++ b/packages/features/auth/src/components/terms-and-conditions-form-field.tsx @@ -21,7 +21,7 @@ export function TermsAndConditionsFormField( <div className={'text-xs'}> <Trans - i18nKey={'auth:acceptTermsAndConditions'} + i18nKey={'auth.acceptTermsAndConditions'} components={{ TermsOfServiceLink: ( <Link @@ -29,7 +29,7 @@ export function TermsAndConditionsFormField( className={'underline'} href={'/terms-of-service'} > - <Trans i18nKey={'auth:termsOfService'} /> + <Trans i18nKey={'auth.termsOfService'} /> </Link> ), PrivacyPolicyLink: ( @@ -38,7 +38,7 @@ export function TermsAndConditionsFormField( className={'underline'} href={'/privacy-policy'} > - <Trans i18nKey={'auth:privacyPolicy'} /> + <Trans i18nKey={'auth.privacyPolicy'} /> </Link> ), }} diff --git a/packages/features/auth/src/components/update-password-form.tsx b/packages/features/auth/src/components/update-password-form.tsx index 54f445b94..9adfc79ed 100644 --- a/packages/features/auth/src/components/update-password-form.tsx +++ b/packages/features/auth/src/components/update-password-form.tsx @@ -3,9 +3,9 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation'; @@ -31,7 +31,7 @@ export function UpdatePasswordForm(params: { }) { const updateUser = useUpdateUser(); const router = useRouter(); - const { t } = useTranslation(); + const t = useTranslations(); const form = useForm({ resolver: zodResolver(PasswordResetSchema), @@ -68,7 +68,7 @@ export function UpdatePasswordForm(params: { router.replace(params.redirectTo); - toast.success(t('account:updatePasswordSuccessMessage')); + toast.success(t('account.updatePasswordSuccessMessage')); })} > <div className={'flex-col space-y-2.5'}> @@ -94,7 +94,7 @@ export function UpdatePasswordForm(params: { </FormControl> <FormDescription> - <Trans i18nKey={'common:repeatPassword'} /> + <Trans i18nKey={'common.repeatPassword'} /> </FormDescription> <FormMessage /> @@ -107,7 +107,7 @@ export function UpdatePasswordForm(params: { type="submit" className={'w-full'} > - <Trans i18nKey={'auth:passwordResetLabel'} /> + <Trans i18nKey={'auth.passwordResetLabel'} /> </Button> </div> </form> @@ -122,7 +122,7 @@ function ErrorState(props: { code: string; }; }) { - const { t } = useTranslation('auth'); + const t = useTranslations('auth'); const errorMessage = t(`errors.${props.error.code}`, { defaultValue: t('errors.resetPasswordError'), @@ -131,17 +131,17 @@ function ErrorState(props: { return ( <div className={'flex flex-col space-y-4'}> <Alert variant={'destructive'}> - <ExclamationTriangleIcon className={'s-6'} /> + <TriangleAlert className={'s-6'} /> <AlertTitle> - <Trans i18nKey={'common:genericError'} /> + <Trans i18nKey={'common.genericError'} /> </AlertTitle> <AlertDescription>{errorMessage}</AlertDescription> </Alert> <Button onClick={props.onRetry} variant={'outline'}> - <Trans i18nKey={'common:retry'} /> + <Trans i18nKey={'common.retry'} /> </Button> </div> ); diff --git a/packages/features/auth/src/schemas/password-reset.schema.ts b/packages/features/auth/src/schemas/password-reset.schema.ts index 193edd600..fd64f1eed 100644 --- a/packages/features/auth/src/schemas/password-reset.schema.ts +++ b/packages/features/auth/src/schemas/password-reset.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema'; diff --git a/packages/features/auth/src/schemas/password-sign-in.schema.ts b/packages/features/auth/src/schemas/password-sign-in.schema.ts index 823446c08..855129caa 100644 --- a/packages/features/auth/src/schemas/password-sign-in.schema.ts +++ b/packages/features/auth/src/schemas/password-sign-in.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { PasswordSchema } from './password.schema'; diff --git a/packages/features/auth/src/schemas/password-sign-up.schema.ts b/packages/features/auth/src/schemas/password-sign-up.schema.ts index 828924d12..ce91f6acb 100644 --- a/packages/features/auth/src/schemas/password-sign-up.schema.ts +++ b/packages/features/auth/src/schemas/password-sign-up.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema'; diff --git a/packages/features/auth/src/schemas/password.schema.ts b/packages/features/auth/src/schemas/password.schema.ts index c31b697d5..7876ff883 100644 --- a/packages/features/auth/src/schemas/password.schema.ts +++ b/packages/features/auth/src/schemas/password.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * Password requirements @@ -36,13 +36,11 @@ export function refineRepeatPassword( ) { if (data.password !== data.repeatPassword) { ctx.addIssue({ - message: 'auth:errors.passwordsDoNotMatch', + message: 'auth.errors.passwordsDoNotMatch', path: ['repeatPassword'], code: 'custom', }); } - - return true; } function validatePassword(password: string, ctx: z.RefinementCtx) { @@ -52,7 +50,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) { if (specialCharsCount < 1) { ctx.addIssue({ - message: 'auth:errors.minPasswordSpecialChars', + message: 'auth.errors.minPasswordSpecialChars', code: 'custom', }); } @@ -63,7 +61,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) { if (numbersCount < 1) { ctx.addIssue({ - message: 'auth:errors.minPasswordNumbers', + message: 'auth.errors.minPasswordNumbers', code: 'custom', }); } @@ -72,11 +70,9 @@ function validatePassword(password: string, ctx: z.RefinementCtx) { if (requirements.uppercase) { if (!/[A-Z]/.test(password)) { ctx.addIssue({ - message: 'auth:errors.uppercasePassword', + message: 'auth.errors.uppercasePassword', code: 'custom', }); } } - - return true; } diff --git a/packages/features/notifications/eslint.config.mjs b/packages/features/notifications/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/features/notifications/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/features/notifications/package.json b/packages/features/notifications/package.json index 385b9fbde..8ada14170 100644 --- a/packages/features/notifications/package.json +++ b/packages/features/notifications/package.json @@ -1,33 +1,7 @@ { "name": "@kit/notifications", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "exports": { - "./api": "./src/server/api.ts", - "./components": "./src/components/index.ts", - "./hooks": "./src/hooks/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/supabase": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@kit/ui": "workspace:*", - "@supabase/supabase-js": "catalog:", - "@tanstack/react-query": "catalog:", - "@types/react": "catalog:", - "lucide-react": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "react-i18next": "catalog:" - }, - "prettier": "@kit/prettier-config", + "private": true, "typesVersions": { "*": { "*": [ @@ -35,7 +9,28 @@ ] } }, + "exports": { + "./api": "./src/server/api.ts", + "./components": "./src/components/index.ts", + "./hooks": "./src/hooks/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, "dependencies": { "@types/node": "catalog:" + }, + "devDependencies": { + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:*", + "@supabase/supabase-js": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/react": "catalog:", + "lucide-react": "catalog:", + "next-intl": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" } } diff --git a/packages/features/notifications/src/components/notifications-popover.tsx b/packages/features/notifications/src/components/notifications-popover.tsx index 7e0b617de..c8c0eb42d 100644 --- a/packages/features/notifications/src/components/notifications-popover.tsx +++ b/packages/features/notifications/src/components/notifications-popover.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Bell, CircleAlert, Info, TriangleAlert, XIcon } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useLocale, useTranslations } from 'next-intl'; import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; @@ -19,7 +19,8 @@ export function NotificationsPopover(params: { accountIds: string[]; onClick?: (notification: Notification) => void; }) { - const { i18n, t } = useTranslation(); + const t = useTranslations(); + const locale = useLocale(); const [open, setOpen] = useState(false); const [notifications, setNotifications] = useState<Notification[]>([]); @@ -53,7 +54,7 @@ export function NotificationsPopover(params: { (new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24), ); - const formatter = new Intl.RelativeTimeFormat(i18n.language, { + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', }); @@ -61,7 +62,7 @@ export function NotificationsPopover(params: { time = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60)); if (time < 5) { - return t('common:justNow'); + return t('common.justNow'); } if (time < 60) { @@ -110,46 +111,39 @@ export function NotificationsPopover(params: { return ( <Popover modal open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <Button className={'relative h-9 w-9'} variant={'ghost'}> - <Bell className={'min-h-4 min-w-4'} /> + <PopoverTrigger + render={<Button size="icon-lg" variant="ghost" className="relative" />} + > + <Bell className={'size-4 min-h-3 min-w-3'} /> - <span - className={cn( - `fade-in animate-in zoom-in absolute top-1 right-1 mt-0 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`, - { - hidden: !notifications.length, - }, - )} - > - {notifications.length} - </span> - </Button> + <span + className={cn( + `fade-in animate-in zoom-in absolute top-1 right-1 mt-0 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 text-[0.6rem] text-white`, + { + hidden: !notifications.length, + }, + )} + > + {notifications.length} + </span> </PopoverTrigger> <PopoverContent - className={'flex w-full max-w-96 flex-col p-0 lg:min-w-64'} + className={'flex w-full max-w-96 flex-col gap-0 lg:min-w-64'} align={'start'} - collisionPadding={20} sideOffset={10} > - <div className={'flex items-center px-3 py-2 text-sm font-semibold'}> - {t('common:notifications')} + <div className={'flex items-center text-sm font-semibold'}> + {t('common.notifications')} </div> <Separator /> <If condition={!notifications.length}> - <div className={'px-3 py-2 text-sm'}> - {t('common:noNotifications')} - </div> + <div className={'text-sm'}>{t('common.noNotifications')}</div> </If> - <div - className={ - 'flex max-h-[60vh] flex-col divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800' - } - > + <div className={'flex max-h-[60vh] flex-col overflow-y-auto'}> {notifications.map((notification) => { const maxChars = 100; @@ -164,11 +158,11 @@ export function NotificationsPopover(params: { const Icon = () => { switch (notification.type) { case 'warning': - return <TriangleAlert className={'h-4 text-yellow-500'} />; + return <TriangleAlert className={'size-3 text-yellow-500'} />; case 'error': - return <CircleAlert className={'text-destructive h-4'} />; + return <CircleAlert className={'text-destructive size-3'} />; default: - return <Info className={'h-4 text-blue-500'} />; + return <Info className={'size-3 text-blue-500'} />; } }; @@ -176,7 +170,7 @@ export function NotificationsPopover(params: { <div key={notification.id.toString()} className={cn( - 'flex min-h-18 flex-col items-start justify-center gap-y-1 px-3 py-2', + 'flex min-h-14 flex-col items-start justify-center gap-y-1 px-1', )} onClick={() => { if (params.onClick) { @@ -185,15 +179,11 @@ export function NotificationsPopover(params: { }} > <div className={'flex w-full items-start justify-between'}> - <div - className={'flex items-start justify-start gap-x-3 py-2'} - > - <div className={'py-0.5'}> - <Icon /> - </div> + <div className={'flex items-start justify-start gap-x-1.5'}> + <div className={'flex flex-col'}> + <div className={'flex items-center gap-x-2 text-sm'}> + <Icon /> - <div className={'flex flex-col space-y-1'}> - <div className={'text-sm'}> <If condition={notification.link} fallback={body}> {(link) => ( <a href={link} className={'hover:underline'}> @@ -209,7 +199,7 @@ export function NotificationsPopover(params: { </div> </div> - <div className={'py-2'}> + <div className={'ml-2'}> <Button className={'max-h-6 max-w-6'} size={'icon'} diff --git a/packages/features/notifications/src/server/notifications.service.ts b/packages/features/notifications/src/server/notifications.service.ts index d49f8f020..960593ea2 100644 --- a/packages/features/notifications/src/server/notifications.service.ts +++ b/packages/features/notifications/src/server/notifications.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '@kit/supabase/database'; diff --git a/packages/features/team-accounts/eslint.config.mjs b/packages/features/team-accounts/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/features/team-accounts/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/features/team-accounts/package.json b/packages/features/team-accounts/package.json index 070c7251e..1be543277 100644 --- a/packages/features/team-accounts/package.json +++ b/packages/features/team-accounts/package.json @@ -1,12 +1,13 @@ { "name": "@kit/team-accounts", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" + "private": true, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } }, "exports": { "./api": "./src/server/api.ts", @@ -16,46 +17,41 @@ "./policies": "./src/server/policies/index.ts", "./services/account-invitations.service": "./src/server/services/account-invitations.service.ts" }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, "dependencies": { - "nanoid": "^5.1.6" + "nanoid": "catalog:" }, "devDependencies": { - "@hookform/resolvers": "^5.2.2", + "@hookform/resolvers": "catalog:", "@kit/accounts": "workspace:*", "@kit/billing-gateway": "workspace:*", "@kit/email-templates": "workspace:*", - "@kit/eslint-config": "workspace:*", "@kit/mailers": "workspace:*", "@kit/monitoring": "workspace:*", "@kit/next": "workspace:*", "@kit/otp": "workspace:*", "@kit/policies": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/shared": "workspace:*", "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", - "@tanstack/react-table": "^8.21.3", + "@tanstack/react-table": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "class-variance-authority": "^0.7.1", - "date-fns": "^4.1.0", + "class-variance-authority": "catalog:", + "date-fns": "catalog:", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", + "next-safe-action": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", "zod": "catalog:" - }, - "prettier": "@kit/prettier-config", - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } } } diff --git a/packages/features/team-accounts/src/components/create-team-account-dialog.tsx b/packages/features/team-accounts/src/components/create-team-account-dialog.tsx index b58235a21..1d5b49869 100644 --- a/packages/features/team-accounts/src/components/create-team-account-dialog.tsx +++ b/packages/features/team-accounts/src/components/create-team-account-dialog.tsx @@ -1,14 +1,5 @@ 'use client'; -import { useMemo, useState, useTransition } from 'react'; - -import { isRedirectError } from 'next/dist/client/components/redirect-error'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm, useWatch } from 'react-hook-form'; - -import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; -import { Button } from '@kit/ui/button'; import { Dialog, DialogContent, @@ -16,24 +7,10 @@ import { DialogHeader, DialogTitle, } from '@kit/ui/dialog'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@kit/ui/form'; -import { If } from '@kit/ui/if'; -import { Input } from '@kit/ui/input'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { Trans } from '@kit/ui/trans'; -import { - CreateTeamSchema, - NON_LATIN_REGEX, -} from '../schema/create-team.schema'; -import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions'; +import { CreateTeamAccountForm } from './create-team-account-form'; export function CreateTeamAccountDialog( props: React.PropsWithChildren<{ @@ -41,172 +18,30 @@ export function CreateTeamAccountDialog( setIsOpen: (isOpen: boolean) => void; }>, ) { + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({ + open: props.isOpen, + onOpenChange: props.setIsOpen, + }); + return ( - <Dialog open={props.isOpen} onOpenChange={props.setIsOpen}> - <DialogContent - onEscapeKeyDown={(e) => e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - > + <Dialog {...dialogProps}> + <DialogContent showCloseButton={!isPending}> <DialogHeader> <DialogTitle> - <Trans i18nKey={'teams:createTeamModalHeading'} /> + <Trans i18nKey={'teams.createTeamModalHeading'} /> </DialogTitle> <DialogDescription> - <Trans i18nKey={'teams:createTeamModalDescription'} /> + <Trans i18nKey={'teams.createTeamModalDescription'} /> </DialogDescription> </DialogHeader> - <CreateOrganizationAccountForm onClose={() => props.setIsOpen(false)} /> + <CreateTeamAccountForm + isPending={isPending} + setIsPending={setIsPending} + onClose={() => setOpen(false)} + /> </DialogContent> </Dialog> ); } - -function CreateOrganizationAccountForm(props: { onClose: () => void }) { - const [error, setError] = useState<{ message?: string } | undefined>(); - const [pending, startTransition] = useTransition(); - - const form = useForm({ - defaultValues: { - name: '', - slug: '', - }, - resolver: zodResolver(CreateTeamSchema), - }); - - const nameValue = useWatch({ control: form.control, name: 'name' }); - - const showSlugField = useMemo( - () => NON_LATIN_REGEX.test(nameValue ?? ''), - [nameValue], - ); - - return ( - <Form {...form}> - <form - data-test={'create-team-form'} - onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - try { - const result = await createTeamAccountAction(data); - - if (result.error) { - setError({ message: result.message }); - } - } catch (e) { - if (!isRedirectError(e)) { - setError({}); - } - } - }); - })} - > - <div className={'flex flex-col space-y-4'}> - <If condition={error}> - <CreateOrganizationErrorAlert message={error?.message} /> - </If> - - <FormField - name={'name'} - render={({ field }) => { - return ( - <FormItem> - <FormLabel> - <Trans i18nKey={'teams:teamNameLabel'} /> - </FormLabel> - - <FormControl> - <Input - data-test={'team-name-input'} - required - minLength={2} - maxLength={50} - placeholder={''} - {...field} - /> - </FormControl> - - <FormDescription> - <Trans i18nKey={'teams:teamNameDescription'} /> - </FormDescription> - - <FormMessage /> - </FormItem> - ); - }} - /> - - <If condition={showSlugField}> - <FormField - name={'slug'} - render={({ field }) => { - return ( - <FormItem> - <FormLabel> - <Trans i18nKey={'teams:teamSlugLabel'} /> - </FormLabel> - - <FormControl> - <Input - data-test={'team-slug-input'} - required - minLength={2} - maxLength={50} - placeholder={'my-team'} - {...field} - /> - </FormControl> - - <FormDescription> - <Trans i18nKey={'teams:teamSlugDescription'} /> - </FormDescription> - - <FormMessage /> - </FormItem> - ); - }} - /> - </If> - - <div className={'flex justify-end space-x-2'}> - <Button - variant={'outline'} - type={'button'} - disabled={pending} - onClick={props.onClose} - > - <Trans i18nKey={'common:cancel'} /> - </Button> - - <Button data-test={'confirm-create-team-button'} disabled={pending}> - {pending ? ( - <Trans i18nKey={'teams:creatingTeam'} /> - ) : ( - <Trans i18nKey={'teams:createTeamSubmitLabel'} /> - )} - </Button> - </div> - </div> - </form> - </Form> - ); -} - -function CreateOrganizationErrorAlert(props: { message?: string }) { - return ( - <Alert variant={'destructive'}> - <AlertTitle> - <Trans i18nKey={'teams:createTeamErrorHeading'} /> - </AlertTitle> - - <AlertDescription> - {props.message ? ( - <Trans i18nKey={props.message} defaults={props.message} /> - ) : ( - <Trans i18nKey={'teams:createTeamErrorMessage'} /> - )} - </AlertDescription> - </Alert> - ); -} diff --git a/packages/features/team-accounts/src/components/create-team-account-form.tsx b/packages/features/team-accounts/src/components/create-team-account-form.tsx new file mode 100644 index 000000000..d7bedcc63 --- /dev/null +++ b/packages/features/team-accounts/src/components/create-team-account-form.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm, useWatch } from 'react-hook-form'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; +import { Button } from '@kit/ui/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@kit/ui/form'; +import { If } from '@kit/ui/if'; +import { Input } from '@kit/ui/input'; +import { Trans } from '@kit/ui/trans'; + +import { + CreateTeamSchema, + NON_LATIN_REGEX, +} from '../schema/create-team.schema'; +import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions'; + +export function CreateTeamAccountForm(props: { + onClose?: () => void; + isPending?: boolean; + setIsPending?: (isPending: boolean) => void; + submitLabel?: string; +}) { + const [error, setError] = useState<{ message?: string } | undefined>(); + + const { execute, isPending } = useAction(createTeamAccountAction, { + onExecute: () => { + setError(undefined); + + if (props.setIsPending) { + props.setIsPending(true); + } + }, + onSuccess: ({ data }) => { + if (data?.error) { + setError({ message: data.message }); + } + }, + onError: () => { + setError({}); + }, + onSettled: () => { + if (props.setIsPending) { + props.setIsPending(false); + } + }, + }); + + const form = useForm({ + defaultValues: { + name: '', + slug: '', + }, + resolver: zodResolver(CreateTeamSchema), + }); + + const nameValue = useWatch({ control: form.control, name: 'name' }); + + const showSlugField = NON_LATIN_REGEX.test(nameValue ?? ''); + + return ( + <Form {...form}> + <form + data-test={'create-team-form'} + onSubmit={form.handleSubmit((data) => execute(data))} + > + <div className={'flex flex-col space-y-4'}> + <If condition={error}> + <CreateTeamAccountErrorAlert message={error?.message} /> + </If> + + <FormField + name={'name'} + render={({ field }) => { + return ( + <FormItem> + <FormLabel> + <Trans i18nKey={'teams.teamNameLabel'} /> + </FormLabel> + + <FormControl> + <Input + data-test={'team-name-input'} + required + minLength={2} + maxLength={50} + placeholder={''} + {...field} + /> + </FormControl> + + <FormDescription> + <Trans i18nKey={'teams.teamNameDescription'} /> + </FormDescription> + + <FormMessage /> + </FormItem> + ); + }} + /> + + <If condition={showSlugField}> + <FormField + name={'slug'} + render={({ field }) => { + return ( + <FormItem> + <FormLabel> + <Trans i18nKey={'teams.teamSlugLabel'} /> + </FormLabel> + + <FormControl> + <Input + data-test={'team-slug-input'} + required + minLength={2} + maxLength={50} + placeholder={'my-team'} + {...field} + /> + </FormControl> + + <FormDescription> + <Trans i18nKey={'teams.teamSlugDescription'} /> + </FormDescription> + + <FormMessage /> + </FormItem> + ); + }} + /> + </If> + + <div className={'flex justify-end space-x-2'}> + <If condition={!!props.onClose}> + <Button + variant={'outline'} + type={'button'} + disabled={isPending || props.isPending} + onClick={props.onClose} + > + <Trans i18nKey={'common.cancel'} /> + </Button> + </If> + + <Button + type="submit" + data-test={'confirm-create-team-button'} + disabled={isPending || props.isPending} + > + {isPending || props.isPending ? ( + <Trans i18nKey={'teams.creatingTeam'} /> + ) : ( + <Trans + i18nKey={props.submitLabel ?? 'teams.createTeamSubmitLabel'} + /> + )} + </Button> + </div> + </div> + </form> + </Form> + ); +} + +function CreateTeamAccountErrorAlert(props: { message?: string }) { + return ( + <Alert variant={'destructive'}> + <AlertTitle> + <Trans i18nKey={'teams.createTeamErrorHeading'} /> + </AlertTitle> + + <AlertDescription> + {props.message ? ( + <Trans i18nKey={props.message} defaults={props.message} /> + ) : ( + <Trans i18nKey={'teams.createTeamErrorMessage'} /> + )} + </AlertDescription> + </Alert> + ); +} diff --git a/packages/features/team-accounts/src/components/index.ts b/packages/features/team-accounts/src/components/index.ts index 63d3ecefd..50c7e89ee 100644 --- a/packages/features/team-accounts/src/components/index.ts +++ b/packages/features/team-accounts/src/components/index.ts @@ -5,4 +5,5 @@ export * from './invitations/account-invitations-table'; export * from './settings/team-account-settings-container'; export * from './invitations/accept-invitation-container'; export * from './create-team-account-dialog'; +export * from './create-team-account-form'; export * from './team-account-workspace-context'; diff --git a/packages/features/team-accounts/src/components/invitations/accept-invitation-container.tsx b/packages/features/team-accounts/src/components/invitations/accept-invitation-container.tsx index 2b9e4e0dc..feb695010 100644 --- a/packages/features/team-accounts/src/components/invitations/accept-invitation-container.tsx +++ b/packages/features/team-accounts/src/components/invitations/accept-invitation-container.tsx @@ -1,12 +1,16 @@ +'use client'; + import Image from 'next/image'; +import { useAction } from 'next-safe-action/hooks'; + +import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; import { If } from '@kit/ui/if'; import { Separator } from '@kit/ui/separator'; import { Trans } from '@kit/ui/trans'; import { acceptInvitationAction } from '../../server/actions/team-invitations-server-actions'; -import { InvitationSubmitButton } from './invitation-submit-button'; import { SignOutInvitationButton } from './sign-out-invitation-button'; export function AcceptInvitationContainer(props: { @@ -28,11 +32,13 @@ export function AcceptInvitationContainer(props: { nextPath: string; }; }) { + const { execute, isPending } = useAction(acceptInvitationAction); + return ( <div className={'flex flex-col items-center space-y-4'}> <Heading className={'text-center'} level={4}> <Trans - i18nKey={'teams:acceptInvitationHeading'} + i18nKey={'teams.acceptInvitationHeading'} values={{ accountName: props.invitation.account.name, }} @@ -53,7 +59,7 @@ export function AcceptInvitationContainer(props: { <div className={'text-muted-foreground text-center text-sm'}> <Trans - i18nKey={'teams:acceptInvitationDescription'} + i18nKey={'teams.acceptInvitationDescription'} values={{ accountName: props.invitation.account.name, }} @@ -64,20 +70,24 @@ export function AcceptInvitationContainer(props: { <form data-test={'join-team-form'} className={'w-full'} - action={acceptInvitationAction} + onSubmit={(e) => { + e.preventDefault(); + + execute({ + inviteToken: props.inviteToken, + nextPath: props.paths.nextPath, + }); + }} > - <input type="hidden" name={'inviteToken'} value={props.inviteToken} /> - - <input - type={'hidden'} - name={'nextPath'} - value={props.paths.nextPath} - /> - - <InvitationSubmitButton - email={props.email} - accountName={props.invitation.account.name} - /> + <Button type={'submit'} className={'w-full'} disabled={isPending}> + <Trans + i18nKey={isPending ? 'teams.joiningTeam' : 'teams.continueAs'} + values={{ + accountName: props.invitation.account.name, + email: props.email, + }} + /> + </Button> </form> <Separator /> @@ -85,7 +95,7 @@ export function AcceptInvitationContainer(props: { <SignOutInvitationButton nextPath={props.paths.signOutNext} /> <span className={'text-muted-foreground text-center text-xs'}> - <Trans i18nKey={'teams:signInWithDifferentAccountDescription'} /> + <Trans i18nKey={'teams.signInWithDifferentAccountDescription'} /> </span> </div> </div> diff --git a/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx b/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx index a3c9c9acc..d4bafeef6 100644 --- a/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx +++ b/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'; import { ColumnDef } from '@tanstack/react-table'; import { Ellipsis } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { Database } from '@kit/supabase/database'; import { Badge } from '@kit/ui/badge'; @@ -43,7 +43,7 @@ export function AccountInvitationsTable({ invitations, permissions, }: AccountInvitationsTableProps) { - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const [search, setSearch] = useState(''); const columns = useGetColumns(permissions); @@ -82,7 +82,7 @@ function useGetColumns(permissions: { canRemoveInvitation: boolean; currentUserRoleHierarchy: number; }): ColumnDef<Invitations[0]>[] { - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); return useMemo( () => [ @@ -96,7 +96,7 @@ function useGetColumns(permissions: { return ( <span data-test={'invitation-email'} - className={'flex items-center space-x-4 text-left'} + className={'flex items-center gap-x-2 text-left'} > <span> <ProfileAvatar text={email} /> @@ -172,19 +172,21 @@ function ActionsDropdown({ return ( <> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant={'ghost'} size={'icon'}> - <Ellipsis className={'h-5 w-5'} /> - </Button> - </DropdownMenuTrigger> + <DropdownMenuTrigger + render={ + <Button variant={'ghost'} size={'icon'}> + <Ellipsis className={'h-5 w-5'} /> + </Button> + } + /> - <DropdownMenuContent> + <DropdownMenuContent className="min-w-52"> <If condition={permissions.canUpdateInvitation}> <DropdownMenuItem data-test={'update-invitation-trigger'} onClick={() => setIsUpdatingRole(true)} > - <Trans i18nKey={'teams:updateInvitation'} /> + <Trans i18nKey={'teams.updateInvitation'} /> </DropdownMenuItem> <If condition={getIsInviteExpired(invitation.expires_at)}> @@ -192,7 +194,7 @@ function ActionsDropdown({ data-test={'renew-invitation-trigger'} onClick={() => setIsRenewingInvite(true)} > - <Trans i18nKey={'teams:renewInvitation'} /> + <Trans i18nKey={'teams.renewInvitation'} /> </DropdownMenuItem> </If> </If> @@ -200,9 +202,10 @@ function ActionsDropdown({ <If condition={permissions.canRemoveInvitation}> <DropdownMenuItem data-test={'remove-invitation-trigger'} + variant="destructive" onClick={() => setIsDeletingInvite(true)} > - <Trans i18nKey={'teams:removeInvitation'} /> + <Trans i18nKey={'teams.removeInvitation'} /> </DropdownMenuItem> </If> </DropdownMenuContent> diff --git a/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx index b20e14ea8..2c6f4fc53 100644 --- a/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx +++ b/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx @@ -1,4 +1,6 @@ -import { useState, useTransition } from 'react'; +'use client'; + +import { useAction } from 'next-safe-action/hooks'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -11,6 +13,7 @@ import { AlertDialogTitle, } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; @@ -25,22 +28,35 @@ export function DeleteInvitationDialog({ setIsOpen: (isOpen: boolean) => void; invitationId: number; }) { + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({ + open: isOpen, + onOpenChange: setIsOpen, + }); + return ( - <AlertDialog open={isOpen} onOpenChange={setIsOpen}> + <AlertDialog + open={dialogProps.open} + onOpenChange={dialogProps.onOpenChange} + > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey="team:deleteInvitation" /> + <Trans i18nKey="teams.deleteInvitation" /> </AlertDialogTitle> <AlertDialogDescription> - <Trans i18nKey="team:deleteInvitationDialogDescription" /> + <Trans i18nKey="teams.deleteInvitationDialogDescription" /> </AlertDialogDescription> </AlertDialogHeader> <DeleteInvitationForm - setIsOpen={setIsOpen} invitationId={invitationId} + isPending={isPending} + setIsPending={setIsPending} + onSuccess={() => { + setIsPending(false); + setOpen(false); + }} /> </AlertDialogContent> </AlertDialog> @@ -49,48 +65,45 @@ export function DeleteInvitationDialog({ function DeleteInvitationForm({ invitationId, - setIsOpen, + isPending, + setIsPending, + onSuccess, }: { invitationId: number; - setIsOpen: (isOpen: boolean) => void; + isPending: boolean; + setIsPending: (pending: boolean) => void; + onSuccess: () => void; }) { - const [isSubmitting, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(); - - const onInvitationRemoved = () => { - startTransition(async () => { - try { - await deleteInvitationAction({ invitationId }); - - setIsOpen(false); - } catch { - setError(true); - } - }); - }; + const { execute, hasErrored } = useAction(deleteInvitationAction, { + onExecute: () => setIsPending(true), + onSuccess: () => onSuccess(), + onSettled: () => setIsPending(false), + }); return ( - <form data-test={'delete-invitation-form'} action={onInvitationRemoved}> + <form + data-test={'delete-invitation-form'} + onSubmit={(e) => { + e.preventDefault(); + execute({ invitationId }); + }} + > <div className={'flex flex-col space-y-6'}> <p className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'common:modalConfirmationQuestion'} /> + <Trans i18nKey={'common.modalConfirmationQuestion'} /> </p> - <If condition={error}> + <If condition={hasErrored}> <RemoveInvitationErrorAlert /> </If> <AlertDialogFooter> - <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <AlertDialogCancel disabled={isPending}> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> - <Button - type={'submit'} - variant={'destructive'} - disabled={isSubmitting} - > - <Trans i18nKey={'teams:deleteInvitation'} /> + <Button type={'submit'} variant={'destructive'} disabled={isPending}> + <Trans i18nKey={'teams.deleteInvitation'} /> </Button> </AlertDialogFooter> </div> @@ -102,11 +115,11 @@ function RemoveInvitationErrorAlert() { return ( <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'teams:deleteInvitationErrorTitle'} /> + <Trans i18nKey={'teams.deleteInvitationErrorTitle'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'teams:deleteInvitationErrorMessage'} /> + <Trans i18nKey={'teams.deleteInvitationErrorMessage'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/team-accounts/src/components/invitations/invitation-submit-button.tsx b/packages/features/team-accounts/src/components/invitations/invitation-submit-button.tsx index 17a74c5ec..ed320787f 100644 --- a/packages/features/team-accounts/src/components/invitations/invitation-submit-button.tsx +++ b/packages/features/team-accounts/src/components/invitations/invitation-submit-button.tsx @@ -14,7 +14,7 @@ export function InvitationSubmitButton(props: { return ( <Button type={'submit'} className={'w-full'} disabled={pending}> <Trans - i18nKey={pending ? 'teams:joiningTeam' : 'teams:continueAs'} + i18nKey={pending ? 'teams.joiningTeam' : 'teams.continueAs'} values={{ accountName: props.accountName, email: props.email, diff --git a/packages/features/team-accounts/src/components/invitations/renew-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/renew-invitation-dialog.tsx index f94d98257..778880339 100644 --- a/packages/features/team-accounts/src/components/invitations/renew-invitation-dialog.tsx +++ b/packages/features/team-accounts/src/components/invitations/renew-invitation-dialog.tsx @@ -1,4 +1,6 @@ -import { useState, useTransition } from 'react'; +'use client'; + +import { useAction } from 'next-safe-action/hooks'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -11,6 +13,7 @@ import { AlertDialogTitle, } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; @@ -27,25 +30,38 @@ export function RenewInvitationDialog({ invitationId: number; email: string; }) { + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({ + open: isOpen, + onOpenChange: setIsOpen, + }); + return ( - <AlertDialog open={isOpen} onOpenChange={setIsOpen}> + <AlertDialog + open={dialogProps.open} + onOpenChange={dialogProps.onOpenChange} + > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey="team:renewInvitation" /> + <Trans i18nKey="team.renewInvitation" /> </AlertDialogTitle> <AlertDialogDescription> <Trans - i18nKey="team:renewInvitationDialogDescription" + i18nKey="team.renewInvitationDialogDescription" values={{ email }} /> </AlertDialogDescription> </AlertDialogHeader> <RenewInvitationForm - setIsOpen={setIsOpen} invitationId={invitationId} + isPending={isPending} + setIsPending={setIsPending} + onSuccess={() => { + setIsPending(false); + setOpen(false); + }} /> </AlertDialogContent> </AlertDialog> @@ -54,47 +70,48 @@ export function RenewInvitationDialog({ function RenewInvitationForm({ invitationId, - setIsOpen, + isPending, + setIsPending, + onSuccess, }: { invitationId: number; - setIsOpen: (isOpen: boolean) => void; + isPending: boolean; + setIsPending: (pending: boolean) => void; + onSuccess: () => void; }) { - const [isSubmitting, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(); - - const inInvitationRenewed = () => { - startTransition(async () => { - try { - await renewInvitationAction({ invitationId }); - - setIsOpen(false); - } catch { - setError(true); - } - }); - }; + const { execute, hasErrored } = useAction(renewInvitationAction, { + onExecute: () => setIsPending(true), + onSuccess: () => onSuccess(), + onSettled: () => setIsPending(false), + }); return ( - <form action={inInvitationRenewed}> + <form + onSubmit={(e) => { + e.preventDefault(); + execute({ invitationId }); + }} + > <div className={'flex flex-col space-y-6'}> <p className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'common:modalConfirmationQuestion'} /> + <Trans i18nKey={'common.modalConfirmationQuestion'} /> </p> - <If condition={error}> + <If condition={hasErrored}> <RenewInvitationErrorAlert /> </If> <AlertDialogFooter> - <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <AlertDialogCancel disabled={isPending}> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> <Button + type={'submit'} data-test={'confirm-renew-invitation'} - disabled={isSubmitting} + disabled={isPending} > - <Trans i18nKey={'teams:renewInvitation'} /> + <Trans i18nKey={'teams.renewInvitation'} /> </Button> </AlertDialogFooter> </div> @@ -106,11 +123,11 @@ function RenewInvitationErrorAlert() { return ( <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'teams:renewInvitationErrorTitle'} /> + <Trans i18nKey={'teams.renewInvitationErrorTitle'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'teams:renewInvitationErrorDescription'} /> + <Trans i18nKey={'teams.renewInvitationErrorDescription'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/team-accounts/src/components/invitations/sign-out-invitation-button.tsx b/packages/features/team-accounts/src/components/invitations/sign-out-invitation-button.tsx index 3cfb166a0..1bd06d53c 100644 --- a/packages/features/team-accounts/src/components/invitations/sign-out-invitation-button.tsx +++ b/packages/features/team-accounts/src/components/invitations/sign-out-invitation-button.tsx @@ -24,7 +24,7 @@ export function SignOutInvitationButton( window.location.assign(safePath); }} > - <Trans i18nKey={'teams:signInWithDifferentAccount'} /> + <Trans i18nKey={'teams.signInWithDifferentAccount'} /> </Button> ); } diff --git a/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx index 31e8dc11d..bd06576e6 100644 --- a/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx +++ b/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx @@ -1,8 +1,9 @@ -import { useState, useTransition } from 'react'; +'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Button } from '@kit/ui/button'; @@ -22,6 +23,7 @@ import { FormLabel, FormMessage, } from '@kit/ui/form'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; @@ -45,16 +47,21 @@ export function UpdateInvitationDialog({ userRole: Role; userRoleHierarchy: number; }) { + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({ + open: isOpen, + onOpenChange: setIsOpen, + }); + return ( - <Dialog open={isOpen} onOpenChange={setIsOpen}> - <DialogContent> + <Dialog {...dialogProps}> + <DialogContent showCloseButton={!isPending}> <DialogHeader> <DialogTitle> - <Trans i18nKey={'teams:updateMemberRoleModalHeading'} /> + <Trans i18nKey={'teams.updateMemberRoleModalHeading'} /> </DialogTitle> <DialogDescription> - <Trans i18nKey={'teams:updateMemberRoleModalDescription'} /> + <Trans i18nKey={'teams.updateMemberRoleModalDescription'} /> </DialogDescription> </DialogHeader> @@ -62,7 +69,12 @@ export function UpdateInvitationDialog({ invitationId={invitationId} userRole={userRole} userRoleHierarchy={userRoleHierarchy} - setIsOpen={setIsOpen} + isPending={isPending} + setIsPending={setIsPending} + onSuccess={() => { + setIsPending(false); + setOpen(false); + }} /> </DialogContent> </Dialog> @@ -73,31 +85,24 @@ function UpdateInvitationForm({ invitationId, userRole, userRoleHierarchy, - setIsOpen, + isPending, + setIsPending, + onSuccess, }: React.PropsWithChildren<{ invitationId: number; userRole: Role; userRoleHierarchy: number; - setIsOpen: (isOpen: boolean) => void; + isPending: boolean; + setIsPending: (pending: boolean) => void; + onSuccess: () => void; }>) { - const { t } = useTranslation('teams'); - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(); + const t = useTranslations('teams'); - const onSubmit = ({ role }: { role: Role }) => { - startTransition(async () => { - try { - await updateInvitationAction({ - invitationId, - role, - }); - - setIsOpen(false); - } catch { - setError(true); - } - }); - }; + const { execute, hasErrored } = useAction(updateInvitationAction, { + onExecute: () => setIsPending(true), + onSuccess: () => onSuccess(), + onSettled: () => setIsPending(false), + }); const form = useForm({ resolver: zodResolver( @@ -122,10 +127,12 @@ function UpdateInvitationForm({ <Form {...form}> <form data-test={'update-invitation-form'} - onSubmit={form.handleSubmit(onSubmit)} + onSubmit={form.handleSubmit(({ role }) => { + execute({ invitationId, role }); + })} className={'flex flex-col space-y-6'} > - <If condition={error}> + <If condition={hasErrored}> <UpdateRoleErrorAlert /> </If> @@ -135,7 +142,7 @@ function UpdateInvitationForm({ return ( <FormItem> <FormLabel> - <Trans i18nKey={'teams:roleLabel'} /> + <Trans i18nKey={'teams.roleLabel'} /> </FormLabel> <FormControl> @@ -145,16 +152,18 @@ function UpdateInvitationForm({ roles={roles} currentUserRole={userRole} value={field.value} - onChange={(newRole) => - form.setValue(field.name, newRole) - } + onChange={(newRole) => { + if (newRole) { + form.setValue(field.name, newRole); + } + }} /> )} </RolesDataProvider> </FormControl> <FormDescription> - <Trans i18nKey={'teams:updateRoleDescription'} /> + <Trans i18nKey={'teams.updateRoleDescription'} /> </FormDescription> <FormMessage /> @@ -163,8 +172,8 @@ function UpdateInvitationForm({ }} /> - <Button type={'submit'} disabled={pending}> - <Trans i18nKey={'teams:updateRoleSubmitLabel'} /> + <Button type={'submit'} disabled={isPending}> + <Trans i18nKey={'teams.updateRoleSubmitLabel'} /> </Button> </form> </Form> @@ -175,11 +184,11 @@ function UpdateRoleErrorAlert() { return ( <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'teams:updateRoleErrorHeading'} /> + <Trans i18nKey={'teams.updateRoleErrorHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'teams:updateRoleErrorMessage'} /> + <Trans i18nKey={'teams.updateRoleErrorMessage'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/team-accounts/src/components/members/account-members-table.tsx b/packages/features/team-accounts/src/components/members/account-members-table.tsx index 1fdd2f074..6db32a6dc 100644 --- a/packages/features/team-accounts/src/components/members/account-members-table.tsx +++ b/packages/features/team-accounts/src/components/members/account-members-table.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'; import { ColumnDef } from '@tanstack/react-table'; import { Ellipsis } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { Database } from '@kit/supabase/database'; import { Badge } from '@kit/ui/badge'; @@ -53,7 +53,7 @@ export function AccountMembersTable({ canManageRoles, }: AccountMembersTableProps) { const [search, setSearch] = useState(''); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const permissions = { canUpdateRole: (targetRole: number) => { @@ -123,7 +123,7 @@ function useGetColumns( currentRoleHierarchy: number; }, ): ColumnDef<Members[0]>[] { - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); return useMemo( () => [ @@ -136,7 +136,7 @@ function useGetColumns( const isSelf = member.user_id === params.currentUserId; return ( - <span className={'flex items-center space-x-4 text-left'}> + <span className={'flex items-center gap-x-2 text-left'}> <span> <ProfileAvatar displayName={displayName} @@ -144,11 +144,13 @@ function useGetColumns( /> </span> - <span>{displayName}</span> + <span className={'flex items-center gap-x-2'}> + <span>{displayName}</span> - <If condition={isSelf}> - <Badge variant={'outline'}>{t('youLabel')}</Badge> - </If> + <If condition={isSelf}> + <Badge variant={'secondary'}>{t('youLabel')}</Badge> + </If> + </span> </span> ); }, @@ -171,13 +173,7 @@ function useGetColumns( <RoleBadge role={role} /> <If condition={isPrimaryOwner}> - <span - className={ - 'rounded-md bg-yellow-400 px-2.5 py-1 text-xs font-medium dark:text-black' - } - > - {t('primaryOwnerLabel')} - </span> + <Badge variant={'warning'}>{t('primaryOwnerLabel')}</Badge> </If> </span> ); @@ -223,6 +219,10 @@ function ActionsDropdown({ const isCurrentUser = member.user_id === currentUserId; const isPrimaryOwner = member.primary_owner_user_id === member.user_id; + const [activeDialog, setActiveDialog] = useState< + 'updateRole' | 'transferOwnership' | 'removeMember' | null + >(null); + if (isCurrentUser || isPrimaryOwner) { return null; } @@ -246,50 +246,70 @@ function ActionsDropdown({ return ( <> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant={'ghost'} size={'icon'}> - <Ellipsis className={'h-5 w-5'} /> - </Button> - </DropdownMenuTrigger> + <DropdownMenuTrigger + render={ + <Button variant={'ghost'} size={'icon'}> + <Ellipsis className={'h-5 w-5'} /> + </Button> + } + /> - <DropdownMenuContent> + <DropdownMenuContent className={'min-w-52'}> <If condition={canUpdateRole}> - <UpdateMemberRoleDialog - userId={member.user_id} - userRole={member.role} - teamAccountId={currentTeamAccountId} - userRoleHierarchy={currentRoleHierarchy} - > - <DropdownMenuItem onSelect={(e) => e.preventDefault()}> - <Trans i18nKey={'teams:updateRole'} /> - </DropdownMenuItem> - </UpdateMemberRoleDialog> + <DropdownMenuItem onClick={() => setActiveDialog('updateRole')}> + <Trans i18nKey={'teams.updateRole'} /> + </DropdownMenuItem> </If> <If condition={permissions.canTransferOwnership}> - <TransferOwnershipDialog - targetDisplayName={member.name ?? member.email} - accountId={member.account_id} - userId={member.user_id} + <DropdownMenuItem + variant="destructive" + onClick={() => setActiveDialog('transferOwnership')} > - <DropdownMenuItem onSelect={(e) => e.preventDefault()}> - <Trans i18nKey={'teams:transferOwnership'} /> - </DropdownMenuItem> - </TransferOwnershipDialog> + <Trans i18nKey={'teams.transferOwnership'} /> + </DropdownMenuItem> </If> <If condition={canRemoveFromAccount}> - <RemoveMemberDialog - teamAccountId={currentTeamAccountId} - userId={member.user_id} + <DropdownMenuItem + variant="destructive" + onClick={() => setActiveDialog('removeMember')} > - <DropdownMenuItem onSelect={(e) => e.preventDefault()}> - <Trans i18nKey={'teams:removeMember'} /> - </DropdownMenuItem> - </RemoveMemberDialog> + <Trans i18nKey={'teams.removeMember'} /> + </DropdownMenuItem> </If> </DropdownMenuContent> </DropdownMenu> + + {activeDialog === 'updateRole' && ( + <UpdateMemberRoleDialog + open + onOpenChange={(open) => !open && setActiveDialog(null)} + userId={member.user_id} + userRole={member.role} + teamAccountId={currentTeamAccountId} + userRoleHierarchy={currentRoleHierarchy} + /> + )} + + {activeDialog === 'transferOwnership' && ( + <TransferOwnershipDialog + open + onOpenChange={(open) => !open && setActiveDialog(null)} + targetDisplayName={member.name ?? member.email} + accountId={member.account_id} + userId={member.user_id} + /> + )} + + {activeDialog === 'removeMember' && ( + <RemoveMemberDialog + open + onOpenChange={(open) => !open && setActiveDialog(null)} + teamAccountId={currentTeamAccountId} + userId={member.user_id} + /> + )} </> ); } diff --git a/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx b/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx index 1b960ee47..1d1f56ce4 100644 --- a/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx +++ b/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery } from '@tanstack/react-query'; import { Mail, Plus, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useAction } from 'next-safe-action/hooks'; import { useFieldArray, useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription } from '@kit/ui/alert'; import { Button } from '@kit/ui/button'; @@ -25,6 +24,7 @@ import { FormItem, FormMessage, } from '@kit/ui/form'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { InputGroup, @@ -64,29 +64,46 @@ export function InviteMembersDialogContainer({ accountSlug: string; userRoleHierarchy: number; }>) { - const [pending, startTransition] = useTransition(); - const [isOpen, setIsOpen] = useState(false); - const { t } = useTranslation('teams'); + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog(); + const t = useTranslations('teams'); + + const { execute } = useAction(createInvitationsAction, { + onExecute: () => setIsPending(true), + onSuccess: ({ data }) => { + if (data?.success) { + toast.success(t('inviteMembersSuccessMessage')); + } else { + toast.error(t('inviteMembersErrorMessage')); + } + }, + onError: () => { + toast.error(t('inviteMembersErrorMessage')); + }, + onSettled: () => { + setIsPending(false); + setOpen(false); + }, + }); // Evaluate policies when dialog is open const { data: policiesResult, isLoading: isLoadingPolicies, error: policiesError, - } = useFetchInvitationsPolicies({ accountSlug, isOpen }); + } = useFetchInvitationsPolicies({ accountSlug, isOpen: dialogProps.open }); return ( - <Dialog open={isOpen} onOpenChange={setIsOpen} modal> - <DialogTrigger asChild>{children}</DialogTrigger> + <Dialog {...dialogProps}> + <DialogTrigger render={children as React.ReactElement} /> - <DialogContent onInteractOutside={(e) => e.preventDefault()}> + <DialogContent showCloseButton={!isPending}> <DialogHeader> <DialogTitle> - <Trans i18nKey={'teams:inviteMembersHeading'} /> + <Trans i18nKey={'teams.inviteMembersHeading'} /> </DialogTitle> <DialogDescription> - <Trans i18nKey={'teams:inviteMembersDescription'} /> + <Trans i18nKey={'teams.inviteMembersDescription'} /> </DialogDescription> </DialogHeader> @@ -95,7 +112,7 @@ export function InviteMembersDialogContainer({ <Spinner className="h-6 w-6" /> <span className="text-muted-foreground text-sm"> - <Trans i18nKey="teams:checkingPolicies" /> + <Trans i18nKey="teams.checkingPolicies" /> </span> </div> </If> @@ -104,7 +121,7 @@ export function InviteMembersDialogContainer({ <Alert variant="destructive"> <AlertDescription> <Trans - i18nKey="teams:policyCheckError" + i18nKey="teams.policyCheckError" values={{ error: policiesError?.message }} /> </AlertDescription> @@ -126,28 +143,12 @@ export function InviteMembersDialogContainer({ <RolesDataProvider maxRoleHierarchy={userRoleHierarchy}> {(roles) => ( <InviteMembersForm - pending={pending} + pending={isPending} roles={roles} onSubmit={(data) => { - startTransition(async () => { - const toastId = toast.loading(t('invitingMembers')); - - const result = await createInvitationsAction({ - accountSlug, - invitations: data.invitations, - }); - - if (result.success) { - toast.success(t('inviteMembersSuccessMessage'), { - id: toastId, - }); - } else { - toast.error(t('inviteMembersErrorMessage'), { - id: toastId, - }); - } - - setIsOpen(false); + execute({ + accountSlug, + invitations: data.invitations, }); }} /> @@ -168,7 +169,7 @@ function InviteMembersForm({ pending: boolean; roles: string[]; }) { - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const form = useForm({ resolver: zodResolver(InviteMembersSchema), @@ -237,7 +238,9 @@ function InviteMembersForm({ roles={roles} value={field.value} onChange={(role) => { - form.setValue(field.name, role); + if (role) { + form.setValue(field.name, role); + } }} /> </FormControl> @@ -251,22 +254,24 @@ function InviteMembersForm({ <div className={'flex items-end justify-end'}> <TooltipProvider> <Tooltip> - <TooltipTrigger asChild> - <Button - variant={'ghost'} - size={'icon'} - type={'button'} - disabled={fieldArray.fields.length <= 1} - data-test={'remove-invite-button'} - aria-label={t('removeInviteButtonLabel')} - onClick={() => { - fieldArray.remove(index); - form.clearErrors(emailInputName); - }} - > - <X className={'h-4'} /> - </Button> - </TooltipTrigger> + <TooltipTrigger + render={ + <Button + variant={'ghost'} + size={'icon'} + type={'button'} + disabled={fieldArray.fields.length <= 1} + data-test={'remove-invite-button'} + aria-label={t('removeInviteButtonLabel')} + onClick={() => { + fieldArray.remove(index); + form.clearErrors(emailInputName); + }} + > + <X className={'h-4'} /> + </Button> + } + /> <TooltipContent> {t('removeInviteButtonLabel')} @@ -294,7 +299,7 @@ function InviteMembersForm({ <Plus className={'mr-1 h-3'} /> <span> - <Trans i18nKey={'teams:addAnotherMemberButtonLabel'} /> + <Trans i18nKey={'teams.addAnotherMemberButtonLabel'} /> </span> </Button> </div> @@ -305,8 +310,8 @@ function InviteMembersForm({ <Trans i18nKey={ pending - ? 'teams:invitingMembers' - : 'teams:inviteMembersButtonLabel' + ? 'teams.invitingMembers' + : 'teams.inviteMembersButtonLabel' } /> </Button> diff --git a/packages/features/team-accounts/src/components/members/membership-role-selector.tsx b/packages/features/team-accounts/src/components/members/membership-role-selector.tsx index cec8e1b97..b44ab16e2 100644 --- a/packages/features/team-accounts/src/components/members/membership-role-selector.tsx +++ b/packages/features/team-accounts/src/components/members/membership-role-selector.tsx @@ -19,7 +19,7 @@ export function MembershipRoleSelector({ roles: Role[]; value: Role; currentUserRole?: Role; - onChange: (role: Role) => unknown; + onChange: (role: Role | null) => unknown; triggerClassName?: string; }) { return ( @@ -28,7 +28,15 @@ export function MembershipRoleSelector({ className={triggerClassName} data-test={'role-selector-trigger'} > - <SelectValue /> + <SelectValue> + {(value) => + value ? ( + <Trans i18nKey={`common.roles.${value}.label`} defaults={value} /> + ) : ( + '' + ) + } + </SelectValue> </SelectTrigger> <SelectContent> @@ -41,7 +49,7 @@ export function MembershipRoleSelector({ value={role} > <span className={'text-sm capitalize'}> - <Trans i18nKey={`common:roles.${role}.label`} defaults={role} /> + <Trans i18nKey={`common.roles.${role}.label`} defaults={role} /> </span> </SelectItem> ); diff --git a/packages/features/team-accounts/src/components/members/remove-member-dialog.tsx b/packages/features/team-accounts/src/components/members/remove-member-dialog.tsx index bce8d2c16..93eeb2e91 100644 --- a/packages/features/team-accounts/src/components/members/remove-member-dialog.tsx +++ b/packages/features/team-accounts/src/components/members/remove-member-dialog.tsx @@ -1,4 +1,6 @@ -import { useState, useTransition } from 'react'; +'use client'; + +import { useAction } from 'next-safe-action/hooks'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -9,38 +11,56 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions'; export function RemoveMemberDialog({ + open, + onOpenChange, teamAccountId, userId, - children, -}: React.PropsWithChildren<{ +}: { + open: boolean; + onOpenChange: (open: boolean) => void; teamAccountId: string; userId: string; -}>) { - return ( - <AlertDialog> - <AlertDialogTrigger asChild>{children}</AlertDialogTrigger> +}) { + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({ + open, + onOpenChange, + }); + return ( + <AlertDialog + open={dialogProps.open} + onOpenChange={dialogProps.onOpenChange} + > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey="teamS:removeMemberModalHeading" /> + <Trans i18nKey="teams.removeMemberModalHeading" /> </AlertDialogTitle> <AlertDialogDescription> - <Trans i18nKey={'teams:removeMemberModalDescription'} /> + <Trans i18nKey={'teams.removeMemberModalDescription'} /> </AlertDialogDescription> </AlertDialogHeader> - <RemoveMemberForm accountId={teamAccountId} userId={userId} /> + <RemoveMemberForm + accountId={teamAccountId} + userId={userId} + isPending={isPending} + setIsPending={setIsPending} + onSuccess={() => { + setIsPending(false); + setOpen(false); + }} + /> </AlertDialogContent> </AlertDialog> ); @@ -49,45 +69,50 @@ export function RemoveMemberDialog({ function RemoveMemberForm({ accountId, userId, + isPending, + setIsPending, + onSuccess, }: { accountId: string; userId: string; + isPending: boolean; + setIsPending: (pending: boolean) => void; + onSuccess: () => void; }) { - const [isSubmitting, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(); - - const onMemberRemoved = () => { - startTransition(async () => { - try { - await removeMemberFromAccountAction({ accountId, userId }); - } catch { - setError(true); - } - }); - }; + const { execute, hasErrored } = useAction(removeMemberFromAccountAction, { + onExecute: () => setIsPending(true), + onSuccess: () => onSuccess(), + onSettled: () => setIsPending(false), + }); return ( - <form action={onMemberRemoved}> + <form + onSubmit={(e) => { + e.preventDefault(); + execute({ accountId, userId }); + }} + > <div className={'flex flex-col space-y-6'}> <p className={'text-muted-foreground text-sm'}> - <Trans i18nKey={'common:modalConfirmationQuestion'} /> + <Trans i18nKey={'common.modalConfirmationQuestion'} /> </p> - <If condition={error}> + <If condition={hasErrored}> <RemoveMemberErrorAlert /> </If> <AlertDialogFooter> - <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <AlertDialogCancel disabled={isPending}> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> <Button + type={'submit'} data-test={'confirm-remove-member'} variant={'destructive'} - disabled={isSubmitting} + disabled={isPending} > - <Trans i18nKey={'teams:removeMemberSubmitLabel'} /> + <Trans i18nKey={'teams.removeMemberSubmitLabel'} /> </Button> </AlertDialogFooter> </div> @@ -99,11 +124,11 @@ function RemoveMemberErrorAlert() { return ( <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'teams:removeMemberErrorHeading'} /> + <Trans i18nKey={'teams.removeMemberErrorHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'teams:removeMemberErrorMessage'} /> + <Trans i18nKey={'teams.removeMemberErrorMessage'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/team-accounts/src/components/members/role-badge.tsx b/packages/features/team-accounts/src/components/members/role-badge.tsx index 9a480bf1b..4b309d0ff 100644 --- a/packages/features/team-accounts/src/components/members/role-badge.tsx +++ b/packages/features/team-accounts/src/components/members/role-badge.tsx @@ -25,7 +25,7 @@ export function RoleBadge({ role }: { role: Role }) { return ( <Badge className={className} variant={isCustom ? 'outline' : 'default'}> <span data-test={'member-role-badge'}> - <Trans i18nKey={`common:roles.${role}.label`} defaults={role} /> + <Trans i18nKey={`common.roles.${role}.label`} defaults={role} /> </span> </Badge> ); diff --git a/packages/features/team-accounts/src/components/members/transfer-ownership-dialog.tsx b/packages/features/team-accounts/src/components/members/transfer-ownership-dialog.tsx index 25198cded..4534a726b 100644 --- a/packages/features/team-accounts/src/components/members/transfer-ownership-dialog.tsx +++ b/packages/features/team-accounts/src/components/members/transfer-ownership-dialog.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm, useWatch } from 'react-hook-form'; import { VerifyOtpForm } from '@kit/otp/components'; @@ -16,10 +15,10 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; import { Form } from '@kit/ui/form'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; @@ -27,30 +26,36 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner import { transferOwnershipAction } from '../../server/actions/team-members-server-actions'; export function TransferOwnershipDialog({ - children, + open, + onOpenChange, targetDisplayName, accountId, userId, }: { - children: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; accountId: string; userId: string; targetDisplayName: string; }) { - const [open, setOpen] = useState(false); + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({ + open, + onOpenChange, + }); return ( - <AlertDialog open={open} onOpenChange={setOpen}> - <AlertDialogTrigger asChild>{children}</AlertDialogTrigger> - + <AlertDialog + open={dialogProps.open} + onOpenChange={dialogProps.onOpenChange} + > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey="team:transferOwnership" /> + <Trans i18nKey="teams.transferOwnership" /> </AlertDialogTitle> <AlertDialogDescription> - <Trans i18nKey="team:transferOwnershipDescription" /> + <Trans i18nKey="teams.transferOwnershipDescription" /> </AlertDialogDescription> </AlertDialogHeader> @@ -58,7 +63,12 @@ export function TransferOwnershipDialog({ accountId={accountId} userId={userId} targetDisplayName={targetDisplayName} - onSuccess={() => setOpen(false)} + isPending={isPending} + setIsPending={setIsPending} + onSuccess={() => { + setIsPending(false); + setOpen(false); + }} /> </AlertDialogContent> </AlertDialog> @@ -69,17 +79,25 @@ function TransferOrganizationOwnershipForm({ accountId, userId, targetDisplayName, + isPending, + setIsPending, onSuccess, }: { userId: string; accountId: string; targetDisplayName: string; + isPending: boolean; + setIsPending: (pending: boolean) => void; onSuccess: () => unknown; }) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(); const { data: user } = useUser(); + const { execute, hasErrored } = useAction(transferOwnershipAction, { + onExecute: () => setIsPending(true), + onSuccess: () => onSuccess(), + onSettled: () => setIsPending(false), + }); + const form = useForm({ resolver: zodResolver(TransferOwnershipConfirmationSchema), defaultValues: { @@ -102,8 +120,8 @@ function TransferOrganizationOwnershipForm({ form.setValue('otp', otpValue, { shouldValidate: true }); }} CancelButton={ - <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <AlertDialogCancel disabled={isPending}> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> } data-test="verify-otp-form" @@ -117,25 +135,17 @@ function TransferOrganizationOwnershipForm({ <form className={'flex flex-col space-y-4 text-sm'} onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - try { - await transferOwnershipAction(data); - - onSuccess(); - } catch { - setError(true); - } - }); + execute(data); })} > - <If condition={error}> + <If condition={hasErrored}> <TransferOwnershipErrorAlert /> </If> <div className="border-destructive rounded-md border p-4"> <p className="text-destructive text-sm"> <Trans - i18nKey={'teams:transferOwnershipDisclaimer'} + i18nKey={'teams.transferOwnershipDisclaimer'} values={{ member: targetDisplayName, }} @@ -148,26 +158,26 @@ function TransferOrganizationOwnershipForm({ <div> <p className={'text-muted-foreground'}> - <Trans i18nKey={'common:modalConfirmationQuestion'} /> + <Trans i18nKey={'common.modalConfirmationQuestion'} /> </p> </div> <AlertDialogFooter> - <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <AlertDialogCancel disabled={isPending}> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> <Button type={'submit'} data-test={'confirm-transfer-ownership-button'} variant={'destructive'} - disabled={pending} + disabled={isPending} > <If - condition={pending} - fallback={<Trans i18nKey={'teams:transferOwnership'} />} + condition={isPending} + fallback={<Trans i18nKey={'teams.transferOwnership'} />} > - <Trans i18nKey={'teams:transferringOwnership'} /> + <Trans i18nKey={'teams.transferringOwnership'} /> </If> </Button> </AlertDialogFooter> @@ -180,11 +190,11 @@ function TransferOwnershipErrorAlert() { return ( <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'teams:transferTeamErrorHeading'} /> + <Trans i18nKey={'teams.transferTeamErrorHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'teams:transferTeamErrorMessage'} /> + <Trans i18nKey={'teams.transferTeamErrorMessage'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx b/packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx index 9313d358a..9fd64f58e 100644 --- a/packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx +++ b/packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx @@ -1,10 +1,12 @@ -import { useState, useTransition } from 'react'; +'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; +import { AlertDialogCancel } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; import { Dialog, @@ -12,7 +14,6 @@ import { DialogDescription, DialogHeader, DialogTitle, - DialogTrigger, } from '@kit/ui/dialog'; import { Form, @@ -23,6 +24,7 @@ import { FormLabel, FormMessage, } from '@kit/ui/form'; +import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; @@ -34,31 +36,35 @@ import { RolesDataProvider } from './roles-data-provider'; type Role = string; export function UpdateMemberRoleDialog({ - children, + open, + onOpenChange, userId, teamAccountId, userRole, userRoleHierarchy, -}: React.PropsWithChildren<{ +}: { + open: boolean; + onOpenChange: (open: boolean) => void; userId: string; teamAccountId: string; userRole: Role; userRoleHierarchy: number; -}>) { - const [open, setOpen] = useState(false); +}) { + const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({ + open, + onOpenChange, + }); return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild>{children}</DialogTrigger> - - <DialogContent> + <Dialog {...dialogProps}> + <DialogContent showCloseButton={!isPending}> <DialogHeader> <DialogTitle> - <Trans i18nKey={'teams:updateMemberRoleModalHeading'} /> + <Trans i18nKey={'teams.updateMemberRoleModalHeading'} /> </DialogTitle> <DialogDescription> - <Trans i18nKey={'teams:updateMemberRoleModalDescription'} /> + <Trans i18nKey={'teams.updateMemberRoleModalDescription'} /> </DialogDescription> </DialogHeader> @@ -69,7 +75,12 @@ export function UpdateMemberRoleDialog({ teamAccountId={teamAccountId} userRole={userRole} roles={data} - onSuccess={() => setOpen(false)} + isPending={isPending} + setIsPending={setIsPending} + onSuccess={() => { + setIsPending(false); + setOpen(false); + }} /> )} </RolesDataProvider> @@ -83,33 +94,25 @@ function UpdateMemberForm({ userRole, teamAccountId, roles, + isPending, + setIsPending, onSuccess, }: React.PropsWithChildren<{ userId: string; userRole: Role; teamAccountId: string; roles: Role[]; + isPending: boolean; + setIsPending: (pending: boolean) => void; onSuccess: () => unknown; }>) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<boolean>(); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); - const onSubmit = ({ role }: { role: Role }) => { - startTransition(async () => { - try { - await updateMemberRoleAction({ - accountId: teamAccountId, - userId, - role, - }); - - onSuccess(); - } catch { - setError(true); - } - }); - }; + const { execute, hasErrored } = useAction(updateMemberRoleAction, { + onExecute: () => setIsPending(true), + onSuccess: () => onSuccess(), + onSettled: () => setIsPending(false), + }); const form = useForm({ resolver: zodResolver( @@ -134,10 +137,16 @@ function UpdateMemberForm({ <Form {...form}> <form data-test={'update-member-role-form'} - onSubmit={form.handleSubmit(onSubmit)} - className={'flex flex-col space-y-6'} + onSubmit={form.handleSubmit(({ role }) => { + execute({ + accountId: teamAccountId, + userId, + role, + }); + })} + className={'flex w-full flex-col space-y-6'} > - <If condition={error}> + <If condition={hasErrored}> <UpdateRoleErrorAlert /> </If> @@ -150,10 +159,15 @@ function UpdateMemberForm({ <FormControl> <MembershipRoleSelector + triggerClassName={'w-full'} roles={roles} currentUserRole={userRole} value={field.value} - onChange={(newRole) => form.setValue('role', newRole)} + onChange={(newRole) => { + if (newRole) { + form.setValue('role', newRole); + } + }} /> </FormControl> @@ -165,9 +179,19 @@ function UpdateMemberForm({ }} /> - <Button data-test={'confirm-update-member-role'} disabled={pending}> - <Trans i18nKey={'teams:updateRoleSubmitLabel'} /> - </Button> + <div className="flex justify-end gap-x-2"> + <AlertDialogCancel disabled={isPending}> + <Trans i18nKey={'common.cancel'} /> + </AlertDialogCancel> + + <Button + type="submit" + data-test={'confirm-update-member-role'} + disabled={isPending} + > + <Trans i18nKey={'teams.updateRoleSubmitLabel'} /> + </Button> + </div> </form> </Form> ); @@ -177,11 +201,11 @@ function UpdateRoleErrorAlert() { return ( <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'teams:updateRoleErrorHeading'} /> + <Trans i18nKey={'teams.updateRoleErrorHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'teams:updateRoleErrorMessage'} /> + <Trans i18nKey={'teams.updateRoleErrorMessage'} /> </AlertDescription> </Alert> ); diff --git a/packages/features/team-accounts/src/components/settings/team-account-danger-zone.tsx b/packages/features/team-accounts/src/components/settings/team-account-danger-zone.tsx index 1fcfb5ca2..560e274ab 100644 --- a/packages/features/team-accounts/src/components/settings/team-account-danger-zone.tsx +++ b/packages/features/team-accounts/src/components/settings/team-account-danger-zone.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useFormStatus } from 'react-dom'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { ErrorBoundary } from '@kit/monitoring/components'; import { VerifyOtpForm } from '@kit/otp/components'; @@ -100,12 +99,12 @@ function DeleteTeamContainer(props: { <div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-1'}> <span className={'text-sm font-medium'}> - <Trans i18nKey={'teams:deleteTeam'} /> + <Trans i18nKey={'teams.deleteTeam'} /> </span> <p className={'text-muted-foreground text-sm'}> <Trans - i18nKey={'teams:deleteTeamDescription'} + i18nKey={'teams.deleteTeamDescription'} values={{ teamName: props.account.name, }} @@ -115,25 +114,27 @@ function DeleteTeamContainer(props: { <div> <AlertDialog> - <AlertDialogTrigger asChild> - <Button - data-test={'delete-team-trigger'} - type={'button'} - variant={'destructive'} - > - <Trans i18nKey={'teams:deleteTeam'} /> - </Button> - </AlertDialogTrigger> + <AlertDialogTrigger + render={ + <Button + data-test={'delete-team-trigger'} + type={'button'} + variant={'destructive'} + > + <Trans i18nKey={'teams.deleteTeam'} /> + </Button> + } + /> - <AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}> + <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey={'teams:deletingTeam'} /> + <Trans i18nKey={'teams.deletingTeam'} /> </AlertDialogTitle> <AlertDialogDescription> <Trans - i18nKey={'teams:deletingTeamDescription'} + i18nKey={'teams.deletingTeamDescription'} values={{ teamName: props.account.name, }} @@ -161,6 +162,8 @@ function DeleteTeamConfirmationForm({ }) { const { data: user } = useUser(); + const { execute, isPending } = useAction(deleteTeamAccountAction); + const form = useForm({ mode: 'onChange', reValidateMode: 'onChange', @@ -188,7 +191,7 @@ function DeleteTeamConfirmationForm({ onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })} CancelButton={ <AlertDialogCancel className={'m-0'}> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> } /> @@ -201,7 +204,10 @@ function DeleteTeamConfirmationForm({ <form data-test={'delete-team-form'} className={'flex flex-col space-y-4'} - action={deleteTeamAccountAction} + onSubmit={(e) => { + e.preventDefault(); + execute({ accountId: id, otp }); + }} > <div className={'flex flex-col space-y-2'}> <div @@ -211,7 +217,7 @@ function DeleteTeamConfirmationForm({ > <div> <Trans - i18nKey={'teams:deleteTeamDisclaimer'} + i18nKey={'teams.deleteTeamDisclaimer'} values={{ teamName: name, }} @@ -219,20 +225,24 @@ function DeleteTeamConfirmationForm({ </div> <div className={'text-sm'}> - <Trans i18nKey={'common:modalConfirmationQuestion'} /> + <Trans i18nKey={'common.modalConfirmationQuestion'} /> </div> </div> - - <input type="hidden" value={id} name={'accountId'} /> - <input type="hidden" value={otp} name={'otp'} /> </div> <AlertDialogFooter> <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> - <DeleteTeamSubmitButton /> + <Button + type="submit" + data-test={'delete-team-form-confirm-button'} + disabled={isPending} + variant={'destructive'} + > + <Trans i18nKey={'teams.deleteTeam'} /> + </Button> </AlertDialogFooter> </form> </Form> @@ -240,26 +250,14 @@ function DeleteTeamConfirmationForm({ ); } -function DeleteTeamSubmitButton() { - const { pending } = useFormStatus(); - - return ( - <Button - data-test={'delete-team-form-confirm-button'} - disabled={pending} - variant={'destructive'} - > - <Trans i18nKey={'teams:deleteTeam'} /> - </Button> - ); -} - function LeaveTeamContainer(props: { account: { name: string; id: string; }; }) { + const { execute, isPending } = useAction(leaveTeamAccountAction); + const form = useForm({ resolver: zodResolver( z.object({ @@ -278,7 +276,7 @@ function LeaveTeamContainer(props: { <div className={'flex flex-col space-y-4'}> <p className={'text-muted-foreground text-sm'}> <Trans - i18nKey={'teams:leaveTeamDescription'} + i18nKey={'teams.leaveTeamDescription'} values={{ teamName: props.account.name, }} @@ -286,26 +284,26 @@ function LeaveTeamContainer(props: { </p> <AlertDialog> - <AlertDialogTrigger asChild> - <div> + <AlertDialogTrigger + render={ <Button data-test={'leave-team-button'} type={'button'} variant={'destructive'} > - <Trans i18nKey={'teams:leaveTeam'} /> + <Trans i18nKey={'teams.leaveTeam'} /> </Button> - </div> - </AlertDialogTrigger> + } + /> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - <Trans i18nKey={'teams:leavingTeamModalHeading'} /> + <Trans i18nKey={'teams.leavingTeamModalHeading'} /> </AlertDialogTitle> <AlertDialogDescription> - <Trans i18nKey={'teams:leavingTeamModalDescription'} /> + <Trans i18nKey={'teams.leavingTeamModalDescription'} /> </AlertDialogDescription> </AlertDialogHeader> @@ -313,21 +311,20 @@ function LeaveTeamContainer(props: { <Form {...form}> <form className={'flex flex-col space-y-4'} - action={leaveTeamAccountAction} + onSubmit={form.handleSubmit((data) => { + execute({ + accountId: props.account.id, + confirmation: data.confirmation, + }); + })} > - <input - type={'hidden'} - value={props.account.id} - name={'accountId'} - /> - <FormField name={'confirmation'} render={({ field }) => { return ( <FormItem> <FormLabel> - <Trans i18nKey={'teams:leaveTeamInputLabel'} /> + <Trans i18nKey={'teams.leaveTeamInputLabel'} /> </FormLabel> <FormControl> @@ -344,7 +341,7 @@ function LeaveTeamContainer(props: { </FormControl> <FormDescription> - <Trans i18nKey={'teams:leaveTeamInputDescription'} /> + <Trans i18nKey={'teams.leaveTeamInputDescription'} /> </FormDescription> <FormMessage /> @@ -355,10 +352,17 @@ function LeaveTeamContainer(props: { <AlertDialogFooter> <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> - <LeaveTeamSubmitButton /> + <Button + type="submit" + data-test={'confirm-leave-organization-button'} + disabled={isPending} + variant={'destructive'} + > + <Trans i18nKey={'teams.leaveTeam'} /> + </Button> </AlertDialogFooter> </form> </Form> @@ -369,36 +373,22 @@ function LeaveTeamContainer(props: { ); } -function LeaveTeamSubmitButton() { - const { pending } = useFormStatus(); - - return ( - <Button - data-test={'confirm-leave-organization-button'} - disabled={pending} - variant={'destructive'} - > - <Trans i18nKey={'teams:leaveTeam'} /> - </Button> - ); -} - function LeaveTeamErrorAlert() { return ( <div className={'flex flex-col space-y-4'}> <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'teams:leaveTeamErrorHeading'} /> + <Trans i18nKey={'teams.leaveTeamErrorHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'common:genericError'} /> + <Trans i18nKey={'common.genericError'} /> </AlertDescription> </Alert> <AlertDialogFooter> <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> </AlertDialogFooter> </div> @@ -410,17 +400,17 @@ function DeleteTeamErrorAlert() { <div className={'flex flex-col space-y-4'}> <Alert variant={'destructive'}> <AlertTitle> - <Trans i18nKey={'teams:deleteTeamErrorHeading'} /> + <Trans i18nKey={'teams.deleteTeamErrorHeading'} /> </AlertTitle> <AlertDescription> - <Trans i18nKey={'common:genericError'} /> + <Trans i18nKey={'common.genericError'} /> </AlertDescription> </Alert> <AlertDialogFooter> <AlertDialogCancel> - <Trans i18nKey={'common:cancel'} /> + <Trans i18nKey={'common.cancel'} /> </AlertDialogCancel> </AlertDialogFooter> </div> @@ -432,11 +422,11 @@ function DangerZoneCard({ children }: React.PropsWithChildren) { <Card className={'border-destructive border'}> <CardHeader> <CardTitle> - <Trans i18nKey={'teams:settings.dangerZone'} /> + <Trans i18nKey={'teams.settings.dangerZone'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'teams:settings.dangerZoneDescription'} /> + <Trans i18nKey={'teams.settings.dangerZoneDescription'} /> </CardDescription> </CardHeader> diff --git a/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx b/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx index 07f86391f..dba89b45a 100644 --- a/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx +++ b/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx @@ -35,11 +35,11 @@ export function TeamAccountSettingsContainer(props: { <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'teams:settings.teamLogo'} /> + <Trans i18nKey={'teams.settings.teamLogo'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'teams:settings.teamLogoDescription'} /> + <Trans i18nKey={'teams.settings.teamLogoDescription'} /> </CardDescription> </CardHeader> @@ -51,11 +51,11 @@ export function TeamAccountSettingsContainer(props: { <Card> <CardHeader> <CardTitle> - <Trans i18nKey={'teams:settings.teamName'} /> + <Trans i18nKey={'teams.settings.teamName'} /> </CardTitle> <CardDescription> - <Trans i18nKey={'teams:settings.teamNameDescription'} /> + <Trans i18nKey={'teams.settings.teamNameDescription'} /> </CardDescription> </CardHeader> diff --git a/packages/features/team-accounts/src/components/settings/update-team-account-image-container.tsx b/packages/features/team-accounts/src/components/settings/update-team-account-image-container.tsx index 793bbcec2..bf24ae9c2 100644 --- a/packages/features/team-accounts/src/components/settings/update-team-account-image-container.tsx +++ b/packages/features/team-accounts/src/components/settings/update-team-account-image-container.tsx @@ -4,7 +4,7 @@ import { useCallback } from 'react'; import type { SupabaseClient } from '@supabase/supabase-js'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { ImageUploader } from '@kit/ui/image-uploader'; @@ -21,7 +21,7 @@ export function UpdateTeamAccountImage(props: { }; }) { const client = useSupabase(); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const createToaster = useCallback( (promise: () => Promise<unknown>) => { @@ -89,11 +89,11 @@ export function UpdateTeamAccountImage(props: { > <div className={'flex flex-col space-y-1'}> <span className={'text-sm'}> - <Trans i18nKey={'account:profilePictureHeading'} /> + <Trans i18nKey={'account.profilePictureHeading'} /> </span> <span className={'text-xs'}> - <Trans i18nKey={'account:profilePictureSubheading'} /> + <Trans i18nKey={'account.profilePictureSubheading'} /> </span> </div> </ImageUploader> diff --git a/packages/features/team-accounts/src/components/settings/update-team-account-name-form.tsx b/packages/features/team-accounts/src/components/settings/update-team-account-name-form.tsx index 0bc3fca0a..d977bb964 100644 --- a/packages/features/team-accounts/src/components/settings/update-team-account-name-form.tsx +++ b/packages/features/team-accounts/src/components/settings/update-team-account-name-form.tsx @@ -1,13 +1,12 @@ 'use client'; -import { useTransition } from 'react'; - -import { isRedirectError } from 'next/dist/client/components/redirect-error'; +import { useRef } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Building, Link } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useAction } from 'next-safe-action/hooks'; import { useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Button } from '@kit/ui/button'; import { @@ -40,8 +39,7 @@ export const UpdateTeamAccountNameForm = (props: { path: string; }) => { - const [pending, startTransition] = useTransition(); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const form = useForm({ resolver: zodResolver(TeamNameFormSchema), @@ -51,6 +49,28 @@ export const UpdateTeamAccountNameForm = (props: { }, }); + const toastId = useRef<string | number>(''); + + const { execute, isPending } = useAction(updateTeamAccountName, { + onExecute: () => { + toastId.current = toast.loading(t('updateTeamLoadingMessage')); + }, + onSuccess: ({ data }) => { + if (data?.success) { + toast.success(t('updateTeamSuccessMessage'), { + id: toastId.current, + }); + } else if (data?.error) { + toast.error(t(data.error), { id: toastId.current }); + } else { + toast.error(t('updateTeamErrorMessage'), { id: toastId.current }); + } + }, + onError: () => { + toast.error(t('updateTeamErrorMessage'), { id: toastId.current }); + }, + }); + const nameValue = useWatch({ control: form.control, name: 'name' }); const showSlugField = containsNonLatinCharacters(nameValue || ''); @@ -61,41 +81,11 @@ export const UpdateTeamAccountNameForm = (props: { data-test={'update-team-account-name-form'} className={'flex flex-col space-y-4'} onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - const toastId = toast.loading(t('updateTeamLoadingMessage')); - - try { - const result = await updateTeamAccountName({ - slug: props.account.slug, - name: data.name, - newSlug: data.newSlug || undefined, - path: props.path, - }); - - if (result.success) { - toast.success(t('updateTeamSuccessMessage'), { - id: toastId, - }); - } else if (result.error) { - toast.error(t(result.error), { - id: toastId, - }); - } else { - toast.error(t('updateTeamErrorMessage'), { - id: toastId, - }); - } - } catch (error) { - if (!isRedirectError(error)) { - toast.error(t('updateTeamErrorMessage'), { - id: toastId, - }); - } else { - toast.success(t('updateTeamSuccessMessage'), { - id: toastId, - }); - } - } + execute({ + slug: props.account.slug, + name: data.name, + newSlug: data.newSlug || undefined, + path: props.path, }); })} > @@ -105,7 +95,7 @@ export const UpdateTeamAccountNameForm = (props: { return ( <FormItem> <FormLabel> - <Trans i18nKey={'teams:teamNameLabel'} /> + <Trans i18nKey={'teams.teamNameLabel'} /> </FormLabel> <FormControl> @@ -117,7 +107,7 @@ export const UpdateTeamAccountNameForm = (props: { <InputGroupInput data-test={'team-name-input'} required - placeholder={t('teams:teamNameInputLabel')} + placeholder={t('teamNameInputLabel')} {...field} /> </InputGroup> @@ -136,7 +126,7 @@ export const UpdateTeamAccountNameForm = (props: { return ( <FormItem> <FormLabel> - <Trans i18nKey={'teams:teamSlugLabel'} /> + <Trans i18nKey={'teams.teamSlugLabel'} /> </FormLabel> <FormControl> @@ -155,7 +145,7 @@ export const UpdateTeamAccountNameForm = (props: { </FormControl> <FormDescription> - <Trans i18nKey={'teams:teamSlugDescription'} /> + <Trans i18nKey={'teams.teamSlugDescription'} /> </FormDescription> <FormMessage /> @@ -167,11 +157,12 @@ export const UpdateTeamAccountNameForm = (props: { <div> <Button + type="submit" className={'w-full md:w-auto'} data-test={'update-team-submit-button'} - disabled={pending} + disabled={isPending} > - <Trans i18nKey={'teams:updateTeamSubmitLabel'} /> + <Trans i18nKey={'teams.updateTeamSubmitLabel'} /> </Button> </div> </form> diff --git a/packages/features/team-accounts/src/schema/accept-invitation.schema.ts b/packages/features/team-accounts/src/schema/accept-invitation.schema.ts index 7a1d6fc36..f35b263c5 100644 --- a/packages/features/team-accounts/src/schema/accept-invitation.schema.ts +++ b/packages/features/team-accounts/src/schema/accept-invitation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const AcceptInvitationSchema = z.object({ inviteToken: z.string().uuid(), diff --git a/packages/features/team-accounts/src/schema/create-team.schema.ts b/packages/features/team-accounts/src/schema/create-team.schema.ts index d93090831..462b8034e 100644 --- a/packages/features/team-accounts/src/schema/create-team.schema.ts +++ b/packages/features/team-accounts/src/schema/create-team.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * @name RESERVED_NAMES_ARRAY @@ -40,20 +40,18 @@ export function containsNonLatinCharacters(value: string): boolean { * @description Schema for validating URL-friendly slugs */ export const SlugSchema = z - .string({ - description: 'URL-friendly identifier for the team', - }) + .string() .min(2) .max(50) .regex(SLUG_REGEX, { - message: 'teams:invalidSlugError', + message: 'teams.invalidSlugError', }) .refine( (slug) => { return !RESERVED_NAMES_ARRAY.includes(slug.toLowerCase()); }, { - message: 'teams:reservedNameError', + message: 'teams.reservedNameError', }, ); @@ -62,9 +60,7 @@ export const SlugSchema = z * @description Schema for team name - allows non-Latin characters */ export const TeamNameSchema = z - .string({ - description: 'The name of the team account', - }) + .string() .min(2) .max(50) .refine( @@ -72,7 +68,7 @@ export const TeamNameSchema = z return !SPECIAL_CHARACTERS_REGEX.test(name); }, { - message: 'teams:specialCharactersError', + message: 'teams.specialCharactersError', }, ) .refine( @@ -80,7 +76,7 @@ export const TeamNameSchema = z return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase()); }, { - message: 'teams:reservedNameError', + message: 'teams.reservedNameError', }, ); @@ -93,10 +89,11 @@ export const CreateTeamSchema = z .object({ name: TeamNameSchema, // Transform empty strings to undefined before validation - slug: z.preprocess( - (val) => (val === '' ? undefined : val), - SlugSchema.optional(), - ), + slug: z + .string() + .optional() + .transform((val) => (val === '' ? undefined : val)) + .pipe(SlugSchema.optional()), }) .refine( (data) => { @@ -107,7 +104,7 @@ export const CreateTeamSchema = z return true; }, { - message: 'teams:slugRequiredForNonLatinName', + message: 'teams.slugRequiredForNonLatinName', path: ['slug'], }, ); diff --git a/packages/features/team-accounts/src/schema/delete-invitation.schema.ts b/packages/features/team-accounts/src/schema/delete-invitation.schema.ts index 896b8ed09..6e763bca1 100644 --- a/packages/features/team-accounts/src/schema/delete-invitation.schema.ts +++ b/packages/features/team-accounts/src/schema/delete-invitation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const DeleteInvitationSchema = z.object({ invitationId: z.number().int(), diff --git a/packages/features/team-accounts/src/schema/delete-team-account.schema.ts b/packages/features/team-accounts/src/schema/delete-team-account.schema.ts index 925883fa3..dbef262e3 100644 --- a/packages/features/team-accounts/src/schema/delete-team-account.schema.ts +++ b/packages/features/team-accounts/src/schema/delete-team-account.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const DeleteTeamAccountSchema = z.object({ accountId: z.string().uuid(), diff --git a/packages/features/team-accounts/src/schema/invite-members.schema.ts b/packages/features/team-accounts/src/schema/invite-members.schema.ts index 4c5f67e3d..4c086db8d 100644 --- a/packages/features/team-accounts/src/schema/invite-members.schema.ts +++ b/packages/features/team-accounts/src/schema/invite-members.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const InviteSchema = z.object({ email: z.string().email(), diff --git a/packages/features/team-accounts/src/schema/leave-team-account.schema.ts b/packages/features/team-accounts/src/schema/leave-team-account.schema.ts index a9168cdae..589e8fe7a 100644 --- a/packages/features/team-accounts/src/schema/leave-team-account.schema.ts +++ b/packages/features/team-accounts/src/schema/leave-team-account.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const LeaveTeamAccountSchema = z.object({ accountId: z.string().uuid(), diff --git a/packages/features/team-accounts/src/schema/remove-member.schema.ts b/packages/features/team-accounts/src/schema/remove-member.schema.ts index b693d33c9..9b8d3800f 100644 --- a/packages/features/team-accounts/src/schema/remove-member.schema.ts +++ b/packages/features/team-accounts/src/schema/remove-member.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const RemoveMemberSchema = z.object({ accountId: z.string().uuid(), diff --git a/packages/features/team-accounts/src/schema/renew-invitation.schema.ts b/packages/features/team-accounts/src/schema/renew-invitation.schema.ts index 340fc7071..9a52942b7 100644 --- a/packages/features/team-accounts/src/schema/renew-invitation.schema.ts +++ b/packages/features/team-accounts/src/schema/renew-invitation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const RenewInvitationSchema = z.object({ invitationId: z.number().positive(), diff --git a/packages/features/team-accounts/src/schema/transfer-ownership-confirmation.schema.ts b/packages/features/team-accounts/src/schema/transfer-ownership-confirmation.schema.ts index 7210dac4c..7e0f84d1c 100644 --- a/packages/features/team-accounts/src/schema/transfer-ownership-confirmation.schema.ts +++ b/packages/features/team-accounts/src/schema/transfer-ownership-confirmation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const TransferOwnershipConfirmationSchema = z.object({ accountId: z.string().uuid(), @@ -6,6 +6,6 @@ export const TransferOwnershipConfirmationSchema = z.object({ otp: z.string().min(6), }); -export type TransferOwnershipConfirmationData = z.infer< +export type TransferOwnershipConfirmationData = z.output< typeof TransferOwnershipConfirmationSchema >; diff --git a/packages/features/team-accounts/src/schema/update-invitation.schema.ts b/packages/features/team-accounts/src/schema/update-invitation.schema.ts index 4882695e9..6811bcbec 100644 --- a/packages/features/team-accounts/src/schema/update-invitation.schema.ts +++ b/packages/features/team-accounts/src/schema/update-invitation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const UpdateInvitationSchema = z.object({ invitationId: z.number(), diff --git a/packages/features/team-accounts/src/schema/update-member-role.schema.ts b/packages/features/team-accounts/src/schema/update-member-role.schema.ts index e3975adf6..f8d98bbfa 100644 --- a/packages/features/team-accounts/src/schema/update-member-role.schema.ts +++ b/packages/features/team-accounts/src/schema/update-member-role.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const RoleSchema = z.object({ role: z.string().min(1), diff --git a/packages/features/team-accounts/src/schema/update-team-name.schema.ts b/packages/features/team-accounts/src/schema/update-team-name.schema.ts index df5a51f2c..a80eec531 100644 --- a/packages/features/team-accounts/src/schema/update-team-name.schema.ts +++ b/packages/features/team-accounts/src/schema/update-team-name.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { SlugSchema, @@ -23,7 +23,7 @@ export const TeamNameFormSchema = z return true; }, { - message: 'teams:slugRequiredForNonLatinName', + message: 'teams.slugRequiredForNonLatinName', path: ['newSlug'], }, ); diff --git a/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts b/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts index 5a043d995..c5ce55a75 100644 --- a/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts @@ -1,18 +1,17 @@ 'use server'; -import 'server-only'; - import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { CreateTeamSchema } from '../../schema/create-team.schema'; import { createAccountCreationPolicyEvaluator } from '../policies'; import { createCreateTeamAccountService } from '../services/create-team-account.service'; -export const createTeamAccountAction = enhanceAction( - async ({ name, slug }, user) => { +export const createTeamAccountAction = authActionClient + .inputSchema(CreateTeamSchema) + .action(async ({ parsedInput: { name, slug }, ctx: { user } }) => { const logger = await getLogger(); const service = createCreateTeamAccountService(); @@ -61,7 +60,7 @@ export const createTeamAccountAction = enhanceAction( if (error === 'duplicate_slug') { return { error: true, - message: 'teams:duplicateSlugError', + message: 'teams.duplicateSlugError', }; } @@ -70,8 +69,4 @@ export const createTeamAccountAction = enhanceAction( const accountHomePath = '/home/' + data.slug; redirect(accountHomePath); - }, - { - schema: CreateTeamSchema, - }, -); + }); diff --git a/packages/features/team-accounts/src/server/actions/delete-team-account-server-actions.ts b/packages/features/team-accounts/src/server/actions/delete-team-account-server-actions.ts index 71100362d..e51bfb28e 100644 --- a/packages/features/team-accounts/src/server/actions/delete-team-account-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/delete-team-account-server-actions.ts @@ -1,10 +1,11 @@ 'use server'; +import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import type { SupabaseClient } from '@supabase/supabase-js'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import type { Database } from '@kit/supabase/database'; @@ -16,14 +17,11 @@ import { createDeleteTeamAccountService } from '../services/delete-team-account. const enableTeamAccountDeletion = process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION === 'true'; -export const deleteTeamAccountAction = enhanceAction( - async (formData: FormData, user) => { +export const deleteTeamAccountAction = authActionClient + .inputSchema(DeleteTeamAccountSchema) + .action(async ({ parsedInput: params, ctx: { user } }) => { const logger = await getLogger(); - const params = DeleteTeamAccountSchema.parse( - Object.fromEntries(formData.entries()), - ); - const otpService = createOtpApi(getSupabaseServerClient()); const otpResult = await otpService.verifyToken({ @@ -57,12 +55,9 @@ export const deleteTeamAccountAction = enhanceAction( logger.info(ctx, `Team account request successfully sent`); - return redirect('/home'); - }, - { - auth: true, - }, -); + revalidatePath('/'); + redirect('/home'); + }); async function deleteTeamAccount(params: { accountId: string; diff --git a/packages/features/team-accounts/src/server/actions/leave-team-account-server-actions.ts b/packages/features/team-accounts/src/server/actions/leave-team-account-server-actions.ts index 0ed33a450..54eaf6c1e 100644 --- a/packages/features/team-accounts/src/server/actions/leave-team-account-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/leave-team-account-server-actions.ts @@ -3,17 +3,15 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { LeaveTeamAccountSchema } from '../../schema/leave-team-account.schema'; import { createLeaveTeamAccountService } from '../services/leave-team-account.service'; -export const leaveTeamAccountAction = enhanceAction( - async (formData: FormData, user) => { - const body = Object.fromEntries(formData.entries()); - const params = LeaveTeamAccountSchema.parse(body); - +export const leaveTeamAccountAction = authActionClient + .inputSchema(LeaveTeamAccountSchema) + .action(async ({ parsedInput: params, ctx: { user } }) => { const service = createLeaveTeamAccountService( getSupabaseServerAdminClient(), ); @@ -25,7 +23,5 @@ export const leaveTeamAccountAction = enhanceAction( revalidatePath('/home/[account]', 'layout'); - return redirect('/home'); - }, - {}, -); + redirect('/home'); + }); diff --git a/packages/features/team-accounts/src/server/actions/team-details-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-details-server-actions.ts index d5d3c389e..fa4928631 100644 --- a/packages/features/team-accounts/src/server/actions/team-details-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-details-server-actions.ts @@ -2,14 +2,15 @@ import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { UpdateTeamNameSchema } from '../../schema/update-team-name.schema'; -export const updateTeamAccountName = enhanceAction( - async (params) => { +export const updateTeamAccountName = authActionClient + .inputSchema(UpdateTeamNameSchema) + .action(async ({ parsedInput: params }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const { name, path, slug, newSlug } = params; @@ -40,7 +41,7 @@ export const updateTeamAccountName = enhanceAction( if (error.code === '23505') { return { success: false, - error: 'teams:duplicateSlugError', + error: 'teams.duplicateSlugError', }; } @@ -60,8 +61,4 @@ export const updateTeamAccountName = enhanceAction( } return { success: true }; - }, - { - schema: UpdateTeamNameSchema, - }, -); + }); diff --git a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts index 3c27ee3e1..676cb8119 100644 --- a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts @@ -3,9 +3,9 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { z } from 'zod'; +import * as z from 'zod'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -26,8 +26,15 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat * @name createInvitationsAction * @description Creates invitations for inviting members. */ -export const createInvitationsAction = enhanceAction( - async (params, user) => { +export const createInvitationsAction = authActionClient + .inputSchema( + InviteMembersSchema.and( + z.object({ + accountSlug: z.string().min(1), + }), + ), + ) + .action(async ({ parsedInput: params, ctx: { user } }) => { const logger = await getLogger(); logger.info( @@ -116,22 +123,15 @@ export const createInvitationsAction = enhanceAction( success: false, }; } - }, - { - schema: InviteMembersSchema.and( - z.object({ - accountSlug: z.string().min(1), - }), - ), - }, -); + }); /** * @name deleteInvitationAction * @description Deletes an invitation specified by the invitation ID. */ -export const deleteInvitationAction = enhanceAction( - async (data) => { +export const deleteInvitationAction = authActionClient + .inputSchema(DeleteInvitationSchema) + .action(async ({ parsedInput: data }) => { const client = getSupabaseServerClient(); const service = createAccountInvitationsService(client); @@ -143,18 +143,15 @@ export const deleteInvitationAction = enhanceAction( return { success: true, }; - }, - { - schema: DeleteInvitationSchema, - }, -); + }); /** * @name updateInvitationAction * @description Updates an invitation. */ -export const updateInvitationAction = enhanceAction( - async (invitation) => { +export const updateInvitationAction = authActionClient + .inputSchema(UpdateInvitationSchema) + .action(async ({ parsedInput: invitation }) => { const client = getSupabaseServerClient(); const service = createAccountInvitationsService(client); @@ -165,23 +162,18 @@ export const updateInvitationAction = enhanceAction( return { success: true, }; - }, - { - schema: UpdateInvitationSchema, - }, -); + }); /** * @name acceptInvitationAction * @description Accepts an invitation to join a team. */ -export const acceptInvitationAction = enhanceAction( - async (data: FormData, user) => { +export const acceptInvitationAction = authActionClient + .inputSchema(AcceptInvitationSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { const client = getSupabaseServerClient(); - const { inviteToken, nextPath } = AcceptInvitationSchema.parse( - Object.fromEntries(data), - ); + const { inviteToken, nextPath } = data; // create the services const perSeatBillingService = createAccountPerSeatBillingService(client); @@ -205,19 +197,17 @@ export const acceptInvitationAction = enhanceAction( // Increase the seats for the account await perSeatBillingService.increaseSeats(accountId); - return redirect(nextPath); - }, - {}, -); + redirect(nextPath); + }); /** * @name renewInvitationAction * @description Renews an invitation. */ -export const renewInvitationAction = enhanceAction( - async (params) => { +export const renewInvitationAction = authActionClient + .inputSchema(RenewInvitationSchema) + .action(async ({ parsedInput: { invitationId } }) => { const client = getSupabaseServerClient(); - const { invitationId } = RenewInvitationSchema.parse(params); const service = createAccountInvitationsService(client); @@ -229,11 +219,7 @@ export const renewInvitationAction = enhanceAction( return { success: true, }; - }, - { - schema: RenewInvitationSchema, - }, -); + }); function revalidateMemberPage() { revalidatePath('/home/[account]/members', 'page'); @@ -247,7 +233,7 @@ function revalidateMemberPage() { * @param accountId - The account ID (already fetched to avoid duplicate queries). */ async function evaluateInvitationsPolicies( - params: z.infer<typeof InviteMembersSchema> & { accountSlug: string }, + params: z.output<typeof InviteMembersSchema> & { accountSlug: string }, user: JWTUserData, accountId: string, ) { @@ -282,7 +268,7 @@ async function evaluateInvitationsPolicies( async function checkInvitationPermissions( accountId: string, userId: string, - invitations: z.infer<typeof InviteMembersSchema>['invitations'], + invitations: z.output<typeof InviteMembersSchema>['invitations'], ): Promise<{ allowed: boolean; reason?: string; diff --git a/packages/features/team-accounts/src/server/actions/team-members-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-members-server-actions.ts index fc90826ff..b8e656eb5 100644 --- a/packages/features/team-accounts/src/server/actions/team-members-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-members-server-actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -17,8 +17,9 @@ import { createAccountMembersService } from '../services/account-members.service * @name removeMemberFromAccountAction * @description Removes a member from an account. */ -export const removeMemberFromAccountAction = enhanceAction( - async ({ accountId, userId }) => { +export const removeMemberFromAccountAction = authActionClient + .inputSchema(RemoveMemberSchema) + .action(async ({ parsedInput: { accountId, userId } }) => { const client = getSupabaseServerClient(); const service = createAccountMembersService(client); @@ -31,18 +32,15 @@ export const removeMemberFromAccountAction = enhanceAction( revalidatePath('/home/[account]', 'layout'); return { success: true }; - }, - { - schema: RemoveMemberSchema, - }, -); + }); /** * @name updateMemberRoleAction * @description Updates the role of a member in an account. */ -export const updateMemberRoleAction = enhanceAction( - async (data) => { +export const updateMemberRoleAction = authActionClient + .inputSchema(UpdateMemberRoleSchema) + .action(async ({ parsedInput: data }) => { const client = getSupabaseServerClient(); const service = createAccountMembersService(client); const adminClient = getSupabaseServerAdminClient(); @@ -54,19 +52,16 @@ export const updateMemberRoleAction = enhanceAction( revalidatePath('/home/[account]', 'layout'); return { success: true }; - }, - { - schema: UpdateMemberRoleSchema, - }, -); + }); /** * @name transferOwnershipAction * @description Transfers the ownership of an account to another member. * Requires OTP verification for security. */ -export const transferOwnershipAction = enhanceAction( - async (data, user) => { +export const transferOwnershipAction = authActionClient + .inputSchema(TransferOwnershipConfirmationSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); @@ -137,8 +132,4 @@ export const transferOwnershipAction = enhanceAction( return { success: true, }; - }, - { - schema: TransferOwnershipConfirmationSchema, - }, -); + }); diff --git a/packages/features/team-accounts/src/server/policies/create-account-policies.ts b/packages/features/team-accounts/src/server/policies/create-account-policies.ts index afac1f740..d7d6a6084 100644 --- a/packages/features/team-accounts/src/server/policies/create-account-policies.ts +++ b/packages/features/team-accounts/src/server/policies/create-account-policies.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { createPolicyRegistry } from '@kit/policies'; /** diff --git a/packages/features/team-accounts/src/server/policies/create-account-policy-evaluator.ts b/packages/features/team-accounts/src/server/policies/create-account-policy-evaluator.ts index 6cbb71c05..7a026eea3 100644 --- a/packages/features/team-accounts/src/server/policies/create-account-policy-evaluator.ts +++ b/packages/features/team-accounts/src/server/policies/create-account-policy-evaluator.ts @@ -1,5 +1,4 @@ import 'server-only'; - import type { EvaluationResult } from '@kit/policies'; import { createPoliciesEvaluator } from '@kit/policies'; diff --git a/packages/features/team-accounts/src/server/policies/invitation-context-builder.ts b/packages/features/team-accounts/src/server/policies/invitation-context-builder.ts index b79d5a25d..fe3e2abe8 100644 --- a/packages/features/team-accounts/src/server/policies/invitation-context-builder.ts +++ b/packages/features/team-accounts/src/server/policies/invitation-context-builder.ts @@ -1,6 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import type { Database } from '@kit/supabase/database'; import { JWTUserData } from '@kit/supabase/types'; @@ -29,7 +29,7 @@ class InvitationContextBuilder { * Build policy context for invitation evaluation with optimized parallel loading */ async buildContext( - params: z.infer<typeof InviteMembersSchema> & { accountSlug: string }, + params: z.output<typeof InviteMembersSchema> & { accountSlug: string }, user: JWTUserData, ): Promise<FeaturePolicyInvitationContext> { // Fetch all data in parallel for optimal performance @@ -43,7 +43,7 @@ class InvitationContextBuilder { * (avoids duplicate account lookup) */ async buildContextWithAccountId( - params: z.infer<typeof InviteMembersSchema> & { accountSlug: string }, + params: z.output<typeof InviteMembersSchema> & { accountSlug: string }, user: JWTUserData, accountId: string, ): Promise<FeaturePolicyInvitationContext> { diff --git a/packages/features/team-accounts/src/server/policies/policies.ts b/packages/features/team-accounts/src/server/policies/policies.ts index f1e9f80e1..d0d3d4c17 100644 --- a/packages/features/team-accounts/src/server/policies/policies.ts +++ b/packages/features/team-accounts/src/server/policies/policies.ts @@ -20,8 +20,8 @@ export const subscriptionRequiredInvitationsPolicy = if (!subscription || !subscription.active) { return deny({ code: 'SUBSCRIPTION_REQUIRED', - message: 'teams:policyErrors.subscriptionRequired', - remediation: 'teams:policyRemediation.subscriptionRequired', + message: 'teams.policyErrors.subscriptionRequired', + remediation: 'teams.policyRemediation.subscriptionRequired', }); } @@ -55,8 +55,8 @@ export const paddleBillingInvitationsPolicy = if (hasPerSeatItems) { return deny({ code: 'PADDLE_TRIAL_RESTRICTION', - message: 'teams:policyErrors.paddleTrialRestriction', - remediation: 'teams:policyRemediation.paddleTrialRestriction', + message: 'teams.policyErrors.paddleTrialRestriction', + remediation: 'teams.policyRemediation.paddleTrialRestriction', }); } } diff --git a/packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts b/packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts index c62247463..814e7cd7e 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts @@ -1,6 +1,6 @@ import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database, Tables } from '@kit/supabase/database'; @@ -18,22 +18,22 @@ const env = z .object({ invitePath: z .string({ - required_error: 'The property invitePath is required', + error: 'The property invitePath is required', }) .min(1), siteURL: z .string({ - required_error: 'NEXT_PUBLIC_SITE_URL is required', + error: 'NEXT_PUBLIC_SITE_URL is required', }) .min(1), productName: z .string({ - required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required', + error: 'NEXT_PUBLIC_PRODUCT_NAME is required', }) .min(1), emailSender: z .string({ - required_error: 'EMAIL_SENDER is required', + error: 'EMAIL_SENDER is required', }) .min(1), }) diff --git a/packages/features/team-accounts/src/server/services/account-invitations.service.ts b/packages/features/team-accounts/src/server/services/account-invitations.service.ts index 8992e2eca..b3a5050b9 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations.service.ts @@ -1,9 +1,8 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { addDays, formatISO } from 'date-fns'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -37,7 +36,7 @@ class AccountInvitationsService { * @description Removes an invitation from the database. * @param params */ - async deleteInvitation(params: z.infer<typeof DeleteInvitationSchema>) { + async deleteInvitation(params: z.output<typeof DeleteInvitationSchema>) { const logger = await getLogger(); const ctx = { @@ -70,7 +69,7 @@ class AccountInvitationsService { * @param params * @description Updates an invitation in the database. */ - async updateInvitation(params: z.infer<typeof UpdateInvitationSchema>) { + async updateInvitation(params: z.output<typeof UpdateInvitationSchema>) { const logger = await getLogger(); const ctx = { @@ -107,7 +106,7 @@ class AccountInvitationsService { } async validateInvitation( - invitation: z.infer<typeof InviteMembersSchema>['invitations'][number], + invitation: z.output<typeof InviteMembersSchema>['invitations'][number], accountSlug: string, ) { const { data: members, error } = await this.client.rpc( @@ -141,7 +140,7 @@ class AccountInvitationsService { invitations, invitedBy, }: { - invitations: z.infer<typeof InviteMembersSchema>['invitations']; + invitations: z.output<typeof InviteMembersSchema>['invitations']; accountSlug: string; invitedBy: string; }) { diff --git a/packages/features/team-accounts/src/server/services/account-members.service.ts b/packages/features/team-accounts/src/server/services/account-members.service.ts index 810a0493b..9c052805d 100644 --- a/packages/features/team-accounts/src/server/services/account-members.service.ts +++ b/packages/features/team-accounts/src/server/services/account-members.service.ts @@ -1,8 +1,7 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -26,7 +25,7 @@ class AccountMembersService { * @description Removes a member from an account. * @param params */ - async removeMemberFromAccount(params: z.infer<typeof RemoveMemberSchema>) { + async removeMemberFromAccount(params: z.output<typeof RemoveMemberSchema>) { const logger = await getLogger(); const ctx = { @@ -75,7 +74,7 @@ class AccountMembersService { * @param adminClient */ async updateMemberRole( - params: z.infer<typeof UpdateMemberRoleSchema>, + params: z.output<typeof UpdateMemberRoleSchema>, adminClient: SupabaseClient<Database>, ) { const logger = await getLogger(); @@ -145,7 +144,7 @@ class AccountMembersService { * @param adminClient */ async transferOwnership( - params: z.infer<typeof TransferOwnershipConfirmationSchema>, + params: z.output<typeof TransferOwnershipConfirmationSchema>, adminClient: SupabaseClient<Database>, ) { const logger = await getLogger(); diff --git a/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts b/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts index fce672922..5292c5caa 100644 --- a/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts +++ b/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { createBillingGatewayService } from '@kit/billing-gateway'; diff --git a/packages/features/team-accounts/src/server/services/create-team-account.service.ts b/packages/features/team-accounts/src/server/services/create-team-account.service.ts index 50c87cc1b..d62f46736 100644 --- a/packages/features/team-accounts/src/server/services/create-team-account.service.ts +++ b/packages/features/team-accounts/src/server/services/create-team-account.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; diff --git a/packages/features/team-accounts/src/server/services/delete-team-account.service.ts b/packages/features/team-accounts/src/server/services/delete-team-account.service.ts index 75e0b3b91..0653780b9 100644 --- a/packages/features/team-accounts/src/server/services/delete-team-account.service.ts +++ b/packages/features/team-accounts/src/server/services/delete-team-account.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { getLogger } from '@kit/shared/logger'; diff --git a/packages/features/team-accounts/src/server/services/leave-team-account.service.ts b/packages/features/team-accounts/src/server/services/leave-team-account.service.ts index e039a2dad..0b95e28c1 100644 --- a/packages/features/team-accounts/src/server/services/leave-team-account.service.ts +++ b/packages/features/team-accounts/src/server/services/leave-team-account.service.ts @@ -1,8 +1,7 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -32,7 +31,7 @@ class LeaveTeamAccountService { * @description Leave a team account * @param params */ - async leaveTeamAccount(params: z.infer<typeof Schema>) { + async leaveTeamAccount(params: z.output<typeof Schema>) { const logger = await getLogger(); const ctx = { diff --git a/packages/i18n/eslint.config.mjs b/packages/i18n/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/i18n/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/i18n/package.json b/packages/i18n/package.json index a194ec51b..12133bd0b 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,41 +1,35 @@ { "name": "@kit/i18n", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts", - "./server": "./src/i18n.server.ts", - "./client": "./src/i18n.client.ts", - "./provider": "./src/i18n-provider.tsx" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/shared": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@tanstack/react-query": "catalog:", - "next": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "react-i18next": "catalog:" - }, - "dependencies": { - "i18next": "catalog:", - "i18next-browser-languagedetector": "catalog:", - "i18next-resources-to-backend": "catalog:" - }, + "private": true, + "type": "module", "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./routing": "./src/routing.ts", + "./navigation": "./src/navigation.ts", + "./provider": "./src/client-provider.tsx" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next-intl": "catalog:" + }, + "devDependencies": { + "@kit/shared": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@tanstack/react-query": "catalog:", + "@types/react": "catalog:", + "next": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" } } diff --git a/packages/i18n/src/client-provider.tsx b/packages/i18n/src/client-provider.tsx new file mode 100644 index 000000000..10ae2b817 --- /dev/null +++ b/packages/i18n/src/client-provider.tsx @@ -0,0 +1,46 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import type { AbstractIntlMessages } from 'next-intl'; +import { NextIntlClientProvider } from 'next-intl'; + +const isDevelopment = process.env.NODE_ENV === 'development'; + +interface I18nClientProviderProps { + locale: string; + messages: AbstractIntlMessages; + children: ReactNode; + timeZone?: string; +} + +/** + * Client-side provider for next-intl. + * Wraps the application and provides translation context to all client components. + */ +export function I18nClientProvider({ + locale, + messages, + timeZone = 'UTC', + children, +}: I18nClientProviderProps) { + return ( + <NextIntlClientProvider + locale={locale} + messages={messages} + timeZone={timeZone} + getMessageFallback={(info) => { + // simply fallback to the key as is + return info.key; + }} + onError={(error) => { + if (isDevelopment) { + // Missing translations are expected and should only log an error + console.warn(`[Dev Only] i18n error: ${error.message}`); + } + }} + > + {children} + </NextIntlClientProvider> + ); +} diff --git a/packages/i18n/src/create-i18n-settings.ts b/packages/i18n/src/create-i18n-settings.ts deleted file mode 100644 index 548703084..000000000 --- a/packages/i18n/src/create-i18n-settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { InitOptions } from 'i18next'; - -/** - * Get i18n settings for i18next. - * @param languages - * @param language - * @param namespaces - */ -export function createI18nSettings({ - languages, - language, - namespaces, -}: { - languages: string[]; - language: string; - namespaces?: string | string[]; -}): InitOptions { - const lng = language; - const ns = namespaces; - - return { - supportedLngs: languages, - fallbackLng: languages[0], - detection: undefined, - showSupportNotice: false, - lng, - preload: false as const, - lowerCaseLng: true as const, - fallbackNS: ns, - missingInterpolationHandler: (text, value, options) => { - console.debug( - `Missing interpolation value for key: ${text}`, - value, - options, - ); - }, - ns, - react: { - useSuspense: true, - }, - }; -} diff --git a/packages/i18n/src/default-locale.ts b/packages/i18n/src/default-locale.ts new file mode 100644 index 000000000..2624aafef --- /dev/null +++ b/packages/i18n/src/default-locale.ts @@ -0,0 +1,7 @@ +/** + * @name defaultLocale + * @description The default locale of the application. + * @type {string} + * @default 'en' + */ +export const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en'; diff --git a/packages/i18n/src/i18n-provider.tsx b/packages/i18n/src/i18n-provider.tsx deleted file mode 100644 index 810e0b298..000000000 --- a/packages/i18n/src/i18n-provider.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; - -import type { InitOptions, i18n } from 'i18next'; - -import { initializeI18nClient } from './i18n.client'; - -let i18nInstance: i18n; - -type Resolver = ( - lang: string, - namespace: string, -) => Promise<Record<string, string>>; - -export function I18nProvider({ - settings, - children, - resolver, -}: React.PropsWithChildren<{ - settings: InitOptions; - resolver: Resolver; -}>) { - useI18nClient(settings, resolver); - - return children; -} - -/** - * @name useI18nClient - * @description A hook that initializes the i18n client. - * @param settings - * @param resolver - */ -function useI18nClient(settings: InitOptions, resolver: Resolver) { - if ( - !i18nInstance || - i18nInstance.language !== settings.lng || - i18nInstance.options.ns?.length !== settings.ns?.length - ) { - throw loadI18nInstance(settings, resolver); - } - - return i18nInstance; -} - -async function loadI18nInstance(settings: InitOptions, resolver: Resolver) { - i18nInstance = await initializeI18nClient(settings, resolver); -} diff --git a/packages/i18n/src/i18n.client.ts b/packages/i18n/src/i18n.client.ts deleted file mode 100644 index 64be377cf..000000000 --- a/packages/i18n/src/i18n.client.ts +++ /dev/null @@ -1,90 +0,0 @@ -import i18next, { type InitOptions, i18n } from 'i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; -import resourcesToBackend from 'i18next-resources-to-backend'; -import { initReactI18next } from 'react-i18next'; - -// Keep track of the number of iterations -let iteration = 0; - -// Maximum number of iterations -const MAX_ITERATIONS = 20; - -/** - * Initialize the i18n instance on the client. - * @param settings - the i18n settings - * @param resolver - a function that resolves the i18n resources - */ -export async function initializeI18nClient( - settings: InitOptions, - resolver: (lang: string, namespace: string) => Promise<object>, -): Promise<i18n> { - const loadedLanguages: string[] = []; - const loadedNamespaces: string[] = []; - - await i18next - .use( - resourcesToBackend(async (language, namespace, callback) => { - const data = await resolver(language, namespace); - - if (!loadedLanguages.includes(language)) { - loadedLanguages.push(language); - } - - if (!loadedNamespaces.includes(namespace)) { - loadedNamespaces.push(namespace); - } - - return callback(null, data); - }), - ) - .use(LanguageDetector) - .use(initReactI18next) - .init( - { - ...settings, - showSupportNotice: false, - detection: { - order: ['cookie', 'htmlTag', 'navigator'], - caches: ['cookie'], - lookupCookie: 'lang', - cookieMinutes: 60 * 24 * 365, // 1 year - cookieOptions: { - sameSite: 'lax', - secure: - typeof window !== 'undefined' && - window.location.protocol === 'https:', - path: '/', - }, - }, - interpolation: { - escapeValue: false, - }, - }, - (err) => { - if (err) { - console.error('Error initializing i18n client', err); - } - }, - ); - - // to avoid infinite loops, we return the i18next instance after a certain number of iterations - // even if the languages and namespaces are not loaded - if (iteration >= MAX_ITERATIONS) { - console.debug(`Max iterations reached: ${MAX_ITERATIONS}`); - - return i18next; - } - - // keep component from rendering if no languages or namespaces are loaded - if (loadedLanguages.length === 0 || loadedNamespaces.length === 0) { - iteration++; - - console.debug( - `Keeping component from rendering if no languages or namespaces are loaded. Iteration: ${iteration}. Will stop after ${MAX_ITERATIONS} iterations.`, - ); - - throw new Error('No languages or namespaces loaded'); - } - - return i18next; -} diff --git a/packages/i18n/src/i18n.server.ts b/packages/i18n/src/i18n.server.ts deleted file mode 100644 index d7f2084c4..000000000 --- a/packages/i18n/src/i18n.server.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { type InitOptions, createInstance } from 'i18next'; -import resourcesToBackend from 'i18next-resources-to-backend'; -import { initReactI18next } from 'react-i18next/initReactI18next'; - -/** - * Initialize the i18n instance on the server. - * This is useful for RSC and SSR. - * @param settings - the i18n settings - * @param resolver - a function that resolves the i18n resources - */ -export async function initializeServerI18n( - settings: InitOptions, - resolver: (language: string, namespace: string) => Promise<object>, -) { - const i18nInstance = createInstance(); - const loadedNamespaces = new Set<string>(); - - await new Promise((resolve) => { - void i18nInstance - .use( - resourcesToBackend(async (language, namespace, callback) => { - try { - const data = await resolver(language, namespace); - loadedNamespaces.add(namespace); - - return callback(null, data); - } catch (error) { - console.log( - `Error loading i18n file: locales/${language}/${namespace}.json`, - error, - ); - - return callback(null, {}); - } - }), - ) - .use({ - type: '3rdParty', - init: async (i18next: typeof i18nInstance) => { - let iterations = 0; - const maxIterations = 100; - - // do not bind this to the i18next instance until it's initialized - while (i18next.isInitializing) { - iterations++; - - if (iterations > maxIterations) { - console.error( - `i18next is not initialized after ${maxIterations} iterations`, - ); - - break; - } - - await new Promise((resolve) => setTimeout(resolve, 1)); - } - - initReactI18next.init(i18next); - resolve(i18next); - }, - }) - .init(settings); - }); - - const namespaces = settings.ns as string[]; - - // If all namespaces are already loaded, return the i18n instance - if (loadedNamespaces.size === namespaces.length) { - return i18nInstance; - } - - // Otherwise, wait for all namespaces to be loaded - - const maxWaitTime = 0.1; // 100 milliseconds - const checkIntervalMs = 5; // 5 milliseconds - - async function waitForNamespaces() { - const startTime = Date.now(); - - while (Date.now() - startTime < maxWaitTime) { - const allNamespacesLoaded = namespaces.every((ns) => - loadedNamespaces.has(ns), - ); - - if (allNamespacesLoaded) { - return true; - } - - await new Promise((resolve) => setTimeout(resolve, checkIntervalMs)); - } - - return false; - } - - const success = await waitForNamespaces(); - - if (!success) { - console.warn( - `Not all namespaces were loaded after ${maxWaitTime}ms. Initialization may be incomplete.`, - ); - } - - return i18nInstance; -} - -/** - * Parse the accept-language header value and return the languages that are included in the accepted languages. - * @param languageHeaderValue - * @param acceptedLanguages - */ -export function parseAcceptLanguageHeader( - languageHeaderValue: string | null | undefined, - acceptedLanguages: string[], -): string[] { - // Return an empty array if the header value is not provided - if (!languageHeaderValue) return []; - - const ignoreWildcard = true; - - // Split the header value by comma and map each language to its quality value - return languageHeaderValue - .split(',') - .map((lang): [number, string] => { - const [locale, q = 'q=1'] = lang.split(';'); - - if (!locale) return [0, '']; - - const trimmedLocale = locale.trim(); - const numQ = Number(q.replace(/q ?=/, '')); - - return [Number.isNaN(numQ) ? 0 : numQ, trimmedLocale]; - }) - .sort(([q1], [q2]) => q2 - q1) // Sort by quality value in descending order - .flatMap(([_, locale]) => { - // Ignore wildcard '*' if 'ignoreWildcard' is true - if (locale === '*' && ignoreWildcard) return []; - - const languageSegment = locale.split('-')[0]; - - if (!languageSegment) return []; - - // Return the locale if it's included in the accepted languages - try { - return acceptedLanguages.includes(languageSegment) - ? [languageSegment] - : []; - } catch { - return []; - } - }); -} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 93475c547..25cf79719 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1 +1,2 @@ -export * from './create-i18n-settings'; +// Export routing configuration as the main export +export * from './routing'; diff --git a/packages/i18n/src/locales.tsx b/packages/i18n/src/locales.tsx new file mode 100644 index 000000000..e3863af82 --- /dev/null +++ b/packages/i18n/src/locales.tsx @@ -0,0 +1,16 @@ +import { defaultLocale } from './default-locale'; + +/** + * @name locales + * @description Supported locales + * @type {string[]} + * @default [defaultLocale] + */ +export const locales: string[] = [ + defaultLocale, + // Add other locales here as needed + // Example: 'es', 'fr', 'de', etc. + // Uncomment the locales below to enable them: + // 'es', // Spanish + // 'fr', // French +]; diff --git a/packages/i18n/src/navigation.ts b/packages/i18n/src/navigation.ts new file mode 100644 index 000000000..f27680aa0 --- /dev/null +++ b/packages/i18n/src/navigation.ts @@ -0,0 +1,10 @@ +import { createNavigation } from 'next-intl/navigation'; + +import { routing } from './routing'; + +/** + * Creates navigation utilities for next-intl. + * These utilities are locale-aware and automatically handle routing with locales. + */ +export const { Link, redirect, usePathname, useRouter, permanentRedirect } = + createNavigation(routing); diff --git a/packages/i18n/src/routing.ts b/packages/i18n/src/routing.ts new file mode 100644 index 000000000..cb838b59c --- /dev/null +++ b/packages/i18n/src/routing.ts @@ -0,0 +1,23 @@ +import { defineRouting } from 'next-intl/routing'; + +import { defaultLocale } from './default-locale'; +import { locales } from './locales'; + +// Define the routing configuration for next-intl +export const routing = defineRouting({ + // All supported locales + locales, + + // Default locale (no prefix in URL) + defaultLocale, + + // Default locale has no prefix, other locales do + // Example: /about (en), /es/about (es), /fr/about (fr) + localePrefix: 'as-needed', + + // Enable automatic locale detection based on browser headers and cookies + localeDetection: true, +}); + +// Export locale types for TypeScript +export type Locale = (typeof routing.locales)[number]; diff --git a/packages/mailers/AGENTS.md b/packages/mailers/AGENTS.md index c23b74dfd..dccb0baca 100644 --- a/packages/mailers/AGENTS.md +++ b/packages/mailers/AGENTS.md @@ -1,66 +1,19 @@ -# Email Service Instructions +# @kit/mailers — Email Service -This file contains guidance for working with the email service supporting Resend and Nodemailer. +## Non-Negotiables -## Basic Usage +1. ALWAYS use `getMailer()` factory from `@kit/mailers` — never instantiate mailer directly +2. ALWAYS use `@kit/email-templates` renderers for HTML — never write inline HTML +3. ALWAYS render template first (`renderXxxEmail()`), then pass `{ html, subject }` to `sendEmail()` +4. NEVER hardcode sender/recipient addresses — use environment config -```typescript -import { getMailer } from '@kit/mailers'; -import { renderAccountDeleteEmail } from '@kit/email-templates'; +## Workflow -async function sendSimpleEmail() { - // Get mailer instance - const mailer = await getMailer(); +1. Render: `const { html, subject } = await renderXxxEmail(props)` +2. Get mailer: `const mailer = await getMailer()` +3. Send: `await mailer.sendEmail({ to, from, subject, html })` - // Send simple email - await mailer.sendEmail({ - to: 'user@example.com', - from: 'noreply@yourdomain.com', - subject: 'Welcome!', - html: '<h1>Welcome!</h1><p>Thank you for joining us.</p>', - }); -} +## Exemplars -async function sendComplexEmail() { - // Send with email template - const { html, subject } = await renderAccountDeleteEmail({ - userDisplayName: user.name, - productName: 'My SaaS App', - }); - - await mailer.sendEmail({ - to: user.email, - from: 'noreply@yourdomain.com', - subject, - html, - }); -} -``` - -## Email Templates - -Email templates are located in `@kit/email-templates` and return `{ html, subject }`: - -```typescript -import { - renderAccountDeleteEmail, - renderWelcomeEmail, - renderPasswordResetEmail -} from '@kit/email-templates'; - -// Render template -const { html, subject } = await renderWelcomeEmail({ - userDisplayName: 'John Doe', - loginUrl: 'https://app.com/login' -}); - -// Send rendered email -const mailer = await getMailer(); - -await mailer.sendEmail({ - to: user.email, - from: 'welcome@yourdomain.com', - subject, - html, -}); -``` \ No newline at end of file +- Contact form: `apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts` +- Invitation dispatch: `packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts` diff --git a/packages/mailers/core/eslint.config.mjs b/packages/mailers/core/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/mailers/core/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/mailers/core/package.json b/packages/mailers/core/package.json index 7defe1c40..552575e01 100644 --- a/packages/mailers/core/package.json +++ b/packages/mailers/core/package.json @@ -1,33 +1,27 @@ { "name": "@kit/mailers", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/mailers-shared": "workspace:*", - "@kit/nodemailer": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/resend": "workspace:*", - "@kit/shared": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/node": "catalog:", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/mailers-shared": "workspace:*", + "@kit/nodemailer": "workspace:*", + "@kit/resend": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/tsconfig": "workspace:*", + "zod": "catalog:" } } diff --git a/packages/mailers/core/src/provider-enum.ts b/packages/mailers/core/src/provider-enum.ts index f003de3ee..48429374d 100644 --- a/packages/mailers/core/src/provider-enum.ts +++ b/packages/mailers/core/src/provider-enum.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const MAILER_PROVIDERS = [ 'nodemailer', diff --git a/packages/mailers/nodemailer/eslint.config.mjs b/packages/mailers/nodemailer/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/mailers/nodemailer/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/mailers/nodemailer/package.json b/packages/mailers/nodemailer/package.json index bccf2177f..bd18bbd28 100644 --- a/packages/mailers/nodemailer/package.json +++ b/packages/mailers/nodemailer/package.json @@ -1,33 +1,28 @@ { "name": "@kit/nodemailer", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts" - }, - "dependencies": { - "nodemailer": "catalog:" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/mailers-shared": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/nodemailer": "catalog:", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "nodemailer": "catalog:" + }, + "devDependencies": { + "@kit/mailers-shared": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@types/nodemailer": "catalog:", + "zod": "catalog:" } } diff --git a/packages/mailers/nodemailer/src/index.ts b/packages/mailers/nodemailer/src/index.ts index 7c20b127b..2ff516d86 100644 --- a/packages/mailers/nodemailer/src/index.ts +++ b/packages/mailers/nodemailer/src/index.ts @@ -1,12 +1,11 @@ import 'server-only'; - -import { z } from 'zod'; +import * as z from 'zod'; import { Mailer, MailerSchema } from '@kit/mailers-shared'; import { getSMTPConfiguration } from './smtp-configuration'; -type Config = z.infer<typeof MailerSchema>; +type Config = z.output<typeof MailerSchema>; export function createNodemailerService() { return new Nodemailer(); diff --git a/packages/mailers/resend/eslint.config.mjs b/packages/mailers/resend/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/mailers/resend/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/mailers/resend/package.json b/packages/mailers/resend/package.json index 9859b2ee7..ab9912911 100644 --- a/packages/mailers/resend/package.json +++ b/packages/mailers/resend/package.json @@ -1,30 +1,24 @@ { "name": "@kit/resend", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/mailers-shared": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/node": "catalog:", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/mailers-shared": "workspace:*", + "@kit/tsconfig": "workspace:*", + "zod": "catalog:" } } diff --git a/packages/mailers/resend/src/index.ts b/packages/mailers/resend/src/index.ts index 8bd067fdf..ef1fad379 100644 --- a/packages/mailers/resend/src/index.ts +++ b/packages/mailers/resend/src/index.ts @@ -1,15 +1,13 @@ import 'server-only'; - -import { z } from 'zod'; +import * as z from 'zod'; import { Mailer, MailerSchema } from '@kit/mailers-shared'; -type Config = z.infer<typeof MailerSchema>; +type Config = z.output<typeof MailerSchema>; const RESEND_API_KEY = z .string({ - description: 'The API key for the Resend API', - required_error: 'Please provide the API key for the Resend API', + error: 'Please provide the API key for the Resend API', }) .parse(process.env.RESEND_API_KEY); diff --git a/packages/mailers/shared/eslint.config.mjs b/packages/mailers/shared/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/mailers/shared/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/mailers/shared/package.json b/packages/mailers/shared/package.json index 8536135bb..274ed64f6 100644 --- a/packages/mailers/shared/package.json +++ b/packages/mailers/shared/package.json @@ -1,28 +1,23 @@ { "name": "@kit/mailers-shared", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/tsconfig": "workspace:*", + "zod": "catalog:" } } diff --git a/packages/mailers/shared/src/mailer.ts b/packages/mailers/shared/src/mailer.ts index ab4578956..836a50d5b 100644 --- a/packages/mailers/shared/src/mailer.ts +++ b/packages/mailers/shared/src/mailer.ts @@ -1,7 +1,7 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { MailerSchema } from './schema/mailer.schema'; export abstract class Mailer<Res = unknown> { - abstract sendEmail(data: z.infer<typeof MailerSchema>): Promise<Res>; + abstract sendEmail(data: z.output<typeof MailerSchema>): Promise<Res>; } diff --git a/packages/mailers/shared/src/schema/mailer.schema.ts b/packages/mailers/shared/src/schema/mailer.schema.ts index 1fd1f5849..d81a7970d 100644 --- a/packages/mailers/shared/src/schema/mailer.schema.ts +++ b/packages/mailers/shared/src/schema/mailer.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const MailerSchema = z .object({ diff --git a/packages/mailers/shared/src/schema/smtp-config.schema.ts b/packages/mailers/shared/src/schema/smtp-config.schema.ts index 9a5094972..7b95f63fe 100644 --- a/packages/mailers/shared/src/schema/smtp-config.schema.ts +++ b/packages/mailers/shared/src/schema/smtp-config.schema.ts @@ -1,28 +1,20 @@ import 'server-only'; - -import { z } from 'zod'; +import * as z from 'zod'; export const SmtpConfigSchema = z.object({ user: z.string({ - description: - 'This is the email account to send emails from. This is specific to the email provider.', - required_error: `Please provide the variable EMAIL_USER`, + error: `Please provide the variable EMAIL_USER`, }), pass: z.string({ - description: 'This is the password for the email account', - required_error: `Please provide the variable EMAIL_PASSWORD`, + error: `Please provide the variable EMAIL_PASSWORD`, }), host: z.string({ - description: 'This is the SMTP host for the email provider', - required_error: `Please provide the variable EMAIL_HOST`, + error: `Please provide the variable EMAIL_HOST`, }), port: z.number({ - description: - 'This is the port for the email provider. Normally 587 or 465.', - required_error: `Please provide the variable EMAIL_PORT`, + error: `Please provide the variable EMAIL_PORT`, }), secure: z.boolean({ - description: 'This is whether the connection is secure or not', - required_error: `Please provide the variable EMAIL_TLS`, + error: `Please provide the variable EMAIL_TLS`, }), }); diff --git a/packages/mcp-server/eslint.config.mjs b/packages/mcp-server/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/mcp-server/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index b7c653f56..3690e85b3 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,12 +1,12 @@ { "name": "@kit/mcp-server", - "private": true, "version": "0.1.0", - "type": "module", - "main": "./build/index.cjs", + "private": true, "bin": { "makerkit-mcp-server": "./build/index.cjs" }, + "type": "module", + "main": "./build/index.cjs", "exports": { "./database": "./src/tools/database.ts", "./components": "./src/tools/components.ts", @@ -28,22 +28,18 @@ }, "scripts": { "clean": "rm -rf .turbo node_modules", - "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", "build": "tsup", "build:watch": "tsup --watch", "test:unit": "vitest run" }, "devDependencies": { "@kit/email-templates": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/tsconfig": "workspace:*", - "@modelcontextprotocol/sdk": "1.27.1", - "@types/node": "catalog:", - "postgres": "3.4.8", + "@modelcontextprotocol/sdk": "catalog:", + "postgres": "catalog:", "tsup": "catalog:", - "typescript": "^5.9.3", - "vitest": "^4.0.18", + "typescript": "catalog:", + "vitest": "catalog:", "zod": "catalog:" - }, - "prettier": "@kit/prettier-config" + } } diff --git a/packages/mcp-server/src/lib/__tests__/process-utils.test.ts b/packages/mcp-server/src/lib/__tests__/process-utils.test.ts index cff615aab..0c4515365 100644 --- a/packages/mcp-server/src/lib/__tests__/process-utils.test.ts +++ b/packages/mcp-server/src/lib/__tests__/process-utils.test.ts @@ -1,4 +1,3 @@ -import { createServer } from 'node:net'; import { afterAll, describe, expect, it } from 'vitest'; import { @@ -11,6 +10,8 @@ import { spawnDetached, } from '../process-utils'; +import { createServer } from 'node:net'; + const pidsToCleanup: number[] = []; afterAll(async () => { diff --git a/packages/mcp-server/src/tools/components.ts b/packages/mcp-server/src/tools/components.ts index 614a7d351..92838b908 100644 --- a/packages/mcp-server/src/tools/components.ts +++ b/packages/mcp-server/src/tools/components.ts @@ -1,13 +1,14 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as z from 'zod/v3'; + import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { z } from 'zod/v3'; interface ComponentInfo { name: string; exportPath: string; filePath: string; - category: 'shadcn' | 'makerkit' | 'utils'; + category: 'shadcn' | 'makerkit' | 'base-ui' | 'hooks' | 'utils'; description: string; } @@ -205,25 +206,32 @@ export class ComponentsTool { private static determineCategory( filePath: string, - ): 'shadcn' | 'makerkit' | 'utils' { + ): ComponentInfo['category'] { if (filePath.includes('/shadcn/')) return 'shadcn'; if (filePath.includes('/makerkit/')) return 'makerkit'; + if (filePath.includes('/base-ui/')) return 'base-ui'; + if (filePath.includes('/hooks/')) return 'hooks'; return 'utils'; } private static async generateDescription( exportName: string, _filePath: string, - category: 'shadcn' | 'makerkit' | 'utils', + category: ComponentInfo['category'], ): Promise<string> { const componentName = exportName.replace('./', ''); - if (category === 'shadcn') { - return this.getShadcnDescription(componentName); - } else if (category === 'makerkit') { - return this.getMakerkitDescription(componentName); - } else { - return this.getUtilsDescription(componentName); + switch (category) { + case 'shadcn': + return this.getShadcnDescription(componentName); + case 'makerkit': + return this.getMakerkitDescription(componentName); + case 'base-ui': + return this.getBaseUiDescription(componentName); + case 'hooks': + return this.getHooksDescription(componentName); + default: + return this.getUtilsDescription(componentName); } } @@ -284,6 +292,21 @@ export class ComponentsTool { 'Displays a form textarea or a component that looks like a textarea', tooltip: 'A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it', + 'context-menu': + 'A menu triggered by right-click or long-press for contextual actions', + empty: 'An empty state placeholder component', + pagination: 'Navigation component for paging through data sets', + 'native-select': 'A native HTML select element with consistent styling', + toggle: 'A two-state button that can be either on or off', + 'menu-bar': + 'A horizontal menu bar with dropdown menus for application commands', + 'aspect-ratio': 'Displays content within a desired ratio', + kbd: 'Keyboard shortcut indicator component', + 'button-group': 'Groups related buttons together with consistent spacing', + 'input-group': 'Groups an input with related addons or buttons', + item: 'A generic list item component with consistent styling', + field: 'A form field wrapper component with label and error handling', + drawer: 'A panel that slides in from the edge of the screen', }; return ( @@ -296,8 +319,6 @@ export class ComponentsTool { if: 'Conditional rendering component that shows children only when condition is true', trans: 'Internationalization component for translating text with interpolation support', - sidebar: - 'Application sidebar component with navigation and collapsible functionality', 'bordered-navigation-menu': 'Navigation menu component with bordered styling', spinner: 'Loading spinner component with customizable size and styling', @@ -316,14 +337,29 @@ export class ComponentsTool { 'Component for selecting application language/locale', stepper: 'Step-by-step navigation component for multi-step processes', 'card-button': 'Clickable card component that acts as a button', - 'multi-step-form': - 'Multi-step form component with validation and navigation', 'app-breadcrumbs': 'Application breadcrumb navigation component', 'empty-state': 'Component for displaying empty states with customizable content', marketing: 'Collection of marketing-focused components and layouts', 'file-uploader': 'File upload component with drag-and-drop and preview functionality', + 'navigation-schema': 'Schema and types for navigation configuration', + 'navigation-utils': + 'Utility functions for navigation path resolution and matching', + 'mode-toggle': 'Toggle button for switching between light and dark mode', + 'mobile-mode-toggle': + 'Mobile-optimized toggle for switching between light and dark mode', + 'lazy-render': + 'Component that defers rendering until visible in the viewport', + 'cookie-banner': 'GDPR-compliant cookie consent banner', + 'version-updater': + 'Component that checks for and prompts application updates', + 'oauth-provider-logo-image': + 'Displays the logo image for an OAuth provider', + 'copy-to-clipboard': 'Button component for copying text to the clipboard', + 'error-boundary': 'React error boundary component with fallback UI', + 'sidebar-navigation': + 'Sidebar navigation component with collapsible sections', }; return ( @@ -332,11 +368,30 @@ export class ComponentsTool { ); } + private static getBaseUiDescription(componentName: string): string { + const descriptions: Record<string, string> = { + 'csp-provider': 'Content Security Policy provider for Base UI components', + }; + + return descriptions[componentName] || `Base UI component: ${componentName}`; + } + + private static getHooksDescription(componentName: string): string { + const descriptions: Record<string, string> = { + 'hooks/use-async-dialog': + 'Hook for managing async dialog state with promise-based open/close', + 'hooks/use-mobile': 'Hook for detecting mobile viewport breakpoints', + 'use-supabase-upload': + 'Hook for uploading files to Supabase Storage with progress tracking', + }; + + return descriptions[componentName] || `React hook: ${componentName}`; + } + private static getUtilsDescription(componentName: string): string { const descriptions: Record<string, string> = { utils: 'Utility functions for styling, class management, and common operations', - 'navigation-schema': 'Schema and types for navigation configuration', }; return descriptions[componentName] || `Utility module: ${componentName}`; diff --git a/packages/mcp-server/src/tools/database.ts b/packages/mcp-server/src/tools/database.ts index 8d33aa10a..b0dd00dbf 100644 --- a/packages/mcp-server/src/tools/database.ts +++ b/packages/mcp-server/src/tools/database.ts @@ -1,8 +1,9 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import postgres from 'postgres'; +import * as z from 'zod/v3'; + import { readFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; -import postgres from 'postgres'; -import { z } from 'zod/v3'; const DATABASE_URL = process.env.DATABASE_URL || @@ -360,7 +361,7 @@ export class DatabaseTool { try { return await readFile(filePath, 'utf8'); - } catch (error) { + } catch (_error) { throw new Error(`Schema file "${fileName}" not found`); } } @@ -457,7 +458,7 @@ export class DatabaseTool { // Fallback to schema files const enumContent = await this.getSchemaContent('01-enums.sql'); return this.parseEnums(enumContent); - } catch (error) { + } catch (_error) { return {}; } } @@ -609,7 +610,7 @@ export class DatabaseTool { onDelete: fk.delete_rule, onUpdate: fk.update_rule, })); - } catch (error) { + } catch (_error) { return []; } } @@ -676,7 +677,7 @@ export class DatabaseTool { }; } return result; - } catch (error) { + } catch (_error) { return {}; } } diff --git a/packages/mcp-server/src/tools/db/index.ts b/packages/mcp-server/src/tools/db/index.ts index d7f4a25cb..bf0585fa2 100644 --- a/packages/mcp-server/src/tools/db/index.ts +++ b/packages/mcp-server/src/tools/db/index.ts @@ -1,7 +1,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { access, readFile, readdir } from 'node:fs/promises'; -import { Socket } from 'node:net'; -import { join } from 'node:path'; import { execFileAsync } from '../../lib/process-utils'; import { type KitDbServiceDeps, createKitDbService } from './kit-db.service'; @@ -16,6 +13,10 @@ import { KitDbStatusOutputSchema, } from './schema'; +import { access, readFile, readdir } from 'node:fs/promises'; +import { Socket } from 'node:net'; +import { join } from 'node:path'; + type TextContent = { type: 'text'; text: string; diff --git a/packages/mcp-server/src/tools/db/kit-db.service.ts b/packages/mcp-server/src/tools/db/kit-db.service.ts index 754eb39db..8e4652b94 100644 --- a/packages/mcp-server/src/tools/db/kit-db.service.ts +++ b/packages/mcp-server/src/tools/db/kit-db.service.ts @@ -1,5 +1,3 @@ -import { join } from 'node:path'; - import type { DbTool, KitDbMigrateInput, @@ -10,6 +8,8 @@ import type { KitDbStatusOutput, } from './schema'; +import { join } from 'node:path'; + type VariantFamily = 'supabase' | 'orm'; interface CommandResult { diff --git a/packages/mcp-server/src/tools/dev/index.ts b/packages/mcp-server/src/tools/dev/index.ts index f3ec9f427..0af66fe99 100644 --- a/packages/mcp-server/src/tools/dev/index.ts +++ b/packages/mcp-server/src/tools/dev/index.ts @@ -1,7 +1,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { access, readFile } from 'node:fs/promises'; -import { Socket } from 'node:net'; -import { join } from 'node:path'; import { execFileAsync, @@ -26,6 +23,10 @@ import { KitMailboxStatusOutputSchema, } from './schema'; +import { access, readFile } from 'node:fs/promises'; +import { Socket } from 'node:net'; +import { join } from 'node:path'; + export function registerKitDevTools(server: McpServer, rootPath?: string) { const service = createKitDevService(createKitDevDeps(rootPath)); diff --git a/packages/mcp-server/src/tools/emails/kit-emails.service.ts b/packages/mcp-server/src/tools/emails/kit-emails.service.ts index 6e490e357..eeda47246 100644 --- a/packages/mcp-server/src/tools/emails/kit-emails.service.ts +++ b/packages/mcp-server/src/tools/emails/kit-emails.service.ts @@ -1,9 +1,9 @@ -import path from 'node:path'; - import { EMAIL_TEMPLATE_RENDERERS } from '@kit/email-templates/registry'; import type { KitEmailsListOutput, KitEmailsReadOutput } from './schema'; +import path from 'node:path'; + export interface KitEmailsDeps { rootPath: string; readFile(filePath: string): Promise<string>; diff --git a/packages/mcp-server/src/tools/env/__tests__/find-workspace-root.test.ts b/packages/mcp-server/src/tools/env/__tests__/find-workspace-root.test.ts index 31d4b3145..63987b3fd 100644 --- a/packages/mcp-server/src/tools/env/__tests__/find-workspace-root.test.ts +++ b/packages/mcp-server/src/tools/env/__tests__/find-workspace-root.test.ts @@ -1,10 +1,11 @@ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { findWorkspaceRoot } from '../scanner'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + describe('findWorkspaceRoot', () => { let tmp: string; diff --git a/packages/mcp-server/src/tools/env/kit-env.service.ts b/packages/mcp-server/src/tools/env/kit-env.service.ts index 75a45ec2c..00dbb1a1a 100644 --- a/packages/mcp-server/src/tools/env/kit-env.service.ts +++ b/packages/mcp-server/src/tools/env/kit-env.service.ts @@ -1,10 +1,10 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - import { envVariables } from './model'; import { getEnvState } from './scanner'; import type { EnvMode, ScanFs } from './types'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + export interface KitEnvDeps { rootPath: string; readFile(filePath: string): Promise<string>; diff --git a/packages/mcp-server/src/tools/env/model.ts b/packages/mcp-server/src/tools/env/model.ts index ec5aff2bf..20b525e00 100644 --- a/packages/mcp-server/src/tools/env/model.ts +++ b/packages/mcp-server/src/tools/env/model.ts @@ -375,6 +375,16 @@ export const envVariables: EnvVariableModel[] = [ return z.coerce.boolean().optional().safeParse(value); }, }, + { + name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY', + displayName: 'Enable Team Accounts Only and disable persoanl accounts.', + description: 'Force disable personal accounts for pure B2B SaaS', + category: 'Features', + type: 'boolean', + validate: ({ value }) => { + return z.coerce.boolean().optional().safeParse(value); + }, + }, { name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', displayName: 'Enable Team Account Creation', @@ -405,6 +415,17 @@ export const envVariables: EnvVariableModel[] = [ return z.coerce.boolean().optional().safeParse(value); }, }, + { + name: 'NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY', + displayName: 'Enable Teams Accounts Only', + description: + 'When enabled, disables personal accounts and only allows team accounts.', + category: 'Features', + type: 'boolean', + validate: ({ value }) => { + return z.coerce.boolean().optional().safeParse(value); + }, + }, { name: 'NEXT_PUBLIC_ENABLE_NOTIFICATIONS', displayName: 'Enable Notifications', diff --git a/packages/mcp-server/src/tools/env/scanner.ts b/packages/mcp-server/src/tools/env/scanner.ts index 671e1d294..64e6ecc73 100644 --- a/packages/mcp-server/src/tools/env/scanner.ts +++ b/packages/mcp-server/src/tools/env/scanner.ts @@ -1,7 +1,3 @@ -import fs from 'fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'path'; - import { envVariables } from './model'; import { AppEnvState, @@ -12,6 +8,10 @@ import { ScanOptions, } from './types'; +import fs from 'fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'path'; + // Define precedence order for each mode const ENV_FILE_PRECEDENCE: Record<EnvMode, string[]> = { development: [ diff --git a/packages/mcp-server/src/tools/mailbox/index.ts b/packages/mcp-server/src/tools/mailbox/index.ts index f3c32c83a..9e07d6aaf 100644 --- a/packages/mcp-server/src/tools/mailbox/index.ts +++ b/packages/mcp-server/src/tools/mailbox/index.ts @@ -1,5 +1,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { Socket } from 'node:net'; import { execFileAsync } from '../../lib/process-utils'; import { @@ -15,6 +14,8 @@ import { KitEmailsSetReadStatusOutputSchema, } from './schema'; +import { Socket } from 'node:net'; + type TextContent = { type: 'text'; text: string; diff --git a/packages/mcp-server/src/tools/migrations.ts b/packages/mcp-server/src/tools/migrations.ts index 61990473c..7e75d1fe5 100644 --- a/packages/mcp-server/src/tools/migrations.ts +++ b/packages/mcp-server/src/tools/migrations.ts @@ -1,10 +1,11 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { readFile, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; -import { z } from 'zod/v3'; +import * as z from 'zod/v3'; import { crossExecFileSync } from '../lib/process-utils'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + export class MigrationsTool { private static _rootPath = process.cwd(); diff --git a/packages/mcp-server/src/tools/prd-manager.ts b/packages/mcp-server/src/tools/prd-manager.ts index 5c36fc110..608267dee 100644 --- a/packages/mcp-server/src/tools/prd-manager.ts +++ b/packages/mcp-server/src/tools/prd-manager.ts @@ -1,7 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as z from 'zod/v3'; + import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { z } from 'zod/v3'; // Custom phase for organizing user stories interface CustomPhase { diff --git a/packages/mcp-server/src/tools/prerequisites/index.ts b/packages/mcp-server/src/tools/prerequisites/index.ts index 1baed3803..d085b76a7 100644 --- a/packages/mcp-server/src/tools/prerequisites/index.ts +++ b/packages/mcp-server/src/tools/prerequisites/index.ts @@ -1,6 +1,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { access, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { execFileAsync } from '../../lib/process-utils'; import { @@ -12,6 +10,9 @@ import { KitPrerequisitesOutputSchema, } from './schema'; +import { access, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + export function registerKitPrerequisitesTool( server: McpServer, rootPath?: string, diff --git a/packages/mcp-server/src/tools/prompts.ts b/packages/mcp-server/src/tools/prompts.ts index e8603096c..bf27bfe6d 100644 --- a/packages/mcp-server/src/tools/prompts.ts +++ b/packages/mcp-server/src/tools/prompts.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod/v3'; +import * as z from 'zod/v3'; interface PromptTemplate { name: string; diff --git a/packages/mcp-server/src/tools/run-checks/__tests__/run-checks.service.test.ts b/packages/mcp-server/src/tools/run-checks/__tests__/run-checks.service.test.ts index 1fe6ff8e8..f162a42a8 100644 --- a/packages/mcp-server/src/tools/run-checks/__tests__/run-checks.service.test.ts +++ b/packages/mcp-server/src/tools/run-checks/__tests__/run-checks.service.test.ts @@ -9,8 +9,6 @@ function createDeps( overrides: Partial<RunChecksDeps> = {}, scripts: Record<string, string> = { typecheck: 'tsc --noEmit', - 'lint:fix': 'eslint . --fix', - 'format:fix': 'prettier . --write', test: 'vitest run', }, ): RunChecksDeps { diff --git a/packages/mcp-server/src/tools/run-checks/index.ts b/packages/mcp-server/src/tools/run-checks/index.ts index 6e6b0ac84..a412bcc2f 100644 --- a/packages/mcp-server/src/tools/run-checks/index.ts +++ b/packages/mcp-server/src/tools/run-checks/index.ts @@ -1,6 +1,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { execFileAsync } from '../../lib/process-utils'; import { @@ -9,6 +7,9 @@ import { } from './run-checks.service'; import { RunChecksInputSchema, RunChecksOutputSchema } from './schema'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + export function registerRunChecksTool(server: McpServer, rootPath?: string) { const service = createRunChecksService(createRunChecksDeps(rootPath)); diff --git a/packages/mcp-server/src/tools/scripts.ts b/packages/mcp-server/src/tools/scripts.ts index e4cfdd61a..b1a5c3e8b 100644 --- a/packages/mcp-server/src/tools/scripts.ts +++ b/packages/mcp-server/src/tools/scripts.ts @@ -1,7 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as z from 'zod/v3'; + import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { z } from 'zod/v3'; interface ScriptInfo { name: string; @@ -93,7 +94,7 @@ export class ScriptsTool { lint: { category: 'linting', description: - 'Run ESLint to check code quality and enforce coding standards', + 'Run Oxlint to check code quality and enforce coding standards', usage: 'CRITICAL: Run after writing code to ensure code quality. Must pass before commits.', importance: 'medium', @@ -102,7 +103,7 @@ export class ScriptsTool { 'lint:fix': { category: 'linting', description: - 'Run ESLint with auto-fix to automatically resolve fixable issues', + 'Run Oxlint with auto-fix to automatically resolve fixable issues', usage: 'Use to automatically fix linting issues. Run before manual fixes.', importance: 'high', @@ -110,14 +111,14 @@ export class ScriptsTool { }, format: { category: 'linting', - description: 'Check code formatting with Prettier across all files', + description: 'Check code formatting with Oxfmt across all files', usage: 'Verify code follows consistent formatting standards.', importance: 'high', }, 'format:fix': { category: 'linting', description: - 'Auto-format all code with Prettier to ensure consistent styling', + 'Auto-format all code with Oxfmt to ensure consistent styling', usage: 'Use to automatically format code. Run before commits.', importance: 'high', healthcheck: true, diff --git a/packages/mcp-server/src/tools/status/index.ts b/packages/mcp-server/src/tools/status/index.ts index eb5aa58c4..99abe8f17 100644 --- a/packages/mcp-server/src/tools/status/index.ts +++ b/packages/mcp-server/src/tools/status/index.ts @@ -1,7 +1,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { access, readFile, stat } from 'node:fs/promises'; -import { Socket } from 'node:net'; -import { join } from 'node:path'; import { execFileAsync } from '../../lib/process-utils'; import { @@ -10,6 +7,10 @@ import { } from './kit-status.service'; import { KitStatusInputSchema, KitStatusOutputSchema } from './schema'; +import { access, readFile, stat } from 'node:fs/promises'; +import { Socket } from 'node:net'; +import { join } from 'node:path'; + export function registerKitStatusTool(server: McpServer, rootPath?: string) { return server.registerTool( 'kit_status', diff --git a/packages/mcp-server/src/tools/status/kit-status.service.ts b/packages/mcp-server/src/tools/status/kit-status.service.ts index c8149a0fe..880b7a5a3 100644 --- a/packages/mcp-server/src/tools/status/kit-status.service.ts +++ b/packages/mcp-server/src/tools/status/kit-status.service.ts @@ -1,7 +1,7 @@ -import { join } from 'node:path'; - import type { KitStatusInput, KitStatusOutput } from './schema'; +import { join } from 'node:path'; + interface VariantDescriptor { variant: string; variant_family: string; diff --git a/packages/mcp-server/src/tools/translations/__tests__/kit-translations.service.test.ts b/packages/mcp-server/src/tools/translations/__tests__/kit-translations.service.test.ts index 3cb115e81..20ef83d82 100644 --- a/packages/mcp-server/src/tools/translations/__tests__/kit-translations.service.test.ts +++ b/packages/mcp-server/src/tools/translations/__tests__/kit-translations.service.test.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -6,6 +5,8 @@ import { createKitTranslationsService, } from '../kit-translations.service'; +import path from 'node:path'; + function createDeps( files: Record<string, string>, directories: string[], @@ -91,7 +92,7 @@ function createDeps( describe('KitTranslationsService.list', () => { it('lists and flattens translations with missing namespace fallback', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({ @@ -122,7 +123,7 @@ describe('KitTranslationsService.list', () => { describe('KitTranslationsService.update', () => { it('updates nested translation keys', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -143,7 +144,7 @@ describe('KitTranslationsService.update', () => { }); it('rejects paths outside locales root', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -164,7 +165,7 @@ describe('KitTranslationsService.update', () => { }); it('rejects namespace path segments', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -187,7 +188,7 @@ describe('KitTranslationsService.update', () => { describe('KitTranslationsService.stats', () => { it('computes coverage using base locale keys', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({ @@ -213,7 +214,7 @@ describe('KitTranslationsService.stats', () => { describe('KitTranslationsService.addNamespace', () => { it('creates namespace JSON in all locale directories', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -237,7 +238,7 @@ describe('KitTranslationsService.addNamespace', () => { }); it('throws if namespace already exists', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -253,7 +254,7 @@ describe('KitTranslationsService.addNamespace', () => { }); it('throws if no locales exist', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -264,7 +265,7 @@ describe('KitTranslationsService.addNamespace', () => { }); it('rejects path traversal in namespace', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -286,7 +287,7 @@ describe('KitTranslationsService.addNamespace', () => { describe('KitTranslationsService.addLocale', () => { it('creates locale directory with namespace files', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({ hello: 'Hello' }), @@ -310,7 +311,7 @@ describe('KitTranslationsService.addLocale', () => { }); it('throws if locale already exists', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -326,7 +327,7 @@ describe('KitTranslationsService.addLocale', () => { }); it('works when no namespaces exist yet', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -337,7 +338,7 @@ describe('KitTranslationsService.addLocale', () => { }); it('rejects path traversal in locale', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -354,7 +355,7 @@ describe('KitTranslationsService.addLocale', () => { describe('KitTranslationsService.removeNamespace', () => { it('deletes namespace files from all locales', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -377,7 +378,7 @@ describe('KitTranslationsService.removeNamespace', () => { }); it('throws if namespace does not exist', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -393,7 +394,7 @@ describe('KitTranslationsService.removeNamespace', () => { }); it('rejects path traversal', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -406,7 +407,7 @@ describe('KitTranslationsService.removeNamespace', () => { describe('KitTranslationsService.removeLocale', () => { it('deletes entire locale directory', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -426,7 +427,7 @@ describe('KitTranslationsService.removeLocale', () => { }); it('throws if locale does not exist', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -437,7 +438,7 @@ describe('KitTranslationsService.removeLocale', () => { }); it('throws when trying to delete base locale', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -454,7 +455,7 @@ describe('KitTranslationsService.removeLocale', () => { }); it('rejects path traversal', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); diff --git a/packages/mcp-server/src/tools/translations/kit-translations.service.ts b/packages/mcp-server/src/tools/translations/kit-translations.service.ts index 960e84d43..95e782aeb 100644 --- a/packages/mcp-server/src/tools/translations/kit-translations.service.ts +++ b/packages/mcp-server/src/tools/translations/kit-translations.service.ts @@ -1,5 +1,3 @@ -import path from 'node:path'; - import type { KitTranslationsAddLocaleInput, KitTranslationsAddLocaleSuccess, @@ -15,6 +13,8 @@ import type { KitTranslationsUpdateSuccess, } from './schema'; +import path from 'node:path'; + export interface KitTranslationsDeps { rootPath: string; readFile(filePath: string): Promise<string>; @@ -408,7 +408,7 @@ export class KitTranslationsService { } private getLocalesRoot() { - return path.resolve(this.deps.rootPath, 'apps', 'web', 'public', 'locales'); + return path.resolve(this.deps.rootPath, 'apps', 'web', 'i18n', 'messages'); } } diff --git a/packages/monitoring/api/eslint.config.mjs b/packages/monitoring/api/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/monitoring/api/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/monitoring/api/package.json b/packages/monitoring/api/package.json index fa27b64cf..ef37cdd11 100644 --- a/packages/monitoring/api/package.json +++ b/packages/monitoring/api/package.json @@ -1,37 +1,32 @@ { "name": "@kit/monitoring", + "version": "0.1.0", "private": true, "sideEffects": false, - "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf ../.turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint ..", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - "./server": "./src/server.ts", - "./instrumentation": "./src/instrumentation.ts", - "./hooks": "./src/hooks/index.ts", - "./components": "./src/components/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/monitoring-core": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/sentry": "workspace:*", - "@kit/shared": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/react": "catalog:", - "react": "catalog:", - "zod": "catalog:" - }, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + "./server": "./src/server.ts", + "./instrumentation": "./src/instrumentation.ts", + "./hooks": "./src/hooks/index.ts", + "./components": "./src/components/index.ts" + }, + "scripts": { + "clean": "git clean -xdf ../.turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/monitoring-core": "workspace:*", + "@kit/sentry": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@types/react": "catalog:", + "react": "catalog:", + "zod": "catalog:" } } diff --git a/packages/monitoring/api/src/get-monitoring-provider.ts b/packages/monitoring/api/src/get-monitoring-provider.ts index ed6d202d6..ecfae796c 100644 --- a/packages/monitoring/api/src/get-monitoring-provider.ts +++ b/packages/monitoring/api/src/get-monitoring-provider.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const MONITORING_PROVIDERS = [ 'sentry', @@ -7,13 +7,11 @@ const MONITORING_PROVIDERS = [ ] as const; export const MONITORING_PROVIDER = z - .enum(MONITORING_PROVIDERS, { - errorMap: () => ({ message: 'Invalid monitoring provider' }), - }) + .enum(MONITORING_PROVIDERS) .optional() .transform((value) => value || undefined); -export type MonitoringProvider = z.infer<typeof MONITORING_PROVIDER>; +export type MonitoringProvider = z.output<typeof MONITORING_PROVIDER>; export function getMonitoringProvider() { const provider = MONITORING_PROVIDER.safeParse( diff --git a/packages/monitoring/core/eslint.config.mjs b/packages/monitoring/core/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/monitoring/core/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/monitoring/core/package.json b/packages/monitoring/core/package.json index cf27e6e62..2391c78d2 100644 --- a/packages/monitoring/core/package.json +++ b/packages/monitoring/core/package.json @@ -1,30 +1,25 @@ { "name": "@kit/monitoring-core", + "version": "0.1.0", "private": true, "sideEffects": false, - "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/react": "catalog:", - "react": "catalog:" - }, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@kit/tsconfig": "workspace:*", + "@types/react": "catalog:", + "react": "catalog:" } } diff --git a/packages/monitoring/sentry/eslint.config.mjs b/packages/monitoring/sentry/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/monitoring/sentry/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/monitoring/sentry/package.json b/packages/monitoring/sentry/package.json index cb19416b0..3fee9a314 100644 --- a/packages/monitoring/sentry/package.json +++ b/packages/monitoring/sentry/package.json @@ -1,36 +1,31 @@ { "name": "@kit/sentry", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts", - "./provider": "./src/components/provider.tsx", - "./config/client": "./src/sentry.client.config.ts", - "./config/server": "./src/sentry.client.server.ts" - }, - "dependencies": { - "@sentry/nextjs": "catalog:" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/monitoring-core": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/react": "catalog:", - "react": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./provider": "./src/components/provider.tsx", + "./config/client": "./src/sentry.client.config.ts", + "./config/server": "./src/sentry.client.server.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sentry/nextjs": "catalog:" + }, + "devDependencies": { + "@kit/monitoring-core": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@types/react": "catalog:", + "react": "catalog:" } } diff --git a/packages/next/AGENTS.md b/packages/next/AGENTS.md index 75c4e74ec..622170a12 100644 --- a/packages/next/AGENTS.md +++ b/packages/next/AGENTS.md @@ -1,58 +1,31 @@ -# Next.js Utilities +# @kit/next — Next.js Utilities -## Quick Reference +## Non-Negotiables -| Function | Import | Purpose | -|----------|--------|---------| -| `enhanceAction` | `@kit/next/actions` | Server actions with auth + validation | -| `enhanceRouteHandler` | `@kit/next/routes` | API routes with auth + validation | - -## Guidelines - -- Server Actions for mutations only, not data-fetching -- Keep actions light - move business logic to services -- Authorization via RLS, not application code -- Use `'use server'` at top of file -- Always validate with Zod schema +1. ALWAYS validate input with Zod schema via `.inputSchema()` +2. ALWAYS use `authActionClient` for authenticated actions, `publicActionClient` for public +3. ALWAYS use `useAction` hook from `next-safe-action/hooks` on the client side +4. ALWAYS use `revalidatePath` after mutations +5. NEVER use server actions for data fetching — mutations only +6. NEVER put business logic in actions — extract to service files +7. NEVER use `router.refresh()` or `router.push()` after server actions +8. NEVER use `adminActionClient` outside admin features — use `authActionClient` ## Skills -For detailed implementation patterns: -- `/server-action-builder` - Complete server action workflow +- `/server-action-builder` — Complete server action workflow -## Server Action Pattern +## Quick Reference -```typescript -'use server'; +| Function | Import | Purpose | +| --------------------- | ----------------------- | ---------------------------------- | +| `authActionClient` | `@kit/next/safe-action` | Authenticated server actions | +| `publicActionClient` | `@kit/next/safe-action` | Public server actions (no auth) | +| `captchaActionClient` | `@kit/next/safe-action` | Server actions with CAPTCHA + auth | +| `enhanceRouteHandler` | `@kit/next/routes` | API routes with auth + validation | -import { enhanceAction } from '@kit/next/actions'; +## Exemplars -export const myAction = enhanceAction( - async function (data, user) { - // data: validated, user: authenticated - return { success: true }; - }, - { - auth: true, - schema: MySchema, - }, -); -``` - -## Route Handler Pattern - -```typescript -import { enhanceRouteHandler } from '@kit/next/routes'; - -export const POST = enhanceRouteHandler( - async function ({ body, user, request }) { - return NextResponse.json({ success: true }); - }, - { auth: true, schema: MySchema }, -); -``` - -## Revalidation - -- Use `revalidatePath` after mutations -- Never use `router.refresh()` or `router.push()` after Server Actions +- Server action: `packages/features/accounts/src/server/personal-accounts-server-actions.ts` +- Route handler: `apps/web/app/[locale]/home/[account]/members/policies/route.ts` +- Client usage: `apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx` diff --git a/packages/next/eslint.config.mjs b/packages/next/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/next/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/next/package.json b/packages/next/package.json index 68070f01c..95ead2b5b 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,35 +1,33 @@ { "name": "@kit/next", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - "./actions": "./src/actions/index.ts", - "./routes": "./src/routes/index.ts" - }, - "devDependencies": { - "@kit/auth": "workspace:*", - "@kit/eslint-config": "workspace:*", - "@kit/monitoring": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/supabase": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@supabase/supabase-js": "catalog:", - "@types/node": "catalog:", - "next": "catalog:", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + "./actions": "./src/actions/index.ts", + "./safe-action": "./src/actions/safe-action-client.ts", + "./routes": "./src/routes/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next-safe-action": "catalog:" + }, + "devDependencies": { + "@kit/auth": "workspace:*", + "@kit/monitoring": "workspace:*", + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@supabase/supabase-js": "catalog:", + "next": "catalog:", + "zod": "catalog:" } } diff --git a/packages/next/src/actions/index.ts b/packages/next/src/actions/index.ts index 7105b83bb..7cf315b04 100644 --- a/packages/next/src/actions/index.ts +++ b/packages/next/src/actions/index.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { redirect } from 'next/navigation'; import { ZodType, z } from 'zod'; @@ -12,6 +11,7 @@ import { JWTUserData } from '@kit/supabase/types'; /** * @name enhanceAction * @description Enhance an action with captcha, schema and auth checks + * @deprecated Use [authActionClient](safe-action-client.ts) instead */ export function enhanceAction< Args, @@ -20,19 +20,22 @@ export function enhanceAction< auth?: boolean; captcha?: boolean; schema?: z.ZodType< - Config['captcha'] extends true ? Args & { captchaToken: string } : Args, - z.ZodTypeDef + Config['captcha'] extends true ? Args & { captchaToken: string } : Args >; }, >( fn: ( - params: Config['schema'] extends ZodType ? z.infer<Config['schema']> : Args, + params: Config['schema'] extends ZodType + ? z.output<Config['schema']> + : Args, user: Config['auth'] extends false ? undefined : JWTUserData, ) => Response | Promise<Response>, config: Config, ) { return async ( - params: Config['schema'] extends ZodType ? z.infer<Config['schema']> : Args, + params: Config['schema'] extends ZodType + ? z.output<Config['schema']> + : Args, ) => { type UserParam = Config['auth'] extends false ? undefined : JWTUserData; @@ -80,6 +83,11 @@ export function enhanceAction< user = auth.data as UserParam; } - return fn(data, user); + return fn( + data as Config['schema'] extends ZodType + ? z.output<Config['schema']> + : Args, + user, + ); }; } diff --git a/packages/next/src/actions/safe-action-client.ts b/packages/next/src/actions/safe-action-client.ts new file mode 100644 index 000000000..8045644b7 --- /dev/null +++ b/packages/next/src/actions/safe-action-client.ts @@ -0,0 +1,55 @@ +import 'server-only'; +import { redirect } from 'next/navigation'; + +import { createSafeActionClient } from 'next-safe-action'; + +import { verifyCaptchaToken } from '@kit/auth/captcha/server'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const baseClient = createSafeActionClient({ + handleServerError: (error) => error.message, +}); + +/** + * @name publicActionClient + * @description Safe action client for public actions that don't require authentication. + */ +export const publicActionClient = baseClient; + +/** + * @name authActionClient + * @description Safe action client for authenticated actions. Adds user context. + */ +export const authActionClient = baseClient.use(async ({ next }) => { + const auth = await requireUser(getSupabaseServerClient()); + + if (!auth.data) { + redirect(auth.redirectTo); + } + + return next({ ctx: { user: auth.data } }); +}); + +/** + * @name captchaActionClient + * @description Safe action client for actions that require CAPTCHA and authentication. + */ +export const captchaActionClient = baseClient.use( + async ({ next, clientInput }) => { + const input = clientInput as Record<string, unknown>; + + const token = + typeof input?.captchaToken === 'string' ? input.captchaToken : ''; + + await verifyCaptchaToken(token); + + const auth = await requireUser(getSupabaseServerClient()); + + if (!auth.data) { + redirect(auth.redirectTo); + } + + return next({ ctx: { user: auth.data } }); + }, +); diff --git a/packages/next/src/routes/index.ts b/packages/next/src/routes/index.ts index 0ad30f17d..ebe6eaae5 100644 --- a/packages/next/src/routes/index.ts +++ b/packages/next/src/routes/index.ts @@ -1,9 +1,8 @@ import 'server-only'; - import { redirect } from 'next/navigation'; import { NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; +import * as z from 'zod'; import { verifyCaptchaToken } from '@kit/auth/captcha/server'; import { requireUser } from '@kit/supabase/require-user'; @@ -22,7 +21,7 @@ interface HandlerParams< > { request: NextRequest; user: RequireAuth extends false ? undefined : JWTUserData; - body: Schema extends z.ZodType ? z.infer<Schema> : undefined; + body: Schema extends z.ZodType ? z.output<Schema> : undefined; params: Record<string, string>; } @@ -48,7 +47,7 @@ interface HandlerParams< */ export const enhanceRouteHandler = < Body, - Params extends Config<z.ZodType<Body, z.ZodTypeDef>>, + Params extends Config<z.ZodType<Body>>, >( // Route handler function handler: diff --git a/packages/otp/eslint.config.mjs b/packages/otp/eslint.config.mjs deleted file mode 100644 index 67b0a0878..000000000 --- a/packages/otp/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@kit/eslint-config/base.js'; - -export default baseConfig; \ No newline at end of file diff --git a/packages/otp/package.json b/packages/otp/package.json index e4557db21..8fa35bcc5 100644 --- a/packages/otp/package.json +++ b/packages/otp/package.json @@ -1,43 +1,39 @@ { "name": "@kit/otp", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/api/index.ts", - "./components": "./src/components/index.ts" - }, - "devDependencies": { - "@hookform/resolvers": "^5.2.2", - "@kit/email-templates": "workspace:*", - "@kit/eslint-config": "workspace:*", - "@kit/mailers": "workspace:*", - "@kit/next": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/shared": "workspace:*", - "@kit/supabase": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@kit/ui": "workspace:*", - "@radix-ui/react-icons": "^1.3.2", - "@supabase/supabase-js": "catalog:", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "react-hook-form": "catalog:", - "zod": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/api/index.ts", + "./components": "./src/components/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "catalog:", + "@kit/email-templates": "workspace:*", + "@kit/mailers": "workspace:*", + "@kit/next": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:*", + "@supabase/supabase-js": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "lucide-react": "catalog:", + "next-safe-action": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-hook-form": "catalog:", + "zod": "catalog:" } } diff --git a/packages/otp/src/components/verify-otp-form.tsx b/packages/otp/src/components/verify-otp-form.tsx index 193ed1942..7e0c41af8 100644 --- a/packages/otp/src/components/verify-otp-form.tsx +++ b/packages/otp/src/components/verify-otp-form.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Button } from '@kit/ui/button'; @@ -61,17 +62,28 @@ export function VerifyOtpForm({ }: VerifyOtpFormProps) { // Track the current step (email entry or OTP verification) const [step, setStep] = useState<'email' | 'otp'>('email'); - const [isPending, startTransition] = useTransition(); // Track errors const [error, setError] = useState<string | null>(null); - // Track verification success - const [, setVerificationSuccess] = useState(false); + + const { execute: executeSendOtp, isPending } = useAction(sendOtpEmailAction, { + onSuccess: ({ data }) => { + if (data?.success) { + setStep('otp'); + setError(null); + } else { + setError(data?.error || 'Failed to send OTP. Please try again.'); + } + }, + onError: () => { + setError('An unexpected error occurred. Please try again.'); + }, + }); // Email form const emailForm = useForm({ resolver: zodResolver(SendOtpSchema), - defaultValues: { + values: { email, }, }); @@ -88,28 +100,14 @@ export function VerifyOtpForm({ const handleSendOtp = () => { setError(null); - startTransition(async () => { - try { - const result = await sendOtpEmailAction({ - purpose, - email, - }); - - if (result.success) { - setStep('otp'); - } else { - setError(result.error || 'Failed to send OTP. Please try again.'); - } - } catch (err) { - setError('An unexpected error occurred. Please try again.'); - console.error('Error sending OTP:', err); - } + executeSendOtp({ + purpose, + email, }); }; // Handle OTP verification - const handleVerifyOtp = (data: z.infer<typeof VerifyOtpSchema>) => { - setVerificationSuccess(true); + const handleVerifyOtp = (data: z.output<typeof VerifyOtpSchema>) => { onSuccess(data.otp); }; @@ -124,7 +122,7 @@ export function VerifyOtpForm({ <div className="flex flex-col gap-y-2"> <p className="text-muted-foreground text-sm"> <Trans - i18nKey="common:otp.requestVerificationCodeDescription" + i18nKey="common.otp.requestVerificationCodeDescription" values={{ email }} /> </p> @@ -132,10 +130,10 @@ export function VerifyOtpForm({ <If condition={Boolean(error)}> <Alert variant="destructive"> - <ExclamationTriangleIcon className="h-4 w-4" /> + <TriangleAlert className="h-4 w-4" /> <AlertTitle> - <Trans i18nKey="common:otp.errorSendingCode" /> + <Trans i18nKey="common.otp.errorSendingCode" /> </AlertTitle> <AlertDescription>{error}</AlertDescription> @@ -153,10 +151,10 @@ export function VerifyOtpForm({ {isPending ? ( <> <Spinner className="mr-2 h-4 w-4" /> - <Trans i18nKey="common:otp.sendingCode" /> + <Trans i18nKey="common.otp.sendingCode" /> </> ) : ( - <Trans i18nKey="common:otp.sendVerificationCode" /> + <Trans i18nKey="common.otp.sendVerificationCode" /> )} </Button> </div> @@ -166,7 +164,7 @@ export function VerifyOtpForm({ <Form {...otpForm}> <div className="flex w-full flex-col items-center gap-y-8"> <div className="text-muted-foreground text-sm"> - <Trans i18nKey="common:otp.codeSentToEmail" values={{ email }} /> + <Trans i18nKey="common.otp.codeSentToEmail" values={{ email }} /> </div> <form @@ -175,10 +173,10 @@ export function VerifyOtpForm({ > <If condition={Boolean(error)}> <Alert variant="destructive"> - <ExclamationTriangleIcon className="h-4 w-4" /> + <TriangleAlert className="h-4 w-4" /> <AlertTitle> - <Trans i18nKey="common:error" /> + <Trans i18nKey="common.error" /> </AlertTitle> <AlertDescription>{error}</AlertDescription> @@ -212,7 +210,7 @@ export function VerifyOtpForm({ </FormControl> <FormDescription> - <Trans i18nKey="common:otp.enterCodeFromEmail" /> + <Trans i18nKey="common.otp.enterCodeFromEmail" /> </FormDescription> <FormMessage /> </FormItem> @@ -229,7 +227,7 @@ export function VerifyOtpForm({ disabled={isPending} onClick={() => setStep('email')} > - <Trans i18nKey="common:otp.requestNewCode" /> + <Trans i18nKey="common.otp.requestNewCode" /> </Button> <Button @@ -240,10 +238,10 @@ export function VerifyOtpForm({ {isPending ? ( <> <Spinner className="mr-2 h-4 w-4" /> - <Trans i18nKey="common:otp.verifying" /> + <Trans i18nKey="common.otp.verifying" /> </> ) : ( - <Trans i18nKey="common:otp.verifyCode" /> + <Trans i18nKey="common.otp.verifyCode" /> )} </Button> </div> diff --git a/packages/otp/src/server/otp-email.service.ts b/packages/otp/src/server/otp-email.service.ts index fc14fb027..7186c0986 100644 --- a/packages/otp/src/server/otp-email.service.ts +++ b/packages/otp/src/server/otp-email.service.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { renderOtpEmail } from '@kit/email-templates'; import { getMailer } from '@kit/mailers'; @@ -6,14 +6,14 @@ import { getLogger } from '@kit/shared/logger'; const EMAIL_SENDER = z .string({ - required_error: 'EMAIL_SENDER is required', + error: 'EMAIL_SENDER is required', }) .min(1) .parse(process.env.EMAIL_SENDER); const PRODUCT_NAME = z .string({ - required_error: 'PRODUCT_NAME is required', + error: 'PRODUCT_NAME is required', }) .min(1) .parse(process.env.NEXT_PUBLIC_PRODUCT_NAME); diff --git a/packages/otp/src/server/otp.service.ts b/packages/otp/src/server/otp.service.ts index a16b6d340..91edde714 100644 --- a/packages/otp/src/server/otp.service.ts +++ b/packages/otp/src/server/otp.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { SupabaseClient } from '@supabase/supabase-js'; import { getLogger } from '@kit/shared/logger'; diff --git a/packages/otp/src/server/server-actions.ts b/packages/otp/src/server/server-actions.ts index 2e491b370..92551742b 100644 --- a/packages/otp/src/server/server-actions.ts +++ b/packages/otp/src/server/server-actions.ts @@ -1,8 +1,8 @@ 'use server'; -import { z } from 'zod'; +import * as z from 'zod'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -25,8 +25,9 @@ const SendOtpEmailSchema = z.object({ /** * Server action to generate an OTP and send it via email */ -export const sendOtpEmailAction = enhanceAction( - async function (data: z.infer<typeof SendOtpEmailSchema>, user) { +export const sendOtpEmailAction = authActionClient + .inputSchema(SendOtpEmailSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { const logger = await getLogger(); const ctx = { name: 'send-otp-email', userId: user.id }; const email = user.email; @@ -87,9 +88,4 @@ export const sendOtpEmailAction = enhanceAction( error instanceof Error ? error.message : 'Failed to send OTP email', }; } - }, - { - schema: SendOtpEmailSchema, - auth: true, - }, -); + }); diff --git a/packages/policies/AGENTS.md b/packages/policies/AGENTS.md index 2fe61838c..cdca60e4d 100644 --- a/packages/policies/AGENTS.md +++ b/packages/policies/AGENTS.md @@ -1,684 +1,19 @@ -# FeaturePolicy API - Registry-Based Policy System +# @kit/policies — Registry-Based Policy System -A unified, registry-based foundation for implementing business rules across all Makerkit features. +## Non-Negotiables -## Overview +1. ALWAYS use `definePolicy` with a unique `id` and register in a registry via `createPolicyRegistry()` +2. NEVER write inline policies in feature code — define in a registry file +3. ALWAYS use `allow()`/`deny()` returns with error codes and remediation messages +4. ALWAYS assign stages (`preliminary`, `submission`) for stage-aware evaluation +5. ALWAYS use `createPoliciesFromRegistry()` to load policies by ID — supports config tuples like `['max-invitations', { maxInvitations: 5 }]` +6. ALWAYS use `createPolicyEvaluator()` and call `evaluatePolicies()` or `evaluateGroups()` +7. NEVER evaluate policies without specifying an operator (`ALL` = AND, `ANY` = OR) -The FeaturePolicy API provides: +## Key Imports -- **Registry-based architecture** - centralized policy management with IDs -- **Configuration support** - policies can accept typed configuration objects -- **Stage-aware evaluation** - policies can be filtered by execution stage -- **Immutable contexts** for safe policy evaluation -- **Customer extensibility** - easy to add custom policies without forking +- `definePolicy`, `allow`, `deny`, `createPolicyRegistry`, `createPoliciesFromRegistry`, `createPolicyEvaluator` — all from `@kit/policies` -## Quick Start +## Exemplar -### 1. Register Policies - -```typescript -import { z } from 'zod'; - -import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies'; - -const registry = createPolicyRegistry(); - -// Register a basic policy -registry.registerPolicy( - definePolicy({ - id: 'email-validation', - stages: ['preliminary', 'submission'], - evaluate: async (context) => { - if (!context.userEmail?.includes('@')) { - return deny({ - code: 'INVALID_EMAIL_FORMAT', - message: 'Invalid email format', - remediation: 'Please provide a valid email address', - }); - } - return allow(); - }, - }), -); - -// Register a configurable policy -registry.registerPolicy( - definePolicy({ - id: 'max-invitations', - configSchema: z.object({ - maxInvitations: z.number().positive(), - }), - evaluate: async (context, config = { maxInvitations: 5 }) => { - if (context.invitations.length > config.maxInvitations) { - return deny({ - code: 'MAX_INVITATIONS_EXCEEDED', - message: `Cannot invite more than ${config.maxInvitations} members`, - remediation: `Reduce invitations to ${config.maxInvitations} or fewer`, - }); - } - return allow(); - }, - }), -); -``` - -### 2. Use Policies from Registry - -```typescript -import { - createPoliciesFromRegistry, - createPolicyEvaluator, - createPolicyRegistry, -} from '@kit/policies'; - -const registry = createPolicyRegistry(); - -// Load policies from registry -const policies = await createPoliciesFromRegistry(registry, [ - 'email-validation', - 'subscription-required', - ['max-invitations', { maxInvitations: 5 }], // with configuration -]); - -const evaluator = createPolicyEvaluator(); -const result = await evaluator.evaluatePolicies(policies, context, 'ALL'); - -if (!result.allowed) { - console.log('Failed reasons:', result.reasons); -} -``` - -### 3. Group Policies with Complex Logic - -```typescript -// Basic group example -const preliminaryGroup = { - operator: 'ALL' as const, - policies: [emailValidationPolicy, authenticationPolicy], -}; - -const billingGroup = { - operator: 'ANY' as const, - policies: [subscriptionPolicy, trialPolicy], -}; - -// Evaluate groups in sequence -const result = await evaluator.evaluateGroups( - [preliminaryGroup, billingGroup], - context, -); -``` - -## Complex Group Flows - -### Real-World Multi-Stage Team Invitation Flow - -```typescript -import { createPolicy, createPolicyEvaluator } from '@kit/policies'; - -// Complex business logic: (Authentication AND Email Validation) AND (Subscription OR Trial) AND Billing Limits -async function validateTeamInvitation(context: InvitationContext) { - const evaluator = createPolicyEvaluator(); - - // Stage 1: Authentication Requirements (ALL must pass) - const authenticationGroup = { - operator: 'ALL' as const, - policies: [ - createPolicy(async (ctx) => - ctx.userId - ? allow({ step: 'authenticated' }) - : deny('Authentication required'), - ), - createPolicy(async (ctx) => - ctx.email.includes('@') - ? allow({ step: 'email-valid' }) - : deny('Valid email required'), - ), - createPolicy(async (ctx) => - ctx.permissions.includes('invite') - ? allow({ step: 'permissions' }) - : deny('Insufficient permissions'), - ), - ], - }; - - // Stage 2: Subscription Validation (ANY sufficient - flexible billing) - const subscriptionGroup = { - operator: 'ANY' as const, - policies: [ - createPolicy(async (ctx) => - ctx.subscription?.active && ctx.subscription.plan === 'enterprise' - ? allow({ billing: 'enterprise' }) - : deny('Enterprise subscription required'), - ), - createPolicy(async (ctx) => - ctx.subscription?.active && ctx.subscription.plan === 'pro' - ? allow({ billing: 'pro' }) - : deny('Pro subscription required'), - ), - createPolicy(async (ctx) => - ctx.trial?.active && ctx.trial.daysRemaining > 0 - ? allow({ billing: 'trial', daysLeft: ctx.trial.daysRemaining }) - : deny('Active trial required'), - ), - ], - }; - - // Stage 3: Final Constraints (ALL must pass) - const constraintsGroup = { - operator: 'ALL' as const, - policies: [ - createPolicy(async (ctx) => - ctx.team.memberCount < ctx.subscription?.maxMembers - ? allow({ constraint: 'member-limit' }) - : deny('Member limit exceeded'), - ), - createPolicy(async (ctx) => - ctx.invitations.length <= 10 - ? allow({ constraint: 'batch-size' }) - : deny('Cannot invite more than 10 members at once'), - ), - ], - }; - - // Execute all groups sequentially - ALL groups must pass - const result = await evaluator.evaluateGroups( - [authenticationGroup, subscriptionGroup, constraintsGroup], - context, - ); - - return { - allowed: result.allowed, - reasons: result.reasons, - metadata: { - stagesCompleted: result.results.length, - authenticationPassed: result.results.some( - (r) => r.metadata?.step === 'authenticated', - ), - billingType: result.results.find((r) => r.metadata?.billing)?.metadata - ?.billing, - constraintsChecked: result.results.some((r) => r.metadata?.constraint), - }, - }; -} -``` - -### Middleware-Style Policy Chain - -```typescript -// Simulate middleware pattern: Auth → Rate Limiting → Business Logic -async function processApiRequest(context: ApiContext) { - const evaluator = createPoliciesEvaluator(); - - // Layer 1: Security (ALL required) - const securityLayer = { - operator: 'ALL' as const, - policies: [ - createPolicy(async (ctx) => - ctx.apiKey && ctx.apiKey.length > 0 - ? allow({ security: 'api-key-valid' }) - : deny('API key required'), - ), - createPolicy(async (ctx) => - ctx.rateLimitRemaining > 0 - ? allow({ security: 'rate-limit-ok' }) - : deny('Rate limit exceeded'), - ), - createPolicy(async (ctx) => - !ctx.blacklisted - ? allow({ security: 'not-blacklisted' }) - : deny('Client is blacklisted'), - ), - ], - }; - - // Layer 2: Authorization (ANY sufficient - flexible access levels) - const authorizationLayer = { - operator: 'ANY' as const, - policies: [ - createPolicy(async (ctx) => - ctx.user.role === 'admin' - ? allow({ access: 'admin' }) - : deny('Admin access denied'), - ), - createPolicy(async (ctx) => - ctx.user.permissions.includes(ctx.requestedResource) - ? allow({ access: 'resource-specific' }) - : deny('Resource access denied'), - ), - createPolicy(async (ctx) => - ctx.user.subscription?.includes('api-access') - ? allow({ access: 'subscription-based' }) - : deny('Subscription access denied'), - ), - ], - }; - - // Layer 3: Business Rules (ALL required) - const businessLayer = { - operator: 'ALL' as const, - policies: [ - createPolicy(async (ctx) => - ctx.request.size <= ctx.maxRequestSize - ? allow({ business: 'size-valid' }) - : deny('Request too large'), - ), - createPolicy(async (ctx) => - ctx.user.dailyQuota > ctx.user.dailyUsage - ? allow({ business: 'quota-available' }) - : deny('Daily quota exceeded'), - ), - ], - }; - - return evaluator.evaluateGroups( - [securityLayer, authorizationLayer, businessLayer], - context, - ); -} -``` - -### Complex Nested Logic with Short-Circuiting - -```typescript -// Complex scenario: (Premium User OR (Basic User AND Low Usage)) AND Security Checks -async function validateFeatureAccess(context: FeatureContext) { - const evaluator = createPoliciesEvaluator(); - - // Group 1: User Tier Logic - demonstrates complex OR conditions - const userTierGroup = { - operator: 'ANY' as const, - policies: [ - // Premium users get immediate access - createPolicy(async (ctx) => - ctx.user.plan === 'premium' - ? allow({ tier: 'premium', reason: 'premium-user' }) - : deny('Not premium user'), - ), - // Enterprise users get immediate access - createPolicy(async (ctx) => - ctx.user.plan === 'enterprise' - ? allow({ tier: 'enterprise', reason: 'enterprise-user' }) - : deny('Not enterprise user'), - ), - // Basic users need additional validation (sub-group logic) - createPolicy(async (ctx) => { - if (ctx.user.plan !== 'basic') { - return deny('Not basic user'); - } - - // Simulate nested AND logic for basic users - const basicUserRequirements = [ - ctx.user.monthlyUsage < 1000, - ctx.user.accountAge > 30, // days - !ctx.user.hasViolations, - ]; - - const allBasicRequirementsMet = basicUserRequirements.every( - (req) => req, - ); - - return allBasicRequirementsMet - ? allow({ tier: 'basic', reason: 'low-usage-basic-user' }) - : deny('Basic user requirements not met'); - }), - ], - }; - - // Group 2: Security Requirements (ALL must pass) - const securityGroup = { - operator: 'ALL' as const, - policies: [ - createPolicy(async (ctx) => - ctx.user.emailVerified - ? allow({ security: 'email-verified' }) - : deny('Email verification required'), - ), - createPolicy(async (ctx) => - ctx.user.twoFactorEnabled || ctx.user.plan === 'basic' - ? allow({ security: '2fa-compliant' }) - : deny('Two-factor authentication required for premium plans'), - ), - createPolicy(async (ctx) => - !ctx.user.suspiciousActivity - ? allow({ security: 'activity-clean' }) - : deny('Suspicious activity detected'), - ), - ], - }; - - return evaluator.evaluateGroups([userTierGroup, securityGroup], context); -} -``` - -### Dynamic Policy Composition - -```typescript -// Dynamically compose policies based on context -async function createContextAwarePolicyFlow(context: DynamicContext) { - const evaluator = createPoliciesEvaluator(); - const groups = []; - - // Always include base security - const baseSecurityGroup = { - operator: 'ALL' as const, - policies: [ - createPolicy(async (ctx) => - ctx.isAuthenticated ? allow() : deny('Authentication required'), - ), - ], - }; - groups.push(baseSecurityGroup); - - // Add user-type specific policies - if (context.user.type === 'admin') { - const adminGroup = { - operator: 'ALL' as const, - policies: [ - createPolicy(async (ctx) => - ctx.user.adminLevel >= ctx.requiredAdminLevel - ? allow({ admin: 'level-sufficient' }) - : deny('Insufficient admin level'), - ), - createPolicy(async (ctx) => - ctx.user.lastLogin > Date.now() - 24 * 60 * 60 * 1000 // 24 hours - ? allow({ admin: 'recent-login' }) - : deny('Admin must have logged in within 24 hours'), - ), - ], - }; - groups.push(adminGroup); - } - - // Add feature-specific policies based on requested feature - if (context.feature.requiresBilling) { - const billingGroup = { - operator: 'ANY' as const, - policies: [ - createPolicy(async (ctx) => - ctx.subscription?.active - ? allow({ billing: 'subscription' }) - : deny('Active subscription required'), - ), - createPolicy(async (ctx) => - ctx.credits && ctx.credits > ctx.feature.creditCost - ? allow({ billing: 'credits' }) - : deny('Insufficient credits'), - ), - ], - }; - groups.push(billingGroup); - } - - // Add rate limiting for high-impact features - if (context.feature.highImpact) { - const rateLimitGroup = { - operator: 'ALL' as const, - policies: [ - createPolicy(async (ctx) => - ctx.rateLimit.remaining > 0 - ? allow({ rateLimit: 'within-limits' }) - : deny('Rate limit exceeded for high-impact features'), - ), - ], - }; - groups.push(rateLimitGroup); - } - - return evaluator.evaluateGroups(groups, context); -} -``` - -### Performance-Optimized Large Group Evaluation - -```typescript -// Handle large numbers of policies efficiently -async function validateComplexBusinessRules(context: BusinessContext) { - const evaluator = createPoliciesEvaluator({ maxCacheSize: 200 }); - - // Group policies by evaluation cost and criticality - const criticalFastGroup = { - operator: 'ALL' as const, - policies: [ - // Fast critical checks first - createPolicy(async (ctx) => - ctx.isActive ? allow() : deny('Account inactive'), - ), - createPolicy(async (ctx) => - ctx.hasPermission ? allow() : deny('No permission'), - ), - createPolicy(async (ctx) => - !ctx.isBlocked ? allow() : deny('Account blocked'), - ), - ], - }; - - const businessLogicGroup = { - operator: 'ANY' as const, - policies: [ - // Complex business rules - createPolicy(async (ctx) => { - // Simulate complex calculation - const score = await calculateRiskScore(ctx); - return score < 0.8 - ? allow({ risk: 'low' }) - : deny('High risk detected'); - }), - createPolicy(async (ctx) => { - // Simulate external API call - const verification = await verifyWithThirdParty(ctx); - return verification.success - ? allow({ external: 'verified' }) - : deny('External verification failed'); - }), - ], - }; - - const finalValidationGroup = { - operator: 'ALL' as const, - policies: [ - // Final checks after complex logic - createPolicy(async (ctx) => - ctx.complianceCheck ? allow() : deny('Compliance check failed'), - ), - ], - }; - - // Use staged evaluation for better performance - const startTime = Date.now(); - - const result = await evaluator.evaluateGroups( - [ - criticalFastGroup, // Fast critical checks first - businessLogicGroup, // Complex logic only if critical checks pass - finalValidationGroup, // Final validation - ], - context, - ); - - const evaluationTime = Date.now() - startTime; - - return { - ...result, - performance: { - evaluationTimeMs: evaluationTime, - groupsEvaluated: result.results.length > 0 ? 3 : 1, - }, - }; -} - -// Helper functions for complex examples -async function calculateRiskScore(context: any): Promise<number> { - // Simulate complex risk calculation - await new Promise((resolve) => setTimeout(resolve, 10)); - return Math.random(); -} - -async function verifyWithThirdParty( - context: any, -): Promise<{ success: boolean }> { - // Simulate external API call - await new Promise((resolve) => setTimeout(resolve, 5)); - return { success: Math.random() > 0.2 }; -} -``` - -## Advanced Usage - -### Configurable Policies - -```typescript -// Create policy factories for configuration -const createMaxInvitationsPolicy = (maxInvitations: number) => - createPolicy(async (context) => { - if (context.invitations.length > maxInvitations) { - return deny({ - code: 'MAX_INVITATIONS_EXCEEDED', - message: `Cannot invite more than ${maxInvitations} members`, - remediation: `Reduce invitations to ${maxInvitations} or fewer`, - }); - } - return allow(); - }); - -// Use with different configurations -const strictPolicy = createMaxInvitationsPolicy(1); -const standardPolicy = createMaxInvitationsPolicy(5); -const permissivePolicy = createMaxInvitationsPolicy(25); -``` - -### Feature-Specific evaluators - -```typescript -// Create feature-specific evaluator with preset configurations -export function createInvitationevaluator( - preset: 'strict' | 'standard' | 'permissive', -) { - const configs = { - strict: { maxInvitationsPerBatch: 1 }, - standard: { maxInvitationsPerBatch: 5 }, - permissive: { maxInvitationsPerBatch: 25 }, - }; - - const config = configs[preset]; - - return { - async validateInvitations(context: InvitationContext) { - const policies = [ - emailValidationPolicy, - createMaxInvitationsPolicy(config.maxInvitationsPerBatch), - subscriptionRequiredPolicy, - paddleBillingPolicy, - ]; - - const evaluator = createPoliciesEvaluator(); - return evaluator.evaluatePolicies(policies, context, 'ALL'); - }, - }; -} - -// Usage -const evaluator = createInvitationevaluator('standard'); -const result = await evaluator.validateInvitations(context); -``` - -### Error Handling - -```typescript -const result = await evaluator.evaluate(); - -if (!result.allowed) { - result.reasons.forEach((reason) => { - console.log(`Policy ${reason.policyId} failed:`); - console.log(` Code: ${reason.code}`); - console.log(` Message: ${reason.message}`); - if (reason.remediation) { - console.log(` Fix: ${reason.remediation}`); - } - }); -} -``` - -### 1. Register Complex Policy with Configuration - -```typescript -import { createPolicyRegistry, definePolicy } from '@kit/policies'; - -const registry = createPolicyRegistry(); - -const customConfigurablePolicy = definePolicy({ - id: 'custom-domain-check', - configSchema: z.object({ - allowedDomains: z.array(z.string()), - strictMode: z.boolean(), - }), - evaluate: async (context, config) => { - const emailDomain = context.userEmail?.split('@')[1]; - - if (config?.strictMode && !config.allowedDomains.includes(emailDomain)) { - return deny({ - code: 'DOMAIN_NOT_ALLOWED', - message: `Email domain ${emailDomain} is not in the allowed list`, - remediation: 'Use an email from an approved domain', - }); - } - - return allow(); - }, -}); - -registry.registerPolicy(customConfigurablePolicy); -``` - -## Key Concepts - -### Group Operators - -- **`ALL` (AND logic)**: All policies in the group must pass - - **Short-circuits on first failure** for performance - - Use for mandatory requirements where every condition must be met - - Example: Authentication AND permissions AND rate limiting - -- **`ANY` (OR logic)**: At least one policy in the group must pass - - **Short-circuits on first success** for performance - - Use for flexible requirements where multiple options are acceptable - - Example: Premium subscription OR trial access OR admin override - -### Group Evaluation Flow - -1. **Sequential Group Processing**: Groups are evaluated in order -2. **All Groups Must Pass**: If any group fails, entire evaluation fails -3. **Short-Circuiting**: Stops on first group failure for performance -4. **Metadata Preservation**: All policy results and metadata are collected - -### Performance Considerations - -- **Order groups by criticality**: Put fast, critical checks first -- **Use caching**: Configure `maxCacheSize` for frequently used policies -- **Group by evaluation cost**: Separate expensive operations -- **Monitor evaluation time**: Track performance for optimization - -## Stage-Aware Evaluation - -Policies can be filtered by execution stage. This is useful for running a subset of policies depending on the situation: - -```typescript -// Only run preliminary checks -const prelimResult = await evaluator.evaluate( - registry, - context, - 'ALL', - 'preliminary', -); - -// Run submission validation -const submitResult = await evaluator.evaluate( - registry, - context, - 'ALL', - 'submission', -); - -// Run all applicable policies -const fullResult = await evaluator.evaluate(registry, context, 'ALL'); -``` +- `packages/features/team-accounts/src/server/policies/policies.ts` — real-world registry with stage-aware, configurable policies diff --git a/packages/policies/eslint.config.mjs b/packages/policies/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/policies/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/policies/package.json b/packages/policies/package.json index 6a3f65e3e..82a11cfc0 100644 --- a/packages/policies/package.json +++ b/packages/policies/package.json @@ -1,10 +1,7 @@ { "name": "@kit/policies", - "private": true, "version": "0.1.0", - "exports": { - ".": "./index.ts" - }, + "private": true, "typesVersions": { "*": { "*": [ @@ -12,18 +9,16 @@ ] } }, + "exports": { + ".": "./index.ts" + }, "scripts": { "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", "typecheck": "tsc --noEmit" }, "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/shared": "workspace:*", "@kit/tsconfig": "workspace:*", "zod": "catalog:" - }, - "prettier": "@kit/prettier-config" + } } diff --git a/packages/shared/eslint.config.mjs b/packages/shared/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/shared/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/shared/package.json b/packages/shared/package.json index 5c941fd38..fa64b35a8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,36 +1,31 @@ { "name": "@kit/shared", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - "./logger": "./src/logger/index.ts", - "./utils": "./src/utils.ts", - "./hooks": "./src/hooks/index.ts", - "./events": "./src/events/index.tsx", - "./registry": "./src/registry/index.ts" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:" - }, - "dependencies": { - "pino": "catalog:" - }, + "private": true, "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + "./logger": "./src/logger/index.ts", + "./utils": "./src/utils.ts", + "./events": "./src/events/index.tsx", + "./registry": "./src/registry/index.ts", + "./env": "./src/env/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next-runtime-env": "catalog:", + "pino": "catalog:" + }, + "devDependencies": { + "@kit/tsconfig": "workspace:*", + "@types/react": "catalog:" } } diff --git a/packages/shared/src/env/index.ts b/packages/shared/src/env/index.ts new file mode 100644 index 000000000..ea02606a5 --- /dev/null +++ b/packages/shared/src/env/index.ts @@ -0,0 +1 @@ +export { env } from 'next-runtime-env'; diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts deleted file mode 100644 index f132daf62..000000000 --- a/packages/shared/src/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './use-csrf-token'; diff --git a/packages/shared/src/hooks/use-csrf-token.ts b/packages/shared/src/hooks/use-csrf-token.ts deleted file mode 100644 index 3ad8faa0c..000000000 --- a/packages/shared/src/hooks/use-csrf-token.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Get the CSRF token from the meta tag. - * @returns The CSRF token. - */ -export function useCsrfToken() { - if (typeof document === 'undefined') { - return ''; - } - - const meta = document.querySelector('meta[name="csrf-token"]'); - - if (!meta) { - return ''; - } - - return meta.getAttribute('content') ?? ''; -} diff --git a/packages/supabase/AGENTS.md b/packages/supabase/AGENTS.md index 3db26ce96..29d47abad 100644 --- a/packages/supabase/AGENTS.md +++ b/packages/supabase/AGENTS.md @@ -1,43 +1,22 @@ -# Database & Authentication +# @kit/supabase — Database & Authentication + +## Non-Negotiables + +1. 3 clients: `getSupabaseServerClient()` (server, RLS enforced), `useSupabase()` (client hook, RLS enforced), `getSupabaseServerAdminClient()` (bypasses RLS, use rarely and only if needed) +2. NEVER use admin client without manually validating authorization first +3. NEVER modify `database.types.ts` manually — regenerate with `pnpm supabase:web:typegen` +4. NEVER add manual auth checks when using standard client — trust RLS +5. ALWAYS add indexes on foreign keys +6. ALWAYS include `account_id` in storage paths +7. Use `Tables<'table_name'>` from `@kit/supabase/database` for type references, don't create new types ## Skills -For database work: -- `/postgres-expert` - Schemas, RLS, migrations +- `/postgres-expert` — Schemas, RLS, migrations, query optimization -## Client Usage +## SQL Helper Functions -### Server Components (Preferred) - -```typescript -import { getSupabaseServerClient } from '@kit/supabase/server-client'; - -const client = getSupabaseServerClient(); -const { data } = await client.from('table').select('*'); -// RLS automatically enforced ``` - -### Client Components - -```typescript -'use client'; -import { useSupabase } from '@kit/supabase/hooks/use-supabase'; - -const supabase = useSupabase(); -``` - -### Admin Client (Use Sparingly) - -```typescript -import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; - -// CRITICAL: Bypasses RLS - validate manually! -const adminClient = getSupabaseServerAdminClient(); -``` - -## Existing Helper Functions - -```sql public.has_role_on_account(account_id, role?) public.has_permission(user_id, account_id, permission) public.is_account_owner(account_id) @@ -46,29 +25,16 @@ public.is_team_member(account_id, user_id) public.is_super_admin() ``` -## Type Generation +## Key Imports -```typescript -import { Tables } from '@kit/supabase/database'; +| Function | Import | +| ------------- | -------------------------------------------------------------------------------- | +| Server client | `getSupabaseServerClient` from `@kit/supabase/server-client` | +| Client hook | `useSupabase` from `@kit/supabase/hooks/use-supabase` | +| Admin client | `getSupabaseServerAdminClient` from `@kit/supabase/server-admin-client` | +| Require user | `requireUser` from `@kit/supabase/require-user` | +| MFA check | `checkRequiresMultiFactorAuthentication` from `@kit/supabase/check-requires-mfa` | -type Account = Tables<'accounts'>; -``` +## Exemplar -Never modify `database.types.ts` - regenerate with `pnpm supabase:web:typegen`. - -## Authentication - -```typescript -import { requireUser } from '@kit/supabase/require-user'; -import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; - -const user = await requireUser(client); -const requiresMfa = await checkRequiresMultiFactorAuthentication(client); -``` - -## Security Guidelines - -- Standard client: Trust RLS -- Admin client: Validate everything manually -- Always add indexes for foreign keys -- Storage paths must include account_id +- `apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts` — server client with RLS diff --git a/packages/supabase/eslint.config.mjs b/packages/supabase/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/supabase/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/supabase/package.json b/packages/supabase/package.json index 852c4b2da..303dfe7a1 100644 --- a/packages/supabase/package.json +++ b/packages/supabase/package.json @@ -1,14 +1,14 @@ { "name": "@kit/supabase", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" + "private": true, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } }, - "prettier": "@kit/prettier-config", "exports": { "./server-client": "./src/clients/server-client.ts", "./server-admin-client": "./src/clients/server-admin-client.ts", @@ -21,27 +21,21 @@ "./auth": "./src/auth.ts", "./types": "./src/types.ts" }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, "dependencies": { "@kit/shared": "workspace:*" }, "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/tsconfig": "workspace:*", - "@supabase/ssr": "^0.8.0", + "@supabase/ssr": "catalog:", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", - "@types/node": "catalog:", "@types/react": "catalog:", "next": "catalog:", "react": "catalog:", "zod": "catalog:" - }, - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } } } diff --git a/packages/supabase/src/auth-callback.service.ts b/packages/supabase/src/auth-callback.service.ts index cb033533d..fcbf1a158 100644 --- a/packages/supabase/src/auth-callback.service.ts +++ b/packages/supabase/src/auth-callback.service.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { AuthError, type EmailOtpType, @@ -306,16 +305,16 @@ function getAuthErrorMessage(params: { error: string; code?: string }) { // this error arises when the user tries to sign in with an expired email link if (params.code) { if (params.code === 'otp_expired') { - return 'auth:errors.otp_expired'; + return 'auth.errors.otp_expired'; } } // this error arises when the user is trying to sign in with a different // browser than the one they used to request the sign in link if (isVerifierError(params.error)) { - return 'auth:errors.codeVerifierMismatch'; + return 'auth.errors.codeVerifierMismatch'; } // fallback to the default error message - return `auth:authenticationErrorAlertBody`; + return `auth.authenticationErrorAlertBody`; } diff --git a/packages/supabase/src/clients/middleware-client.ts b/packages/supabase/src/clients/middleware-client.ts index 01c1e1f00..23d3e3bdd 100644 --- a/packages/supabase/src/clients/middleware-client.ts +++ b/packages/supabase/src/clients/middleware-client.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { type NextRequest, NextResponse } from 'next/server'; import { createServerClient } from '@supabase/ssr'; diff --git a/packages/supabase/src/clients/server-admin-client.ts b/packages/supabase/src/clients/server-admin-client.ts index 8d73273d1..1cb82cd04 100644 --- a/packages/supabase/src/clients/server-admin-client.ts +++ b/packages/supabase/src/clients/server-admin-client.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { createClient } from '@supabase/supabase-js'; import { Database } from '../database.types'; diff --git a/packages/supabase/src/clients/server-client.ts b/packages/supabase/src/clients/server-client.ts index 434700059..8ce1b6a74 100644 --- a/packages/supabase/src/clients/server-client.ts +++ b/packages/supabase/src/clients/server-client.ts @@ -1,5 +1,4 @@ import 'server-only'; - import { cookies } from 'next/headers'; import { createServerClient } from '@supabase/ssr'; diff --git a/packages/supabase/src/get-secret-key.ts b/packages/supabase/src/get-secret-key.ts index 90848198b..a1baf7e87 100644 --- a/packages/supabase/src/get-secret-key.ts +++ b/packages/supabase/src/get-secret-key.ts @@ -1,9 +1,8 @@ import 'server-only'; - -import { z } from 'zod'; +import * as z from 'zod'; const message = - 'Invalid Supabase Secret Key. Please add the environment variable SUPABASE_SECRET_KEY or SUPABASE_SERVICE_ROLE_KEY.'; + 'Invalid Supabase Secret Key. Please add the environment variable SUPABASE_SECRET_KEY.'; /** * @name getSupabaseSecretKey @@ -13,14 +12,12 @@ const message = export function getSupabaseSecretKey() { return z .string({ - required_error: message, + error: message, }) .min(1, { message: message, }) - .parse( - process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY, - ); + .parse(process.env.SUPABASE_SECRET_KEY); } /** diff --git a/packages/supabase/src/get-supabase-client-keys.ts b/packages/supabase/src/get-supabase-client-keys.ts index 23596b77f..1f3a3eee9 100644 --- a/packages/supabase/src/get-supabase-client-keys.ts +++ b/packages/supabase/src/get-supabase-client-keys.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * Returns and validates the Supabase client keys from the environment. @@ -7,18 +7,14 @@ export function getSupabaseClientKeys() { return z .object({ url: z.string({ - description: `This is the URL of your hosted Supabase instance. Please provide the variable NEXT_PUBLIC_SUPABASE_URL.`, - required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_URL`, + error: `Please provide the variable NEXT_PUBLIC_SUPABASE_URL`, }), publicKey: z.string({ - description: `This is the public key provided by Supabase. It is a public key used client-side. Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY.`, - required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY`, + error: `Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY`, }), }) .parse({ url: process.env.NEXT_PUBLIC_SUPABASE_URL, - publicKey: - process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY || - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + publicKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY, }); } diff --git a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts index d6ba8e718..1ea57928c 100644 --- a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts +++ b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts @@ -12,7 +12,7 @@ export function useSignInWithEmailPassword() { const response = await client.auth.signInWithPassword(credentials); if (response.error) { - throw response.error.message; + throw response.error; } const user = response.data?.user; diff --git a/packages/supabase/src/hooks/use-sign-in-with-provider.ts b/packages/supabase/src/hooks/use-sign-in-with-provider.ts index d68700b89..61860a47a 100644 --- a/packages/supabase/src/hooks/use-sign-in-with-provider.ts +++ b/packages/supabase/src/hooks/use-sign-in-with-provider.ts @@ -12,7 +12,7 @@ export function useSignInWithProvider() { const response = await client.auth.signInWithOAuth(credentials); if (response.error) { - throw response.error.message; + throw response.error; } return response.data; diff --git a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts index 7523b96b9..6bf9dfcee 100644 --- a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts +++ b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts @@ -49,7 +49,7 @@ export function useSignUpWithEmailAndPassword() { throw new WeakPasswordError(errorObj.reasons ?? []); } - throw response.error.message; + throw response.error; } const user = response.data?.user; diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index cd2d3efdf..9ece37bf4 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -1,71 +1,41 @@ -# UI Components & Styling +# @kit/ui — UI Components & Styling + +## Component Library + +This project uses **Base UI** (not Radix UI). Key differences: + +- NEVER use `asChild` prop — Base UI uses `render` prop for element composition +- ALWAYS use the `render` prop pattern when you need to render a custom element (e.g., `<Button nativeButton={false} render={<Link />} />`) + +## Non-Negotiables + +1. ALWAYS import as `@kit/ui/<name>` — no deep paths, no matter the folder structure +2. ALWAYS use `cn()` from `@kit/ui/utils` for class merging +3. ALWAYS use semantic Tailwind classes (`bg-background`, `text-muted-foreground`) — NEVER hardcoded colors (`bg-white`, `text-gray-500`) +4. ALWAYS add `data-test` attributes on interactive elements +5. ALWAYS add `FormMessage` to every form field for error display +6. ALWAYS consider error-handling, not just happy paths. +7. ALWAYS Ensure UI surfaces useful and human-readable errors, not internal ones. +8. NEVER add generics to `useForm` — let Zod resolver infer types +9. NEVER use `watch()` — use `useWatch` hook instead when using React Hook Form +10. NEVER use Radix UI patterns (`asChild`, `@radix-ui/*` imports) — this project uses Base UI ## Skills -For forms: -- `/react-form-builder` - Forms with validation and server actions - -## Import Convention - -Always use `@kit/ui/{component}`: - -```tsx -import { Button } from '@kit/ui/button'; -import { Card } from '@kit/ui/card'; -import { If } from '@kit/ui/if'; -import { Trans } from '@kit/ui/trans'; -import { toast } from '@kit/ui/sonner'; -import { cn } from '@kit/ui/utils'; -``` - -## Styling - -- Tailwind CSS v4 with semantic classes -- Prefer: `bg-background`, `text-muted-foreground`, `border-border` -- Use `cn()` for class merging -- Never use hardcoded colors like `bg-white` +- `/react-form-builder` — Full form implementation workflow with react-hook-form + Zod ## Key Components -| Component | Usage | -|-----------|-------| -| `If` | Conditional rendering | -| `Trans` | Internationalization | -| `toast` | Notifications | -| `Form*` | Form fields | -| `Button` | Actions | -| `Card` | Content containers | -| `Alert` | Error/info messages | +| Component | Import | +| ------------------------- | -------------------------------------------------------------------------------------- | +| Button, Card, Input, etc. | `@kit/ui/<name>` | +| Form fields | `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormMessage` from `@kit/ui/form` | +| Translations | `Trans` from `@kit/ui/trans` | +| Toast | `toast` from `@kit/ui/sonner` | +| Conditional render | `If` from `@kit/ui/if` | +| Class merging | `cn` from `@kit/ui/utils` | -## Conditional Rendering +## Zod -```tsx -import { If } from '@kit/ui/if'; - -<If condition={isLoading} fallback={<Content />}> - <Spinner /> -</If> -``` - -## Internationalization - -```tsx -import { Trans } from '@kit/ui/trans'; - -<Trans i18nKey="namespace:key" values={{ name }} /> -``` - -## Testing Attributes - -Always add `data-test` for E2E: - -```tsx -<button data-test="submit-button">Submit</button> -``` - -## Form Guidelines - -- Use `react-hook-form` with `zodResolver` -- Never add generics to `useForm` -- Use `useWatch` instead of `watch()` -- Always include `FormMessage` for errors +- ALWAYS import Zod as `import * as z from 'zod'` +- Place schemas in a separate file so they can be reused with server actions diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md index 43c994c2d..eef4bd20c 100644 --- a/packages/ui/CLAUDE.md +++ b/packages/ui/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md +@AGENTS.md \ No newline at end of file diff --git a/packages/ui/components.json b/packages/ui/components.json index 32cc1dd8c..2f34d6528 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -1,15 +1,16 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "base-nova", "rsc": true, "tsx": true, "tailwind": { - "config": "./tailwind.config.ts", + "config": "", "css": "../../apps/web/styles/globals.css", - "baseColor": "slate", + "baseColor": "neutral", "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "~/components", "utils": "~/utils", diff --git a/packages/ui/eslint.config.mjs b/packages/ui/eslint.config.mjs deleted file mode 100644 index 97563ae8d..000000000 --- a/packages/ui/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; diff --git a/packages/ui/package.json b/packages/ui/package.json index bf83e808f..560749b9d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,53 +1,17 @@ { "name": "@kit/ui", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@hookform/resolvers": "^5.2.2", - "@radix-ui/react-icons": "^1.3.2", - "clsx": "^2.1.1", - "cmdk": "1.1.1", - "input-otp": "1.4.2", - "lucide-react": "catalog:", - "radix-ui": "1.4.3", - "react-dropzone": "^15.0.0", - "react-top-loading-bar": "3.0.2", - "recharts": "2.15.3", - "tailwind-merge": "^3.5.0" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@supabase/supabase-js": "catalog:", - "@tanstack/react-query": "catalog:", - "@tanstack/react-table": "^8.21.3", - "@types/node": "catalog:", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "class-variance-authority": "^0.7.1", - "date-fns": "^4.1.0", - "next": "catalog:", - "next-themes": "0.4.6", - "prettier": "^3.8.1", - "react-day-picker": "^9.13.2", - "react-hook-form": "catalog:", - "react-i18next": "catalog:", - "sonner": "^2.0.7", - "tailwindcss": "catalog:", - "typescript": "^5.9.3", - "zod": "catalog:" - }, - "prettier": "@kit/prettier-config", + "private": true, + "type": "module", "imports": { "#utils": [ "./src/lib/utils/index.ts" + ], + "#lib/utils": [ + "./src/lib/utils/index.ts" + ], + "#components/*": [ + "./src/shadcn/*" ] }, "exports": { @@ -59,6 +23,8 @@ "./card": "./src/shadcn/card.tsx", "./checkbox": "./src/shadcn/checkbox.tsx", "./command": "./src/shadcn/command.tsx", + "./context-menu": "./src/shadcn/context-menu.tsx", + "./empty": "./src/shadcn/empty.tsx", "./data-table": "./src/shadcn/data-table.tsx", "./dialog": "./src/shadcn/dialog.tsx", "./dropdown-menu": "./src/shadcn/dropdown-menu.tsx", @@ -73,10 +39,15 @@ "./sheet": "./src/shadcn/sheet.tsx", "./slider": "./src/shadcn/slider.tsx", "./table": "./src/shadcn/table.tsx", + "./pagination": "./src/shadcn/pagination.tsx", + "./native-select": "./src/shadcn/native-select.tsx", + "./toggle": "./src/shadcn/toggle.tsx", "./tabs": "./src/shadcn/tabs.tsx", "./tooltip": "./src/shadcn/tooltip.tsx", + "./menu-bar": "./src/shadcn/menu-bar.tsx", "./sonner": "./src/shadcn/sonner.tsx", "./heading": "./src/shadcn/heading.tsx", + "./aspect-ratio": "./src/shadcn/aspect-ratio.tsx", "./alert": "./src/shadcn/alert.tsx", "./badge": "./src/shadcn/badge.tsx", "./radio-group": "./src/shadcn/radio-group.tsx", @@ -87,24 +58,24 @@ "./breadcrumb": "./src/shadcn/breadcrumb.tsx", "./chart": "./src/shadcn/chart.tsx", "./skeleton": "./src/shadcn/skeleton.tsx", - "./shadcn-sidebar": "./src/shadcn/sidebar.tsx", + "./sidebar": "./src/shadcn/sidebar.tsx", "./collapsible": "./src/shadcn/collapsible.tsx", "./kbd": "./src/shadcn/kbd.tsx", "./button-group": "./src/shadcn/button-group.tsx", "./input-group": "./src/shadcn/input-group.tsx", "./item": "./src/shadcn/item.tsx", "./field": "./src/shadcn/field.tsx", + "./drawer": "./src/shadcn/drawer.tsx", "./utils": "./src/lib/utils/index.ts", "./if": "./src/makerkit/if.tsx", "./trans": "./src/makerkit/trans.tsx", - "./sidebar": "./src/makerkit/sidebar.tsx", "./navigation-schema": "./src/makerkit/navigation-config.schema.ts", + "./navigation-utils": "./src/makerkit/navigation-utils.ts", "./bordered-navigation-menu": "./src/makerkit/bordered-navigation-menu.tsx", "./spinner": "./src/makerkit/spinner.tsx", "./page": "./src/makerkit/page.tsx", "./image-uploader": "./src/makerkit/image-uploader.tsx", "./global-loader": "./src/makerkit/global-loader.tsx", - "./auth-change-listener": "./src/makerkit/auth-change-listener.tsx", "./loading-overlay": "./src/makerkit/loading-overlay.tsx", "./profile-avatar": "./src/makerkit/profile-avatar.tsx", "./mode-toggle": "./src/makerkit/mode-toggle.tsx", @@ -112,21 +83,64 @@ "./enhanced-data-table": "./src/makerkit/data-table.tsx", "./language-selector": "./src/makerkit/language-selector.tsx", "./stepper": "./src/makerkit/stepper.tsx", + "./lazy-render": "./src/makerkit/lazy-render.tsx", "./cookie-banner": "./src/makerkit/cookie-banner.tsx", "./card-button": "./src/makerkit/card-button.tsx", "./version-updater": "./src/makerkit/version-updater.tsx", - "./multi-step-form": "./src/makerkit/multi-step-form.tsx", "./app-breadcrumbs": "./src/makerkit/app-breadcrumbs.tsx", "./empty-state": "./src/makerkit/empty-state.tsx", "./marketing": "./src/makerkit/marketing/index.tsx", "./oauth-provider-logo-image": "./src/makerkit/oauth-provider-logo-image.tsx", - "./file-uploader": "./src/makerkit/file-uploader.tsx" + "./copy-to-clipboard": "./src/makerkit/copy-to-clipboard.tsx", + "./error-boundary": "./src/makerkit/error-boundary.tsx", + "./hooks/use-async-dialog": "./src/hooks/use-async-dialog.ts", + "./hooks/use-mobile": "./src/hooks/use-mobile.ts", + "./sidebar-navigation": "./src/makerkit/sidebar-navigation.tsx", + "./file-uploader": "./src/makerkit/file-uploader.tsx", + "./use-supabase-upload": "./src/hooks/use-supabase-upload.ts", + "./csp-provider": "./src/base-ui/csp-provider.tsx" }, - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit", + "test:unit": "vitest run" + }, + "dependencies": { + "@base-ui/react": "^1.3.0", + "@hookform/resolvers": "^5.2.2", + "@kit/shared": "workspace:*", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "react-dropzone": "^15.0.0", + "react-resizable-panels": "catalog:", + "react-top-loading-bar": "^3.0.2", + "recharts": "3.7.0", + "tailwind-merge": "^3.5.0" + }, + "devDependencies": { + "@kit/i18n": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@supabase/supabase-js": "catalog:", + "@tanstack/react-query": "catalog:", + "@tanstack/react-table": "^8.21.3", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "class-variance-authority": "^0.7.1", + "date-fns": "^4.1.0", + "next": "catalog:", + "next-intl": "^4.8.3", + "next-safe-action": "^8.1.8", + "next-themes": "0.4.6", + "react-day-picker": "^9.14.0", + "react-hook-form": "catalog:", + "shadcn": "catalog:", + "sonner": "^2.0.7", + "tailwindcss": "catalog:", + "vaul": "^1.1.2", + "vitest": "catalog:", + "zod": "catalog:" } } diff --git a/packages/ui/src/base-ui/csp-provider.tsx b/packages/ui/src/base-ui/csp-provider.tsx new file mode 100644 index 000000000..b75e6df24 --- /dev/null +++ b/packages/ui/src/base-ui/csp-provider.tsx @@ -0,0 +1,3 @@ +'use client'; + +export { CSPProvider } from '@base-ui/react/csp-provider'; diff --git a/packages/ui/src/hooks/use-async-dialog-state.test.ts b/packages/ui/src/hooks/use-async-dialog-state.test.ts new file mode 100644 index 000000000..3b41ecad4 --- /dev/null +++ b/packages/ui/src/hooks/use-async-dialog-state.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { createAsyncDialogState } from './use-async-dialog-state'; + +describe('createAsyncDialogState', () => { + it('starts idle for a closed dialog', () => { + const state = createAsyncDialogState(); + + expect(state.getSessionId()).toBe(0); + expect(state.isPending()).toBe(false); + expect(state.isCurrentSession(0)).toBe(true); + }); + + it('tracks pending state for the active session', () => { + const state = createAsyncDialogState(true); + const sessionId = state.getSessionId(); + + state.setPending(sessionId, true); + + expect(state.isPending()).toBe(true); + + state.setPending(sessionId, false); + + expect(state.isPending()).toBe(false); + }); + + it('keeps pending true until all overlapping requests settle', () => { + const state = createAsyncDialogState(true); + const sessionId = state.getSessionId(); + + state.setPending(sessionId, true); + state.setPending(sessionId, true); + + expect(state.isPending()).toBe(true); + + state.setPending(sessionId, false); + + expect(state.isPending()).toBe(true); + + state.setPending(sessionId, false); + + expect(state.isPending()).toBe(false); + }); + + it('ignores extra false transitions', () => { + const state = createAsyncDialogState(true); + const sessionId = state.getSessionId(); + + state.setPending(sessionId, false); + + expect(state.isPending()).toBe(false); + }); + + it('creates a new session when reopening the dialog', () => { + const state = createAsyncDialogState(true); + const firstSessionId = state.getSessionId(); + + state.syncOpen(false); + state.syncOpen(true); + + expect(state.getSessionId()).toBe(firstSessionId + 1); + expect(state.isCurrentSession(firstSessionId)).toBe(false); + }); + + it('ignores stale pending updates from an older session after reopen', () => { + const state = createAsyncDialogState(true); + const firstSessionId = state.getSessionId(); + + state.setPending(firstSessionId, true); + state.syncOpen(false); + state.syncOpen(true); + + expect(state.isPending()).toBe(false); + + state.setPending(firstSessionId, false); + + expect(state.isPending()).toBe(false); + }); + + it('allows new session requests after a reopen even if the old one was pending', () => { + const state = createAsyncDialogState(true); + const firstSessionId = state.getSessionId(); + + state.setPending(firstSessionId, true); + state.syncOpen(false); + state.syncOpen(true); + + const secondSessionId = state.getSessionId(); + + state.setPending(secondSessionId, true); + + expect(state.isPending()).toBe(true); + + state.setPending(firstSessionId, false); + + expect(state.isPending()).toBe(true); + + state.setPending(secondSessionId, false); + + expect(state.isPending()).toBe(false); + }); + + it('does not bump the session id for repeated syncs with the same open value', () => { + const state = createAsyncDialogState(); + + state.syncOpen(false); + expect(state.getSessionId()).toBe(0); + + state.syncOpen(true); + const openSessionId = state.getSessionId(); + + state.syncOpen(true); + + expect(state.getSessionId()).toBe(openSessionId); + }); +}); diff --git a/packages/ui/src/hooks/use-async-dialog-state.ts b/packages/ui/src/hooks/use-async-dialog-state.ts new file mode 100644 index 000000000..48767274c --- /dev/null +++ b/packages/ui/src/hooks/use-async-dialog-state.ts @@ -0,0 +1,46 @@ +interface AsyncDialogState { + getSessionId: () => number; + isCurrentSession: (sessionId: number) => boolean; + isPending: () => boolean; + setPending: (sessionId: number, pending: boolean) => void; + syncOpen: (open: boolean) => void; +} + +export function createAsyncDialogState(initialOpen = false): AsyncDialogState { + let isOpen = initialOpen; + let sessionId = initialOpen ? 1 : 0; + const pendingCountBySession = new Map<number, number>(); + + const getPendingCount = () => pendingCountBySession.get(sessionId) ?? 0; + + return { + getSessionId: () => sessionId, + isCurrentSession: (candidateSessionId) => candidateSessionId === sessionId, + isPending: () => getPendingCount() > 0, + setPending: (targetSessionId, pending) => { + if (targetSessionId !== sessionId) return; + + const currentPendingCount = + pendingCountBySession.get(targetSessionId) ?? 0; + const nextPendingCount = pending + ? currentPendingCount + 1 + : Math.max(0, currentPendingCount - 1); + + if (nextPendingCount === 0) { + pendingCountBySession.delete(targetSessionId); + return; + } + + pendingCountBySession.set(targetSessionId, nextPendingCount); + }, + syncOpen: (nextOpen) => { + if (nextOpen === isOpen) return; + + isOpen = nextOpen; + + if (nextOpen) { + sessionId += 1; + } + }, + }; +} diff --git a/packages/ui/src/hooks/use-async-dialog.ts b/packages/ui/src/hooks/use-async-dialog.ts new file mode 100644 index 000000000..b7483d08e --- /dev/null +++ b/packages/ui/src/hooks/use-async-dialog.ts @@ -0,0 +1,114 @@ +'use client'; + +import { useCallback, useMemo, useReducer, useRef } from 'react'; + +import { createAsyncDialogState } from './use-async-dialog-state'; + +interface UseAsyncDialogOptions { + /** + * External controlled open state (optional). + * If not provided, the hook manages its own internal state. + */ + open?: boolean; + /** + * External controlled onOpenChange callback (optional). + * If not provided, the hook manages its own internal state. + */ + onOpenChange?: (open: boolean) => void; +} + +interface UseAsyncDialogReturn { + /** Whether the dialog is open */ + open: boolean; + /** Programmatic control for the current dialog session */ + setOpen: (open: boolean) => void; + /** Whether an async operation is in progress */ + isPending: boolean; + /** Set pending state - call from action callbacks */ + setIsPending: (pending: boolean) => void; + /** Props to spread on Dialog component */ + dialogProps: { + open: boolean; + onOpenChange: (open: boolean) => void; + disablePointerDismissal: true; + }; +} + +/** + * Hook for managing dialog state with async operation protection. + * + * Prevents dialog from closing via Escape or backdrop click while + * an async operation is in progress. Programmatic updates remain tied + * to the dialog session that created them, so stale async completions + * do not close a newer reopened dialog. + */ +export function useAsyncDialog( + options: UseAsyncDialogOptions = {}, +): UseAsyncDialogReturn { + const { open: externalOpen, onOpenChange: externalOnOpenChange } = options; + + const [, forceRender] = useReducer((value: number) => value + 1, 0); + const [internalOpen, setInternalOpen] = useReducer( + (_: boolean, next: boolean) => next, + false, + ); + const stateRef = useRef(createAsyncDialogState(Boolean(externalOpen))); + + const isControlled = externalOpen !== undefined; + const open = isControlled ? externalOpen : internalOpen; + + stateRef.current.syncOpen(open); + + const isPending = stateRef.current.isPending(); + const sessionId = stateRef.current.getSessionId(); + + const setOpen = useCallback( + (newOpen: boolean) => { + if (!stateRef.current.isCurrentSession(sessionId)) return; + + if (isControlled && externalOnOpenChange) { + externalOnOpenChange(newOpen); + } else { + setInternalOpen(newOpen); + } + }, + [externalOnOpenChange, isControlled, sessionId], + ); + + const setIsPending = useCallback( + (pending: boolean) => { + if (!stateRef.current.isCurrentSession(sessionId)) return; + + stateRef.current.setPending(sessionId, pending); + forceRender(); + }, + [sessionId], + ); + + const guardedOnOpenChange = useCallback( + (newOpen: boolean) => { + if (!newOpen && stateRef.current.isPending()) return; + + setOpen(newOpen); + }, + [setOpen], + ); + + const dialogProps = useMemo( + () => + ({ + open, + onOpenChange: guardedOnOpenChange, + disablePointerDismissal: true, + }) as const, + [guardedOnOpenChange, open], + ); + + return { + open, + setOpen, + isPending, + setIsPending, + dialogProps, + }; +} diff --git a/packages/ui/src/hooks/use-mobile.tsx b/packages/ui/src/hooks/use-mobile.ts similarity index 94% rename from packages/ui/src/hooks/use-mobile.tsx rename to packages/ui/src/hooks/use-mobile.ts index a5d406126..821f8ff4a 100644 --- a/packages/ui/src/hooks/use-mobile.tsx +++ b/packages/ui/src/hooks/use-mobile.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -const MOBILE_BREAKPOINT = 1024; +const MOBILE_BREAKPOINT = 768; export function useIsMobile() { const [isMobile, setIsMobile] = React.useState<boolean | undefined>( diff --git a/packages/ui/src/lib/utils/__tests__/is-route-active.test.ts b/packages/ui/src/lib/utils/__tests__/is-route-active.test.ts new file mode 100644 index 000000000..e4eecc812 --- /dev/null +++ b/packages/ui/src/lib/utils/__tests__/is-route-active.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; + +import { isRouteActive } from '../is-route-active'; + +describe('isRouteActive', () => { + describe('exact matching', () => { + it('returns true for exact path match', () => { + expect(isRouteActive('/projects', '/projects')).toBe(true); + }); + + it('returns true for exact path match with trailing slash normalization', () => { + expect(isRouteActive('/projects/', '/projects')).toBe(true); + expect(isRouteActive('/projects', '/projects/')).toBe(true); + }); + + it('returns true for root path exact match', () => { + expect(isRouteActive('/', '/')).toBe(true); + }); + }); + + describe('prefix matching (default behavior)', () => { + it('returns true when current path is child of nav path', () => { + expect(isRouteActive('/projects', '/projects/123')).toBe(true); + expect(isRouteActive('/projects', '/projects/123/edit')).toBe(true); + expect(isRouteActive('/projects', '/projects/new')).toBe(true); + }); + + it('returns false when current path is not a child', () => { + expect(isRouteActive('/projects', '/settings')).toBe(false); + expect(isRouteActive('/projects', '/projectslist')).toBe(false); // Not a child, just starts with same chars + }); + + it('returns false for root path when on other routes', () => { + // Root path should only match exactly, not prefix-match everything + expect(isRouteActive('/', '/projects')).toBe(false); + expect(isRouteActive('/', '/dashboard')).toBe(false); + }); + + it('handles nested paths correctly', () => { + expect(isRouteActive('/settings/profile', '/settings/profile/edit')).toBe( + true, + ); + expect(isRouteActive('/settings/profile', '/settings/billing')).toBe( + false, + ); + }); + }); + + describe('custom regex matching (highlightMatch)', () => { + it('uses regex pattern when provided', () => { + // Exact match only pattern + expect( + isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard$'), + ).toBe(false); + expect(isRouteActive('/dashboard', '/dashboard', '^/dashboard$')).toBe( + true, + ); + }); + + it('supports multiple paths in regex', () => { + const pattern = '^/(projects|settings/projects)'; + + expect(isRouteActive('/projects', '/projects', pattern)).toBe(true); + expect(isRouteActive('/projects', '/settings/projects', pattern)).toBe( + true, + ); + expect(isRouteActive('/projects', '/settings', pattern)).toBe(false); + }); + + it('supports complex regex patterns', () => { + // Match any dashboard sub-route + expect( + isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard/'), + ).toBe(true); + // Note: Exact match check runs before regex, so '/dashboard' matches '/dashboard' + expect(isRouteActive('/dashboard', '/dashboard', '^/dashboard/')).toBe( + true, // Exact match takes precedence + ); + // But different nav path won't match + expect(isRouteActive('/other', '/dashboard', '^/dashboard/')).toBe(false); + }); + }); + + describe('query parameter handling', () => { + it('ignores query parameters in path', () => { + expect(isRouteActive('/projects?tab=active', '/projects')).toBe(true); + expect(isRouteActive('/projects', '/projects?tab=active')).toBe(true); + }); + + it('ignores query parameters in current path', () => { + expect(isRouteActive('/projects', '/projects/123?view=details')).toBe( + true, + ); + }); + }); + + describe('trailing slash handling', () => { + it('normalizes trailing slashes in both paths', () => { + expect(isRouteActive('/projects/', '/projects/')).toBe(true); + expect(isRouteActive('/projects/', '/projects')).toBe(true); + expect(isRouteActive('/projects', '/projects/')).toBe(true); + }); + + it('handles nested paths with trailing slashes', () => { + expect(isRouteActive('/projects/', '/projects/123/')).toBe(true); + }); + }); + + describe('locale handling', () => { + it('strips locale prefix from paths when locale is provided', () => { + const options = { locale: 'en' }; + + expect( + isRouteActive('/projects', '/en/projects', undefined, options), + ).toBe(true); + expect( + isRouteActive('/projects', '/en/projects/123', undefined, options), + ).toBe(true); + }); + + it('auto-detects locale from path when locales array is provided', () => { + const options = { locales: ['en', 'de', 'fr'] }; + + expect( + isRouteActive('/projects', '/en/projects', undefined, options), + ).toBe(true); + expect( + isRouteActive('/projects', '/de/projects', undefined, options), + ).toBe(true); + expect( + isRouteActive('/projects', '/fr/projects/123', undefined, options), + ).toBe(true); + }); + + it('handles case-insensitive locale detection', () => { + // Locale detection is case-insensitive, but stripping requires case match + const options = { locales: ['en', 'de'] }; + + // These work because locale case matches + expect( + isRouteActive('/projects', '/en/projects', undefined, options), + ).toBe(true); + expect( + isRouteActive('/projects', '/de/projects', undefined, options), + ).toBe(true); + }); + + it('does not strip non-locale prefixes', () => { + const options = { locales: ['en', 'de'] }; + + // 'projects' is not a locale, so shouldn't be stripped + expect( + isRouteActive('/settings', '/projects/settings', undefined, options), + ).toBe(false); + }); + + it('handles locale-only paths', () => { + const options = { locale: 'en' }; + + expect(isRouteActive('/', '/en', undefined, options)).toBe(true); + expect(isRouteActive('/', '/en/', undefined, options)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('handles empty string path', () => { + expect(isRouteActive('', '/')).toBe(true); + expect(isRouteActive('/', '')).toBe(true); + }); + + it('handles paths with special characters', () => { + expect(isRouteActive('/user/@me', '/user/@me')).toBe(true); + expect(isRouteActive('/search', '/search?q=hello+world')).toBe(true); + }); + + it('handles deep nested paths', () => { + expect(isRouteActive('/a/b/c/d', '/a/b/c/d/e/f/g')).toBe(true); + expect(isRouteActive('/a/b/c/d', '/a/b/c')).toBe(false); + }); + + it('handles similar path prefixes', () => { + // '/project' should not match '/projects' + expect(isRouteActive('/project', '/projects')).toBe(false); + + // '/projects' should not match '/project' + expect(isRouteActive('/projects', '/project')).toBe(false); + }); + + it('handles paths with numbers', () => { + expect(isRouteActive('/org/123', '/org/123/members')).toBe(true); + expect(isRouteActive('/org/123', '/org/456')).toBe(false); + }); + }); + + describe('real-world navigation scenarios', () => { + it('sidebar navigation highlighting', () => { + // Dashboard link should highlight on dashboard and sub-pages + expect(isRouteActive('/dashboard', '/dashboard')).toBe(true); + expect(isRouteActive('/dashboard', '/dashboard/analytics')).toBe(true); + expect(isRouteActive('/dashboard', '/settings')).toBe(false); + + // Projects link should highlight on projects list and detail pages + expect(isRouteActive('/projects', '/projects')).toBe(true); + expect(isRouteActive('/projects', '/projects/proj-1')).toBe(true); + expect(isRouteActive('/projects', '/projects/proj-1/tasks')).toBe(true); + + // Home link should only highlight on home + expect(isRouteActive('/', '/')).toBe(true); + expect(isRouteActive('/', '/projects')).toBe(false); + }); + + it('settings navigation with nested routes', () => { + // Settings general + expect(isRouteActive('/settings', '/settings')).toBe(true); + expect(isRouteActive('/settings', '/settings/profile')).toBe(true); + expect(isRouteActive('/settings', '/settings/billing')).toBe(true); + + // Settings profile specifically + expect(isRouteActive('/settings/profile', '/settings/profile')).toBe( + true, + ); + expect(isRouteActive('/settings/profile', '/settings/billing')).toBe( + false, + ); + }); + + it('organization routes with dynamic segments', () => { + expect( + isRouteActive('/org/[slug]', '/org/my-org', undefined, undefined), + ).toBe(false); // Template path won't match + + expect(isRouteActive('/org/my-org', '/org/my-org/settings')).toBe(true); + }); + }); +}); diff --git a/packages/ui/src/lib/utils/is-route-active.ts b/packages/ui/src/lib/utils/is-route-active.ts index 453bfe13a..fb5cb03d5 100644 --- a/packages/ui/src/lib/utils/is-route-active.ts +++ b/packages/ui/src/lib/utils/is-route-active.ts @@ -1,108 +1,128 @@ const ROOT_PATH = '/'; +export type RouteActiveOptions = { + locale?: string; + locales?: string[]; +}; + /** * @name isRouteActive - * @description A function to check if a route is active. This is used to - * @param end - * @param path - * @param currentPath + * @description Check if a route is active for navigation highlighting. + * + * Default behavior: prefix matching (highlights parent when on child routes) + * Custom behavior: provide a regex pattern via highlightMatch + * + * @param path - The navigation item's path + * @param currentPath - The current browser path + * @param highlightMatch - Optional regex pattern for custom matching + * @param options - Locale options for path normalization + * + * @example + * // Default: /projects highlights for /projects, /projects/123, /projects/123/edit + * isRouteActive('/projects', '/projects/123') // true + * + * // Exact match only + * isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard$') // false + * + * // Multiple paths + * isRouteActive('/projects', '/settings/projects', '^/(projects|settings/projects)') // true */ export function isRouteActive( path: string, currentPath: string, - end?: boolean | ((path: string) => boolean), + highlightMatch?: string, + options?: RouteActiveOptions, ) { - // if the path is the same as the current path, we return true - if (path === currentPath) { + const locale = + options?.locale ?? detectLocaleFromPath(currentPath, options?.locales); + + const normalizedPath = normalizePath(path, { ...options, locale }); + const normalizedCurrentPath = normalizePath(currentPath, { + ...options, + locale, + }); + + // Exact match always returns true + if (normalizedPath === normalizedCurrentPath) { return true; } - // if the end prop is a function, we call it with the current path - if (typeof end === 'function') { - return !end(currentPath); + // Custom regex match + if (highlightMatch) { + const regex = new RegExp(highlightMatch); + return regex.test(normalizedCurrentPath); } - // otherwise - we use the evaluateIsRouteActive function - const defaultEnd = end ?? true; - const oneLevelDeep = 1; - const threeLevelsDeep = 3; - - // how far down should segments be matched? - const depth = defaultEnd ? oneLevelDeep : threeLevelsDeep; - - return checkIfRouteIsActive(path, currentPath, depth); -} - -/** - * @name checkIfRouteIsActive - * @description A function to check if a route is active. This is used to - * highlight the active link in the navigation. - * @param targetLink - The link to check against - * @param currentRoute - the current route - * @param depth - how far down should segments be matched? - */ -export function checkIfRouteIsActive( - targetLink: string, - currentRoute: string, - depth = 1, -) { - // we remove any eventual query param from the route's URL - const currentRoutePath = currentRoute.split('?')[0] ?? ''; - - if (!isRoot(currentRoutePath) && isRoot(targetLink)) { + // Default: prefix matching - highlight when current path starts with nav path + // Special case: root path should only match exactly + if (normalizedPath === ROOT_PATH) { return false; } - if (!currentRoutePath.includes(targetLink)) { - return false; - } - - const isSameRoute = targetLink === currentRoutePath; - - if (isSameRoute) { - return true; - } - - return hasMatchingSegments(targetLink, currentRoutePath, depth); + return ( + normalizedCurrentPath.startsWith(normalizedPath + '/') || + normalizedCurrentPath === normalizedPath + ); } function splitIntoSegments(href: string) { return href.split('/').filter(Boolean); } -function hasMatchingSegments( - targetLink: string, - currentRoute: string, - depth: number, -) { - const segments = splitIntoSegments(targetLink); - const matchingSegments = numberOfMatchingSegments(currentRoute, segments); +function normalizePath(path: string, options?: RouteActiveOptions) { + const [pathname = ROOT_PATH] = path.split('?'); + const normalizedPath = + pathname.length > 1 && pathname.endsWith('/') + ? pathname.slice(0, -1) + : pathname || ROOT_PATH; - if (targetLink === currentRoute) { - return true; + if (!options?.locale && !options?.locales?.length) { + return normalizedPath || ROOT_PATH; } - // how far down should segments be matched? - // - if depth = 1 => only highlight the links of the immediate parent - // - if depth = 2 => for url = /account match /account/organization/members - return matchingSegments > segments.length - (depth - 1); -} + const locale = + options?.locale ?? detectLocaleFromPath(normalizedPath, options?.locales); -function numberOfMatchingSegments(href: string, segments: string[]) { - let count = 0; - - for (const segment of splitIntoSegments(href)) { - // for as long as the segments match, keep counting + 1 - if (segments.includes(segment)) { - count += 1; - } else { - return count; - } + if (!locale || !hasLocalePrefix(normalizedPath, locale)) { + return normalizedPath || ROOT_PATH; } - return count; + return stripLocalePrefix(normalizedPath, locale); } -function isRoot(path: string) { - return path === ROOT_PATH; +function detectLocaleFromPath( + path: string, + locales: string[] | undefined, +): string | undefined { + if (!locales?.length) { + return undefined; + } + + const [firstSegment] = splitIntoSegments(path); + + if (!firstSegment) { + return undefined; + } + + return locales.find( + (locale) => locale.toLowerCase() === firstSegment.toLowerCase(), + ); +} + +function hasLocalePrefix(path: string, locale: string) { + return path === `/${locale}` || path.startsWith(`/${locale}/`); +} + +function stripLocalePrefix(path: string, locale: string) { + if (!hasLocalePrefix(path, locale)) { + return path || ROOT_PATH; + } + + const withoutPrefix = path.slice(locale.length + 1); + + if (!withoutPrefix) { + return ROOT_PATH; + } + + return withoutPrefix.startsWith('/') ? withoutPrefix : `/${withoutPrefix}`; } diff --git a/packages/ui/src/makerkit/app-breadcrumbs.tsx b/packages/ui/src/makerkit/app-breadcrumbs.tsx index 9a161fb18..4fd65e0ce 100644 --- a/packages/ui/src/makerkit/app-breadcrumbs.tsx +++ b/packages/ui/src/makerkit/app-breadcrumbs.tsx @@ -3,7 +3,7 @@ import { Fragment } from 'react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { useParams, usePathname } from 'next/navigation'; import { Breadcrumb, @@ -23,7 +23,14 @@ export function AppBreadcrumbs(props: { maxDepth?: number; }) { const pathName = usePathname(); - const splitPath = pathName.split('/').filter(Boolean); + const { locale } = useParams(); + + // Remove the locale from the path + const splitPath = pathName + .split('/') + .filter(Boolean) + .filter((path) => path !== locale); + const values = props.values ?? {}; const maxDepth = props.maxDepth ?? 6; @@ -48,7 +55,7 @@ export function AppBreadcrumbs(props: { values[path] ) : ( <Trans - i18nKey={`common:routes.${unslugify(path)}`} + i18nKey={`common.routes.${unslugify(path)}`} defaults={unslugify(path)} /> ); @@ -60,18 +67,20 @@ export function AppBreadcrumbs(props: { condition={index < visiblePaths.length - 1} fallback={label} > - <BreadcrumbLink asChild> - <Link - href={ - '/' + - splitPath - .slice(0, splitPath.indexOf(path) + 1) - .join('/') - } - > - {label} - </Link> - </BreadcrumbLink> + <BreadcrumbLink + render={ + <Link + href={ + '/' + + splitPath + .slice(0, splitPath.indexOf(path) + 1) + .join('/') + } + > + {label} + </Link> + } + /> </If> </BreadcrumbItem> diff --git a/packages/ui/src/makerkit/authenticity-token.tsx b/packages/ui/src/makerkit/authenticity-token.tsx deleted file mode 100644 index a9bc433b2..000000000 --- a/packages/ui/src/makerkit/authenticity-token.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; - -export function AuthenticityToken() { - const token = useCsrfToken(); - - return <input type="hidden" name="csrf_token" value={token} />; -} - -function useCsrfToken() { - if (typeof window === 'undefined') return ''; - - return ( - document - .querySelector('meta[name="csrf-token"]') - ?.getAttribute('content') ?? '' - ); -} diff --git a/packages/ui/src/makerkit/bordered-navigation-menu.tsx b/packages/ui/src/makerkit/bordered-navigation-menu.tsx index 69d71a4e5..e397b9a4f 100644 --- a/packages/ui/src/makerkit/bordered-navigation-menu.tsx +++ b/packages/ui/src/makerkit/bordered-navigation-menu.tsx @@ -3,6 +3,8 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useLocale } from 'next-intl'; + import { cn, isRouteActive } from '../lib/utils'; import { Button } from '../shadcn/button'; import { @@ -25,44 +27,48 @@ export function BorderedNavigationMenu(props: React.PropsWithChildren) { export function BorderedNavigationMenuItem(props: { path: string; label: React.ReactNode | string; - end?: boolean | ((path: string) => boolean); + highlightMatch?: string; active?: boolean; className?: string; buttonClassName?: string; }) { + const locale = useLocale(); const pathname = usePathname(); - const active = props.active ?? isRouteActive(props.path, pathname, props.end); + const active = + props.active ?? + isRouteActive(props.path, pathname, props.highlightMatch, { locale }); return ( <NavigationMenuItem className={props.className}> <Button - asChild + nativeButton={false} + render={ + <Link + href={props.path} + className={cn('text-sm', { + 'text-secondary-foreground': active, + 'text-secondary-foreground/80 hover:text-secondary-foreground': + !active, + })} + /> + } variant={'ghost'} className={cn('relative active:shadow-xs', props.buttonClassName)} > - <Link - href={props.path} - className={cn('text-sm', { - 'text-secondary-foreground': active, - 'text-secondary-foreground/80 hover:text-secondary-foreground': - !active, - })} - > - {typeof props.label === 'string' ? ( - <Trans i18nKey={props.label} defaults={props.label} /> - ) : ( - props.label - )} + {typeof props.label === 'string' ? ( + <Trans i18nKey={props.label} defaults={props.label} /> + ) : ( + props.label + )} - {active ? ( - <span - className={cn( - 'bg-primary animate-in fade-in zoom-in-90 absolute -bottom-2.5 left-0 h-0.5 w-full', - )} - /> - ) : null} - </Link> + {active ? ( + <span + className={cn( + 'bg-primary animate-in fade-in zoom-in-90 absolute -bottom-2.5 left-0 h-0.5 w-full', + )} + /> + ) : null} </Button> </NavigationMenuItem> ); diff --git a/packages/ui/src/makerkit/card-button.tsx b/packages/ui/src/makerkit/card-button.tsx index 62a2aae94..eda587d52 100644 --- a/packages/ui/src/makerkit/card-button.tsx +++ b/packages/ui/src/makerkit/card-button.tsx @@ -1,117 +1,122 @@ import * as React from 'react'; +import { cn } from '#utils'; +import { useRender } from '@base-ui/react/use-render'; import { ChevronRight } from 'lucide-react'; -import { Slot } from 'radix-ui'; - -import { cn } from '../lib/utils'; export const CardButton: React.FC< { - asChild?: boolean; + render?: React.ReactElement; className?: string; - children: React.ReactNode; + children?: React.ReactNode; } & React.ButtonHTMLAttributes<HTMLButtonElement> -> = function CardButton({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'button'; - - return ( - <Comp - className={cn( +> = function CardButton({ className, render, children, ...props }) { + return useRender({ + render, + defaultTagName: 'button', + props: { + ...props, + className: cn( 'group hover:bg-secondary/20 active:bg-secondary active:bg-secondary/50 dark:shadow-primary/20 relative flex h-36 flex-col rounded-lg border transition-all hover:shadow-xs active:shadow-lg', className, - )} - {...props} - > - <Slot.Slottable>{props.children}</Slot.Slottable> - </Comp> - ); + ), + children, + }, + }); }; export const CardButtonTitle: React.FC< { - asChild?: boolean; + render?: React.ReactElement; children: React.ReactNode; } & React.HTMLAttributes<HTMLDivElement> -> = function CardButtonTitle({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'div'; - - return ( - <Comp - className={cn( +> = function CardButtonTitle({ className, render, children, ...props }) { + return useRender({ + render, + defaultTagName: 'div', + props: { + ...props, + className: cn( className, - 'text-muted-foreground group-hover:text-secondary-foreground align-super text-sm font-medium transition-colors', - )} - {...props} - > - <Slot.Slottable>{props.children}</Slot.Slottable> - </Comp> - ); + 'text-muted-foreground group-hover:text-secondary-foreground text-left align-super text-sm font-medium transition-colors', + ), + children, + }, + }); }; export const CardButtonHeader: React.FC< { children: React.ReactNode; - asChild?: boolean; + render?: React.ReactElement; displayArrow?: boolean; } & React.HTMLAttributes<HTMLDivElement> > = function CardButtonHeader({ className, - asChild, + render, displayArrow = true, + children, ...props }) { - const Comp = asChild ? Slot.Root : 'div'; + const content = ( + <> + {children} - return ( - <Comp className={cn(className, 'p-4')} {...props}> - <Slot.Slottable> - {props.children} - - <ChevronRight - className={cn( - 'text-muted-foreground group-hover:text-secondary-foreground absolute top-4 right-2 h-4 transition-colors', - { - hidden: !displayArrow, - }, - )} - /> - </Slot.Slottable> - </Comp> + <ChevronRight + className={cn( + 'text-muted-foreground group-hover:text-secondary-foreground absolute top-4 right-2 h-4 transition-colors', + { + hidden: !displayArrow, + }, + )} + /> + </> ); + + return useRender({ + render, + defaultTagName: 'div', + props: { + ...props, + className: cn(className, 'p-4'), + children: content, + }, + }); }; export const CardButtonContent: React.FC< { - asChild?: boolean; + render?: React.ReactElement; children: React.ReactNode; } & React.HTMLAttributes<HTMLDivElement> -> = function CardButtonContent({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'div'; - - return ( - <Comp className={cn(className, 'flex flex-1 flex-col px-4')} {...props}> - <Slot.Slottable>{props.children}</Slot.Slottable> - </Comp> - ); +> = function CardButtonContent({ className, render, children, ...props }) { + return useRender({ + render, + defaultTagName: 'div', + props: { + ...props, + className: cn(className, 'flex flex-1 flex-col px-4'), + children, + }, + }); }; export const CardButtonFooter: React.FC< { - asChild?: boolean; + render?: React.ReactElement; children: React.ReactNode; } & React.HTMLAttributes<HTMLDivElement> -> = function CardButtonFooter({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'div'; - - return ( - <Comp - className={cn( +> = function CardButtonFooter({ className, render, children, ...props }) { + return useRender({ + render, + defaultTagName: 'div', + props: { + ...props, + className: cn( className, 'mt-auto flex h-0 w-full flex-col justify-center border-t px-4', - )} - {...props} - > - <Slot.Slottable>{props.children}</Slot.Slottable> - </Comp> - ); + ), + children, + }, + }); }; diff --git a/packages/ui/src/makerkit/cookie-banner.tsx b/packages/ui/src/makerkit/cookie-banner.tsx index 6f0c1c711..76156c8ca 100644 --- a/packages/ui/src/makerkit/cookie-banner.tsx +++ b/packages/ui/src/makerkit/cookie-banner.tsx @@ -2,11 +2,9 @@ import { useCallback, useMemo, useState } from 'react'; -import dynamic from 'next/dynamic'; - -import { Dialog as DialogPrimitive } from 'radix-ui'; - import { Button } from '../shadcn/button'; +import { Dialog, DialogContent } from '../shadcn/dialog'; +import { Heading } from '../shadcn/heading'; import { Trans } from './trans'; // configure this as you wish @@ -18,11 +16,7 @@ enum ConsentStatus { Unknown = 'unknown', } -export const CookieBanner = dynamic(async () => CookieBannerComponent, { - ssr: false, -}); - -export function CookieBannerComponent() { +export function CookieBanner() { const { status, accept, reject } = useCookieConsent(); if (!isBrowser()) { @@ -34,16 +28,17 @@ export function CookieBannerComponent() { } return ( - <DialogPrimitive.Root open modal={false}> - <DialogPrimitive.Content - onOpenAutoFocus={(e) => e.preventDefault()} - className={`dark:shadow-primary-500/40 bg-background animate-in fade-in zoom-in-95 slide-in-from-bottom-16 fill-mode-both fixed bottom-0 z-50 w-full max-w-lg border p-6 shadow-2xl delay-1000 duration-1000 lg:bottom-[2rem] lg:left-[2rem] lg:h-48 lg:rounded-lg`} + <Dialog open modal={false}> + <DialogContent + className={`dark:shadow-primary-500/40 bg-background animate-in fade-in zoom-in-95 slide-in-from-bottom-16 fill-mode-both fixed bottom-0 w-full max-w-lg border p-6 shadow-2xl delay-1000 duration-1000 lg:bottom-[2rem] lg:left-[2rem] lg:h-48 lg:rounded-lg`} > - <DialogPrimitive.Title className="text-lg font-semibold"> - <Trans i18nKey={'cookieBanner.title'} /> - </DialogPrimitive.Title> - <div className={'flex flex-col space-y-4'}> + <div> + <Heading level={3}> + <Trans i18nKey={'cookieBanner.title'} /> + </Heading> + </div> + <div className={'text-gray-500 dark:text-gray-400'}> <Trans i18nKey={'cookieBanner.description'} /> </div> @@ -58,8 +53,8 @@ export function CookieBannerComponent() { </Button> </div> </div> - </DialogPrimitive.Content> - </DialogPrimitive.Root> + </DialogContent> + </Dialog> ); } diff --git a/packages/ui/src/makerkit/copy-to-clipboard.tsx b/packages/ui/src/makerkit/copy-to-clipboard.tsx new file mode 100644 index 000000000..4f86537f1 --- /dev/null +++ b/packages/ui/src/makerkit/copy-to-clipboard.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { ReactNode, useCallback, useState } from 'react'; + +import { Check, Copy } from 'lucide-react'; + +import { cn } from '../lib/utils'; +import { toast } from '../shadcn/sonner'; + +interface CopyToClipboardProps { + children: ReactNode; + value?: string; + className?: string; + tooltipText?: string; + successMessage?: string; + errorMessage?: string; +} + +/** + * A component that copies text to clipboard when clicked + */ +export function CopyToClipboard({ + children, + className, + value = undefined, + tooltipText = 'Copy to clipboard', + successMessage = 'Copied to clipboard', + errorMessage = 'Failed to copy to clipboard', +}: CopyToClipboardProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback( + (e: React.MouseEvent<HTMLSpanElement>) => { + e.stopPropagation(); + + const textToCopy = children?.toString() || ''; + + navigator.clipboard + .writeText(value ?? textToCopy) + .then(() => { + setCopied(true); + toast.success(successMessage); + setTimeout(() => setCopied(false), 2000); + }) + .catch((error) => { + console.error('Failed to copy text: ', error); + toast.error(errorMessage); + }); + }, + [children, value, successMessage, errorMessage], + ); + + if (typeof value === 'undefined') { + return children; + } + + return ( + <button + title={tooltipText} + onClick={handleCopy} + className={cn( + 'group group/button -mx-1 inline-flex cursor-pointer items-center gap-1 rounded px-1 transition-colors hover:underline', + className, + )} + > + {children} + + <span className="text-muted-foreground transition-opacity"> + {copied ? ( + <Check className="h-3.5 w-3.5 text-green-500" /> + ) : ( + <Copy className="h-3.5 w-3.5" /> + )} + </span> + </button> + ); +} diff --git a/packages/ui/src/makerkit/data-table.tsx b/packages/ui/src/makerkit/data-table.tsx index 7d5623263..aeda9d063 100644 --- a/packages/ui/src/makerkit/data-table.tsx +++ b/packages/ui/src/makerkit/data-table.tsx @@ -295,7 +295,7 @@ export function DataTable<RecordData extends DataItem>({ return ( <div className="flex h-full flex-1 flex-col"> <Table - data-testid="data-table" + data-testidid="data-table" {...tableProps} className={cn( 'bg-background border-collapse border-spacing-0', @@ -493,7 +493,7 @@ export function DataTable<RecordData extends DataItem>({ <If condition={rows.length === 0}> <div className={'flex flex-1 flex-col items-center p-8'}> <span className="text-muted-foreground text-center text-sm"> - {noResultsMessage || <Trans i18nKey={'common:noData'} />} + {noResultsMessage || <Trans i18nKey={'common.noData'} />} </span> </div> </If> @@ -544,7 +544,7 @@ function Pagination<T>({ <div className="flex items-center space-x-4"> <span className="text-muted-foreground flex items-center text-xs"> <Trans - i18nKey={'common:pageOfPages'} + i18nKey={'common.pageOfPages'} values={{ page: currentPageIndex + 1, total: table.getPageCount(), diff --git a/packages/ui/src/makerkit/dropzone.tsx b/packages/ui/src/makerkit/dropzone.tsx index efd2feea5..964c87a56 100644 --- a/packages/ui/src/makerkit/dropzone.tsx +++ b/packages/ui/src/makerkit/dropzone.tsx @@ -8,7 +8,7 @@ import { } from 'react'; import { CheckCircle, File, Loader2, Upload, X } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { type UseSupabaseUploadReturn } from '../hooks/use-supabase-upload'; import { cn } from '../lib/utils'; @@ -97,7 +97,7 @@ const DropzoneContent = ({ className }: { className?: string }) => { isSuccess, } = useDropzoneContext(); - const { t } = useTranslation(); + const t = useTranslations(); const exceedMaxFiles = files.length > maxFiles; @@ -120,7 +120,7 @@ const DropzoneContent = ({ className }: { className?: string }) => { <p className="text-primary text-sm"> <Trans - i18nKey="common:dropzone.success" + i18nKey="common.dropzone.success" values={{ count: files.length }} /> </p> @@ -165,7 +165,7 @@ const DropzoneContent = ({ className }: { className?: string }) => { {file.errors .map((e) => e.message.startsWith('File is larger than') - ? t('common:dropzone.errorMessageFileSizeTooLarge', { + ? t('common.dropzone.errorMessageFileSizeTooLarge', { size: formatBytes(file.size, 2), maxSize: formatBytes(maxFileSize, 2), }) @@ -175,18 +175,18 @@ const DropzoneContent = ({ className }: { className?: string }) => { </p> ) : loading && !isSuccessfullyUploaded ? ( <p className="text-muted-foreground text-xs"> - <Trans i18nKey="common:dropzone.uploading" /> + <Trans i18nKey="common.dropzone.uploading" /> </p> ) : fileError ? ( <p className="text-destructive text-xs"> <Trans - i18nKey="common:dropzone.errorMessage" + i18nKey="common.dropzone.errorMessage" values={{ message: fileError.message }} /> </p> ) : isSuccessfullyUploaded ? ( <p className="text-primary text-xs"> - <Trans i18nKey="common:dropzone.success" /> + <Trans i18nKey="common.dropzone.success" /> </p> ) : ( <p className="text-muted-foreground text-xs"> @@ -211,7 +211,7 @@ const DropzoneContent = ({ className }: { className?: string }) => { {exceedMaxFiles && ( <p className="text-destructive mt-2 text-left text-sm"> <Trans - i18nKey="common:dropzone.errorMaxFiles" + i18nKey="common.dropzone.errorMaxFiles" values={{ count: maxFiles, files: files.length - maxFiles }} /> </p> @@ -226,14 +226,14 @@ const DropzoneContent = ({ className }: { className?: string }) => { {loading ? ( <> <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - <Trans i18nKey="common:dropzone.uploading" /> + <Trans i18nKey="common.dropzone.uploading" /> </> ) : ( <span className="flex items-center"> <Upload size={20} className="mr-2 h-4 w-4" /> <Trans - i18nKey="common:dropzone.uploadFiles" + i18nKey="common.dropzone.uploadFiles" values={{ count: files.length, }} @@ -260,30 +260,30 @@ const DropzoneEmptyState = ({ className }: { className?: string }) => { <p className="text-sm"> <Trans - i18nKey="common:dropzone.uploadFiles" + i18nKey="common.dropzone.uploadFiles" values={{ count: maxFiles }} /> </p> <div className="flex flex-col items-center gap-y-1"> <p className="text-muted-foreground text-xs"> - <Trans i18nKey="common:dropzone.dragAndDrop" />{' '} + <Trans i18nKey="common.dropzone.dragAndDrop" />{' '} <a onClick={() => inputRef.current?.click()} className="hover:text-foreground cursor-pointer underline transition" > <Trans - i18nKey="common:dropzone.select" + i18nKey="common.dropzone.select" values={{ count: maxFiles === 1 ? `file` : 'files' }} /> </a>{' '} - <Trans i18nKey="common:dropzone.toUpload" /> + <Trans i18nKey="common.dropzone.toUpload" /> </p> {maxFileSize !== Number.POSITIVE_INFINITY && ( <p className="text-muted-foreground text-xs"> <Trans - i18nKey="common:dropzone.maxFileSize" + i18nKey="common.dropzone.maxFileSize" values={{ size: formatBytes(maxFileSize, 2) }} /> </p> diff --git a/packages/ui/src/makerkit/error-boundary.tsx b/packages/ui/src/makerkit/error-boundary.tsx new file mode 100644 index 000000000..22b708ede --- /dev/null +++ b/packages/ui/src/makerkit/error-boundary.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children?: ReactNode; + fallback: ReactNode; +} + +interface State { + hasError: boolean; +} + +export class ErrorBoundary extends Component<Props, State> { + public state: State = { + hasError: false, + }; + + public static getDerivedStateFromError(_: Error): State { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return this.props.fallback; + } + + return this.props.children; + } +} diff --git a/packages/ui/src/makerkit/image-uploader.tsx b/packages/ui/src/makerkit/image-uploader.tsx index 5ddb9ca50..aa7bd2fd0 100644 --- a/packages/ui/src/makerkit/image-uploader.tsx +++ b/packages/ui/src/makerkit/image-uploader.tsx @@ -92,7 +92,7 @@ export function ImageUploader( <div> <Button onClick={onClear} size={'sm'} variant={'ghost'}> - <Trans i18nKey={'common:clear'} /> + <Trans i18nKey={'common.clear'} /> </Button> </div> </div> diff --git a/packages/ui/src/makerkit/language-selector.tsx b/packages/ui/src/makerkit/language-selector.tsx index b26685803..7b1836d08 100644 --- a/packages/ui/src/makerkit/language-selector.tsx +++ b/packages/ui/src/makerkit/language-selector.tsx @@ -1,8 +1,10 @@ 'use client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState, useTransition } from 'react'; -import { useTranslation } from 'react-i18next'; +import { useLocale } from 'next-intl'; + +import { usePathname, useRouter } from '@kit/i18n/navigation'; import { Select, @@ -12,60 +14,61 @@ import { SelectValue, } from '../shadcn/select'; -export function LanguageSelector({ - onChange, -}: { +interface LanguageSelectorProps { + locales?: string[]; onChange?: (locale: string) => unknown; -}) { - const { i18n } = useTranslation(); - const { language: currentLanguage, options } = i18n; +} - const locales = (options.supportedLngs as string[]).filter( - (locale) => locale.toLowerCase() !== 'cimode', - ); +const DEFAULT_STRATEGY = 'path'; + +export function LanguageSelector({ + locales = [], + onChange, +}: LanguageSelectorProps) { + const currentLocale = useLocale(); + const handleChangeLocale = useChangeLocale(); + const [value, setValue] = useState(currentLocale); const languageNames = useMemo(() => { - return new Intl.DisplayNames([currentLanguage], { + return new Intl.DisplayNames([currentLocale], { type: 'language', }); - }, [currentLanguage]); - - const [value, setValue] = useState(i18n.language); + }, [currentLocale]); const languageChanged = useCallback( - async (locale: string) => { + (locale: string | null) => { + if (!locale) return; + setValue(locale); if (onChange) { onChange(locale); } - await i18n.changeLanguage(locale); - - // refresh cached translations - window.location.reload(); + handleChangeLocale(locale); }, - [i18n, onChange], + [onChange, handleChangeLocale], ); + if (locales.length <= 1) { + return null; + } + return ( <Select value={value} onValueChange={languageChanged}> <SelectTrigger> - <SelectValue /> + <SelectValue className="capitalize"> + {(value) => (value ? languageNames.of(value) : value)} + </SelectValue> </SelectTrigger> <SelectContent> {locales.map((locale) => { - const label = capitalize(languageNames.of(locale) ?? locale); - - const option = { - value: locale, - label, - }; + const label = languageNames.of(locale) ?? locale; return ( - <SelectItem value={option.value} key={option.value}> - {option.label} + <SelectItem value={locale} key={locale} className="capitalize"> + {label} </SelectItem> ); })} @@ -74,6 +77,46 @@ export function LanguageSelector({ ); } -function capitalize(lang: string) { - return lang.slice(0, 1).toUpperCase() + lang.slice(1); +function useChangeLocale(strategy: `cookie` | `path` = DEFAULT_STRATEGY) { + const changeLocaleViaPath = useChangeLocaleViaPath(); + const changeLocaleViaCookie = useChangeLocaleViaCookie(); + + return useCallback( + (locale: string) => { + switch (strategy) { + case 'cookie': + return changeLocaleViaCookie(locale); + case 'path': + return changeLocaleViaPath(locale); + } + }, + [strategy, changeLocaleViaCookie, changeLocaleViaPath], + ); +} + +function useChangeLocaleViaCookie() { + const router = useRouter(); + + return useCallback( + (locale: string) => { + document.cookie = `lang=${locale}; Path=/; SameSite=Lax`; + router.refresh(); + }, + [router], + ); +} + +function useChangeLocaleViaPath() { + const pathname = usePathname(); + const router = useRouter(); + const [, startTransition] = useTransition(); + + return useCallback( + (locale: string) => { + startTransition(() => { + router.replace(pathname, { locale }); + }); + }, + [router, pathname], + ); } diff --git a/packages/ui/src/makerkit/marketing/cta-button.tsx b/packages/ui/src/makerkit/marketing/cta-button.tsx index 2fbcb2855..a24bf99c7 100644 --- a/packages/ui/src/makerkit/marketing/cta-button.tsx +++ b/packages/ui/src/makerkit/marketing/cta-button.tsx @@ -2,18 +2,15 @@ import { cn } from '../../lib/utils'; import { Button } from '../../shadcn/button'; export const CtaButton: React.FC<React.ComponentProps<typeof Button>> = - function CtaButtonComponent({ className, children, ...props }) { + function CtaButtonComponent({ className, children, render, ...props }) { return ( <Button - className={cn( - 'h-12 rounded-xl px-4 text-base font-semibold', - className, - { - ['dark:shadow-primary/30 transition-all hover:shadow-2xl']: - props.variant === 'default' || !props.variant, - }, - )} - asChild + size="lg" + className={cn(className, { + ['dark:shadow-primary/30 transition-all hover:shadow-xl']: + props.variant === 'default' || !props.variant, + })} + render={render} {...props} > {children} diff --git a/packages/ui/src/makerkit/marketing/gradient-secondary-text.tsx b/packages/ui/src/makerkit/marketing/gradient-secondary-text.tsx index c6c4ba8f3..ec43d5cd9 100644 --- a/packages/ui/src/makerkit/marketing/gradient-secondary-text.tsx +++ b/packages/ui/src/makerkit/marketing/gradient-secondary-text.tsx @@ -1,23 +1,27 @@ -import { Slot } from 'radix-ui'; +import { useRender } from '@base-ui/react/use-render'; import { cn } from '../../lib/utils'; export const GradientSecondaryText: React.FC< React.HTMLAttributes<HTMLSpanElement> & { - asChild?: boolean; + render?: React.ReactElement; } -> = function GradientSecondaryTextComponent({ className, ...props }) { - const Comp = props.asChild ? Slot.Root : 'span'; - - return ( - <Comp - className={cn( +> = function GradientSecondaryTextComponent({ + className, + render, + children, + ...props +}) { + return useRender({ + render, + defaultTagName: 'span', + props: { + ...props, + className: cn( 'dark:from-foreground/60 dark:to-foreground text-secondary-foreground dark:bg-linear-to-r dark:bg-clip-text dark:text-transparent', className, - )} - {...props} - > - <Slot.Slottable>{props.children}</Slot.Slottable> - </Comp> - ); + ), + children, + }, + }); }; diff --git a/packages/ui/src/makerkit/marketing/header.tsx b/packages/ui/src/makerkit/marketing/header.tsx index a2dd834de..156253a69 100644 --- a/packages/ui/src/makerkit/marketing/header.tsx +++ b/packages/ui/src/makerkit/marketing/header.tsx @@ -1,9 +1,11 @@ import { cn } from '../../lib/utils'; +import { If } from '../if'; interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> { logo?: React.ReactNode; navigation?: React.ReactNode; actions?: React.ReactNode; + centered?: boolean; } export const Header: React.FC<HeaderProps> = function ({ @@ -11,8 +13,19 @@ export const Header: React.FC<HeaderProps> = function ({ logo, navigation, actions, + centered = true, ...props }) { + const grids = { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + }; + + const gridAmount = [logo, navigation, actions].filter(Boolean).length; + + const gridClassName = grids[gridAmount as keyof typeof grids]; + return ( <div className={cn( @@ -21,11 +34,23 @@ export const Header: React.FC<HeaderProps> = function ({ )} {...props} > - <div className="container"> - <div className="grid h-14 grid-cols-3 items-center"> - <div className={'mx-auto md:mx-0'}>{logo}</div> - <div className="order-first md:order-none">{navigation}</div> - <div className="flex items-center justify-end gap-x-2">{actions}</div> + <div + className={cn({ + 'container mx-auto': centered, + })} + > + <div className={cn('grid h-14 items-center', gridClassName)}> + {logo} + + <If condition={navigation}> + <div className="order-first md:order-none">{navigation}</div> + </If> + + <If condition={actions}> + <div className="flex items-center justify-end gap-x-2"> + {actions} + </div> + </If> </div> </div> </div> diff --git a/packages/ui/src/makerkit/marketing/hero-title.tsx b/packages/ui/src/makerkit/marketing/hero-title.tsx index 67b1b1930..9e1310f06 100644 --- a/packages/ui/src/makerkit/marketing/hero-title.tsx +++ b/packages/ui/src/makerkit/marketing/hero-title.tsx @@ -1,23 +1,22 @@ -import { Slot } from 'radix-ui'; +import { useRender } from '@base-ui/react/use-render'; import { cn } from '../../lib/utils'; export const HeroTitle: React.FC< React.HTMLAttributes<HTMLHeadingElement> & { - asChild?: boolean; + render?: React.ReactElement; } -> = function HeroTitleComponent({ children, className, ...props }) { - const Comp = props.asChild ? Slot.Root : 'h1'; - - return ( - <Comp - className={cn( +> = function HeroTitleComponent({ children, className, render, ...props }) { + return useRender({ + render, + defaultTagName: 'h1', + props: { + ...props, + className: cn( 'hero-title flex flex-col text-center font-sans text-4xl font-medium tracking-tighter sm:text-6xl lg:max-w-5xl lg:text-7xl xl:max-w-6xl dark:text-white', className, - )} - {...props} - > - <Slot.Slottable>{children}</Slot.Slottable> - </Comp> - ); + ), + children, + }, + }); }; diff --git a/packages/ui/src/makerkit/marketing/hero.tsx b/packages/ui/src/makerkit/marketing/hero.tsx index 153500d56..efb30b4ef 100644 --- a/packages/ui/src/makerkit/marketing/hero.tsx +++ b/packages/ui/src/makerkit/marketing/hero.tsx @@ -52,9 +52,9 @@ export function Hero({ {subtitle && ( <div className="flex max-w-3xl"> - <h3 className="text-secondary-foreground/70 p-0 text-center font-sans text-xl font-medium tracking-tight"> + <h2 className="text-secondary-foreground/70 p-0 text-center font-sans text-xl font-medium tracking-tight"> {subtitle} - </h3> + </h2> </div> )} </div> diff --git a/packages/ui/src/makerkit/marketing/newsletter-signup.tsx b/packages/ui/src/makerkit/marketing/newsletter-signup.tsx index 693669429..cf310bff0 100644 --- a/packages/ui/src/makerkit/marketing/newsletter-signup.tsx +++ b/packages/ui/src/makerkit/marketing/newsletter-signup.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { cn } from '../../lib/utils'; import { Button } from '../../shadcn/button'; @@ -16,10 +16,10 @@ import { import { Input } from '../../shadcn/input'; const NewsletterFormSchema = z.object({ - email: z.string().email('Please enter a valid email address'), + email: z.email(), }); -type NewsletterFormValues = z.infer<typeof NewsletterFormSchema>; +type NewsletterFormValues = z.output<typeof NewsletterFormSchema>; interface NewsletterSignupProps extends React.HTMLAttributes<HTMLDivElement> { onSignup: (data: NewsletterFormValues) => void; @@ -49,13 +49,13 @@ export function NewsletterSignup({ className="flex flex-col gap-y-3" > <FormField - control={form.control} name="email" render={({ field }) => ( <FormItem> - <FormControl> - <Input placeholder={placeholder} {...field} /> - </FormControl> + <FormControl + render={<Input placeholder={placeholder} {...field} />} + /> + <FormMessage /> </FormItem> )} diff --git a/packages/ui/src/makerkit/marketing/pill.tsx b/packages/ui/src/makerkit/marketing/pill.tsx index d69532588..1fa67cf19 100644 --- a/packages/ui/src/makerkit/marketing/pill.tsx +++ b/packages/ui/src/makerkit/marketing/pill.tsx @@ -1,4 +1,6 @@ -import { Slot } from 'radix-ui'; +'use client'; + +import { useRender } from '@base-ui/react/use-render'; import { cn } from '../../lib/utils'; import { GradientSecondaryText } from './gradient-secondary-text'; @@ -6,54 +8,58 @@ import { GradientSecondaryText } from './gradient-secondary-text'; export const Pill: React.FC< React.HTMLAttributes<HTMLHeadingElement> & { label?: React.ReactNode; - asChild?: boolean; + render?: React.ReactElement; } -> = function PillComponent({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'h3'; - - return ( - <Comp - className={cn( - 'flex min-h-9 items-center gap-x-1.5 rounded-full border px-1.5 py-1 text-center text-sm font-medium text-transparent', - className, - )} - {...props} - > - {props.label && ( +> = function PillComponent({ className, render, label, children, ...props }) { + const content = ( + <> + {label && ( <span className={ - 'text-primary-foreground bg-primary rounded-2xl border px-1.5 py-1.5 text-xs font-bold tracking-tight' + 'text-primary-foreground bg-primary rounded-2xl border px-1.5 py-0.5 text-xs font-bold tracking-tight' } > - {props.label} + {label} </span> )} - <Slot.Slottable> - <GradientSecondaryText - className={'flex items-center gap-x-2 font-semibold tracking-tight'} - > - {props.children} - </GradientSecondaryText> - </Slot.Slottable> - </Comp> + <GradientSecondaryText + className={'flex items-center gap-x-2 font-semibold tracking-tight'} + > + {children} + </GradientSecondaryText> + </> ); + + return useRender({ + render, + defaultTagName: 'h3', + props: { + ...props, + className: cn( + 'bg-muted/50 flex min-h-10 items-center gap-x-1.5 rounded-full border px-2 py-1 text-center text-sm font-medium text-transparent', + className, + ), + children: content, + }, + }); }; export const PillActionButton: React.FC< React.HTMLAttributes<HTMLButtonElement> & { - asChild?: boolean; + render?: React.ReactElement; } -> = ({ asChild, ...props }) => { - const Comp = asChild ? Slot.Root : 'button'; - - return ( - <Comp - {...props} - className={ - 'text-secondary-foreground bg-input active:bg-primary active:text-primary-foreground hover:ring-muted-foreground/50 rounded-full px-1.5 py-1.5 text-center text-sm font-medium ring ring-transparent transition-colors' - } - > - {props.children} - </Comp> - ); +> = ({ render, children, className, ...props }) => { + return useRender({ + render, + defaultTagName: 'button', + props: { + ...props, + className: cn( + 'text-secondary-foreground bg-input active:bg-primary active:text-primary-foreground hover:ring-muted-foreground/50 rounded-full px-1.5 py-1.5 text-center text-sm font-medium ring ring-transparent transition-colors', + className, + ), + children, + 'aria-label': 'Action button', + }, + }); }; diff --git a/packages/ui/src/makerkit/mobile-mode-toggle.tsx b/packages/ui/src/makerkit/mobile-mode-toggle.tsx index fbefba0ab..92c9f9ee8 100644 --- a/packages/ui/src/makerkit/mobile-mode-toggle.tsx +++ b/packages/ui/src/makerkit/mobile-mode-toggle.tsx @@ -31,7 +31,5 @@ export function MobileModeToggle(props: { className?: string }) { } function setCookieTheme(theme: string) { - const secure = - typeof window !== 'undefined' && window.location.protocol === 'https:'; - document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`; + document.cookie = `theme=${theme}; path=/; max-age=31536000`; } diff --git a/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx b/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx deleted file mode 100644 index 1566fc429..000000000 --- a/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { ChevronDown } from 'lucide-react'; - -import { Button } from '../shadcn/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../shadcn/dropdown-menu'; -import { Trans } from './trans'; - -function MobileNavigationDropdown({ - links, -}: { - links: { - path: string; - label: string; - }[]; -}) { - const path = usePathname(); - - const currentPathName = useMemo(() => { - return Object.values(links).find((link) => link.path === path)?.label; - }, [links, path]); - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant={'secondary'} className={'w-full'}> - <span - className={'flex w-full items-center justify-between space-x-2'} - > - <span> - <Trans i18nKey={currentPathName} defaults={currentPathName} /> - </span> - - <ChevronDown className={'h-5'} /> - </span> - </Button> - </DropdownMenuTrigger> - - <DropdownMenuContent - className={ - 'dark:divide-dark-700 w-screen divide-y divide-gray-100' + - ' rounded-none' - } - > - {Object.values(links).map((link) => { - return ( - <DropdownMenuItem asChild key={link.path}> - <Link - className={'flex h-12 w-full items-center'} - href={link.path} - > - <Trans i18nKey={link.label} defaults={link.label} /> - </Link> - </DropdownMenuItem> - ); - })} - </DropdownMenuContent> - </DropdownMenu> - ); -} - -export default MobileNavigationDropdown; diff --git a/packages/ui/src/makerkit/mobile-navigation-menu.tsx b/packages/ui/src/makerkit/mobile-navigation-menu.tsx deleted file mode 100644 index bf0630114..000000000 --- a/packages/ui/src/makerkit/mobile-navigation-menu.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { ChevronDown } from 'lucide-react'; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../shadcn/dropdown-menu'; -import { Trans } from './trans'; - -function MobileNavigationDropdown({ - links, -}: { - links: { - path: string; - label: string; - }[]; -}) { - const path = usePathname(); - - const items = useMemo( - function MenuItems() { - return Object.values(links).map((link) => { - return ( - <DropdownMenuItem key={link.path}> - <Link - className={'flex h-full w-full items-center'} - href={link.path} - > - <Trans i18nKey={link.label} defaults={link.label} /> - </Link> - </DropdownMenuItem> - ); - }); - }, - [links], - ); - - const currentPathName = useMemo(() => { - return Object.values(links).find((link) => link.path === path)?.label; - }, [links, path]); - - return ( - <DropdownMenu> - <DropdownMenuTrigger className={'w-full'}> - <div - className={ - 'Button dark:ring-dark-700 w-full justify-start ring-2 ring-gray-100' - } - > - <span - className={ - 'ButtonNormal flex w-full items-center justify-between space-x-2' - } - > - <span> - <Trans i18nKey={currentPathName} defaults={currentPathName} /> - </span> - - <ChevronDown className={'h-5'} /> - </span> - </div> - </DropdownMenuTrigger> - - <DropdownMenuContent>{items}</DropdownMenuContent> - </DropdownMenu> - ); -} - -export default MobileNavigationDropdown; diff --git a/packages/ui/src/makerkit/mode-toggle.tsx b/packages/ui/src/makerkit/mode-toggle.tsx index 5a68ee455..8dae25df2 100644 --- a/packages/ui/src/makerkit/mode-toggle.tsx +++ b/packages/ui/src/makerkit/mode-toggle.tsx @@ -7,9 +7,17 @@ import { useTheme } from 'next-themes'; import { cn } from '../lib/utils'; import { Button } from '../shadcn/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '../shadcn/card'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSub, @@ -36,13 +44,13 @@ export function ModeToggle(props: { className?: string }) { key={mode} onClick={() => { setTheme(mode); - setCookieTheme(mode); + setCookeTheme(mode); }} > <Icon theme={mode} /> <span> - <Trans i18nKey={`common:${mode}Theme`} /> + <Trans i18nKey={`common.${mode}Theme`} /> </span> </DropdownMenuItem> ); @@ -51,12 +59,14 @@ export function ModeToggle(props: { className?: string }) { return ( <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="icon" className={props.className}> - <Sun className="h-[0.9rem] w-[0.9rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" /> - <Moon className="absolute h-[0.9rem] w-[0.9rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" /> - <span className="sr-only">Toggle theme</span> - </Button> + <DropdownMenuTrigger + render={ + <Button variant="ghost" size="icon" className={props.className} /> + } + > + <Sun className="h-[0.9rem] w-[0.9rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" /> + <Moon className="absolute h-[0.9rem] w-[0.9rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" /> + <span className="sr-only">Toggle theme</span> </DropdownMenuTrigger> <DropdownMenuContent align="end">{Items}</DropdownMenuContent> @@ -74,20 +84,18 @@ export function SubMenuModeToggle() { return ( <DropdownMenuItem - className={cn('flex cursor-pointer items-center space-x-2', { + className={cn('flex cursor-pointer items-center gap-x-2', { 'bg-muted': isSelected, })} key={mode} onClick={() => { setTheme(mode); - setCookieTheme(mode); + setCookeTheme(mode); }} > <Icon theme={mode} /> - <span> - <Trans i18nKey={`common:${mode}Theme`} /> - </span> + <Trans i18nKey={`common.${mode}Theme`} /> </DropdownMenuItem> ); }), @@ -95,20 +103,16 @@ export function SubMenuModeToggle() { ); return ( - <> + <DropdownMenuGroup> <DropdownMenuSub> <DropdownMenuSubTrigger className={ - 'hidden w-full items-center justify-between gap-x-3 lg:flex' + 'hidden w-full items-center justify-between gap-x-2 lg:flex' } > - <span className={'flex space-x-2'}> - <Icon theme={resolvedTheme} /> + <Icon theme={resolvedTheme} /> - <span> - <Trans i18nKey={'common:theme'} /> - </span> - </span> + <Trans i18nKey={'common.theme'} /> </DropdownMenuSubTrigger> <DropdownMenuSubContent>{MenuItems}</DropdownMenuSubContent> @@ -116,19 +120,17 @@ export function SubMenuModeToggle() { <div className={'lg:hidden'}> <DropdownMenuLabel> - <Trans i18nKey={'common:theme'} /> + <Trans i18nKey={'common.theme'} /> </DropdownMenuLabel> {MenuItems} </div> - </> + </DropdownMenuGroup> ); } -function setCookieTheme(theme: string) { - const secure = - typeof window !== 'undefined' && window.location.protocol === 'https:'; - document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`; +function setCookeTheme(theme: string) { + document.cookie = `theme=${theme}; path=/; max-age=31536000`; } function Icon({ theme }: { theme: string | undefined }) { @@ -141,3 +143,53 @@ function Icon({ theme }: { theme: string | undefined }) { return <Computer className="h-4" />; } } + +export function ThemePreferenceCard({ + currentTheme, +}: { + currentTheme: string; +}) { + const { setTheme, theme = currentTheme } = useTheme(); + + return ( + <Card> + <CardHeader> + <CardTitle> + <Trans i18nKey="common.theme" /> + </CardTitle> + + <CardDescription> + <Trans i18nKey="common.themeDescription" /> + </CardDescription> + </CardHeader> + + <CardContent> + <div className="grid grid-cols-3 gap-3"> + {MODES.map((mode) => { + const isSelected = theme === mode; + + return ( + <Button + key={mode} + variant={isSelected ? 'default' : 'outline'} + className={'flex items-center justify-center gap-2'} + onClick={() => { + setTheme(mode); + setCookeTheme(mode); + }} + > + {mode === 'light' && <Sun className="size-4" />} + {mode === 'dark' && <Moon className="size-4" />} + {mode === 'system' && <Computer className="size-4" />} + + <span className="text-sm"> + <Trans i18nKey={`common.${mode}Theme`} /> + </span> + </Button> + ); + })} + </div> + </CardContent> + </Card> + ); +} diff --git a/packages/ui/src/makerkit/multi-step-form.tsx b/packages/ui/src/makerkit/multi-step-form.tsx deleted file mode 100644 index 906ea886a..000000000 --- a/packages/ui/src/makerkit/multi-step-form.tsx +++ /dev/null @@ -1,436 +0,0 @@ -'use client'; - -import React, { - HTMLProps, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; - -import { useMutation } from '@tanstack/react-query'; -import { Slot } from 'radix-ui'; -import { Path, UseFormReturn } from 'react-hook-form'; -import { z } from 'zod'; - -import { cn } from '../lib/utils'; - -interface MultiStepFormProps<T extends z.ZodType> { - schema: T; - form: UseFormReturn<z.infer<T>>; - onSubmit: (data: z.infer<T>) => void; - useStepTransition?: boolean; - className?: string; -} - -type StepProps = React.PropsWithChildren< - { - name: string; - asChild?: boolean; - } & React.HTMLProps<HTMLDivElement> ->; - -const MultiStepFormContext = createContext<ReturnType< - typeof useMultiStepForm -> | null>(null); - -/** - * @name MultiStepForm - * @description Multi-step form component for React - * @param schema - * @param form - * @param onSubmit - * @param children - * @param className - * @constructor - */ -export function MultiStepForm<T extends z.ZodType>({ - schema, - form, - onSubmit, - children, - className, -}: React.PropsWithChildren<MultiStepFormProps<T>>) { - const steps = useMemo( - () => - React.Children.toArray(children).filter( - (child): child is React.ReactElement<StepProps> => - React.isValidElement(child) && child.type === MultiStepFormStep, - ), - [children], - ); - - const header = useMemo(() => { - return React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && child.type === MultiStepFormHeader, - ); - }, [children]); - - const footer = useMemo(() => { - return React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && child.type === MultiStepFormFooter, - ); - }, [children]); - - const stepNames = steps.map((step) => step.props.name); - const multiStepForm = useMultiStepForm(schema, form, stepNames, onSubmit); - - return ( - <MultiStepFormContext.Provider value={multiStepForm}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className={cn(className, 'flex size-full flex-col overflow-hidden')} - > - {header} - - <div className="relative transition-transform duration-500"> - {steps.map((step, index) => { - const isActive = index === multiStepForm.currentStepIndex; - - return ( - <AnimatedStep - key={step.props.name} - direction={multiStepForm.direction} - isActive={isActive} - index={index} - currentIndex={multiStepForm.currentStepIndex} - > - {step} - </AnimatedStep> - ); - })} - </div> - - {footer} - </form> - </MultiStepFormContext.Provider> - ); -} - -export function MultiStepFormContextProvider(props: { - children: (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode; -}) { - const ctx = useMultiStepFormContext(); - - if (Array.isArray(props.children)) { - const [child] = props.children; - - return ( - child as (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode - )(ctx); - } - - return props.children(ctx); -} - -export const MultiStepFormStep: React.FC< - React.PropsWithChildren< - { - asChild?: boolean; - ref?: React.Ref<HTMLDivElement>; - } & HTMLProps<HTMLDivElement> - > -> = function MultiStepFormStep({ children, asChild, ...props }) { - const Cmp = asChild ? Slot.Root : 'div'; - - return ( - <Cmp {...props}> - <Slot.Slottable>{children}</Slot.Slottable> - </Cmp> - ); -}; - -export function useMultiStepFormContext<Schema extends z.ZodType>() { - const context = useContext(MultiStepFormContext) as ReturnType< - typeof useMultiStepForm<Schema> - >; - - if (!context) { - throw new Error( - 'useMultiStepFormContext must be used within a MultiStepForm', - ); - } - - return context; -} - -/** - * @name useMultiStepForm - * @description Hook for multi-step forms - * @param schema - * @param form - * @param stepNames - * @param onSubmit - */ -export function useMultiStepForm<Schema extends z.ZodType>( - schema: Schema, - form: UseFormReturn<z.infer<Schema>>, - stepNames: string[], - onSubmit: (data: z.infer<Schema>) => void, -) { - const [state, setState] = useState({ - currentStepIndex: 0, - direction: undefined as 'forward' | 'backward' | undefined, - }); - - const isStepValid = useCallback(() => { - const currentStepName = stepNames[state.currentStepIndex] as Path< - z.TypeOf<Schema> - >; - - if (schema instanceof z.ZodObject) { - const currentStepSchema = schema.shape[currentStepName] as z.ZodType; - - // the user may not want to validate the current step - // or the step doesn't contain any form field - if (!currentStepSchema) { - return true; - } - - const currentStepData = form.getValues(currentStepName) ?? {}; - const result = currentStepSchema.safeParse(currentStepData); - - return result.success; - } - - throw new Error(`Unsupported schema type: ${schema.constructor.name}`); - }, [schema, form, stepNames, state.currentStepIndex]); - - const nextStep = useCallback( - <Ev extends React.SyntheticEvent>(e: Ev) => { - // prevent form submission when the user presses Enter - // or if the user forgets [type="button"] on the button - e.preventDefault(); - - const isValid = isStepValid(); - - if (!isValid) { - const currentStepName = stepNames[state.currentStepIndex] as Path< - z.TypeOf<Schema> - >; - - if (schema instanceof z.ZodObject) { - const currentStepSchema = schema.shape[currentStepName] as z.ZodType; - - if (currentStepSchema) { - const fields = Object.keys( - (currentStepSchema as z.ZodObject<never>).shape, - ); - - const keys = fields.map((field) => `${currentStepName}.${field}`); - - // trigger validation for all fields in the current step - for (const key of keys) { - void form.trigger(key as Path<z.TypeOf<Schema>>); - } - - return; - } - } - } - - if (isValid && state.currentStepIndex < stepNames.length - 1) { - setState((prevState) => { - return { - ...prevState, - direction: 'forward', - currentStepIndex: prevState.currentStepIndex + 1, - }; - }); - } - }, - [isStepValid, state.currentStepIndex, stepNames, schema, form], - ); - - const prevStep = useCallback( - <Ev extends React.SyntheticEvent>(e: Ev) => { - // prevent form submission when the user presses Enter - // or if the user forgets [type="button"] on the button - e.preventDefault(); - - if (state.currentStepIndex > 0) { - setState((prevState) => { - return { - ...prevState, - direction: 'backward', - currentStepIndex: prevState.currentStepIndex - 1, - }; - }); - } - }, - [state.currentStepIndex], - ); - - const goToStep = useCallback( - (index: number) => { - if (index >= 0 && index < stepNames.length && isStepValid()) { - setState((prevState) => { - return { - ...prevState, - direction: - index > prevState.currentStepIndex ? 'forward' : 'backward', - currentStepIndex: index, - }; - }); - } - }, - [isStepValid, stepNames.length], - ); - - const isValid = form.formState.isValid; - const errors = form.formState.errors; - - const mutation = useMutation({ - mutationFn: () => { - return form.handleSubmit(onSubmit)(); - }, - }); - - return useMemo( - () => ({ - form, - currentStep: stepNames[state.currentStepIndex] as string, - currentStepIndex: state.currentStepIndex, - totalSteps: stepNames.length, - isFirstStep: state.currentStepIndex === 0, - isLastStep: state.currentStepIndex === stepNames.length - 1, - nextStep, - prevStep, - goToStep, - direction: state.direction, - isStepValid, - isValid, - errors, - mutation, - }), - [ - form, - mutation, - stepNames, - state.currentStepIndex, - state.direction, - nextStep, - prevStep, - goToStep, - isStepValid, - isValid, - errors, - ], - ); -} - -export const MultiStepFormHeader: React.FC< - React.PropsWithChildren< - { - asChild?: boolean; - } & HTMLProps<HTMLDivElement> - > -> = function MultiStepFormHeader({ children, asChild, ...props }) { - const Cmp = asChild ? Slot.Root : 'div'; - - return ( - <Cmp {...props}> - <Slot.Slottable>{children}</Slot.Slottable> - </Cmp> - ); -}; - -export const MultiStepFormFooter: React.FC< - React.PropsWithChildren< - { - asChild?: boolean; - } & HTMLProps<HTMLDivElement> - > -> = function MultiStepFormFooter({ children, asChild, ...props }) { - const Cmp = asChild ? Slot.Root : 'div'; - - return ( - <Cmp {...props}> - <Slot.Slottable>{children}</Slot.Slottable> - </Cmp> - ); -}; - -/** - * @name createStepSchema - * @description Create a schema for a multi-step form - * @param steps - */ -export function createStepSchema<T extends Record<string, z.ZodType>>( - steps: T, -) { - return z.object(steps); -} - -interface AnimatedStepProps { - direction: 'forward' | 'backward' | undefined; - isActive: boolean; - index: number; - currentIndex: number; -} - -function AnimatedStep({ - isActive, - direction, - children, - index, - currentIndex, -}: React.PropsWithChildren<AnimatedStepProps>) { - const [shouldRender, setShouldRender] = useState(isActive); - const stepRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (isActive) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setShouldRender(true); - } else { - const timer = setTimeout(() => setShouldRender(false), 300); - - return () => clearTimeout(timer); - } - }, [isActive]); - - useEffect(() => { - if (isActive && stepRef.current) { - const focusableElement = stepRef.current.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - - if (focusableElement) { - (focusableElement as HTMLElement).focus(); - } - } - }, [isActive]); - - if (!shouldRender) { - return null; - } - - const baseClasses = - ' top-0 left-0 w-full h-full transition-all duration-300 ease-in-out animate-in fade-in zoom-in-95'; - - const visibilityClasses = isActive ? 'opacity-100' : 'opacity-0 absolute'; - - const transformClasses = cn( - 'translate-x-0', - isActive - ? {} - : { - '-translate-x-full': direction === 'forward' || index < currentIndex, - 'translate-x-full': direction === 'backward' || index > currentIndex, - }, - ); - - const className = cn(baseClasses, visibilityClasses, transformClasses); - - return ( - <div ref={stepRef} className={className} aria-hidden={!isActive}> - {children} - </div> - ); -} diff --git a/packages/ui/src/makerkit/navigation-config.schema.ts b/packages/ui/src/makerkit/navigation-config.schema.ts index 7d9a9710b..2f8ab63a8 100644 --- a/packages/ui/src/makerkit/navigation-config.schema.ts +++ b/packages/ui/src/makerkit/navigation-config.schema.ts @@ -1,9 +1,10 @@ -import { z } from 'zod'; +import * as z from 'zod'; -const RouteMatchingEnd = z - .union([z.boolean(), z.function().args(z.string()).returns(z.boolean())]) - .default(false) - .optional(); +const RouteContextSchema = z + .enum(['personal', 'organization', 'all']) + .default('all'); + +export type RouteContext = z.output<typeof RouteContextSchema>; const Divider = z.object({ divider: z.literal(true), @@ -13,19 +14,21 @@ const RouteSubChild = z.object({ label: z.string(), path: z.string(), Icon: z.custom<React.ReactNode>().optional(), - end: RouteMatchingEnd, + highlightMatch: z.string().optional(), renderAction: z.custom<React.ReactNode>().optional(), + context: RouteContextSchema.optional(), }); const RouteChild = z.object({ label: z.string(), path: z.string(), Icon: z.custom<React.ReactNode>().optional(), - end: RouteMatchingEnd, + highlightMatch: z.string().optional(), children: z.array(RouteSubChild).default([]).optional(), collapsible: z.boolean().default(false).optional(), collapsed: z.boolean().default(false).optional(), renderAction: z.custom<React.ReactNode>().optional(), + context: RouteContextSchema.optional(), }); const RouteGroup = z.object({ @@ -37,12 +40,8 @@ const RouteGroup = z.object({ }); export const NavigationConfigSchema = z.object({ - style: z.enum(['custom', 'sidebar', 'header']).default('sidebar'), - sidebarCollapsed: z - .enum(['false', 'true']) - .default('true') - .optional() - .transform((value) => value === `true`), - sidebarCollapsedStyle: z.enum(['offcanvas', 'icon', 'none']).default('icon'), + sidebarCollapsed: z.stringbool().optional().default(false), + sidebarCollapsedStyle: z.enum(['icon', 'offcanvas', 'none']).default('icon'), routes: z.array(z.union([RouteGroup, Divider])), + style: z.enum(['sidebar', 'header', 'custom']).default('sidebar'), }); diff --git a/packages/ui/src/makerkit/navigation-utils.ts b/packages/ui/src/makerkit/navigation-utils.ts new file mode 100644 index 000000000..68cfca1d1 --- /dev/null +++ b/packages/ui/src/makerkit/navigation-utils.ts @@ -0,0 +1,104 @@ +import * as z from 'zod'; + +import { + NavigationConfigSchema, + type RouteContext, +} from './navigation-config.schema'; + +type AccountMode = 'personal-only' | 'organizations-only' | 'hybrid'; + +/** + * Determines if a navigation item should be visible based on context and mode + */ +function shouldShowNavItem( + itemContext: RouteContext, + currentContext: 'personal' | 'organization', + mode: AccountMode, +): boolean { + // In organizations-only mode, skip personal-only items + if (mode === 'organizations-only' && itemContext === 'personal') { + return false; + } + + // In personal-only mode, skip organization-only items + if (mode === 'personal-only' && itemContext === 'organization') { + return false; + } + + // Items for 'all' contexts are always visible + if (itemContext === 'all') { + return true; + } + + // Filter by current context + return itemContext === currentContext; +} + +/** + * Filter navigation routes based on account context and mode + * Adapts navigation based on: + * 1. Current context (personal vs organization) + * 2. Account mode configuration (personal-only, organizations-only, hybrid) + */ +export function getContextAwareNavigation( + routes: z.output<typeof NavigationConfigSchema>['routes'], + params: { + isOrganization: boolean; + mode: AccountMode; + sidebarCollapsed?: boolean; + }, +) { + const currentContext = params.isOrganization ? 'organization' : 'personal'; + + const filteredRoutes = routes + .map((section) => { + // Pass through dividers unchanged + if ('divider' in section) { + return section; + } + + const filteredChildren = section.children + .filter((child) => + shouldShowNavItem( + child.context ?? 'all', + currentContext, + params.mode, + ), + ) + .map((child) => { + // Filter nested children if present + if (child.children && child.children.length > 0) { + const filteredNestedChildren = child.children.filter((subChild) => + shouldShowNavItem( + subChild.context ?? 'all', + currentContext, + params.mode, + ), + ); + + return { + ...child, + children: filteredNestedChildren, + }; + } + + return child; + }); + + // Skip empty sections + if (filteredChildren.length === 0) { + return null; + } + + return { + ...section, + children: filteredChildren, + }; + }) + .filter((section) => section !== null); + + return NavigationConfigSchema.parse({ + routes: filteredRoutes, + sidebarCollapsed: params.sidebarCollapsed ?? false, + }); +} diff --git a/packages/ui/src/makerkit/page.tsx b/packages/ui/src/makerkit/page.tsx index 278e81067..ed2e88eb5 100644 --- a/packages/ui/src/makerkit/page.tsx +++ b/packages/ui/src/makerkit/page.tsx @@ -14,10 +14,6 @@ type PageProps = React.PropsWithChildren<{ sticky?: boolean; }>; -const ENABLE_SIDEBAR_TRIGGER = process.env.NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER - ? process.env.NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER === 'true' - : true; - export function Page(props: PageProps) { switch (props.style) { case 'header': @@ -32,7 +28,7 @@ export function Page(props: PageProps) { } function PageWithSidebar(props: PageProps) { - const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props); + const { Navigation, Children } = getSlotsFromPage(props); return ( <div @@ -46,8 +42,6 @@ function PageWithSidebar(props: PageProps) { 'mx-auto flex h-screen w-full min-w-0 flex-1 flex-col bg-inherit' } > - {MobileNavigation} - <div className={'bg-background flex min-w-0 flex-1 flex-col px-4 lg:px-0'} > @@ -153,33 +147,22 @@ export function PageHeader({ title, description, className, - displaySidebarTrigger = ENABLE_SIDEBAR_TRIGGER, }: React.PropsWithChildren<{ className?: string; title?: string | React.ReactNode; description?: string | React.ReactNode; - displaySidebarTrigger?: boolean; }>) { return ( - <div - className={cn( - 'flex items-center justify-between py-5 lg:px-4', - className, - )} - > + <div className={cn('flex items-center justify-between py-4', className)}> <div className={'flex flex-col gap-y-2'}> <div className="flex items-center gap-x-2.5"> - {displaySidebarTrigger ? ( - <SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground hidden h-4.5 w-4.5 cursor-pointer lg:inline-flex" /> - ) : null} + <SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground h-4.5 w-4.5 cursor-pointer" /> <If condition={description}> - <If condition={displaySidebarTrigger}> - <Separator - orientation="vertical" - className="hidden h-4 w-px lg:group-data-[minimized]:block" - /> - </If> + <Separator + orientation="vertical" + className="hidden h-4 w-px lg:group-data-[collapsible=icon]:block" + /> <PageDescription>{description}</PageDescription> </If> diff --git a/packages/ui/src/makerkit/profile-avatar.tsx b/packages/ui/src/makerkit/profile-avatar.tsx index c4e6b9446..d804cec15 100644 --- a/packages/ui/src/makerkit/profile-avatar.tsx +++ b/packages/ui/src/makerkit/profile-avatar.tsx @@ -18,18 +18,13 @@ type ProfileAvatarProps = (SessionProps | TextProps) & { export function ProfileAvatar(props: ProfileAvatarProps) { const avatarClassName = cn( props.className, - 'mx-auto h-9 w-9 group-focus:ring-2', + 'mx-auto size-8 group-focus:ring-2', ); if ('text' in props) { return ( <Avatar className={avatarClassName}> - <AvatarFallback - className={cn( - props.fallbackClassName, - 'animate-in fade-in uppercase', - )} - > + <AvatarFallback className={cn(props.fallbackClassName, 'uppercase')}> {props.text.slice(0, 1)} </AvatarFallback> </Avatar> @@ -40,11 +35,9 @@ export function ProfileAvatar(props: ProfileAvatarProps) { return ( <Avatar className={avatarClassName}> - <AvatarImage src={props.pictureUrl ?? undefined} /> + <AvatarImage src={props.pictureUrl || undefined} /> - <AvatarFallback - className={cn(props.fallbackClassName, 'animate-in fade-in')} - > + <AvatarFallback className={cn(props.fallbackClassName, 'uppercase')}> <span suppressHydrationWarning className={'uppercase'}> {initials} </span> diff --git a/packages/ui/src/makerkit/sidebar-navigation.tsx b/packages/ui/src/makerkit/sidebar-navigation.tsx new file mode 100644 index 000000000..65e1f6d6b --- /dev/null +++ b/packages/ui/src/makerkit/sidebar-navigation.tsx @@ -0,0 +1,405 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { ChevronDown } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import * as z from 'zod'; + +import { cn, isRouteActive } from '../lib/utils'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '../shadcn/collapsible'; +import { + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarSeparator, + useSidebar, +} from '../shadcn/sidebar'; +import { If } from './if'; +import { NavigationConfigSchema } from './navigation-config.schema'; +import { Trans } from './trans'; + +type SidebarNavigationConfig = z.output<typeof NavigationConfigSchema>; +type SidebarNavigationRoute = SidebarNavigationConfig['routes'][number]; + +type SidebarNavigationRouteGroup = Extract< + SidebarNavigationRoute, + { children: unknown } +>; + +type SidebarNavigationRouteChild = + SidebarNavigationRouteGroup['children'][number]; + +function getSidebarNavigationTooltip( + open: boolean, + t: ReturnType<typeof useTranslations>, + label: string, +) { + if (open) { + return undefined; + } + + return t.has(label) ? t(label) : label; +} + +function MaybeCollapsible({ + enabled, + defaultOpen, + children, +}: React.PropsWithChildren<{ + enabled: boolean; + defaultOpen: boolean; +}>) { + if (!enabled) { + return <>{children}</>; + } + + return ( + <Collapsible defaultOpen={defaultOpen} className={'group/collapsible'}> + {children} + </Collapsible> + ); +} + +function MaybeCollapsibleContent({ + enabled, + children, +}: React.PropsWithChildren<{ + enabled: boolean; +}>) { + if (!enabled) { + return <>{children}</>; + } + + return <CollapsibleContent>{children}</CollapsibleContent>; +} + +function SidebarNavigationRouteItem({ + item, + index, + open, + currentLocale, + currentPath, + t, +}: { + item: SidebarNavigationRoute; + index: number; + open: boolean; + currentLocale: ReturnType<typeof useLocale>; + currentPath: string; + t: ReturnType<typeof useTranslations>; +}) { + if ('divider' in item) { + return <SidebarSeparator />; + } + + return ( + <SidebarNavigationRouteGroupItem + item={item} + index={index} + open={open} + currentLocale={currentLocale} + currentPath={currentPath} + t={t} + /> + ); +} + +function SidebarNavigationRouteGroupLabel({ + label, + collapsible, + open, +}: { + label: string; + collapsible: boolean; + open: boolean; +}) { + const className = cn({ hidden: !open }); + + return ( + <If + condition={collapsible} + fallback={ + <SidebarGroupLabel className={className}> + <Trans i18nKey={label} defaults={label} /> + </SidebarGroupLabel> + } + > + <SidebarGroupLabel className={className}> + <CollapsibleTrigger> + <Trans i18nKey={label} defaults={label} /> + + <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" /> + </CollapsibleTrigger> + </SidebarGroupLabel> + </If> + ); +} + +function SidebarNavigationSubItems({ + items, + open, + currentLocale, + currentPath, +}: { + items: SidebarNavigationRouteChild['children']; + open: boolean; + currentLocale: ReturnType<typeof useLocale>; + currentPath: string; +}) { + return ( + <If condition={items}> + {(items) => + items.length > 0 && ( + <SidebarMenuSub + className={cn({ + 'mx-0 px-1.5': !open, + })} + > + {items.map((child) => { + const isActive = isRouteActive( + child.path, + currentPath, + child.highlightMatch, + { locale: currentLocale }, + ); + + const linkClassName = cn('flex items-center', { + 'mx-auto w-full gap-0! [&>svg]:flex-1': !open, + }); + + const spanClassName = cn( + 'w-auto transition-opacity duration-300', + { + 'w-0 opacity-0': !open, + }, + ); + + return ( + <SidebarMenuSubItem key={child.path}> + <SidebarMenuSubButton + isActive={isActive} + render={ + <Link className={linkClassName} href={child.path}> + {child.Icon} + + <span className={spanClassName}> + <Trans i18nKey={child.label} defaults={child.label} /> + </span> + </Link> + } + /> + </SidebarMenuSubItem> + ); + })} + </SidebarMenuSub> + ) + } + </If> + ); +} + +function SidebarNavigationRouteChildItem({ + child, + open, + currentLocale, + currentPath, + t, +}: { + child: SidebarNavigationRouteChild; + open: boolean; + currentLocale: ReturnType<typeof useLocale>; + currentPath: string; + t: ReturnType<typeof useTranslations>; +}) { + const collapsible = Boolean('collapsible' in child && child.collapsible); + const tooltip = getSidebarNavigationTooltip(open, t, child.label); + + const triggerItem = collapsible ? ( + <CollapsibleTrigger + render={ + <SidebarMenuButton tooltip={tooltip}> + <span + className={cn('flex items-center gap-2', { + 'mx-auto w-full gap-0 [&>svg]:flex-1 [&>svg]:shrink-0': !open, + })} + > + {child.Icon} + + <span + className={cn( + 'transition-width w-auto transition-opacity duration-500', + { + 'w-0 opacity-0': !open, + }, + )} + > + <Trans i18nKey={child.label} defaults={child.label} /> + </span> + + <ChevronDown + className={cn( + 'ml-auto size-4 transition-transform group-data-[state=open]/collapsible:rotate-180', + { + 'hidden size-0': !open, + }, + )} + /> + </span> + </SidebarMenuButton> + } + /> + ) : ( + (() => { + const path = 'path' in child ? child.path : ''; + + const isActive = isRouteActive(path, currentPath, child.highlightMatch, { + locale: currentLocale, + }); + + return ( + <SidebarMenuButton + isActive={isActive} + tooltip={tooltip} + render={ + <Link + className={cn('flex items-center gap-x-2', { + 'mx-auto gap-0! [&>svg]:flex-1': !open, + })} + href={path} + > + {child.Icon} + + <span + className={cn('w-auto transition-opacity duration-300', { + 'w-0 opacity-0': !open, + })} + > + <Trans i18nKey={child.label} defaults={child.label} /> + </span> + </Link> + } + /> + ); + })() + ); + + return ( + <MaybeCollapsible enabled={collapsible} defaultOpen={!child.collapsed}> + <SidebarMenuItem> + {triggerItem} + + <MaybeCollapsibleContent enabled={collapsible}> + <SidebarNavigationSubItems + items={child.children} + open={open} + currentLocale={currentLocale} + currentPath={currentPath} + /> + </MaybeCollapsibleContent> + + <If condition={child.renderAction}> + <SidebarMenuAction>{child.renderAction}</SidebarMenuAction> + </If> + </SidebarMenuItem> + </MaybeCollapsible> + ); +} + +function SidebarNavigationRouteGroupItem({ + item, + index, + open, + currentLocale, + currentPath, + t, +}: { + item: SidebarNavigationRouteGroup; + index: number; + open: boolean; + currentLocale: ReturnType<typeof useLocale>; + currentPath: string; + t: ReturnType<typeof useTranslations>; +}) { + const collapsible = Boolean(item.collapsible); + + return ( + <MaybeCollapsible enabled={collapsible} defaultOpen={!item.collapsed}> + <SidebarGroup key={item.label} className={cn({ 'p-0!': !open })}> + <SidebarNavigationRouteGroupLabel + label={item.label} + collapsible={collapsible} + open={open} + /> + + <If condition={item.renderAction}> + <SidebarGroupAction title={item.label}> + {item.renderAction} + </SidebarGroupAction> + </If> + + <SidebarGroupContent> + <SidebarMenu + className={cn({ + 'gap-y-0.5!': open, + 'items-center': !open, + })} + > + <MaybeCollapsibleContent enabled={collapsible}> + {item.children.map((child, childIndex) => ( + <SidebarNavigationRouteChildItem + key={`group-${index}-${childIndex}`} + child={child} + open={open} + currentLocale={currentLocale} + currentPath={currentPath} + t={t} + /> + ))} + </MaybeCollapsibleContent> + </SidebarMenu> + </SidebarGroupContent> + </SidebarGroup> + </MaybeCollapsible> + ); +} + +export function SidebarNavigation({ + config, +}: React.PropsWithChildren<{ + config: z.output<typeof NavigationConfigSchema>; +}>) { + const currentLocale = useLocale(); + const currentPath = usePathname() ?? ''; + const { open } = useSidebar(); + const t = useTranslations(); + + return ( + <div className={cn('flex flex-col', { 'gap-y-0': open, 'gap-y-1': !open })}> + {config.routes.map((item, index) => ( + <SidebarNavigationRouteItem + key={'divider' in item ? `divider-${index}` : `collapsible-${index}`} + item={item} + index={index} + open={open} + currentLocale={currentLocale} + currentPath={currentPath} + t={t} + /> + ))} + </div> + ); +} diff --git a/packages/ui/src/makerkit/sidebar.tsx b/packages/ui/src/makerkit/sidebar.tsx deleted file mode 100644 index b7ca2b26a..000000000 --- a/packages/ui/src/makerkit/sidebar.tsx +++ /dev/null @@ -1,373 +0,0 @@ -'use client'; - -import { useContext, useId, useState } from 'react'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { cva } from 'class-variance-authority'; -import { ChevronDown } from 'lucide-react'; -import { z } from 'zod'; - -import { cn, isRouteActive } from '../lib/utils'; -import { Button } from '../shadcn/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '../shadcn/tooltip'; -import { SidebarContext } from './context/sidebar.context'; -import { If } from './if'; -import type { NavigationConfigSchema } from './navigation-config.schema'; -import { Trans } from './trans'; - -export type SidebarConfig = z.infer<typeof NavigationConfigSchema>; - -export { SidebarContext }; - -/** - * @deprecated - * This component is deprecated and will be removed in a future version. - * Please use the Shadcn Sidebar component instead. - */ -export function Sidebar(props: { - collapsed?: boolean; - expandOnHover?: boolean; - className?: string; - children: - | React.ReactNode - | ((props: { - collapsed: boolean; - setCollapsed: (collapsed: boolean) => void; - }) => React.ReactNode); -}) { - const [collapsed, setCollapsed] = useState(props.collapsed ?? false); - const [isExpanded, setIsExpanded] = useState(false); - - const expandOnHover = - props.expandOnHover ?? - process.env.NEXT_PUBLIC_EXPAND_SIDEBAR_ON_HOVER === 'true'; - - const sidebarSizeClassName = getSidebarSizeClassName(collapsed, isExpanded); - - const className = getClassNameBuilder( - cn(props.className ?? '', sidebarSizeClassName, {}), - )(); - - const containerClassName = cn(sidebarSizeClassName, 'bg-inherit', { - 'max-w-[4rem]': expandOnHover && isExpanded, - }); - - const ctx = { collapsed, setCollapsed }; - - const onMouseEnter = - props.collapsed && expandOnHover - ? () => { - setCollapsed(false); - setIsExpanded(true); - } - : undefined; - - const onMouseLeave = - props.collapsed && expandOnHover - ? () => { - if (!isRadixPopupOpen()) { - setCollapsed(true); - setIsExpanded(false); - } else { - onRadixPopupClose(() => { - setCollapsed(true); - setIsExpanded(false); - }); - } - } - : undefined; - - return ( - <SidebarContext.Provider value={ctx}> - <div - className={containerClassName} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - > - <div aria-expanded={!collapsed} className={className}> - {typeof props.children === 'function' - ? props.children(ctx) - : props.children} - </div> - </div> - </SidebarContext.Provider> - ); -} - -export function SidebarContent({ - children, - className: customClassName, -}: React.PropsWithChildren<{ - className?: string; -}>) { - const { collapsed } = useContext(SidebarContext); - - const className = cn( - 'flex w-full flex-col space-y-1.5 py-1', - customClassName, - { - 'px-4': !collapsed, - 'px-2': collapsed, - }, - ); - - return <div className={className}>{children}</div>; -} - -function SidebarGroupWrapper({ - id, - sidebarCollapsed, - collapsible, - isGroupCollapsed, - setIsGroupCollapsed, - label, -}: { - id: string; - sidebarCollapsed: boolean; - collapsible: boolean; - isGroupCollapsed: boolean; - setIsGroupCollapsed: (isGroupCollapsed: boolean) => void; - label: React.ReactNode; -}) { - const className = cn( - 'px-container group flex items-center justify-between space-x-2.5', - { - 'py-2.5': !sidebarCollapsed, - }, - ); - - if (collapsible) { - return ( - <button - aria-expanded={!isGroupCollapsed} - aria-controls={id} - onClick={() => setIsGroupCollapsed(!isGroupCollapsed)} - className={className} - > - <span - className={'text-muted-foreground text-xs font-semibold uppercase'} - > - {label} - </span> - - <If condition={collapsible}> - <ChevronDown - className={cn(`h-3 transition duration-300`, { - 'rotate-180': !isGroupCollapsed, - })} - /> - </If> - </button> - ); - } - - if (sidebarCollapsed) { - return null; - } - - return ( - <div className={className}> - <span className={'text-muted-foreground text-xs font-semibold uppercase'}> - {label} - </span> - </div> - ); -} - -export function SidebarGroup({ - label, - collapsed = false, - collapsible = true, - children, -}: React.PropsWithChildren<{ - label: string | React.ReactNode; - collapsible?: boolean; - collapsed?: boolean; -}>) { - const { collapsed: sidebarCollapsed } = useContext(SidebarContext); - const [isGroupCollapsed, setIsGroupCollapsed] = useState(collapsed); - const id = useId(); - - return ( - <div - className={cn('flex flex-col', { - 'gap-y-2 py-1': !collapsed, - })} - > - <SidebarGroupWrapper - id={id} - sidebarCollapsed={sidebarCollapsed} - collapsible={collapsible} - isGroupCollapsed={isGroupCollapsed} - setIsGroupCollapsed={setIsGroupCollapsed} - label={label} - /> - - <If condition={collapsible ? !isGroupCollapsed : true}> - <div id={id} className={'flex flex-col space-y-1.5'}> - {children} - </div> - </If> - </div> - ); -} - -export function SidebarDivider() { - return ( - <div className={'dark:border-dark-800 my-2 border-t border-gray-100'} /> - ); -} - -export function SidebarItem({ - end, - path, - children, - Icon, -}: React.PropsWithChildren<{ - path: string; - Icon: React.ReactNode; - end?: boolean | ((path: string) => boolean); -}>) { - const { collapsed } = useContext(SidebarContext); - const currentPath = usePathname() ?? ''; - - const active = isRouteActive(path, currentPath, end ?? false); - const variant = active ? 'secondary' : 'ghost'; - - return ( - <TooltipProvider delayDuration={0}> - <Tooltip disableHoverableContent> - <TooltipTrigger asChild> - <Button - asChild - className={cn( - 'active:bg-secondary/60 flex w-full text-sm shadow-none', - { - 'justify-start space-x-2.5': !collapsed, - 'hover:bg-initial': active, - }, - )} - size={'sm'} - variant={variant} - > - <Link href={path}> - {Icon} - <span - className={cn('w-auto transition-opacity duration-300', { - 'w-0 opacity-0': collapsed, - })} - > - {children} - </span> - </Link> - </Button> - </TooltipTrigger> - - <If condition={collapsed}> - <TooltipContent side={'right'} sideOffset={10}> - {children} - </TooltipContent> - </If> - </Tooltip> - </TooltipProvider> - ); -} - -function getClassNameBuilder(className: string) { - return cva([ - cn( - 'group/sidebar transition-width fixed box-content flex h-screen w-2/12 flex-col bg-inherit backdrop-blur-xs duration-200', - className, - ), - ]); -} - -function getSidebarSizeClassName(collapsed: boolean, isExpanded: boolean) { - return cn(['z-50 flex w-full flex-col'], { - 'dark:shadow-primary/20 lg:w-[17rem]': !collapsed, - 'lg:w-[4rem]': collapsed, - shadow: isExpanded, - }); -} - -function getRadixPopup() { - return document.querySelector('[data-radix-popper-content-wrapper]'); -} - -function isRadixPopupOpen() { - return getRadixPopup() !== null; -} - -function onRadixPopupClose(callback: () => void) { - const element = getRadixPopup(); - - if (element) { - const observer = new MutationObserver(() => { - if (!getRadixPopup()) { - callback(); - - observer.disconnect(); - } - }); - - observer.observe(element.parentElement!, { - childList: true, - subtree: true, - }); - } -} - -export function SidebarNavigation({ - config, -}: React.PropsWithChildren<{ - config: SidebarConfig; -}>) { - return ( - <> - {config.routes.map((item, index) => { - if ('divider' in item) { - return <SidebarDivider key={index} />; - } - - if ('children' in item) { - return ( - <SidebarGroup - key={item.label} - label={<Trans i18nKey={item.label} defaults={item.label} />} - collapsible={item.collapsible} - collapsed={item.collapsed} - > - {item.children.map((child) => { - if ('collapsible' in child && child.collapsible) { - throw new Error( - 'Collapsible groups are not supported in the old Sidebar. Please migrate to the new Sidebar.', - ); - } - - if ('path' in child) { - return ( - <SidebarItem - key={child.path} - end={child.end} - path={child.path} - Icon={child.Icon} - > - <Trans i18nKey={child.label} defaults={child.label} /> - </SidebarItem> - ); - } - })} - </SidebarGroup> - ); - } - })} - </> - ); -} diff --git a/packages/ui/src/makerkit/trans.tsx b/packages/ui/src/makerkit/trans.tsx index 110b63820..144103bc2 100644 --- a/packages/ui/src/makerkit/trans.tsx +++ b/packages/ui/src/makerkit/trans.tsx @@ -1,5 +1,171 @@ -import { Trans as TransComponent } from 'react-i18next/TransWithoutContext'; +import React from 'react'; -export function Trans(props: React.ComponentProps<typeof TransComponent>) { - return <TransComponent {...props} />; +import { useTranslations } from 'next-intl'; + +import { ErrorBoundary } from './error-boundary'; + +interface TransProps { + /** + * The i18n key to translate. Supports dot notation for nested keys. + * Example: 'auth.login.title' or 'common.buttons.submit' + */ + i18nKey: string | undefined; + /** + * Default text to use if the translation key is not found. + */ + defaults?: React.ReactNode; + /** + * Values to interpolate into the translation. + * Example: { name: 'John' } for a translation like "Hello {name}" + */ + values?: Record<string, unknown>; + /** + * The translation namespace (optional, will be extracted from i18nKey if not provided). + */ + ns?: string; + /** + * Components to use for rich text interpolation. + * Can be either: + * - A function: (chunks) => <strong>{chunks}</strong> + * - A React element: <strong /> (for backward compatibility) + */ + components?: Record< + string, + | ((chunks: React.ReactNode) => React.ReactNode) + | React.ReactElement + | React.ComponentType + >; +} + +/** + * Trans component for displaying translated text using next-intl. + * Provides backward compatibility with i18next Trans component API. + */ +export function Trans({ + i18nKey, + defaults, + values, + ns, + components, +}: TransProps) { + return ( + <ErrorBoundary fallback={<>{defaults ?? i18nKey}</>}> + <Translate + i18nKey={i18nKey!} + defaults={defaults} + values={values} + ns={ns} + components={components} + /> + </ErrorBoundary> + ); +} + +function normalizeI18nKey(key: string | undefined): string { + if (!key) return ''; + + // Intercept i18next-style "namespace:key" format and convert to "namespace.key" + if (key.includes(':')) { + const normalized = key.replace(':', '.'); + + console.warn( + `[Trans] Detected i18next-style key "${key}". next-intl only supports dot notation (e.g. "${normalized}"). Please update to the new format.`, + ); + + return normalized; + } + + return key; +} + +function Translate({ i18nKey, defaults, values, ns, components }: TransProps) { + const normalizedKey = normalizeI18nKey(i18nKey); + + // Extract namespace and key from i18nKey if it contains a dot + const [namespace, ...keyParts] = normalizedKey.split('.'); + const key = keyParts.length > 0 ? keyParts.join('.') : namespace; + const translationNamespace = ns ?? (keyParts.length > 0 ? namespace : ''); + + // Get translations for the namespace + const t = useTranslations(translationNamespace || undefined); + + // Use rich text translation if components are provided + if (components) { + // Convert React elements to functions for next-intl compatibility + const normalizedComponents = Object.entries(components).reduce( + (acc, [key, value]) => { + // If it's already a function, use it directly + if (typeof value === 'function' && !React.isValidElement(value)) { + acc[key] = value as ( + chunks: React.ReactNode, + ) => React.ReactNode | React.ReactElement; + } + // If it's a React element, clone it with chunks as children + else if (React.isValidElement(value)) { + acc[key] = (chunks: React.ReactNode) => { + // If the element already has children (like nested Trans components), + // preserve them instead of replacing with chunks + const element = value as React.ReactElement<{ + children?: React.ReactNode; + }>; + + if (element.props.children) { + return element; + } + + // Otherwise, clone the element with chunks as children + return React.cloneElement(element, {}, chunks); + }; + } else { + acc[key] = value as ( + chunks: React.ReactNode, + ) => React.ReactNode | React.ReactElement; + } + return acc; + }, + {} as Record< + string, + (chunks: React.ReactNode) => React.ReactNode | React.ReactElement + >, + ); + + let translation: React.ReactNode; + + try { + // Fall back to defaults if the translation key doesn't exist + if (!t.has(key as never) && defaults) { + return defaults; + } + + // Merge values and normalized components for t.rich() + // Components take precedence over values with the same name + const richParams = { + ...values, + ...normalizedComponents, + }; + + translation = t.rich(key as never, richParams as never); + } catch { + // Fallback to defaults or i18nKey if translation fails + translation = defaults ?? i18nKey; + } + + return translation; + } + + // Regular translation without components + let translation: React.ReactNode; + + try { + if (!t.has(key as never) && defaults) { + return defaults; + } + + translation = values ? t(key as never, values as never) : t(key as never); + } catch { + // Fallback to defaults or i18nKey if translation fails + translation = defaults ?? i18nKey; + } + + return translation; } diff --git a/packages/ui/src/makerkit/version-updater.tsx b/packages/ui/src/makerkit/version-updater.tsx index a99b2bede..53d908043 100644 --- a/packages/ui/src/makerkit/version-updater.tsx +++ b/packages/ui/src/makerkit/version-updater.tsx @@ -1,12 +1,15 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { RocketIcon } from 'lucide-react'; +import { env } from '@kit/shared/env'; + import { AlertDialog, + AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, @@ -27,53 +30,42 @@ let version: string | null = null; */ const DEFAULT_REFETCH_INTERVAL = 60; -/** - * Default interval time in seconds to check for new version - */ -const VERSION_UPDATER_REFETCH_INTERVAL_SECONDS = - process.env.NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS; - export function VersionUpdater(props: { intervalTimeInSecond?: number }) { const { data } = useVersionUpdater(props); const [dismissed, setDismissed] = useState(false); - const [showDialog, setShowDialog] = useState<boolean>(false); + const [open, setOpen] = useState(false); - useEffect(() => { - if (data?.didChange && !dismissed) { - // eslint-disable-next-line - setShowDialog(data?.didChange ?? false); - } - }, [data?.didChange, dismissed]); + if (data?.didChange && !dismissed && !open) { + setOpen(true); + } return ( - <AlertDialog open={showDialog} onOpenChange={setShowDialog}> + <AlertDialog open={open} onOpenChange={setOpen}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle className={'flex items-center gap-x-2'}> <RocketIcon className={'h-4'} /> <span> - <Trans i18nKey="common:newVersionAvailable" /> + <Trans i18nKey="common.newVersionAvailable" /> </span> </AlertDialogTitle> <AlertDialogDescription> - <Trans i18nKey="common:newVersionAvailableDescription" /> + <Trans i18nKey="common.newVersionAvailableDescription" /> </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> - <Button - variant={'outline'} + <AlertDialogCancel onClick={() => { - setShowDialog(false); setDismissed(true); }} > - <Trans i18nKey="common:back" /> - </Button> + <Trans i18nKey="common.back" /> + </AlertDialogCancel> <Button onClick={() => window.location.reload()}> - <Trans i18nKey="common:newVersionSubmitButton" /> + <Trans i18nKey="common.newVersionSubmitButton" /> </Button> </AlertDialogFooter> </AlertDialogContent> @@ -82,9 +74,11 @@ export function VersionUpdater(props: { intervalTimeInSecond?: number }) { } function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) { - const interval = VERSION_UPDATER_REFETCH_INTERVAL_SECONDS - ? Number(VERSION_UPDATER_REFETCH_INTERVAL_SECONDS) - : DEFAULT_REFETCH_INTERVAL; + const intervalEnv = env( + 'NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS', + ); + + const interval = intervalEnv ? Number(intervalEnv) : DEFAULT_REFETCH_INTERVAL; const refetchInterval = (props.intervalTimeInSecond ?? interval) * 1000; @@ -99,9 +93,7 @@ function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) { refetchInterval, initialData: null, queryFn: async () => { - const url = new URL('/api/version', process.env.NEXT_PUBLIC_SITE_URL); - const response = await fetch(url.toString()); - + const response = await fetch('/api/version'); const currentVersion = await response.text(); const oldVersion = version; diff --git a/packages/ui/src/shadcn/accordion.tsx b/packages/ui/src/shadcn/accordion.tsx index 9c35256dd..cd702638b 100644 --- a/packages/ui/src/shadcn/accordion.tsx +++ b/packages/ui/src/shadcn/accordion.tsx @@ -1,49 +1,79 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'; +import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; -import { ChevronDownIcon } from '@radix-ui/react-icons'; -import { Accordion as AccordionPrimitive } from 'radix-ui'; +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + <AccordionPrimitive.Root + data-slot="accordion" + className={cn('flex w-full flex-col', className)} + {...props} + /> + ); +} -import { cn } from '../lib/utils'; +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + <AccordionPrimitive.Item + data-slot="accordion-item" + className={cn('not-last:border-b', className)} + {...props} + /> + ); +} -const Accordion = AccordionPrimitive.Root; +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + data-slot="accordion-trigger" + className={cn( + 'group/accordion-trigger focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-3 aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4', + className, + )} + {...props} + > + {children} + <ChevronDownIcon + data-slot="accordion-trigger-icon" + className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" + /> + <ChevronUpIcon + data-slot="accordion-trigger-icon" + className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" + /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> + ); +} -const AccordionItem: React.FC< - React.ComponentPropsWithRef<typeof AccordionPrimitive.Item> -> = ({ className, ...props }) => ( - <AccordionPrimitive.Item className={cn('border-b', className)} {...props} /> -); -AccordionItem.displayName = 'AccordionItem'; - -const AccordionTrigger: React.FC< - React.ComponentPropsWithRef<typeof AccordionPrimitive.Trigger> -> = ({ className, children, ...props }) => ( - <AccordionPrimitive.Header className="flex"> - <AccordionPrimitive.Trigger - className={cn( - 'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180', - className, - )} +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + <AccordionPrimitive.Panel + data-slot="accordion-content" + className="data-open:animate-accordion-down data-closed:animate-accordion-up overflow-hidden text-sm" {...props} > - {children} - <ChevronDownIcon className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" /> - </AccordionPrimitive.Trigger> - </AccordionPrimitive.Header> -); -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; - -const AccordionContent: React.FC< - React.ComponentPropsWithRef<typeof AccordionPrimitive.Content> -> = ({ className, children, ...props }) => ( - <AccordionPrimitive.Content - className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm" - {...props} - > - <div className={cn('pt-0 pb-4', className)}>{children}</div> - </AccordionPrimitive.Content> -); -AccordionContent.displayName = AccordionPrimitive.Content.displayName; + <div + className={cn( + '[&_a]:hover:text-foreground h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4', + className, + )} + > + {children} + </div> + </AccordionPrimitive.Panel> + ); +} export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/src/shadcn/alert-dialog.tsx b/packages/ui/src/shadcn/alert-dialog.tsx index c1e72fa02..1d90e111a 100644 --- a/packages/ui/src/shadcn/alert-dialog.tsx +++ b/packages/ui/src/shadcn/alert-dialog.tsx @@ -2,126 +2,187 @@ import * as React from 'react'; -import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'; -import { cn } from '../lib/utils'; -import { buttonVariants } from './button'; +import { Button } from './button'; -const AlertDialog = AlertDialogPrimitive.Root; +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />; +} -const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> + ); +} -const AlertDialogPortal = AlertDialogPrimitive.Portal; +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> + ); +} -const AlertDialogOverlay: React.FC< - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> -> = ({ className, ...props }) => ( - <AlertDialogPrimitive.Overlay - className={cn( - 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', - className, - )} - {...props} - /> -); -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; - -const AlertDialogContent: React.FC< - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> -> = ({ className, ...props }) => ( - <AlertDialogPortal> - <AlertDialogOverlay /> - <AlertDialogPrimitive.Content +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + <AlertDialogPrimitive.Backdrop + data-slot="alert-dialog-overlay" className={cn( - 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg', + 'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs', className, )} {...props} /> - </AlertDialogPortal> -); -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + ); +} -const AlertDialogHeader = ({ +function AlertDialogContent({ + className, + size = 'default', + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: 'default' | 'sm'; +}) { + return ( + <AlertDialogPortal> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Popup + data-slot="alert-dialog-content" + data-size={size} + className={cn( + 'group/alert-dialog-content bg-background ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm', + className, + )} + {...props} + /> + </AlertDialogPortal> + ); +} + +function AlertDialogHeader({ className, ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn('flex flex-col gap-y-3 text-center sm:text-left', className)} - {...props} - /> -); -AlertDialogHeader.displayName = 'AlertDialogHeader'; +}: React.ComponentProps<'div'>) { + return ( + <div + data-slot="alert-dialog-header" + className={cn( + 'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]', + className, + )} + {...props} + /> + ); +} -const AlertDialogFooter = ({ +function AlertDialogFooter({ className, ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', - className, - )} - {...props} - /> -); -AlertDialogFooter.displayName = 'AlertDialogFooter'; +}: React.ComponentProps<'div'>) { + return ( + <div + data-slot="alert-dialog-footer" + className={cn( + 'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end', + className, + )} + {...props} + /> + ); +} -const AlertDialogTitle: React.FC< - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> -> = ({ className, ...props }) => ( - <AlertDialogPrimitive.Title - className={cn('text-lg font-semibold', className)} - {...props} - /> -); -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( + <div + data-slot="alert-dialog-media" + className={cn( + "bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", + className, + )} + {...props} + /> + ); +} -const AlertDialogDescription: React.FC< - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> -> = ({ className, ...props }) => ( - <AlertDialogPrimitive.Description - className={cn('text-muted-foreground text-sm', className)} - {...props} - /> -); -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName; +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { + return ( + <AlertDialogPrimitive.Title + data-slot="alert-dialog-title" + className={cn( + 'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2', + className, + )} + {...props} + /> + ); +} -const AlertDialogAction: React.FC< - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> -> = ({ className, ...props }) => ( - <AlertDialogPrimitive.Action - className={cn(buttonVariants(), className)} - {...props} - /> -); -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) { + return ( + <AlertDialogPrimitive.Description + data-slot="alert-dialog-description" + className={cn( + 'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3', + className, + )} + {...props} + /> + ); +} -const AlertDialogCancel: React.FC< - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> -> = ({ className, ...props }) => ( - <AlertDialogPrimitive.Cancel - className={cn( - buttonVariants({ variant: 'outline' }), - 'mt-2 sm:mt-0', - className, - )} - {...props} - /> -); -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps<typeof Button>) { + return ( + <Button + data-slot="alert-dialog-action" + className={cn(className)} + {...props} + /> + ); +} + +function AlertDialogCancel({ + className, + variant = 'outline', + size = 'default', + ...props +}: AlertDialogPrimitive.Close.Props & + Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) { + return ( + <AlertDialogPrimitive.Close + data-slot="alert-dialog-cancel" + className={cn(className)} + render={<Button variant={variant} size={size} />} + {...props} + /> + ); +} export { AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, AlertDialogAction, AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, }; diff --git a/packages/ui/src/shadcn/alert.tsx b/packages/ui/src/shadcn/alert.tsx index a01ca93fa..f11f13546 100644 --- a/packages/ui/src/shadcn/alert.tsx +++ b/packages/ui/src/shadcn/alert.tsx @@ -1,22 +1,19 @@ import * as React from 'react'; +import { cn } from '#lib/utils'; import { type VariantProps, cva } from 'class-variance-authority'; -import { cn } from '../lib/utils'; - const alertVariants = cva( - '[&>svg]:text-foreground relative flex w-full flex-col gap-y-2 rounded-lg border bg-linear-to-r px-4 py-3.5 text-sm [&>svg]:absolute [&>svg]:top-4 [&>svg]:left-4 [&>svg+div]:translate-y-[-3px] [&>svg~*]:pl-7', + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: 'bg-background text-foreground', + default: 'bg-card text-card-foreground', + success: '[&>*]:text-green-600!', + warning: '[&>*]:text-yellow-600!', + info: 'bg-card text-card-foreground', destructive: - 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', - success: - 'border-green-600/50 text-green-600 dark:border-green-600 [&>svg]:text-green-600', - warning: - 'border-orange-600/50 text-orange-600 dark:border-orange-600 [&>svg]:text-orange-600', - info: 'border-blue-600/50 text-blue-600 dark:border-blue-600 [&>svg]:text-blue-600', + 'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current', }, }, defaultVariants: { @@ -25,37 +22,58 @@ const alertVariants = cva( }, ); -const Alert: React.FC< - React.ComponentPropsWithRef<'div'> & VariantProps<typeof alertVariants> -> = ({ className, variant, ...props }) => ( - <div - role="alert" - className={cn(alertVariants({ variant }), className)} - {...props} - /> -); -Alert.displayName = 'Alert'; +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) { + return ( + <div + data-slot="alert" + role="alert" + className={cn(alertVariants({ variant }), className)} + {...props} + /> + ); +} -const AlertTitle: React.FC<React.ComponentPropsWithRef<'h5'>> = ({ +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="alert-title" + className={cn( + '[&_a]:hover:text-foreground font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3', + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ className, ...props -}) => ( - <h5 - className={cn('leading-none font-bold tracking-tight', className)} - {...props} - /> -); -AlertTitle.displayName = 'AlertTitle'; +}: React.ComponentProps<'div'>) { + return ( + <div + data-slot="alert-description" + className={cn( + 'text-muted-foreground [&_a]:hover:text-foreground text-sm text-balance md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4', + className, + )} + {...props} + /> + ); +} -const AlertDescription: React.FC<React.ComponentPropsWithRef<'div'>> = ({ - className, - ...props -}) => ( - <div - className={cn('text-sm font-normal [&_p]:leading-relaxed', className)} - {...props} - /> -); -AlertDescription.displayName = 'AlertDescription'; +function AlertAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="alert-action" + className={cn('absolute top-2 right-2', className)} + {...props} + /> + ); +} -export { Alert, AlertTitle, AlertDescription }; +export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/packages/ui/src/shadcn/aspect-ratio.tsx b/packages/ui/src/shadcn/aspect-ratio.tsx new file mode 100644 index 000000000..c178613e2 --- /dev/null +++ b/packages/ui/src/shadcn/aspect-ratio.tsx @@ -0,0 +1,22 @@ +import { cn } from '#lib/utils'; + +function AspectRatio({ + ratio, + className, + ...props +}: React.ComponentProps<'div'> & { ratio: number }) { + return ( + <div + data-slot="aspect-ratio" + style={ + { + '--ratio': ratio, + } as React.CSSProperties + } + className={cn('relative aspect-(--ratio)', className)} + {...props} + /> + ); +} + +export { AspectRatio }; diff --git a/packages/ui/src/shadcn/avatar.tsx b/packages/ui/src/shadcn/avatar.tsx index 4ebceb4e9..3dfea9b6b 100644 --- a/packages/ui/src/shadcn/avatar.tsx +++ b/packages/ui/src/shadcn/avatar.tsx @@ -2,44 +2,108 @@ import * as React from 'react'; -import { Avatar as AvatarPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'; -import { cn } from '../lib/utils'; +function Avatar({ + className, + size = 'default', + ...props +}: AvatarPrimitive.Root.Props & { + size?: 'default' | 'sm' | 'lg'; +}) { + return ( + <AvatarPrimitive.Root + data-slot="avatar" + data-size={size} + className={cn( + 'group/avatar after:border-border relative flex size-8 shrink-0 rounded-md select-none after:absolute after:inset-0 after:rounded-md after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten', + className, + )} + {...props} + /> + ); +} -const Avatar: React.FC< - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> -> = ({ className, ...props }) => ( - <AvatarPrimitive.Root - className={cn( - 'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', - className, - )} - {...props} - /> -); -Avatar.displayName = AvatarPrimitive.Root.displayName; +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + <AvatarPrimitive.Image + data-slot="avatar-image" + className={cn( + 'aspect-square size-full rounded-md object-cover', + className, + )} + {...props} + /> + ); +} -const AvatarImage: React.FC< - React.ComponentPropsWithRef<typeof AvatarPrimitive.Image> -> = ({ className, ...props }) => ( - <AvatarPrimitive.Image - className={cn('aspect-square h-full w-full object-cover', className)} - {...props} - /> -); -AvatarImage.displayName = AvatarPrimitive.Image.displayName; +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + <AvatarPrimitive.Fallback + data-slot="avatar-fallback" + className={cn( + 'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-md text-sm group-data-[size=sm]/avatar:text-xs', + className, + )} + {...props} + /> + ); +} -const AvatarFallback: React.FC< - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> -> = ({ className, ...props }) => ( - <AvatarPrimitive.Fallback - className={cn( - 'bg-muted flex h-full w-full items-center justify-center rounded-full', - className, - )} - {...props} - /> -); -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; +function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { + return ( + <span + data-slot="avatar-badge" + className={cn( + 'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none', + 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden', + 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + className, + )} + {...props} + /> + ); +} -export { Avatar, AvatarImage, AvatarFallback }; +function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="avatar-group" + className={cn( + 'group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2', + className, + )} + {...props} + /> + ); +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( + <div + data-slot="avatar-group-count" + className={cn( + 'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', + className, + )} + {...props} + /> + ); +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +}; diff --git a/packages/ui/src/shadcn/badge.tsx b/packages/ui/src/shadcn/badge.tsx index 8eb013528..d0967e2e4 100644 --- a/packages/ui/src/shadcn/badge.tsx +++ b/packages/ui/src/shadcn/badge.tsx @@ -1,21 +1,28 @@ -import * as React from 'react'; - +import { cn } from '#lib/utils'; +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; import { type VariantProps, cva } from 'class-variance-authority'; -import { cn } from '../lib/utils'; - const badgeVariants = cva( - 'focus:ring-ring inline-flex items-center rounded-md border px-1.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-hidden', + 'group/badge focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:pointer-events-none [&>svg]:size-3!', { variants: { variant: { - default: 'bg-primary text-primary-foreground border-transparent', - secondary: 'bg-secondary text-secondary-foreground border-transparent', - destructive: 'text-destructive border-destructive', - outline: 'text-foreground', - success: 'border-green-500 text-green-500', - warning: 'border-orange-500 text-orange-500', - info: 'border-blue-500 text-blue-500', + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + info: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + secondary: + 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + destructive: + 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', + outline: + 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: + 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + link: 'text-primary underline-offset-4 hover:underline', + success: + 'bg-green-600/10 text-green-600 dark:bg-green-600/20 [&>svg]:text-green-600', + warning: + 'border-yellow-600 text-yellow-600 dark:border-yellow-600 [&>svg]:text-yellow-600', }, }, defaultVariants: { @@ -24,15 +31,26 @@ const badgeVariants = cva( }, ); -export interface BadgeProps - extends - React.HTMLAttributes<HTMLDivElement>, - VariantProps<typeof badgeVariants> {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return ( - <div className={cn(badgeVariants({ variant }), className)} {...props} /> - ); +function Badge({ + className, + variant = 'default', + render, + ...props +}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) { + return useRender({ + defaultTagName: 'span', + props: mergeProps<'span'>( + { + className: cn(badgeVariants({ variant }), className), + }, + props, + ), + render, + state: { + slot: 'badge', + variant, + }, + }); } export { Badge, badgeVariants }; diff --git a/packages/ui/src/shadcn/breadcrumb.tsx b/packages/ui/src/shadcn/breadcrumb.tsx index 75d5df02a..b78900562 100644 --- a/packages/ui/src/shadcn/breadcrumb.tsx +++ b/packages/ui/src/shadcn/breadcrumb.tsx @@ -1,106 +1,115 @@ import * as React from 'react'; -import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'; -import { Slot } from 'radix-ui'; - -import { cn } from '../lib/utils'; - -const Breadcrumb: React.FC< - React.ComponentPropsWithoutRef<'nav'> & { - separator?: React.ReactNode; - } -> = ({ ...props }) => <nav aria-label="breadcrumb" {...props} />; -Breadcrumb.displayName = 'Breadcrumb'; - -const BreadcrumbList: React.FC<React.ComponentPropsWithRef<'ol'>> = ({ - className, - ...props -}) => ( - <ol - className={cn( - 'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words', - className, - )} - {...props} - /> -); -BreadcrumbList.displayName = 'BreadcrumbList'; - -const BreadcrumbItem: React.FC<React.ComponentPropsWithRef<'li'>> = ({ - className, - ...props -}) => ( - <li - className={cn('inline-flex items-center gap-1.5', className)} - {...props} - /> -); -BreadcrumbItem.displayName = 'BreadcrumbItem'; - -const BreadcrumbLink: React.FC< - React.ComponentPropsWithoutRef<'a'> & { - asChild?: boolean; - } -> = ({ asChild, className, ...props }) => { - const Comp = asChild ? Slot.Root : 'a'; +import { cn } from '#lib/utils'; +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; +import { ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react'; +function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) { return ( - <Comp + <nav + aria-label="breadcrumb" + data-slot="breadcrumb" + className={cn(className)} + {...props} + /> + ); +} + +function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) { + return ( + <ol + data-slot="breadcrumb-list" className={cn( - 'text-foreground transition-colors hover:underline', + 'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word', className, )} {...props} /> ); -}; -BreadcrumbLink.displayName = 'BreadcrumbLink'; +} -const BreadcrumbPage: React.FC<React.ComponentPropsWithoutRef<'span'>> = ({ +function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) { + return ( + <li + data-slot="breadcrumb-item" + className={cn('inline-flex items-center gap-1', className)} + {...props} + /> + ); +} + +function BreadcrumbLink({ className, + render, ...props -}) => ( - <span - role="link" - aria-disabled="true" - aria-current="page" - className={cn('text-foreground font-normal', className)} - {...props} - /> -); -BreadcrumbPage.displayName = 'BreadcrumbPage'; +}: useRender.ComponentProps<'a'>) { + return useRender({ + defaultTagName: 'a', + props: mergeProps<'a'>( + { + className: cn('hover:text-foreground transition-colors', className), + }, + props, + ), + render, + state: { + slot: 'breadcrumb-link', + }, + }); +} -const BreadcrumbSeparator = ({ +function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) { + return ( + <span + data-slot="breadcrumb-page" + role="link" + aria-disabled="true" + aria-current="page" + className={cn('text-foreground font-normal', className)} + {...props} + /> + ); +} + +function BreadcrumbSeparator({ children, className, ...props -}: React.ComponentProps<'li'>) => ( - <li - role="presentation" - aria-hidden="true" - className={cn('[&>svg]:size-3.5', className)} - {...props} - > - {children ?? <ChevronRightIcon />} - </li> -); -BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'; +}: React.ComponentProps<'li'>) { + return ( + <li + data-slot="breadcrumb-separator" + role="presentation" + aria-hidden="true" + className={cn('[&>svg]:size-3.5', className)} + {...props} + > + {children ?? <ChevronRightIcon />} + </li> + ); +} -const BreadcrumbEllipsis = ({ +function BreadcrumbEllipsis({ className, ...props -}: React.ComponentProps<'span'>) => ( - <span - role="presentation" - aria-hidden="true" - className={cn('flex h-9 w-9 items-center justify-center', className)} - {...props} - > - <DotsHorizontalIcon className="h-4 w-4" /> - <span className="sr-only">More</span> - </span> -); -BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'; +}: React.ComponentProps<'span'>) { + return ( + <span + data-slot="breadcrumb-ellipsis" + role="presentation" + aria-hidden="true" + className={cn( + 'flex size-5 items-center justify-center [&>svg]:size-4', + className, + )} + {...props} + > + <MoreHorizontalIcon /> + <span className="sr-only">More</span> + </span> + ); +} export { Breadcrumb, diff --git a/packages/ui/src/shadcn/button-group.tsx b/packages/ui/src/shadcn/button-group.tsx index 92fc18f48..a59cb8d71 100644 --- a/packages/ui/src/shadcn/button-group.tsx +++ b/packages/ui/src/shadcn/button-group.tsx @@ -1,18 +1,19 @@ +import { cn } from '#lib/utils'; +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; import { type VariantProps, cva } from 'class-variance-authority'; -import { Slot } from 'radix-ui'; -import { cn } from '../lib/utils/cn'; import { Separator } from './separator'; const buttonGroupVariants = cva( - "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", { variants: { orientation: { horizontal: - '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none', + '*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0', vertical: - 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none', + 'flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0', }, }, defaultVariants: { @@ -39,22 +40,25 @@ function ButtonGroup({ function ButtonGroupText({ className, - asChild = false, + render, ...props -}: React.ComponentProps<'div'> & { - asChild?: boolean; -}) { - const Comp = asChild ? Slot.Root : 'div'; - - return ( - <Comp - className={cn( - "bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", - className, - )} - {...props} - /> - ); +}: useRender.ComponentProps<'div'>) { + return useRender({ + defaultTagName: 'div', + props: mergeProps<'div'>( + { + className: cn( + "bg-muted flex items-center gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", + className, + ), + }, + props, + ), + render, + state: { + slot: 'button-group-text', + }, + }); } function ButtonGroupSeparator({ @@ -67,7 +71,7 @@ function ButtonGroupSeparator({ data-slot="button-group-separator" orientation={orientation} className={cn( - 'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto', + 'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto', className, )} {...props} diff --git a/packages/ui/src/shadcn/button.tsx b/packages/ui/src/shadcn/button.tsx index 3d971cc71..0da02f052 100644 --- a/packages/ui/src/shadcn/button.tsx +++ b/packages/ui/src/shadcn/button.tsx @@ -1,32 +1,39 @@ -import * as React from 'react'; +'use client'; -import { cva } from 'class-variance-authority'; -import type { VariantProps } from 'class-variance-authority'; -import { Slot } from 'radix-ui'; - -import { cn } from '../lib/utils'; +import { cn } from '#lib/utils'; +import { Button as ButtonPrimitive } from '@base-ui/react/button'; +import { type VariantProps, cva } from 'class-variance-authority'; const buttonVariants = cva( - 'focus-visible:ring-ring inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50', + "group/button focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: - 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs', - destructive: - 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-xs', + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', outline: - 'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-xs', + 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: - 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs', - ghost: 'hover:bg-accent hover:text-accent-foreground', - link: 'decoration-primary underline-offset-4 hover:underline', + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + ghost: + 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', + destructive: + 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', + link: 'text-primary underline-offset-4 hover:underline', + custom: '', }, size: { - default: 'h-9 px-4 py-2', - sm: 'h-8 rounded-md px-3 text-xs', - lg: 'h-10 rounded-md px-8', - icon: 'h-9 w-9', + default: + 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', + icon: 'size-8', + 'icon-xs': + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': + 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-9', + custom: '', }, }, defaultVariants: { @@ -36,30 +43,19 @@ const buttonVariants = cva( }, ); -export interface ButtonProps - extends - React.ComponentPropsWithRef<'button'>, - VariantProps<typeof buttonVariants> { - asChild?: boolean; -} - -const Button: React.FC<ButtonProps> = ({ +function Button({ className, - variant, - size, - asChild = false, + variant = 'default', + size = 'default', ...props -}) => { - const Comp = asChild ? Slot.Root : 'button'; - +}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { return ( - <Comp + <ButtonPrimitive + data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> ); -}; - -Button.displayName = 'Button'; +} export { Button, buttonVariants }; diff --git a/packages/ui/src/shadcn/calendar.tsx b/packages/ui/src/shadcn/calendar.tsx index 09b3dbb3d..91c4caea9 100644 --- a/packages/ui/src/shadcn/calendar.tsx +++ b/packages/ui/src/shadcn/calendar.tsx @@ -2,14 +2,19 @@ import * as React from 'react'; +import { cn } from '#lib/utils'; import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, } from 'lucide-react'; -import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'; +import { + type DayButton, + DayPicker, + type Locale, + getDefaultClassNames, +} from 'react-day-picker'; -import { cn } from '../lib/utils'; import { Button, buttonVariants } from './button'; function Calendar({ @@ -18,6 +23,7 @@ function Calendar({ showOutsideDays = true, captionLayout = 'label', buttonVariant = 'ghost', + locale, formatters, components, ...props @@ -30,15 +36,16 @@ function Calendar({ <DayPicker showOutsideDays={showOutsideDays} className={cn( - 'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent', + 'group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent', String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, className, )} captionLayout={captionLayout} + locale={locale} formatters={{ formatMonthDropdown: (date) => - date.toLocaleString('default', { month: 'short' }), + date.toLocaleString(locale?.code, { month: 'short' }), ...formatters, }} classNames={{ @@ -71,21 +78,24 @@ function Calendar({ defaultClassNames.dropdowns, ), dropdown_root: cn( - 'has-focus:border-ring border-input has-focus:ring-ring/50 relative rounded-md border shadow-xs has-focus:ring-[3px]', + 'relative rounded-(--cell-radius)', defaultClassNames.dropdown_root, ), - dropdown: cn('absolute inset-0 opacity-0', defaultClassNames.dropdown), + dropdown: cn( + 'bg-popover absolute inset-0 opacity-0', + defaultClassNames.dropdown, + ), caption_label: cn( 'font-medium select-none', captionLayout === 'label' ? 'text-sm' - : '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5', + : '[&>svg]:text-muted-foreground flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5', defaultClassNames.caption_label, ), table: 'w-full border-collapse', weekdays: cn('flex', defaultClassNames.weekdays), weekday: cn( - 'text-muted-foreground flex-1 rounded-md text-[0.8rem] font-normal select-none', + 'text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none', defaultClassNames.weekday, ), week: cn('mt-2 flex w-full', defaultClassNames.week), @@ -98,17 +108,23 @@ function Calendar({ defaultClassNames.week_number, ), day: cn( - 'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md', + 'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)', + props.showWeekNumber + ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)' + : '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)', defaultClassNames.day, ), range_start: cn( - 'bg-accent rounded-l-md', + 'bg-muted after:bg-muted relative isolate z-0 rounded-l-(--cell-radius) after:absolute after:inset-y-0 after:right-0 after:w-4', defaultClassNames.range_start, ), range_middle: cn('rounded-none', defaultClassNames.range_middle), - range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end), + range_end: cn( + 'bg-muted after:bg-muted relative isolate z-0 rounded-r-(--cell-radius) after:absolute after:inset-y-0 after:left-0 after:w-4', + defaultClassNames.range_end, + ), today: cn( - 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', + 'bg-muted text-foreground rounded-(--cell-radius) data-[selected=true]:rounded-none', defaultClassNames.today, ), outside: cn( @@ -153,7 +169,9 @@ function Calendar({ <ChevronDownIcon className={cn('size-4', className)} {...props} /> ); }, - DayButton: CalendarDayButton, + DayButton: ({ ...props }) => ( + <CalendarDayButton locale={locale} {...props} /> + ), WeekNumber: ({ children, ...props }) => { return ( <td {...props}> @@ -174,8 +192,9 @@ function CalendarDayButton({ className, day, modifiers, + locale, ...props -}: React.ComponentProps<typeof DayButton>) { +}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) { const defaultClassNames = getDefaultClassNames(); const ref = React.useRef<HTMLButtonElement>(null); @@ -185,10 +204,9 @@ function CalendarDayButton({ return ( <Button - ref={ref} variant="ghost" size="icon" - data-day={day.date.toLocaleDateString()} + data-day={day.date.toLocaleDateString(locale?.code)} data-selected-single={ modifiers.selected && !modifiers.range_start && @@ -199,7 +217,7 @@ function CalendarDayButton({ data-range-end={modifiers.range_end} data-range-middle={modifiers.range_middle} className={cn( - 'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70', + 'group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70', defaultClassNames.day, className, )} diff --git a/packages/ui/src/shadcn/card.tsx b/packages/ui/src/shadcn/card.tsx index 4e83a8343..0ade52a86 100644 --- a/packages/ui/src/shadcn/card.tsx +++ b/packages/ui/src/shadcn/card.tsx @@ -1,64 +1,103 @@ import * as React from 'react'; -import { cn } from '../lib/utils'; +import { cn } from '#lib/utils'; -const Card: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ +function Card({ className, + size = 'default', ...props -}) => ( - <div - className={cn('bg-card text-card-foreground rounded-lg border', className)} - {...props} - /> -); -Card.displayName = 'Card'; +}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { + return ( + <div + data-slot="card" + data-size={size} + className={cn( + 'group/card bg-card text-card-foreground ring-foreground/10 flex flex-col gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl', + className, + )} + {...props} + /> + ); +} -const CardHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ - className, - ...props -}) => ( - <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> -); -CardHeader.displayName = 'CardHeader'; +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="card-header" + className={cn( + 'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3', + className, + )} + {...props} + /> + ); +} -const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({ - className, - ...props -}) => ( - <h3 - className={cn('leading-none font-semibold tracking-tight', className)} - {...props} - /> -); -CardTitle.displayName = 'CardTitle'; +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="card-title" + className={cn( + 'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', + className, + )} + {...props} + /> + ); +} -const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({ - className, - ...props -}) => ( - <p className={cn('text-muted-foreground text-sm', className)} {...props} /> -); -CardDescription.displayName = 'CardDescription'; +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="card-description" + className={cn('text-muted-foreground text-sm', className)} + {...props} + /> + ); +} -const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ - className, - ...props -}) => <div className={cn('p-6 pt-0', className)} {...props} />; -CardContent.displayName = 'CardContent'; +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="card-action" + className={cn( + 'col-start-2 row-span-2 row-start-1 self-start justify-self-end', + className, + )} + {...props} + /> + ); +} -const CardFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ - className, - ...props -}) => ( - <div className={cn('flex items-center p-6 pt-0', className)} {...props} /> -); -CardFooter.displayName = 'CardFooter'; +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="card-content" + className={cn('px-4 group-data-[size=sm]/card:px-3', className)} + {...props} + /> + ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="card-footer" + className={cn( + 'bg-muted/50 flex items-center rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3', + className, + )} + {...props} + /> + ); +} export { Card, CardHeader, CardFooter, CardTitle, + CardAction, CardDescription, CardContent, }; diff --git a/packages/ui/src/shadcn/carousel.tsx b/packages/ui/src/shadcn/carousel.tsx new file mode 100644 index 000000000..af9e2eb04 --- /dev/null +++ b/packages/ui/src/shadcn/carousel.tsx @@ -0,0 +1,243 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from 'embla-carousel-react'; +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; + +import { Button } from './button'; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: 'horizontal' | 'vertical'; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType<typeof useEmblaCarousel>[0]; + api: ReturnType<typeof useEmblaCarousel>[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext<CarouselContextProps | null>(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error('useCarousel must be used within a <Carousel />'); + } + + return context; +} + +function Carousel({ + orientation = 'horizontal', + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<'div'> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === 'horizontal' ? 'x' : 'y', + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); + + return () => { + api?.off('select', onSelect); + }; + }, [api, onSelect]); + + return ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + onKeyDownCapture={handleKeyDown} + className={cn('relative', className)} + role="region" + aria-roledescription="carousel" + data-slot="carousel" + {...props} + > + {children} + </div> + </CarouselContext.Provider> + ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) { + const { carouselRef, orientation } = useCarousel(); + + return ( + <div + ref={carouselRef} + className="overflow-hidden" + data-slot="carousel-content" + > + <div + className={cn( + 'flex', + orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', + className, + )} + {...props} + /> + </div> + ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) { + const { orientation } = useCarousel(); + + return ( + <div + role="group" + aria-roledescription="slide" + data-slot="carousel-item" + className={cn( + 'min-w-0 shrink-0 grow-0 basis-full', + orientation === 'horizontal' ? 'pl-4' : 'pt-4', + className, + )} + {...props} + /> + ); +} + +function CarouselPrevious({ + className, + variant = 'outline', + size = 'icon-sm', + ...props +}: React.ComponentProps<typeof Button>) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + <Button + data-slot="carousel-previous" + variant={variant} + size={size} + className={cn( + 'absolute touch-manipulation rounded-full', + orientation === 'horizontal' + ? 'top-1/2 -left-12 -translate-y-1/2' + : '-top-12 left-1/2 -translate-x-1/2 rotate-90', + className, + )} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props} + > + <ChevronLeftIcon /> + <span className="sr-only">Previous slide</span> + </Button> + ); +} + +function CarouselNext({ + className, + variant = 'outline', + size = 'icon-sm', + ...props +}: React.ComponentProps<typeof Button>) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + <Button + data-slot="carousel-next" + variant={variant} + size={size} + className={cn( + 'absolute touch-manipulation rounded-full', + orientation === 'horizontal' + ? 'top-1/2 -right-12 -translate-y-1/2' + : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', + className, + )} + disabled={!canScrollNext} + onClick={scrollNext} + {...props} + > + <ChevronRightIcon /> + <span className="sr-only">Next slide</span> + </Button> + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +}; diff --git a/packages/ui/src/shadcn/chart.tsx b/packages/ui/src/shadcn/chart.tsx index d1f729b6e..e971ce894 100644 --- a/packages/ui/src/shadcn/chart.tsx +++ b/packages/ui/src/shadcn/chart.tsx @@ -3,27 +3,64 @@ import * as React from 'react'; import * as RechartsPrimitive from 'recharts'; +import type { LegendPayload } from 'recharts/types/component/DefaultLegendContent'; +import { + NameType, + Payload, + ValueType, +} from 'recharts/types/component/DefaultTooltipContent'; +import type { Props as LegendProps } from 'recharts/types/component/Legend'; +import { TooltipContentProps } from 'recharts/types/component/Tooltip'; -import { cn } from '../lib/utils'; +import { cn } from '@kit/ui/utils'; // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: '', dark: '.dark' } as const; -export type ChartConfig = Record< - string, - { +export type ChartConfig = { + [k in string]: { label?: React.ReactNode; icon?: React.ComponentType; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> } - ) ->; + ); +}; type ChartContextProps = { config: ChartConfig; }; +export type CustomTooltipProps = TooltipContentProps<ValueType, NameType> & { + className?: string; + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: 'line' | 'dot' | 'dashed'; + nameKey?: string; + labelKey?: string; + labelFormatter?: ( + label: TooltipContentProps<number, string>['label'], + payload: TooltipContentProps<number, string>['payload'], + ) => React.ReactNode; + formatter?: ( + value: number | string, + name: string, + item: Payload<number | string, string>, + index: number, + payload: ReadonlyArray<Payload<number | string, string>>, + ) => React.ReactNode; + labelClassName?: string; + color?: string; +}; + +export type ChartLegendContentProps = { + className?: string; + hideIcon?: boolean; + verticalAlign?: LegendProps['verticalAlign']; + payload?: LegendPayload[]; + nameKey?: string; +}; + const ChartContext = React.createContext<ChartContextProps | null>(null); function useChart() { @@ -36,20 +73,25 @@ function useChart() { return context; } -const ChartContainer: React.FC< - React.ComponentProps<'div'> & { - config: ChartConfig; - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >['children']; - } -> = ({ id, className, children, config, ...props }) => { +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children']; +}) { const uniqueId = React.useId(); - const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`; + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; return ( <ChartContext.Provider value={{ config }}> <div + data-slot="chart" data-chart={chartId} className={cn( "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", @@ -64,12 +106,11 @@ const ChartContainer: React.FC< </div> </ChartContext.Provider> ); -}; -ChartContainer.displayName = 'Chart'; +} const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme ?? config.color, + ([, config]) => config.theme || config.color, ); if (!colorConfig.length) { @@ -82,17 +123,17 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { __html: Object.entries(THEMES) .map( ([theme, prefix]) => ` -${prefix} [data-chart=${id}] { -${colorConfig - .map(([key, itemConfig]) => { - const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? - itemConfig.color; - return color ? ` --color-${key}: ${color};` : null; - }) - .join('\n')} -} -`, + ${prefix} [data-chart=${id}] { + ${colorConfig + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color; + return color ? ` --color-${key}: ${color};` : null; + }) + .join('\n')} + } + `, ) .join('\n'), }} @@ -102,46 +143,39 @@ ${colorConfig const ChartTooltip = RechartsPrimitive.Tooltip; -const ChartTooltipContent: React.FC< - React.ComponentPropsWithRef<typeof RechartsPrimitive.Tooltip> & - React.ComponentPropsWithRef<'div'> & { - hideLabel?: boolean; - hideIndicator?: boolean; - indicator?: 'line' | 'dot' | 'dashed'; - nameKey?: string; - labelKey?: string; - } -> = ({ - ref, +function ChartTooltipContent({ active, payload, + label, className, indicator = 'dot', hideLabel = false, hideIndicator = false, - label, labelFormatter, - labelClassName, formatter, + labelClassName, color, nameKey, labelKey, -}) => { +}: CustomTooltipProps) { const { config } = useChart(); const tooltipLabel = React.useMemo(() => { - if (hideLabel ?? !payload?.length) { + if (hideLabel || !payload?.length) { return null; } const [item] = payload; - const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`; + const key = `${labelKey || item?.dataKey || item?.name || 'value'}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); + const value = (() => { + const v = + !labelKey && typeof label === 'string' + ? (config[label as keyof typeof config]?.label ?? label) + : itemConfig?.label; - const value = - !labelKey && typeof label === 'string' - ? (config[label]?.label ?? label) - : itemConfig?.label; + return typeof v === 'string' || typeof v === 'number' ? v : undefined; + })(); if (labelFormatter) { return ( @@ -174,7 +208,6 @@ const ChartTooltipContent: React.FC< return ( <div - ref={ref} className={cn( 'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl', className, @@ -183,9 +216,9 @@ const ChartTooltipContent: React.FC< {!nestLabel ? tooltipLabel : null} <div className="grid gap-1.5"> {payload.map((item, index) => { - const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`; + const key = `${nameKey || item.name || item.dataKey || 'value'}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color ?? item.payload.fill ?? item.color; + const indicatorColor = color || item.payload.fill || item.color; return ( <div @@ -232,7 +265,7 @@ const ChartTooltipContent: React.FC< <div className="grid gap-1.5"> {nestLabel ? tooltipLabel : null} <span className="text-muted-foreground"> - {itemConfig?.label ?? item.name} + {itemConfig?.label || item.name} </span> </div> {item.value && ( @@ -249,26 +282,17 @@ const ChartTooltipContent: React.FC< </div> </div> ); -}; - -ChartTooltipContent.displayName = 'ChartTooltip'; +} const ChartLegend = RechartsPrimitive.Legend; -const ChartLegendContent: React.FC< - React.ComponentPropsWithRef<'div'> & - Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & { - hideIcon?: boolean; - nameKey?: string; - } -> = ({ +function ChartLegendContent({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey, - ref, -}) => { +}: ChartLegendContentProps) { const { config } = useChart(); if (!payload?.length) { @@ -277,7 +301,6 @@ const ChartLegendContent: React.FC< return ( <div - ref={ref} className={cn( 'flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', @@ -285,7 +308,7 @@ const ChartLegendContent: React.FC< )} > {payload.map((item) => { - const key = `${nameKey ?? item.dataKey ?? 'value'}`; + const key = `${nameKey || item.dataKey || 'value'}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); return ( @@ -311,8 +334,7 @@ const ChartLegendContent: React.FC< })} </div> ); -}; -ChartLegendContent.displayName = 'ChartLegend'; +} // Helper to extract item config from a payload. function getPayloadConfigFromPayload( @@ -320,7 +342,7 @@ function getPayloadConfigFromPayload( payload: unknown, key: string, ) { - if (typeof payload !== 'object' || !payload) { + if (typeof payload !== 'object' || payload === null) { return undefined; } @@ -348,7 +370,9 @@ function getPayloadConfigFromPayload( ] as string; } - return configLabelKey in config ? config[configLabelKey] : config[key]; + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config]; } export { diff --git a/packages/ui/src/shadcn/checkbox.tsx b/packages/ui/src/shadcn/checkbox.tsx index cd8c4d89e..ef9df147a 100644 --- a/packages/ui/src/shadcn/checkbox.tsx +++ b/packages/ui/src/shadcn/checkbox.tsx @@ -1,29 +1,27 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'; +import { CheckIcon } from 'lucide-react'; -import { CheckIcon } from '@radix-ui/react-icons'; -import { Checkbox as CheckboxPrimitive } from 'radix-ui'; - -import { cn } from '../lib/utils'; - -const Checkbox: React.FC< - React.ComponentPropsWithRef<typeof CheckboxPrimitive.Root> -> = ({ className, ...props }) => ( - <CheckboxPrimitive.Root - className={cn( - 'peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-xs border shadow-xs focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50', - className, - )} - {...props} - > - <CheckboxPrimitive.Indicator - className={cn('flex items-center justify-center text-current')} +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + <CheckboxPrimitive.Root + data-slot="checkbox" + className={cn( + 'peer border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3', + className, + )} + {...props} > - <CheckIcon className="h-4 w-4" /> - </CheckboxPrimitive.Indicator> - </CheckboxPrimitive.Root> -); -Checkbox.displayName = CheckboxPrimitive.Root.displayName; + <CheckboxPrimitive.Indicator + data-slot="checkbox-indicator" + className="grid place-content-center text-current transition-none [&>svg]:size-3.5" + > + <CheckIcon /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> + ); +} export { Checkbox }; diff --git a/packages/ui/src/shadcn/collapsible.tsx b/packages/ui/src/shadcn/collapsible.tsx index 47fad3aeb..93a385188 100644 --- a/packages/ui/src/shadcn/collapsible.tsx +++ b/packages/ui/src/shadcn/collapsible.tsx @@ -1,11 +1,21 @@ 'use client'; -import { Collapsible as CollapsiblePrimitive } from 'radix-ui'; +import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'; -const Collapsible = CollapsiblePrimitive.Root; +function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { + return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; +} -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ( + <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} /> + ); +} -const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; +function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { + return ( + <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} /> + ); +} export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/packages/ui/src/shadcn/combobox.tsx b/packages/ui/src/shadcn/combobox.tsx new file mode 100644 index 000000000..3ab8395ab --- /dev/null +++ b/packages/ui/src/shadcn/combobox.tsx @@ -0,0 +1,301 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; +import { CheckIcon, ChevronDownIcon, XIcon } from 'lucide-react'; + +import { Button } from './button'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from './input-group'; + +const Combobox = ComboboxPrimitive.Root; + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />; +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + <ComboboxPrimitive.Trigger + data-slot="combobox-trigger" + className={cn("[&_svg:not([class*='size-'])]:size-4", className)} + {...props} + > + {children} + <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4" /> + </ComboboxPrimitive.Trigger> + ); +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + <ComboboxPrimitive.Clear + data-slot="combobox-clear" + render={<InputGroupButton variant="ghost" size="icon-xs" />} + className={cn(className)} + {...props} + > + <XIcon className="pointer-events-none" /> + </ComboboxPrimitive.Clear> + ); +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean; + showClear?: boolean; +}) { + return ( + <InputGroup className={cn('w-auto', className)}> + <ComboboxPrimitive.Input + render={<InputGroupInput disabled={disabled} />} + {...props} + /> + <InputGroupAddon align="inline-end"> + {showTrigger && ( + <InputGroupButton + size="icon-xs" + variant="ghost" + render={<ComboboxTrigger />} + data-slot="input-group-button" + className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" + disabled={disabled} + /> + )} + {showClear && <ComboboxClear disabled={disabled} />} + </InputGroupAddon> + {children} + </InputGroup> + ); +} + +function ComboboxContent({ + className, + side = 'bottom', + sideOffset = 6, + align = 'start', + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor' + >) { + return ( + <ComboboxPrimitive.Portal> + <ComboboxPrimitive.Positioner + side={side} + sideOffset={sideOffset} + align={align} + alignOffset={alignOffset} + anchor={anchor} + className="isolate z-50" + > + <ComboboxPrimitive.Popup + data-slot="combobox-content" + data-chips={!!anchor} + className={cn( + 'group/combobox-content bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none', + className, + )} + {...props} + /> + </ComboboxPrimitive.Positioner> + </ComboboxPrimitive.Portal> + ); +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + <ComboboxPrimitive.List + data-slot="combobox-list" + className={cn( + 'no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0', + className, + )} + {...props} + /> + ); +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + <ComboboxPrimitive.Item + data-slot="combobox-item" + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + {children} + <ComboboxPrimitive.ItemIndicator + render={ + <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" /> + } + > + <CheckIcon className="pointer-events-none" /> + </ComboboxPrimitive.ItemIndicator> + </ComboboxPrimitive.Item> + ); +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + <ComboboxPrimitive.Group + data-slot="combobox-group" + className={cn(className)} + {...props} + /> + ); +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + <ComboboxPrimitive.GroupLabel + data-slot="combobox-label" + className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)} + {...props} + /> + ); +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} /> + ); +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + <ComboboxPrimitive.Empty + data-slot="combobox-empty" + className={cn( + 'text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex', + className, + )} + {...props} + /> + ); +} + +function ComboboxSeparator({ + className, + ...props +}: ComboboxPrimitive.Separator.Props) { + return ( + <ComboboxPrimitive.Separator + data-slot="combobox-separator" + className={cn('bg-border -mx-1 my-1 h-px', className)} + {...props} + /> + ); +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & + ComboboxPrimitive.Chips.Props) { + return ( + <ComboboxPrimitive.Chips + data-slot="combobox-chips" + className={cn( + 'border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-3 has-aria-invalid:ring-3 has-data-[slot=combobox-chip]:px-1', + className, + )} + {...props} + /> + ); +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean; +}) { + return ( + <ComboboxPrimitive.Chip + data-slot="combobox-chip" + className={cn( + 'bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0', + className, + )} + {...props} + > + {children} + {showRemove && ( + <ComboboxPrimitive.ChipRemove + render={<Button variant="ghost" size="icon-xs" />} + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + <XIcon className="pointer-events-none" /> + </ComboboxPrimitive.ChipRemove> + )} + </ComboboxPrimitive.Chip> + ); +} + +function ComboboxChipsInput({ + className, + ...props +}: ComboboxPrimitive.Input.Props) { + return ( + <ComboboxPrimitive.Input + data-slot="combobox-chip-input" + className={cn('min-w-16 flex-1 outline-none', className)} + {...props} + /> + ); +} + +function useComboboxAnchor() { + return React.useRef<HTMLDivElement | null>(null); +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +}; diff --git a/packages/ui/src/shadcn/command.tsx b/packages/ui/src/shadcn/command.tsx index 1f9df9ab4..e9c9f1b78 100644 --- a/packages/ui/src/shadcn/command.tsx +++ b/packages/ui/src/shadcn/command.tsx @@ -2,128 +2,184 @@ import * as React from 'react'; -import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { cn } from '#lib/utils'; import { Command as CommandPrimitive } from 'cmdk'; +import { CheckIcon, SearchIcon } from 'lucide-react'; -import { cn } from '../lib/utils'; -import { Dialog, DialogContent } from './dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from './dialog'; +import { InputGroup, InputGroupAddon } from './input-group'; -const Command: React.FC< - React.ComponentPropsWithRef<typeof CommandPrimitive> -> = ({ className, ...props }) => ( - <CommandPrimitive - className={cn( - 'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', - className, - )} - {...props} - /> -); -Command.displayName = CommandPrimitive.displayName; +function Command({ + className, + ...props +}: React.ComponentProps<typeof CommandPrimitive>) { + return ( + <CommandPrimitive + data-slot="command" + className={cn( + 'bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-xl! p-1', + className, + )} + {...props} + /> + ); +} -type CommandDialogProps = React.ComponentProps<typeof Dialog>; - -const CommandDialog = ({ children, ...props }: CommandDialogProps) => { +function CommandDialog({ + title = 'Command Palette', + description = 'Search for a command to run...', + children, + className, + showCloseButton = false, + ...props +}: Omit<React.ComponentProps<typeof Dialog>, 'children'> & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; + children: React.ReactNode; +}) { return ( <Dialog {...props}> - <DialogContent className="overflow-hidden p-0"> - <Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> - {children} - </Command> + <DialogHeader className="sr-only"> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + <DialogContent + className={cn( + 'top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0', + className, + )} + showCloseButton={showCloseButton} + > + {children} </DialogContent> </Dialog> ); -}; +} -const CommandInput: React.FC< - React.ComponentPropsWithRef<typeof CommandPrimitive.Input> -> = ({ className, ...props }) => ( - <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> - <MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" /> - <CommandPrimitive.Input - className={cn( - 'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', - className, - )} - {...props} - /> - </div> -); - -CommandInput.displayName = CommandPrimitive.Input.displayName; - -const CommandList: React.FC< - React.ComponentPropsWithRef<typeof CommandPrimitive.List> -> = ({ className, ...props }) => ( - <CommandPrimitive.List - className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)} - {...props} - /> -); - -CommandList.displayName = CommandPrimitive.List.displayName; - -const CommandEmpty: React.FC< - React.ComponentPropsWithRef<typeof CommandPrimitive.Empty> -> = (props) => ( - <CommandPrimitive.Empty className="py-6 text-center text-sm" {...props} /> -); - -CommandEmpty.displayName = CommandPrimitive.Empty.displayName; - -const CommandGroup: React.FC< - React.ComponentPropsWithRef<typeof CommandPrimitive.Group> -> = ({ className, ...props }) => ( - <CommandPrimitive.Group - className={cn( - 'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium', - className, - )} - {...props} - /> -); - -CommandGroup.displayName = CommandPrimitive.Group.displayName; - -const CommandSeparator: React.FC< - React.ComponentPropsWithRef<typeof CommandPrimitive.Separator> -> = ({ className, ...props }) => ( - <CommandPrimitive.Separator - className={cn('bg-border -mx-1 h-px', className)} - {...props} - /> -); -CommandSeparator.displayName = CommandPrimitive.Separator.displayName; - -const CommandItem: React.FC< - React.ComponentPropsWithRef<typeof CommandPrimitive.Item> -> = ({ className, ...props }) => ( - <CommandPrimitive.Item - className={cn( - "aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center rounded-xs px-2 py-1.5 text-sm outline-hidden select-none data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50", - className, - )} - {...props} - /> -); - -CommandItem.displayName = CommandPrimitive.Item.displayName; - -const CommandShortcut = ({ +function CommandInput({ className, ...props -}: React.HTMLAttributes<HTMLSpanElement>) => { +}: React.ComponentProps<typeof CommandPrimitive.Input>) { return ( - <span + <div data-slot="command-input-wrapper" className="p-1 pb-0"> + <InputGroup className="border-input/30 bg-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!"> + <CommandPrimitive.Input + data-slot="command-input" + className={cn( + 'w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', + className, + )} + {...props} + /> + <InputGroupAddon> + <SearchIcon className="size-4 shrink-0 opacity-50" /> + </InputGroupAddon> + </InputGroup> + </div> + ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps<typeof CommandPrimitive.List>) { + return ( + <CommandPrimitive.List + data-slot="command-list" className={cn( - 'text-muted-foreground ml-auto text-xs tracking-widest', + 'no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none', className, )} {...props} /> ); -}; -CommandShortcut.displayName = 'CommandShortcut'; +} + +function CommandEmpty({ + className, + ...props +}: React.ComponentProps<typeof CommandPrimitive.Empty>) { + return ( + <CommandPrimitive.Empty + data-slot="command-empty" + className={cn('py-6 text-center text-sm', className)} + {...props} + /> + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps<typeof CommandPrimitive.Group>) { + return ( + <CommandPrimitive.Group + data-slot="command-group" + className={cn( + 'text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium', + className, + )} + {...props} + /> + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps<typeof CommandPrimitive.Separator>) { + return ( + <CommandPrimitive.Separator + data-slot="command-separator" + className={cn('bg-border -mx-1 h-px', className)} + {...props} + /> + ); +} + +function CommandItem({ + className, + children, + ...props +}: React.ComponentProps<typeof CommandPrimitive.Item>) { + return ( + <CommandPrimitive.Item + data-slot="command-item" + className={cn( + "group/command-item data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + {children} + <CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" /> + </CommandPrimitive.Item> + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + <span + data-slot="command-shortcut" + className={cn( + 'text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest', + className, + )} + {...props} + /> + ); +} export { Command, diff --git a/packages/ui/src/shadcn/context-menu.tsx b/packages/ui/src/shadcn/context-menu.tsx new file mode 100644 index 000000000..3e1e44d9a --- /dev/null +++ b/packages/ui/src/shadcn/context-menu.tsx @@ -0,0 +1,272 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu'; +import { CheckIcon, ChevronRightIcon } from 'lucide-react'; + +function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) { + return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />; +} + +function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) { + return ( + <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} /> + ); +} + +function ContextMenuTrigger({ + className, + ...props +}: ContextMenuPrimitive.Trigger.Props) { + return ( + <ContextMenuPrimitive.Trigger + data-slot="context-menu-trigger" + className={cn('select-none', className)} + {...props} + /> + ); +} + +function ContextMenuContent({ + className, + align = 'start', + alignOffset = 4, + side = 'right', + sideOffset = 0, + ...props +}: ContextMenuPrimitive.Popup.Props & + Pick< + ContextMenuPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'side' | 'sideOffset' + >) { + return ( + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Positioner + className="isolate z-50 outline-none" + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + > + <ContextMenuPrimitive.Popup + data-slot="context-menu-content" + className={cn( + 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none', + className, + )} + {...props} + /> + </ContextMenuPrimitive.Positioner> + </ContextMenuPrimitive.Portal> + ); +} + +function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) { + return ( + <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} /> + ); +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: ContextMenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + <ContextMenuPrimitive.GroupLabel + data-slot="context-menu-label" + data-inset={inset} + className={cn( + 'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', + className, + )} + {...props} + /> + ); +} + +function ContextMenuItem({ + className, + inset, + variant = 'default', + ...props +}: ContextMenuPrimitive.Item.Props & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + <ContextMenuPrimitive.Item + data-slot="context-menu-item" + data-inset={inset} + data-variant={variant} + className={cn( + "group/context-menu-item focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + /> + ); +} + +function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) { + return ( + <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} /> + ); +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: ContextMenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + <ContextMenuPrimitive.SubmenuTrigger + data-slot="context-menu-sub-trigger" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + {children} + <ChevronRightIcon className="ml-auto" /> + </ContextMenuPrimitive.SubmenuTrigger> + ); +} + +function ContextMenuSubContent({ + ...props +}: React.ComponentProps<typeof ContextMenuContent>) { + return ( + <ContextMenuContent + data-slot="context-menu-sub-content" + className="shadow-lg" + side="right" + {...props} + /> + ); +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: ContextMenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + <ContextMenuPrimitive.CheckboxItem + data-slot="context-menu-checkbox-item" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + checked={checked} + {...props} + > + <span className="pointer-events-none absolute right-2"> + <ContextMenuPrimitive.CheckboxItemIndicator> + <CheckIcon /> + </ContextMenuPrimitive.CheckboxItemIndicator> + </span> + {children} + </ContextMenuPrimitive.CheckboxItem> + ); +} + +function ContextMenuRadioGroup({ + ...props +}: ContextMenuPrimitive.RadioGroup.Props) { + return ( + <ContextMenuPrimitive.RadioGroup + data-slot="context-menu-radio-group" + {...props} + /> + ); +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: ContextMenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + <ContextMenuPrimitive.RadioItem + data-slot="context-menu-radio-item" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + <span className="pointer-events-none absolute right-2"> + <ContextMenuPrimitive.RadioItemIndicator> + <CheckIcon /> + </ContextMenuPrimitive.RadioItemIndicator> + </span> + {children} + </ContextMenuPrimitive.RadioItem> + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: ContextMenuPrimitive.Separator.Props) { + return ( + <ContextMenuPrimitive.Separator + data-slot="context-menu-separator" + className={cn('bg-border -mx-1 my-1 h-px', className)} + {...props} + /> + ); +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + <span + data-slot="context-menu-shortcut" + className={cn( + 'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', + className, + )} + {...props} + /> + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/packages/ui/src/shadcn/data-table.tsx b/packages/ui/src/shadcn/data-table.tsx index 77e1b24eb..2163db715 100644 --- a/packages/ui/src/shadcn/data-table.tsx +++ b/packages/ui/src/shadcn/data-table.tsx @@ -75,7 +75,7 @@ export function DataTable<TData, TValue>({ ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> - <Trans i18nKey={'common:noData'} /> + <Trans i18nKey={'common.noData'} /> </TableCell> </TableRow> )} diff --git a/packages/ui/src/shadcn/dialog.tsx b/packages/ui/src/shadcn/dialog.tsx index 1d105e3ab..e1932c482 100644 --- a/packages/ui/src/shadcn/dialog.tsx +++ b/packages/ui/src/shadcn/dialog.tsx @@ -2,111 +2,156 @@ import * as React from 'react'; -import { Cross2Icon } from '@radix-ui/react-icons'; -import { Dialog as DialogPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'; +import { XIcon } from 'lucide-react'; -import { cn } from '../lib/utils'; +import { Button } from './button'; -const Dialog = DialogPrimitive.Root; +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return <DialogPrimitive.Root data-slot="dialog" {...props} />; +} -const DialogTrigger = DialogPrimitive.Trigger; +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; +} -const DialogPortal = DialogPrimitive.Portal; +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; +} -const DialogClose = DialogPrimitive.Close; +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; +} -const DialogOverlay: React.FC< - React.ComponentPropsWithRef<typeof DialogPrimitive.Overlay> -> = ({ className, ...props }) => ( - <DialogPrimitive.Overlay - className={cn( - 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', - className, - )} - {...props} - /> -); -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; - -const DialogContent: React.FC< - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> -> = ({ className, children, ...props }) => ( - <DialogPortal> - <DialogOverlay /> - <DialogPrimitive.Content +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + <DialogPrimitive.Backdrop + data-slot="dialog-overlay" className={cn( - 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg', + 'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs', + className, + )} + {...props} + /> + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean; +}) { + return ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Popup + data-slot="dialog-content" + className={cn( + 'bg-background ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm', + className, + )} + {...props} + > + {children} + {showCloseButton && ( + <DialogPrimitive.Close + data-slot="dialog-close" + render={ + <Button + variant="ghost" + className="absolute top-2 right-2" + size="icon-sm" + /> + } + > + <XIcon /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + )} + </DialogPrimitive.Popup> + </DialogPortal> + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="dialog-header" + className={cn('flex flex-col gap-2', className)} + {...props} + /> + ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean; +}) { + return ( + <div + data-slot="dialog-footer" + className={cn( + 'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 sm:flex-row sm:justify-end', className, )} {...props} > {children} - <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> - <Cross2Icon className="h-4 w-4" /> - <span className="sr-only">Close</span> - </DialogPrimitive.Close> - </DialogPrimitive.Content> - </DialogPortal> -); -DialogContent.displayName = DialogPrimitive.Content.displayName; + {showCloseButton && ( + <DialogPrimitive.Close render={<Button variant="outline" />}> + Close + </DialogPrimitive.Close> + )} + </div> + ); +} -const DialogHeader = ({ +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + <DialogPrimitive.Title + data-slot="dialog-title" + className={cn('text-base leading-none font-medium', className)} + {...props} + /> + ); +} + +function DialogDescription({ className, ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn('flex flex-col space-y-1.5 text-left', className)} - {...props} - /> -); -DialogHeader.displayName = 'DialogHeader'; - -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', - className, - )} - {...props} - /> -); -DialogFooter.displayName = 'DialogFooter'; - -const DialogTitle: React.FC< - React.ComponentPropsWithRef<typeof DialogPrimitive.Title> -> = ({ className, ...props }) => ( - <DialogPrimitive.Title - className={cn( - 'text-lg leading-none font-semibold tracking-tight', - className, - )} - {...props} - /> -); -DialogTitle.displayName = DialogPrimitive.Title.displayName; - -const DialogDescription: React.FC< - React.ComponentPropsWithRef<typeof DialogPrimitive.Description> -> = ({ className, ...props }) => ( - <DialogPrimitive.Description - className={cn('text-muted-foreground text-sm', className)} - {...props} - /> -); -DialogDescription.displayName = DialogPrimitive.Description.displayName; +}: DialogPrimitive.Description.Props) { + return ( + <DialogPrimitive.Description + data-slot="dialog-description" + className={cn( + 'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', + className, + )} + {...props} + /> + ); +} export { Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, DialogClose, DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, }; diff --git a/packages/ui/src/shadcn/direction.tsx b/packages/ui/src/shadcn/direction.tsx new file mode 100644 index 000000000..78363f969 --- /dev/null +++ b/packages/ui/src/shadcn/direction.tsx @@ -0,0 +1,6 @@ +'use client'; + +export { + DirectionProvider, + useDirection, +} from '@base-ui/react/direction-provider'; diff --git a/packages/ui/src/shadcn/drawer.tsx b/packages/ui/src/shadcn/drawer.tsx new file mode 100644 index 000000000..531848529 --- /dev/null +++ b/packages/ui/src/shadcn/drawer.tsx @@ -0,0 +1,131 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import { Drawer as DrawerPrimitive } from 'vaul'; + +function Drawer({ + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Root>) { + return <DrawerPrimitive.Root data-slot="drawer" {...props} />; +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { + return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />; +} + +function DrawerPortal({ + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Portal>) { + return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />; +} + +function DrawerClose({ + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Close>) { + return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) { + return ( + <DrawerPrimitive.Overlay + data-slot="drawer-overlay" + className={cn( + 'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs', + className, + )} + {...props} + /> + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Content>) { + return ( + <DrawerPortal data-slot="drawer-portal"> + <DrawerOverlay /> + <DrawerPrimitive.Content + data-slot="drawer-content" + className={cn( + 'group/drawer-content bg-background fixed z-50 flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm', + className, + )} + {...props} + > + <div className="bg-muted mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" /> + {children} + </DrawerPrimitive.Content> + </DrawerPortal> + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="drawer-header" + className={cn( + 'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left', + className, + )} + {...props} + /> + ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="drawer-footer" + className={cn('mt-auto flex flex-col gap-2 p-4', className)} + {...props} + /> + ); +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Title>) { + return ( + <DrawerPrimitive.Title + data-slot="drawer-title" + className={cn('text-foreground text-base font-medium', className)} + {...props} + /> + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Description>) { + return ( + <DrawerPrimitive.Description + data-slot="drawer-description" + className={cn('text-muted-foreground text-sm', className)} + {...props} + /> + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/packages/ui/src/shadcn/dropdown-menu.tsx b/packages/ui/src/shadcn/dropdown-menu.tsx index c43ff0121..9b59dcc6c 100644 --- a/packages/ui/src/shadcn/dropdown-menu.tsx +++ b/packages/ui/src/shadcn/dropdown-menu.tsx @@ -2,189 +2,271 @@ import * as React from 'react'; -import { - CheckIcon, - ChevronRightIcon, - DotFilledIcon, -} from '@radix-ui/react-icons'; -import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { Menu as MenuPrimitive } from '@base-ui/react/menu'; +import { CheckIcon, ChevronRightIcon } from 'lucide-react'; -import { cn } from '../lib/utils'; +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />; +} -const DropdownMenu = DropdownMenuPrimitive.Root; +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />; +} -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />; +} -const DropdownMenuGroup = DropdownMenuPrimitive.Group; +function DropdownMenuContent({ + align = 'start', + alignOffset = 0, + side = 'bottom', + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'side' | 'sideOffset' + >) { + return ( + <MenuPrimitive.Portal> + <MenuPrimitive.Positioner + className="isolate z-50 outline-none" + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + > + <MenuPrimitive.Popup + data-slot="dropdown-menu-content" + className={cn( + 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden', + className, + )} + {...props} + /> + </MenuPrimitive.Positioner> + </MenuPrimitive.Portal> + ); +} -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />; +} -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const DropdownMenuSubTrigger: React.FC< - React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.SubTrigger> & { - inset?: boolean; - } -> = ({ className, inset, children, ...props }) => ( - <DropdownMenuPrimitive.SubTrigger - className={cn( - 'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-xs px-2 py-1.5 text-sm outline-hidden select-none', - inset && 'pl-8', - className, - )} - {...props} - > - {children} - <ChevronRightIcon className="ml-auto h-4 w-4" /> - </DropdownMenuPrimitive.SubTrigger> -); -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent: React.FC< - React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.SubContent> -> = ({ className, ...props }) => ( - <DropdownMenuPrimitive.SubContent - className={cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg', - className, - )} - {...props} - /> -); -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent: React.FC< - React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.Content> -> = ({ className, sideOffset = 4, ...props }) => ( - <DropdownMenuPrimitive.Portal> - <DropdownMenuPrimitive.Content - sideOffset={sideOffset} +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.GroupLabel + data-slot="dropdown-menu-label" + data-inset={inset} className={cn( - 'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md', - 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className, )} {...props} /> - </DropdownMenuPrimitive.Portal> -); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + ); +} -const DropdownMenuItem: React.FC< - React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.Item> & { - inset?: boolean; - } -> = ({ className, inset, ...props }) => ( - <DropdownMenuPrimitive.Item - className={cn( - 'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-xs px-2 py-1.5 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50', - inset && 'pl-8', - className, - )} - {...props} - /> -); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem: React.FC< - React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.CheckboxItem> -> = ({ className, children, checked, ...props }) => ( - <DropdownMenuPrimitive.CheckboxItem - className={cn( - 'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50', - className, - )} - checked={checked} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <CheckIcon className="h-4 w-4" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.CheckboxItem> -); -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem: React.FC< - React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.RadioItem> -> = ({ className, children, ...props }) => ( - <DropdownMenuPrimitive.RadioItem - className={cn( - 'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50', - className, - )} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <DotFilledIcon className="h-4 w-4 fill-current" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.RadioItem> -); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel: React.FC< - React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.Label> & { - inset?: boolean; - } -> = ({ className, inset, ...props }) => ( - <DropdownMenuPrimitive.Label - className={cn( - 'px-2 py-1.5 text-sm font-semibold', - inset && 'pl-8', - className, - )} - {...props} - /> -); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator: React.FC< - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> -> = ({ className, ...props }) => ( - <DropdownMenuPrimitive.Separator - className={cn('bg-muted -mx-1 my-1 h-px', className)} - {...props} - /> -); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ +function DropdownMenuItem({ className, + inset, + variant = 'default', ...props -}: React.HTMLAttributes<HTMLSpanElement>) => { +}: MenuPrimitive.Item.Props & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { return ( - <span - className={cn('ml-auto text-xs tracking-widest opacity-60', className)} + <MenuPrimitive.Item + data-slot="dropdown-menu-item" + data-inset={inset} + data-variant={variant} + className={cn( + "group/dropdown-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} {...props} /> ); -}; -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.SubmenuTrigger + data-slot="dropdown-menu-sub-trigger" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + {children} + <ChevronRightIcon className="ml-auto" /> + </MenuPrimitive.SubmenuTrigger> + ); +} + +function DropdownMenuSubContent({ + align = 'start', + alignOffset = -3, + side = 'right', + sideOffset = 0, + className, + ...props +}: React.ComponentProps<typeof DropdownMenuContent>) { + return ( + <DropdownMenuContent + data-slot="dropdown-menu-sub-content" + className={cn( + 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 w-auto min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100', + className, + )} + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + {...props} + /> + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.CheckboxItem + data-slot="dropdown-menu-checkbox-item" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + checked={checked} + {...props} + > + <span + className="pointer-events-none absolute right-2 flex items-center justify-center" + data-slot="dropdown-menu-checkbox-item-indicator" + > + <MenuPrimitive.CheckboxItemIndicator> + <CheckIcon /> + </MenuPrimitive.CheckboxItemIndicator> + </span> + {children} + </MenuPrimitive.CheckboxItem> + ); +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + <MenuPrimitive.RadioGroup + data-slot="dropdown-menu-radio-group" + {...props} + /> + ); +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.RadioItem + data-slot="dropdown-menu-radio-item" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + <span + className="pointer-events-none absolute right-2 flex items-center justify-center" + data-slot="dropdown-menu-radio-item-indicator" + > + <MenuPrimitive.RadioItemIndicator> + <CheckIcon /> + </MenuPrimitive.RadioItemIndicator> + </span> + {children} + </MenuPrimitive.RadioItem> + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + <MenuPrimitive.Separator + data-slot="dropdown-menu-separator" + className={cn('bg-border -mx-1 my-1 h-px', className)} + {...props} + /> + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + <span + data-slot="dropdown-menu-shortcut" + className={cn( + 'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', + className, + )} + {...props} + /> + ); +} export { DropdownMenu, + DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, DropdownMenuSub, - DropdownMenuSubContent, DropdownMenuSubTrigger, - DropdownMenuRadioGroup, + DropdownMenuSubContent, }; diff --git a/packages/ui/src/shadcn/empty.tsx b/packages/ui/src/shadcn/empty.tsx new file mode 100644 index 000000000..b44d7752b --- /dev/null +++ b/packages/ui/src/shadcn/empty.tsx @@ -0,0 +1,100 @@ +import { cn } from '#lib/utils'; +import { type VariantProps, cva } from 'class-variance-authority'; + +function Empty({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="empty" + className={cn( + 'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance', + className, + )} + {...props} + /> + ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="empty-header" + className={cn('flex max-w-sm flex-col items-center gap-2', className)} + {...props} + /> + ); +} + +const emptyMediaVariants = cva( + 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-transparent', + icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function EmptyMedia({ + className, + variant = 'default', + ...props +}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) { + return ( + <div + data-slot="empty-icon" + data-variant={variant} + className={cn(emptyMediaVariants({ variant, className }))} + {...props} + /> + ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="empty-title" + className={cn('text-sm font-medium tracking-tight', className)} + {...props} + /> + ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( + <div + data-slot="empty-description" + className={cn( + 'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', + className, + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="empty-content" + className={cn( + 'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance', + className, + )} + {...props} + /> + ); +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +}; diff --git a/packages/ui/src/shadcn/field.tsx b/packages/ui/src/shadcn/field.tsx index 1ff14b0ff..898135153 100644 --- a/packages/ui/src/shadcn/field.tsx +++ b/packages/ui/src/shadcn/field.tsx @@ -2,9 +2,9 @@ import { useMemo } from 'react'; +import { cn } from '#lib/utils'; import { type VariantProps, cva } from 'class-variance-authority'; -import { cn } from '../lib/utils/cn'; import { Label } from './label'; import { Separator } from './separator'; @@ -13,8 +13,7 @@ function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { <fieldset data-slot="field-set" className={cn( - 'flex flex-col gap-6', - 'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', + 'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', className, )} {...props} @@ -32,9 +31,7 @@ function FieldLegend({ data-slot="field-legend" data-variant={variant} className={cn( - 'mb-3 font-medium', - 'data-[variant=legend]:text-base', - 'data-[variant=label]:text-sm', + 'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base', className, )} {...props} @@ -47,7 +44,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { <div data-slot="field-group" className={cn( - 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4', + 'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4', className, )} {...props} @@ -56,21 +53,15 @@ function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { } const fieldVariants = cva( - 'group/field data-[invalid=true]:text-destructive flex w-full gap-3', + 'group/field data-[invalid=true]:text-destructive flex w-full gap-2', { variants: { orientation: { - vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'], - horizontal: [ - 'flex-row items-center', - '[&>[data-slot=field-label]]:flex-auto', - 'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', - ], - responsive: [ - 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto', - '@md/field-group:[&>[data-slot=field-label]]:flex-auto', - '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', - ], + vertical: 'flex-col *:w-full [&>.sr-only]:w-auto', + horizontal: + 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + responsive: + 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', }, }, defaultVariants: { @@ -100,7 +91,7 @@ function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { <div data-slot="field-content" className={cn( - 'group/field-content flex flex-1 flex-col gap-1.5 leading-snug', + 'group/field-content flex flex-1 flex-col gap-0.5 leading-snug', className, )} {...props} @@ -116,9 +107,8 @@ function FieldLabel({ <Label data-slot="field-label" className={cn( - 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50', - 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4', - 'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10', + 'group/field-label peer/field-label has-data-checked:border-primary/30 has-data-checked:bg-primary/5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5', + 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col', className, )} {...props} @@ -144,8 +134,8 @@ function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) { <p data-slot="field-description" className={cn( - 'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance', - 'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5', + 'text-muted-foreground text-left text-sm leading-normal font-normal group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5', + 'last:mt-0 nth-last-2:-mt-1', '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4', className, )} @@ -197,17 +187,21 @@ function FieldError({ return children; } - if (!errors) { + if (!errors?.length) { return null; } - if (errors?.length === 1 && errors[0]?.message) { - return errors[0].message; + const uniqueErrors = [ + ...new Map(errors.map((error) => [error?.message, error])).values(), + ]; + + if (uniqueErrors?.length == 1) { + return uniqueErrors[0]?.message; } return ( <ul className="ml-4 flex list-disc flex-col gap-1"> - {errors.map( + {uniqueErrors.map( (error, index) => error?.message && <li key={index}>{error.message}</li>, )} diff --git a/packages/ui/src/shadcn/form.tsx b/packages/ui/src/shadcn/form.tsx index 6ebaf0ed1..6dc55c4d7 100644 --- a/packages/ui/src/shadcn/form.tsx +++ b/packages/ui/src/shadcn/form.tsx @@ -2,12 +2,11 @@ import * as React from 'react'; -import { Label as LabelPrimitive } from 'radix-ui'; -import { Slot } from 'radix-ui'; +import { cn } from '#utils'; +import { useRender } from '@base-ui/react/use-render'; import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form'; import { Controller, FormProvider, useFormContext } from 'react-hook-form'; -import { cn } from '../lib/utils'; import { Trans } from '../makerkit/trans'; import { Label } from './label'; @@ -41,7 +40,6 @@ const useFormField = () => { const fieldContext = React.useContext(FormFieldContext); const itemContext = React.useContext(FormItemContext); const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { @@ -82,9 +80,10 @@ const FormItem: React.FC<React.ComponentPropsWithRef<'div'>> = ({ }; FormItem.displayName = 'FormItem'; -const FormLabel: React.FC< - React.ComponentPropsWithRef<typeof LabelPrimitive.Root> -> = ({ className, ...props }) => { +const FormLabel: React.FC<React.ComponentPropsWithRef<typeof Label>> = ({ + className, + ...props +}) => { const { error, formItemId } = useFormField(); return ( @@ -98,23 +97,29 @@ const FormLabel: React.FC< FormLabel.displayName = 'FormLabel'; const FormControl: React.FC< - React.ComponentPropsWithoutRef<typeof Slot.Root> + React.PropsWithChildren & { + className?: string; + render?: React.ReactElement; + } > = ({ ...props }) => { const { error, formItemId, formDescriptionId, formMessageId } = useFormField(); - return ( - <Slot.Root - id={formItemId} - aria-describedby={ - !error - ? `${formDescriptionId}` - : `${formDescriptionId} ${formMessageId}` - } - aria-invalid={!!error} - {...props} - /> - ); + return useRender({ + defaultTagName: 'div', + render: props.render, + props: { + ...props, + id: formItemId, + 'aria-labelledby': formItemId, + 'aria-describedby': !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}`, + 'aria-invalid': !!error, + className: cn(props.className), + children: props.children, + }, + }); }; FormControl.displayName = 'FormControl'; @@ -134,11 +139,9 @@ const FormDescription: React.FC<React.ComponentPropsWithRef<'p'>> = ({ }; FormDescription.displayName = 'FormDescription'; -const FormMessage: React.FC<React.ComponentPropsWithRef<'p'>> = ({ - className, - children, - ...props -}) => { +const FormMessage: React.FC< + React.ComponentPropsWithRef<'p'> & { params?: Record<string, unknown> } +> = ({ className, children, params = {}, ...props }) => { const { error, formMessageId } = useFormField(); const body = error ? String(error?.message) : children; @@ -153,7 +156,7 @@ const FormMessage: React.FC<React.ComponentPropsWithRef<'p'>> = ({ {...props} > {typeof body === 'string' ? ( - <Trans i18nKey={body} defaults={body} /> + <Trans i18nKey={body} defaults={body} values={params} /> ) : ( body )} diff --git a/packages/ui/src/shadcn/hover-card.tsx b/packages/ui/src/shadcn/hover-card.tsx new file mode 100644 index 000000000..07b39da19 --- /dev/null +++ b/packages/ui/src/shadcn/hover-card.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { cn } from '#lib/utils'; +import { PreviewCard as PreviewCardPrimitive } from '@base-ui/react/preview-card'; + +function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { + return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />; +} + +function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { + return ( + <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} /> + ); +} + +function HoverCardContent({ + className, + side = 'bottom', + sideOffset = 4, + align = 'center', + alignOffset = 4, + ...props +}: PreviewCardPrimitive.Popup.Props & + Pick< + PreviewCardPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'side' | 'sideOffset' + >) { + return ( + <PreviewCardPrimitive.Portal data-slot="hover-card-portal"> + <PreviewCardPrimitive.Positioner + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + className="isolate z-50" + > + <PreviewCardPrimitive.Popup + data-slot="hover-card-content" + className={cn( + 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 w-64 origin-(--transform-origin) rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100', + className, + )} + {...props} + /> + </PreviewCardPrimitive.Positioner> + </PreviewCardPrimitive.Portal> + ); +} + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/packages/ui/src/shadcn/input-group.tsx b/packages/ui/src/shadcn/input-group.tsx index d0a60e1b8..d7b1f66a0 100644 --- a/packages/ui/src/shadcn/input-group.tsx +++ b/packages/ui/src/shadcn/input-group.tsx @@ -2,12 +2,12 @@ import * as React from 'react'; +import { cn } from '#lib/utils'; import { type VariantProps, cva } from 'class-variance-authority'; -import { cn } from '../lib/utils/cn'; -import { Button } from '../shadcn/button'; -import { Input } from '../shadcn/input'; -import { Textarea } from '../shadcn/textarea'; +import { Button } from './button'; +import { Input } from './input'; +import { Textarea } from './textarea'; function InputGroup({ className, ...props }: React.ComponentProps<'div'>) { return ( @@ -15,21 +15,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) { data-slot="input-group" role="group" className={cn( - 'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none', - 'h-9 has-[>textarea]:h-auto', - - // Variants based on alignment. - 'has-[>[data-align=inline-start]]:[&>input]:pl-2', - 'has-[>[data-align=inline-end]]:[&>input]:pr-2', - 'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3', - 'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3', - - // Focus state. - 'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]', - - // Error state. - 'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40', - + 'group/input-group border-input has-disabled:bg-input/50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 relative flex h-8 w-full min-w-0 items-center rounded-lg border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5', className, )} {...props} @@ -43,13 +29,13 @@ const inputGroupAddonVariants = cva( variants: { align: { 'inline-start': - 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]', + 'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]', 'inline-end': - 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]', + 'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]', 'block-start': - 'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3', + 'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2', 'block-end': - 'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3', + 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2', }, }, defaultVariants: { @@ -85,10 +71,10 @@ const inputGroupButtonVariants = cva( { variants: { size: { - xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", - sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5', + xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: '', 'icon-xs': - 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0', + 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0', 'icon-sm': 'size-8 p-0 has-[>svg]:p-0', }, }, @@ -104,8 +90,10 @@ function InputGroupButton({ variant = 'ghost', size = 'xs', ...props -}: Omit<React.ComponentProps<typeof Button>, 'size'> & - VariantProps<typeof inputGroupButtonVariants>) { +}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> & + VariantProps<typeof inputGroupButtonVariants> & { + type?: 'button' | 'submit' | 'reset'; + }) { return ( <Button type={type} @@ -137,7 +125,7 @@ function InputGroupInput({ <Input data-slot="input-group-control" className={cn( - 'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent', + 'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent', className, )} {...props} @@ -153,7 +141,7 @@ function InputGroupTextarea({ <Textarea data-slot="input-group-control" className={cn( - 'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent', + 'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent', className, )} {...props} diff --git a/packages/ui/src/shadcn/input-otp.tsx b/packages/ui/src/shadcn/input-otp.tsx index fdc43d028..9e6ac2907 100644 --- a/packages/ui/src/shadcn/input-otp.tsx +++ b/packages/ui/src/shadcn/input-otp.tsx @@ -2,51 +2,60 @@ import * as React from 'react'; -import { DashIcon } from '@radix-ui/react-icons'; +import { cn } from '#lib/utils'; import { OTPInput, OTPInputContext } from 'input-otp'; +import { MinusIcon } from 'lucide-react'; -import { cn } from '../lib/utils'; - -const InputOTP: React.FC<React.ComponentPropsWithoutRef<typeof OTPInput>> = ({ +function InputOTP({ className, containerClassName, ...props -}) => ( - <OTPInput - containerClassName={cn( - 'flex items-center gap-2 has-disabled:opacity-50', - containerClassName, - )} - className={cn('disabled:cursor-not-allowed', className)} - {...props} - /> -); -InputOTP.displayName = 'InputOTP'; +}: React.ComponentProps<typeof OTPInput> & { + containerClassName?: string; +}) { + return ( + <OTPInput + data-slot="input-otp" + containerClassName={cn( + 'cn-input-otp flex items-center has-disabled:opacity-50', + containerClassName, + )} + spellCheck={false} + className={cn('disabled:cursor-not-allowed', className)} + {...props} + /> + ); +} -const InputOTPGroup: React.FC<React.ComponentPropsWithoutRef<'div'>> = ({ +function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="input-otp-group" + className={cn( + 'has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 flex items-center rounded-lg has-aria-invalid:ring-3', + className, + )} + {...props} + /> + ); +} + +function InputOTPSlot({ + index, className, ...props -}) => <div className={cn('flex items-center', className)} {...props} />; - -InputOTPGroup.displayName = 'InputOTPGroup'; - -const InputOTPSlot: React.FC< - React.ComponentPropsWithRef<'div'> & { index: number } -> = ({ index, className, ...props }) => { +}: React.ComponentProps<'div'> & { + index: number; +}) { const inputOTPContext = React.useContext(OTPInputContext); - const slot = inputOTPContext.slots[index]; - - if (!slot) { - return null; - } - - const { char, isActive, hasFakeCaret } = slot; + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; return ( <div + data-slot="input-otp-slot" + data-active={isActive} className={cn( - 'border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all first:rounded-l-md first:border-l last:rounded-r-md', - isActive && 'ring-ring z-10 ring-1', + 'border-input aria-invalid:border-destructive data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40 relative flex size-8 items-center justify-center border-y border-r text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg data-[active=true]:z-10 data-[active=true]:ring-3', className, )} {...props} @@ -59,16 +68,19 @@ const InputOTPSlot: React.FC< )} </div> ); -}; -InputOTPSlot.displayName = 'InputOTPSlot'; +} -const InputOTPSeparator: React.FC<React.ComponentPropsWithoutRef<'div'>> = ({ - ...props -}) => ( - <div role="separator" {...props}> - <DashIcon /> - </div> -); -InputOTPSeparator.displayName = 'InputOTPSeparator'; +function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="input-otp-separator" + className="flex items-center [&_svg:not([class*='size-'])]:size-4" + role="separator" + {...props} + > + <MinusIcon /> + </div> + ); +} export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/packages/ui/src/shadcn/input.tsx b/packages/ui/src/shadcn/input.tsx index 705df78e9..bdeb4fa85 100644 --- a/packages/ui/src/shadcn/input.tsx +++ b/packages/ui/src/shadcn/input.tsx @@ -1,26 +1,20 @@ import * as React from 'react'; -import { cn } from '../lib/utils'; +import { cn } from '#lib/utils'; +import { Input as InputPrimitive } from '@base-ui/react/input'; -export type InputProps = React.ComponentPropsWithRef<'input'>; - -const Input: React.FC<InputProps> = ({ - className, - type = 'text', - ...props -}) => { +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { return ( - <input + <InputPrimitive type={type} + data-slot="input" className={cn( - 'border-input file:text-foreground hover:border-ring/50 placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-2xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + 'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm', className, )} {...props} /> ); -}; - -Input.displayName = 'Input'; +} export { Input }; diff --git a/packages/ui/src/shadcn/item.tsx b/packages/ui/src/shadcn/item.tsx index 31ea82c99..745eaaf99 100644 --- a/packages/ui/src/shadcn/item.tsx +++ b/packages/ui/src/shadcn/item.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; +import { cn } from '#lib/utils'; +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; import { type VariantProps, cva } from 'class-variance-authority'; -import { Slot } from 'radix-ui'; -import { cn } from '../lib/utils'; import { Separator } from './separator'; function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) { @@ -11,7 +12,10 @@ function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) { <div role="list" data-slot="item-group" - className={cn('group/item-group flex flex-col', className)} + className={cn( + 'group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2', + className, + )} {...props} /> ); @@ -25,24 +29,25 @@ function ItemSeparator({ <Separator data-slot="item-separator" orientation="horizontal" - className={cn('my-0', className)} + className={cn('my-2', className)} {...props} /> ); } const itemVariants = cva( - 'group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors', + 'group/item focus-visible:border-ring focus-visible:ring-ring/50 [a]:hover:bg-muted flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors', { variants: { variant: { - default: 'bg-transparent', + default: 'border-transparent', outline: 'border-border', - muted: 'bg-muted/50', + muted: 'bg-muted/50 border-transparent', }, size: { - default: 'gap-4 p-4', - sm: 'gap-2.5 px-4 py-3', + default: 'gap-2.5 px-3 py-2.5', + sm: 'gap-2.5 px-3 py-2.5', + xs: 'gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0', }, }, defaultVariants: { @@ -56,32 +61,35 @@ function Item({ className, variant = 'default', size = 'default', - asChild = false, + render, ...props -}: React.ComponentProps<'div'> & - VariantProps<typeof itemVariants> & { asChild?: boolean }) { - const Comp = asChild ? Slot.Root : 'div'; - - return ( - <Comp - data-slot="item" - data-variant={variant} - data-size={size} - className={cn(itemVariants({ variant, size, className }))} - {...props} - /> - ); +}: useRender.ComponentProps<'div'> & VariantProps<typeof itemVariants>) { + return useRender({ + defaultTagName: 'div', + props: mergeProps<'div'>( + { + className: cn(itemVariants({ variant, size, className })), + }, + props, + ), + render, + state: { + slot: 'item', + variant, + size, + }, + }); } const itemMediaVariants = cva( - 'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none', + 'flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none', { variants: { variant: { default: 'bg-transparent', - icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4", + icon: "[&_svg:not([class*='size-'])]:size-4", image: - 'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover', + 'size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover', }, }, defaultVariants: { @@ -110,7 +118,7 @@ function ItemContent({ className, ...props }: React.ComponentProps<'div'>) { <div data-slot="item-content" className={cn( - 'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', + 'flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none', className, )} {...props} @@ -123,7 +131,7 @@ function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) { <div data-slot="item-title" className={cn( - 'flex w-fit items-center gap-2 text-sm leading-snug font-medium', + 'line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4', className, )} {...props} @@ -136,8 +144,7 @@ function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) { <p data-slot="item-description" className={cn( - 'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance', - '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4', + 'text-muted-foreground [&>a:hover]:text-primary line-clamp-2 text-left text-sm leading-normal font-normal group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4', className, )} {...props} diff --git a/packages/ui/src/shadcn/kbd.tsx b/packages/ui/src/shadcn/kbd.tsx index b3f74faa9..22365513a 100644 --- a/packages/ui/src/shadcn/kbd.tsx +++ b/packages/ui/src/shadcn/kbd.tsx @@ -1,13 +1,11 @@ -import { cn } from '../lib/utils/cn'; +import { cn } from '#lib/utils'; function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { return ( <kbd data-slot="kbd" className={cn( - 'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none', - "[&_svg:not([class*='size-'])]:size-3", - '[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10', + "bg-muted text-muted-foreground in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none [&_svg:not([class*='size-'])]:size-3", className, )} {...props} diff --git a/packages/ui/src/shadcn/label.tsx b/packages/ui/src/shadcn/label.tsx index 7b40265ae..3ca7e1f77 100644 --- a/packages/ui/src/shadcn/label.tsx +++ b/packages/ui/src/shadcn/label.tsx @@ -2,21 +2,19 @@ import * as React from 'react'; -import { type VariantProps, cva } from 'class-variance-authority'; -import { Label as LabelPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; -import { cn } from '../lib/utils'; - -const labelVariants = cva( - 'text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70', -); - -const Label: React.FC< - React.ComponentPropsWithRef<typeof LabelPrimitive.Root> & - VariantProps<typeof labelVariants> -> = ({ className, ...props }) => ( - <LabelPrimitive.Root className={cn(labelVariants(), className)} {...props} /> -); -Label.displayName = LabelPrimitive.Root.displayName; +function Label({ className, ...props }: React.ComponentProps<'label'>) { + return ( + <label + data-slot="label" + className={cn( + 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50', + className, + )} + {...props} + /> + ); +} export { Label }; diff --git a/packages/ui/src/shadcn/menu-bar.tsx b/packages/ui/src/shadcn/menu-bar.tsx new file mode 100644 index 000000000..193d7ce32 --- /dev/null +++ b/packages/ui/src/shadcn/menu-bar.tsx @@ -0,0 +1,254 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '#utils'; +import { Menu as MenuPrimitive } from '@base-ui/react/menu'; +import { Menubar as MenubarPrimitive } from '@base-ui/react/menubar'; +import { CheckIcon } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from './dropdown-menu'; + +function Menubar({ className, ...props }: MenubarPrimitive.Props) { + return ( + <MenubarPrimitive + data-slot="menubar" + className={cn('cn-menubar flex items-center', className)} + {...props} + /> + ); +} + +function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) { + return <DropdownMenu data-slot="menubar-menu" {...props} />; +} + +function MenubarGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuGroup>) { + return <DropdownMenuGroup data-slot="menubar-group" {...props} />; +} + +function MenubarPortal({ + ...props +}: React.ComponentProps<typeof DropdownMenuPortal>) { + return <DropdownMenuPortal data-slot="menubar-portal" {...props} />; +} + +function MenubarTrigger({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuTrigger>) { + return ( + <DropdownMenuTrigger + data-slot="menubar-trigger" + className={cn( + 'cn-menubar-trigger flex items-center outline-hidden select-none', + className, + )} + {...props} + /> + ); +} + +function MenubarContent({ + className, + align = 'start', + alignOffset = -4, + sideOffset = 8, + ...props +}: React.ComponentProps<typeof DropdownMenuContent>) { + return ( + <DropdownMenuContent + data-slot="menubar-content" + align={align} + alignOffset={alignOffset} + sideOffset={sideOffset} + className={cn('cn-menubar-content cn-menu-target', className)} + {...props} + /> + ); +} + +function MenubarItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps<typeof DropdownMenuItem>) { + return ( + <DropdownMenuItem + data-slot="menubar-item" + data-inset={inset} + data-variant={variant} + className={cn('cn-menubar-item group/menubar-item', className)} + {...props} + /> + ); +} + +function MenubarCheckboxItem({ + className, + children, + checked, + ...props +}: MenuPrimitive.CheckboxItem.Props) { + return ( + <MenuPrimitive.CheckboxItem + data-slot="menubar-checkbox-item" + className={cn( + 'cn-menubar-checkbox-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', + className, + )} + checked={checked} + {...props} + > + <span className="cn-menubar-checkbox-item-indicator pointer-events-none absolute flex items-center justify-center"> + <MenuPrimitive.CheckboxItemIndicator> + <CheckIcon /> + </MenuPrimitive.CheckboxItemIndicator> + </span> + {children} + </MenuPrimitive.CheckboxItem> + ); +} + +function MenubarRadioGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuRadioGroup>) { + return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />; +} + +function MenubarRadioItem({ + className, + children, + ...props +}: MenuPrimitive.RadioItem.Props) { + return ( + <MenuPrimitive.RadioItem + data-slot="menubar-radio-item" + className={cn( + 'cn-menubar-radio-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0', + className, + )} + {...props} + > + <span className="cn-menubar-radio-item-indicator pointer-events-none absolute flex items-center justify-center"> + <MenuPrimitive.RadioItemIndicator> + <CheckIcon /> + </MenuPrimitive.RadioItemIndicator> + </span> + {children} + </MenuPrimitive.RadioItem> + ); +} + +function MenubarLabel({ + className, + inset, + ...props +}: React.ComponentProps<typeof DropdownMenuLabel>) { + return ( + <DropdownMenuLabel + data-slot="menubar-label" + data-inset={inset} + className={cn('cn-menubar-label', className)} + {...props} + /> + ); +} + +function MenubarSeparator({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuSeparator>) { + return ( + <DropdownMenuSeparator + data-slot="menubar-separator" + className={cn('cn-menubar-separator -mx-1 my-1 h-px', className)} + {...props} + /> + ); +} + +function MenubarShortcut({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuShortcut>) { + return ( + <DropdownMenuShortcut + data-slot="menubar-shortcut" + className={cn('cn-menubar-shortcut ml-auto', className)} + {...props} + /> + ); +} + +function MenubarSub({ + ...props +}: React.ComponentProps<typeof DropdownMenuSub>) { + return <DropdownMenuSub data-slot="menubar-sub" {...props} />; +} + +function MenubarSubTrigger({ + className, + inset, + ...props +}: React.ComponentProps<typeof DropdownMenuSubTrigger> & { + inset?: boolean; +}) { + return ( + <DropdownMenuSubTrigger + data-slot="menubar-sub-trigger" + data-inset={inset} + className={cn('cn-menubar-sub-trigger', className)} + {...props} + /> + ); +} + +function MenubarSubContent({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuSubContent>) { + return ( + <DropdownMenuSubContent + data-slot="menubar-sub-content" + className={cn('cn-menubar-sub-content', className)} + {...props} + /> + ); +} + +export { + Menubar, + MenubarPortal, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarGroup, + MenubarSeparator, + MenubarLabel, + MenubarItem, + MenubarShortcut, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSub, + MenubarSubTrigger, + MenubarSubContent, +}; diff --git a/packages/ui/src/shadcn/menubar.tsx b/packages/ui/src/shadcn/menubar.tsx new file mode 100644 index 000000000..042f5c243 --- /dev/null +++ b/packages/ui/src/shadcn/menubar.tsx @@ -0,0 +1,285 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import { Menu as MenuPrimitive } from '@base-ui/react/menu'; +import { Menubar as MenubarPrimitive } from '@base-ui/react/menubar'; +import { CheckIcon } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from './dropdown-menu'; + +function Menubar({ className, ...props }: MenubarPrimitive.Props) { + return ( + <MenubarPrimitive + data-slot="menubar" + className={cn( + 'bg-background flex h-8 items-center gap-0.5 rounded-lg border p-[3px]', + className, + )} + {...props} + /> + ); +} + +function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) { + return <DropdownMenu data-slot="menubar-menu" {...props} />; +} + +function MenubarGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuGroup>) { + return <DropdownMenuGroup data-slot="menubar-group" {...props} />; +} + +function MenubarPortal({ + ...props +}: React.ComponentProps<typeof DropdownMenuPortal>) { + return <DropdownMenuPortal data-slot="menubar-portal" {...props} />; +} + +function MenubarTrigger({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuTrigger>) { + return ( + <DropdownMenuTrigger + data-slot="menubar-trigger" + className={cn( + 'hover:bg-muted aria-expanded:bg-muted flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none', + className, + )} + {...props} + /> + ); +} + +function MenubarContent({ + className, + align = 'start', + alignOffset = -4, + sideOffset = 8, + ...props +}: React.ComponentProps<typeof DropdownMenuContent>) { + return ( + <DropdownMenuContent + data-slot="menubar-content" + align={align} + alignOffset={alignOffset} + sideOffset={sideOffset} + className={cn( + 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100', + className, + )} + {...props} + /> + ); +} + +function MenubarItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps<typeof DropdownMenuItem>) { + return ( + <DropdownMenuItem + data-slot="menubar-item" + data-inset={inset} + data-variant={variant} + className={cn( + "group/menubar-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive! gap-1.5 rounded-md px-1.5 py-1 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + /> + ); +} + +function MenubarCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.CheckboxItem + data-slot="menubar-checkbox-item" + data-inset={inset} + className={cn( + 'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0', + className, + )} + checked={checked} + {...props} + > + <span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4"> + <MenuPrimitive.CheckboxItemIndicator> + <CheckIcon /> + </MenuPrimitive.CheckboxItemIndicator> + </span> + {children} + </MenuPrimitive.CheckboxItem> + ); +} + +function MenubarRadioGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuRadioGroup>) { + return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />; +} + +function MenubarRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + <MenuPrimitive.RadioItem + data-slot="menubar-radio-item" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + <span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4"> + <MenuPrimitive.RadioItemIndicator> + <CheckIcon /> + </MenuPrimitive.RadioItemIndicator> + </span> + {children} + </MenuPrimitive.RadioItem> + ); +} + +function MenubarLabel({ + className, + inset, + ...props +}: React.ComponentProps<typeof DropdownMenuLabel> & { + inset?: boolean; +}) { + return ( + <DropdownMenuLabel + data-slot="menubar-label" + data-inset={inset} + className={cn( + 'px-1.5 py-1 text-sm font-medium data-inset:pl-7', + className, + )} + {...props} + /> + ); +} + +function MenubarSeparator({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuSeparator>) { + return ( + <DropdownMenuSeparator + data-slot="menubar-separator" + className={cn('bg-border -mx-1 my-1 h-px', className)} + {...props} + /> + ); +} + +function MenubarShortcut({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuShortcut>) { + return ( + <DropdownMenuShortcut + data-slot="menubar-shortcut" + className={cn( + 'text-muted-foreground group-focus/menubar-item:text-accent-foreground ml-auto text-xs tracking-widest', + className, + )} + {...props} + /> + ); +} + +function MenubarSub({ + ...props +}: React.ComponentProps<typeof DropdownMenuSub>) { + return <DropdownMenuSub data-slot="menubar-sub" {...props} />; +} + +function MenubarSubTrigger({ + className, + inset, + ...props +}: React.ComponentProps<typeof DropdownMenuSubTrigger> & { + inset?: boolean; +}) { + return ( + <DropdownMenuSubTrigger + data-slot="menubar-sub-trigger" + data-inset={inset} + className={cn( + "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + /> + ); +} + +function MenubarSubContent({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuSubContent>) { + return ( + <DropdownMenuSubContent + data-slot="menubar-sub-content" + className={cn( + 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 min-w-32 rounded-lg p-1 shadow-lg ring-1 duration-100', + className, + )} + {...props} + /> + ); +} + +export { + Menubar, + MenubarPortal, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarGroup, + MenubarSeparator, + MenubarLabel, + MenubarItem, + MenubarShortcut, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSub, + MenubarSubTrigger, + MenubarSubContent, +}; diff --git a/packages/ui/src/shadcn/native-select.tsx b/packages/ui/src/shadcn/native-select.tsx new file mode 100644 index 000000000..b574130e9 --- /dev/null +++ b/packages/ui/src/shadcn/native-select.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import { ChevronDownIcon } from 'lucide-react'; + +type NativeSelectProps = Omit<React.ComponentProps<'select'>, 'size'> & { + size?: 'sm' | 'default'; +}; + +function NativeSelect({ + className, + size = 'default', + ...props +}: NativeSelectProps) { + return ( + <div + className={cn( + 'group/native-select relative w-fit has-[select:disabled]:opacity-50', + className, + )} + data-slot="native-select-wrapper" + data-size={size} + > + <select + data-slot="native-select" + data-size={size} + className="border-input selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-8 w-full min-w-0 appearance-none rounded-lg border bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:ring-3 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5" + {...props} + /> + <ChevronDownIcon + className="text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 select-none" + aria-hidden="true" + data-slot="native-select-icon" + /> + </div> + ); +} + +function NativeSelectOption({ ...props }: React.ComponentProps<'option'>) { + return <option data-slot="native-select-option" {...props} />; +} + +function NativeSelectOptGroup({ + className, + ...props +}: React.ComponentProps<'optgroup'>) { + return ( + <optgroup + data-slot="native-select-optgroup" + className={cn(className)} + {...props} + /> + ); +} + +export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }; diff --git a/packages/ui/src/shadcn/navigation-menu.tsx b/packages/ui/src/shadcn/navigation-menu.tsx index 157adae50..7c97b7164 100644 --- a/packages/ui/src/shadcn/navigation-menu.tsx +++ b/packages/ui/src/shadcn/navigation-menu.tsx @@ -1,119 +1,170 @@ -'use client'; - -import * as React from 'react'; - -import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { cn } from '#lib/utils'; +import { NavigationMenu as NavigationMenuPrimitive } from '@base-ui/react/navigation-menu'; import { cva } from 'class-variance-authority'; -import { NavigationMenu as NavigationMenuPrimitive } from 'radix-ui'; +import { ChevronDownIcon } from 'lucide-react'; -import { cn } from '../lib/utils'; - -const NavigationMenu: React.FC< - React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Root> -> = ({ className, children, ...props }) => ( - <NavigationMenuPrimitive.Root - className={cn( - 'relative z-10 flex max-w-max flex-1 items-center justify-center', - className, - )} - {...props} - > - {children} - <NavigationMenuViewport /> - </NavigationMenuPrimitive.Root> -); -NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; - -const NavigationMenuList: React.FC< - React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List> -> = ({ className, ...props }) => ( - <NavigationMenuPrimitive.List - className={cn( - 'group flex flex-1 list-none items-center justify-center space-x-1', - className, - )} - {...props} - /> -); -NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; - -const NavigationMenuItem = NavigationMenuPrimitive.Item; - -const navigationMenuTriggerStyle = cva( - 'group bg-background hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-active:bg-accent/50 data-[state=open]:bg-accent/50 inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-hidden disabled:pointer-events-none disabled:opacity-50', -); - -const NavigationMenuTrigger: React.FC< - React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Trigger> -> = ({ className, children, ...props }) => ( - <NavigationMenuPrimitive.Trigger - className={cn(navigationMenuTriggerStyle(), 'group', className)} - {...props} - > - {children}{' '} - <ChevronDownIcon - className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" - aria-hidden="true" - /> - </NavigationMenuPrimitive.Trigger> -); -NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; - -const NavigationMenuContent: React.FC< - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> -> = ({ className, ...props }) => ( - <NavigationMenuPrimitive.Content - className={cn( - 'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full md:absolute md:w-auto', - className, - )} - {...props} - /> -); -NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; - -const NavigationMenuLink = NavigationMenuPrimitive.Link; - -const NavigationMenuViewport: React.FC< - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> -> = ({ className, ...props }) => ( - <div className={cn('absolute top-full left-0 flex justify-center')}> - <NavigationMenuPrimitive.Viewport +function NavigationMenu({ + align = 'start', + className, + children, + ...props +}: NavigationMenuPrimitive.Root.Props & + Pick<NavigationMenuPrimitive.Positioner.Props, 'align'>) { + return ( + <NavigationMenuPrimitive.Root + data-slot="navigation-menu" className={cn( - 'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow-xs md:w-[var(--radix-navigation-menu-viewport-width)]', + 'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center', + className, + )} + {...props} + > + {children} + <NavigationMenuPositioner align={align} /> + </NavigationMenuPrimitive.Root> + ); +} + +function NavigationMenuList({ + className, + ...props +}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) { + return ( + <NavigationMenuPrimitive.List + data-slot="navigation-menu-list" + className={cn( + 'group flex flex-1 list-none items-center justify-center gap-0', className, )} {...props} /> - </div> -); -NavigationMenuViewport.displayName = - NavigationMenuPrimitive.Viewport.displayName; + ); +} -const NavigationMenuIndicator: React.FC< - React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Indicator> -> = ({ className, ...props }) => ( - <NavigationMenuPrimitive.Indicator - className={cn( - 'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden', - className, - )} - {...props} - > - <div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" /> - </NavigationMenuPrimitive.Indicator> +function NavigationMenuItem({ + className, + ...props +}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) { + return ( + <NavigationMenuPrimitive.Item + data-slot="navigation-menu-item" + className={cn('relative', className)} + {...props} + /> + ); +} + +const navigationMenuTriggerStyle = cva( + 'group/navigation-menu-trigger bg-background hover:bg-muted focus:bg-muted focus-visible:ring-ring/50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none focus-visible:ring-3 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50', ); -NavigationMenuIndicator.displayName = - NavigationMenuPrimitive.Indicator.displayName; + +function NavigationMenuTrigger({ + className, + children, + ...props +}: NavigationMenuPrimitive.Trigger.Props) { + return ( + <NavigationMenuPrimitive.Trigger + data-slot="navigation-menu-trigger" + className={cn(navigationMenuTriggerStyle(), 'group', className)} + {...props} + > + {children}{' '} + <ChevronDownIcon + className="relative top-px ml-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:rotate-180" + aria-hidden="true" + /> + </NavigationMenuPrimitive.Trigger> + ); +} + +function NavigationMenuContent({ + className, + ...props +}: NavigationMenuPrimitive.Content.Props) { + return ( + <NavigationMenuPrimitive.Content + data-slot="navigation-menu-content" + className={cn( + 'data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:ring-foreground/10 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none', + className, + )} + {...props} + /> + ); +} + +function NavigationMenuPositioner({ + className, + side = 'bottom', + sideOffset = 8, + align = 'start', + alignOffset = 0, + ...props +}: NavigationMenuPrimitive.Positioner.Props) { + return ( + <NavigationMenuPrimitive.Portal> + <NavigationMenuPrimitive.Positioner + side={side} + sideOffset={sideOffset} + align={align} + alignOffset={alignOffset} + className={cn( + 'isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0', + className, + )} + {...props} + > + <NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) bg-popover text-popover-foreground ring-foreground/10 relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg shadow ring-1 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0"> + <NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" /> + </NavigationMenuPrimitive.Popup> + </NavigationMenuPrimitive.Positioner> + </NavigationMenuPrimitive.Portal> + ); +} + +function NavigationMenuLink({ + className, + ...props +}: NavigationMenuPrimitive.Link.Props) { + return ( + <NavigationMenuPrimitive.Link + data-slot="navigation-menu-link" + className={cn( + "hover:bg-muted focus:bg-muted focus-visible:ring-ring/50 data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none focus-visible:ring-3 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + /> + ); +} + +function NavigationMenuIndicator({ + className, + ...props +}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) { + return ( + <NavigationMenuPrimitive.Icon + data-slot="navigation-menu-indicator" + className={cn( + 'data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden', + className, + )} + {...props} + > + <div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" /> + </NavigationMenuPrimitive.Icon> + ); +} export { - navigationMenuTriggerStyle, NavigationMenu, - NavigationMenuList, - NavigationMenuItem, NavigationMenuContent, - NavigationMenuTrigger, - NavigationMenuLink, NavigationMenuIndicator, - NavigationMenuViewport, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, + NavigationMenuPositioner, }; diff --git a/packages/ui/src/shadcn/pagination.tsx b/packages/ui/src/shadcn/pagination.tsx new file mode 100644 index 000000000..4091a00e5 --- /dev/null +++ b/packages/ui/src/shadcn/pagination.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from 'lucide-react'; + +import { Button } from './button'; + +function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { + return ( + <nav + role="navigation" + aria-label="pagination" + data-slot="pagination" + className={cn('mx-auto flex w-full justify-center', className)} + {...props} + /> + ); +} + +function PaginationContent({ + className, + ...props +}: React.ComponentProps<'ul'>) { + return ( + <ul + data-slot="pagination-content" + className={cn('flex items-center gap-0.5', className)} + {...props} + /> + ); +} + +function PaginationItem({ ...props }: React.ComponentProps<'li'>) { + return <li data-slot="pagination-item" {...props} />; +} + +type PaginationLinkProps = { + isActive?: boolean; +} & Pick<React.ComponentProps<typeof Button>, 'size'> & + React.ComponentProps<'a'>; + +function PaginationLink({ + className, + isActive, + size = 'icon', + ...props +}: PaginationLinkProps) { + return ( + <Button + variant={isActive ? 'outline' : 'ghost'} + size={size} + className={cn(className)} + nativeButton={false} + render={ + <a + aria-current={isActive ? 'page' : undefined} + data-slot="pagination-link" + data-active={isActive} + {...props} + /> + } + /> + ); +} + +function PaginationPrevious({ + className, + text = 'Previous', + ...props +}: React.ComponentProps<typeof PaginationLink> & { text?: string }) { + return ( + <PaginationLink + aria-label="Go to previous page" + size="default" + className={cn('pl-1.5!', className)} + {...props} + > + <ChevronLeftIcon data-icon="inline-start" /> + <span className="hidden sm:block">{text}</span> + </PaginationLink> + ); +} + +function PaginationNext({ + className, + text = 'Next', + ...props +}: React.ComponentProps<typeof PaginationLink> & { text?: string }) { + return ( + <PaginationLink + aria-label="Go to next page" + size="default" + className={cn('pr-1.5!', className)} + {...props} + > + <span className="hidden sm:block">{text}</span> + <ChevronRightIcon data-icon="inline-end" /> + </PaginationLink> + ); +} + +function PaginationEllipsis({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + <span + aria-hidden + data-slot="pagination-ellipsis" + className={cn( + "flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + <MoreHorizontalIcon /> + <span className="sr-only">More pages</span> + </span> + ); +} + +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +}; diff --git a/packages/ui/src/shadcn/popover.tsx b/packages/ui/src/shadcn/popover.tsx index abc6e793f..7b707ca04 100644 --- a/packages/ui/src/shadcn/popover.tsx +++ b/packages/ui/src/shadcn/popover.tsx @@ -2,32 +2,89 @@ import * as React from 'react'; -import { Popover as PopoverPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { Popover as PopoverPrimitive } from '@base-ui/react/popover'; -import { cn } from '../lib/utils'; +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return <PopoverPrimitive.Root data-slot="popover" {...props} />; +} -const Popover = PopoverPrimitive.Root; +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; +} -const PopoverTrigger = PopoverPrimitive.Trigger; +function PopoverContent({ + className, + align = 'center', + alignOffset = 0, + side = 'bottom', + sideOffset = 4, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'side' | 'sideOffset' + >) { + return ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Positioner + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + className="isolate z-50" + > + <PopoverPrimitive.Popup + data-slot="popover-content" + className={cn( + 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100', + className, + )} + {...props} + /> + </PopoverPrimitive.Positioner> + </PopoverPrimitive.Portal> + ); +} -const PopoverAnchor = PopoverPrimitive.Anchor; - -const PopoverContent: React.FC< - React.ComponentProps<typeof PopoverPrimitive.Content> -> = ({ className, align = 'center', sideOffset = 4, ...props }) => ( - <PopoverPrimitive.Portal> - <PopoverPrimitive.Content - align={align} - sideOffset={sideOffset} - className={cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden', - className, - )} +function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="popover-header" + className={cn('flex flex-col gap-0.5 text-sm', className)} {...props} /> - </PopoverPrimitive.Portal> -); + ); +} -PopoverContent.displayName = PopoverPrimitive.Content.displayName; +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( + <PopoverPrimitive.Title + data-slot="popover-title" + className={cn('font-medium', className)} + {...props} + /> + ); +} -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + <PopoverPrimitive.Description + data-slot="popover-description" + className={cn('text-muted-foreground', className)} + {...props} + /> + ); +} + +export { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +}; diff --git a/packages/ui/src/shadcn/progress.tsx b/packages/ui/src/shadcn/progress.tsx index 99f0d1616..243e42193 100644 --- a/packages/ui/src/shadcn/progress.tsx +++ b/packages/ui/src/shadcn/progress.tsx @@ -1,28 +1,82 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Progress as ProgressPrimitive } from '@base-ui/react/progress'; -import { Progress as ProgressPrimitive } from 'radix-ui'; +function Progress({ + className, + children, + value, + ...props +}: ProgressPrimitive.Root.Props) { + return ( + <ProgressPrimitive.Root + value={value} + data-slot="progress" + className={cn('flex flex-wrap gap-3', className)} + {...props} + > + {children} + <ProgressTrack> + <ProgressIndicator /> + </ProgressTrack> + </ProgressPrimitive.Root> + ); +} -import { cn } from '../lib/utils'; - -const Progress: React.FC< - React.ComponentProps<typeof ProgressPrimitive.Root> -> = ({ className, value, ...props }) => ( - <ProgressPrimitive.Root - className={cn( - 'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', - className, - )} - {...props} - > - <ProgressPrimitive.Indicator - className="bg-primary h-full w-full flex-1 transition-all" - style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }} +function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { + return ( + <ProgressPrimitive.Track + className={cn( + 'bg-muted relative flex h-1 w-full items-center overflow-x-hidden rounded-full', + className, + )} + data-slot="progress-track" + {...props} /> - </ProgressPrimitive.Root> -); + ); +} -Progress.displayName = ProgressPrimitive.Root.displayName; +function ProgressIndicator({ + className, + ...props +}: ProgressPrimitive.Indicator.Props) { + return ( + <ProgressPrimitive.Indicator + data-slot="progress-indicator" + className={cn('bg-primary h-full transition-all', className)} + {...props} + /> + ); +} -export { Progress }; +function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { + return ( + <ProgressPrimitive.Label + className={cn('text-sm font-medium', className)} + data-slot="progress-label" + {...props} + /> + ); +} + +function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) { + return ( + <ProgressPrimitive.Value + className={cn( + 'text-muted-foreground ml-auto text-sm tabular-nums', + className, + )} + data-slot="progress-value" + {...props} + /> + ); +} + +export { + Progress, + ProgressTrack, + ProgressIndicator, + ProgressLabel, + ProgressValue, +}; diff --git a/packages/ui/src/shadcn/radio-group.tsx b/packages/ui/src/shadcn/radio-group.tsx index 351193a8c..2e03d6492 100644 --- a/packages/ui/src/shadcn/radio-group.tsx +++ b/packages/ui/src/shadcn/radio-group.tsx @@ -1,67 +1,66 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Radio as RadioPrimitive } from '@base-ui/react/radio'; +import { RadioGroup as RadioGroupPrimitive } from '@base-ui/react/radio-group'; -import { CheckIcon } from '@radix-ui/react-icons'; -import { RadioGroup as RadioGroupPrimitive } from 'radix-ui'; - -import { cn } from '../lib/utils'; - -const RadioGroup: React.FC< - React.ComponentPropsWithRef<typeof RadioGroupPrimitive.Root> -> = ({ className, ...props }) => { +function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) { return ( - <RadioGroupPrimitive.Root - className={cn('grid gap-2', className)} + <RadioGroupPrimitive + data-slot="radio-group" + className={cn('grid w-full gap-2', className)} {...props} /> ); -}; -RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; +} -const RadioGroupItem: React.FC< - React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> -> = ({ className, ...props }) => { +function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) { return ( - <RadioGroupPrimitive.Item + <RadioPrimitive.Root + data-slot="radio-group-item" className={cn( - 'border-primary text-primary focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border focus:outline-hidden focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50', + 'group/radio-group-item peer border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary relative flex aspect-square size-4 shrink-0 rounded-full border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3', className, )} {...props} > - <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> - <CheckIcon className="fill-primary animate-in fade-in slide-in-from-left-4 h-3.5 w-3.5" /> - </RadioGroupPrimitive.Indicator> - </RadioGroupPrimitive.Item> + <RadioPrimitive.Indicator + data-slot="radio-group-indicator" + className="flex size-4 items-center justify-center" + > + <span className="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" /> + </RadioPrimitive.Indicator> + </RadioPrimitive.Root> ); -}; -RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; +} -const RadioGroupItemLabel = ( - props: React.PropsWithChildren<{ - className?: string; - selected?: boolean; - }>, -) => { +function RadioGroupItemLabel({ + selected, + className, + children, + ...props +}: React.PropsWithChildren<{ + selected?: boolean; +}> & + React.LabelHTMLAttributes<unknown>) { return ( <label - data-selected={props.selected} + data-selected={selected} className={cn( - props.className, + className, 'flex cursor-pointer rounded-md' + ' border-input items-center space-x-4 border' + - 'focus-within:border-primary active:bg-muted p-2.5 text-sm transition-all', + ' focus-within:border-primary active:bg-muted p-2.5 text-sm transition-all', { - [`bg-muted/70`]: props.selected, - [`hover:bg-muted/50`]: !props.selected, + [`bg-muted/70`]: selected, + [`hover:bg-muted/50`]: !selected, }, )} + {...props} > - {props.children} + {children} </label> ); -}; -RadioGroupItemLabel.displayName = 'RadioGroupItemLabel'; +} export { RadioGroup, RadioGroupItem, RadioGroupItemLabel }; diff --git a/packages/ui/src/shadcn/resizable.tsx b/packages/ui/src/shadcn/resizable.tsx new file mode 100644 index 000000000..423df638e --- /dev/null +++ b/packages/ui/src/shadcn/resizable.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { cn } from '#lib/utils'; +import * as ResizablePrimitive from 'react-resizable-panels'; + +function ResizablePanelGroup({ + className, + ...props +}: ResizablePrimitive.GroupProps) { + return ( + <ResizablePrimitive.Group + data-slot="resizable-panel-group" + className={cn( + 'flex h-full w-full aria-[orientation=vertical]:flex-col', + className, + )} + {...props} + /> + ); +} + +function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { + return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />; +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: ResizablePrimitive.SeparatorProps & { + withHandle?: boolean; +}) { + return ( + <ResizablePrimitive.Separator + data-slot="resizable-handle" + className={cn( + 'bg-border ring-offset-background focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90', + className, + )} + {...props} + > + {withHandle && ( + <div className="bg-border z-10 flex h-6 w-1 shrink-0 rounded-lg" /> + )} + </ResizablePrimitive.Separator> + ); +} + +export { ResizableHandle, ResizablePanel, ResizablePanelGroup }; diff --git a/packages/ui/src/shadcn/scroll-area.tsx b/packages/ui/src/shadcn/scroll-area.tsx index fb5dc2989..8db0a697c 100644 --- a/packages/ui/src/shadcn/scroll-area.tsx +++ b/packages/ui/src/shadcn/scroll-area.tsx @@ -2,44 +2,54 @@ import * as React from 'react'; -import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'; -import { cn } from '../lib/utils'; +function ScrollArea({ + className, + children, + ...props +}: ScrollAreaPrimitive.Root.Props) { + return ( + <ScrollAreaPrimitive.Root + data-slot="scroll-area" + className={cn('relative', className)} + {...props} + > + <ScrollAreaPrimitive.Viewport + data-slot="scroll-area-viewport" + className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1" + > + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> + ); +} -const ScrollArea: React.FC< - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> -> = ({ className, children, ...props }) => ( - <ScrollAreaPrimitive.Root - className={cn('relative overflow-hidden', className)} - {...props} - > - <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> - {children} - </ScrollAreaPrimitive.Viewport> - <ScrollBar /> - <ScrollAreaPrimitive.Corner /> - </ScrollAreaPrimitive.Root> -); -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; - -const ScrollBar: React.FC< - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> -> = ({ className, orientation = 'vertical', ...props }) => ( - <ScrollAreaPrimitive.ScrollAreaScrollbar - orientation={orientation} - className={cn( - 'flex touch-none transition-colors select-none', - orientation === 'vertical' && - 'h-full w-2.5 border-l border-l-transparent p-[1px]', - orientation === 'horizontal' && - 'h-2.5 border-t border-t-transparent p-[1px]', - className, - )} - {...props} - > - <ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" /> - </ScrollAreaPrimitive.ScrollAreaScrollbar> -); -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; +function ScrollBar({ + className, + orientation = 'vertical', + ...props +}: ScrollAreaPrimitive.Scrollbar.Props) { + return ( + <ScrollAreaPrimitive.Scrollbar + data-slot="scroll-area-scrollbar" + data-orientation={orientation} + orientation={orientation} + className={cn( + 'flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent', + className, + )} + {...props} + > + <ScrollAreaPrimitive.Thumb + data-slot="scroll-area-thumb" + className="bg-border relative flex-1 rounded-full" + /> + </ScrollAreaPrimitive.Scrollbar> + ); +} export { ScrollArea, ScrollBar }; diff --git a/packages/ui/src/shadcn/select.tsx b/packages/ui/src/shadcn/select.tsx index 3bb62bfa3..3db197717 100644 --- a/packages/ui/src/shadcn/select.tsx +++ b/packages/ui/src/shadcn/select.tsx @@ -2,150 +2,201 @@ import * as React from 'react'; -import { - CaretSortIcon, - CheckIcon, - ChevronDownIcon, - ChevronUpIcon, -} from '@radix-ui/react-icons'; -import { Select as SelectPrimitive } from 'radix-ui'; - -import { cn } from '../lib/utils'; +import { cn } from '#lib/utils'; +import { Select as SelectPrimitive } from '@base-ui/react/select'; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; const Select = SelectPrimitive.Root; -const SelectGroup = SelectPrimitive.Group; +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return ( + <SelectPrimitive.Group + data-slot="select-group" + className={cn('scroll-my-1 p-1', className)} + {...props} + /> + ); +} -const SelectValue = SelectPrimitive.Value; +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + <SelectPrimitive.Value + data-slot="select-value" + className={cn('flex flex-1 text-left', className)} + {...props} + /> + ); +} -const SelectTrigger: React.FC< - React.ComponentPropsWithRef<typeof SelectPrimitive.Trigger> -> = ({ className, children, ...props }) => ( - <SelectPrimitive.Trigger - className={cn( - 'border-input ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-2xs focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', - className, - )} - {...props} - > - {children} - <SelectPrimitive.Icon asChild> - <CaretSortIcon className="h-4 w-4 opacity-50" /> - </SelectPrimitive.Icon> - </SelectPrimitive.Trigger> -); -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; - -const SelectScrollUpButton: React.FC< - React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollUpButton> -> = ({ className, ...props }) => ( - <SelectPrimitive.ScrollUpButton - className={cn( - 'flex cursor-default items-center justify-center py-1', - className, - )} - {...props} - > - <ChevronUpIcon /> - </SelectPrimitive.ScrollUpButton> -); -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; - -const SelectScrollDownButton: React.FC< - React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollDownButton> -> = ({ className, ...props }) => ( - <SelectPrimitive.ScrollDownButton - className={cn( - 'flex cursor-default items-center justify-center py-1', - className, - )} - {...props} - > - <ChevronDownIcon /> - </SelectPrimitive.ScrollDownButton> -); -SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName; - -const SelectContent: React.FC< - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> -> = ({ className, children, position = 'popper', ...props }) => ( - <SelectPrimitive.Portal> - <SelectPrimitive.Content +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: 'sm' | 'default'; +}) { + return ( + <SelectPrimitive.Trigger + data-slot="select-trigger" + data-size={size} className={cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md', - position === 'popper' && - 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', + "border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} - position={position} {...props} > - <SelectScrollUpButton /> - <SelectPrimitive.Viewport - className={cn( - 'p-1', - position === 'popper' && - 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]', - )} + {children} + <SelectPrimitive.Icon + render={ + <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4" /> + } + /> + </SelectPrimitive.Trigger> + ); +} + +function SelectContent({ + className, + children, + side = 'bottom', + sideOffset = 4, + align = 'center', + alignOffset = 0, + alignItemWithTrigger = true, + ...props +}: SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger' + >) { + return ( + <SelectPrimitive.Portal> + <SelectPrimitive.Positioner + side={side} + sideOffset={sideOffset} + align={align} + alignOffset={alignOffset} + alignItemWithTrigger={alignItemWithTrigger} + className="isolate z-50" > + <SelectPrimitive.Popup + data-slot="select-content" + data-align-trigger={alignItemWithTrigger} + className={cn( + 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none', + className, + )} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.List>{children}</SelectPrimitive.List> + <SelectScrollDownButton /> + </SelectPrimitive.Popup> + </SelectPrimitive.Positioner> + </SelectPrimitive.Portal> + ); +} + +function SelectLabel({ + className, + ...props +}: SelectPrimitive.GroupLabel.Props) { + return ( + <SelectPrimitive.GroupLabel + data-slot="select-label" + className={cn('text-muted-foreground px-1.5 py-1 text-xs', className)} + {...props} + /> + ); +} + +function SelectItem({ + className, + children, + ...props +}: SelectPrimitive.Item.Props) { + return ( + <SelectPrimitive.Item + data-slot="select-item" + className={cn( + "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", + className, + )} + {...props} + > + <SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap"> {children} - </SelectPrimitive.Viewport> - <SelectScrollDownButton /> - </SelectPrimitive.Content> - </SelectPrimitive.Portal> -); -SelectContent.displayName = SelectPrimitive.Content.displayName; - -const SelectLabel: React.FC< - React.ComponentPropsWithRef<typeof SelectPrimitive.Label> -> = ({ className, ...props }) => ( - <SelectPrimitive.Label - className={cn('px-2 py-1.5 text-sm font-semibold', className)} - {...props} - /> -); -SelectLabel.displayName = SelectPrimitive.Label.displayName; - -const SelectItem: React.FC< - React.ComponentPropsWithRef<typeof SelectPrimitive.Item> -> = ({ className, children, ...props }) => ( - <SelectPrimitive.Item - className={cn( - 'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-xs py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50', - className, - )} - {...props} - > - <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> - <SelectPrimitive.ItemIndicator> - <CheckIcon className="h-4 w-4" /> + </SelectPrimitive.ItemText> + <SelectPrimitive.ItemIndicator + render={ + <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" /> + } + > + <CheckIcon className="pointer-events-none" /> </SelectPrimitive.ItemIndicator> - </span> - <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> - </SelectPrimitive.Item> -); -SelectItem.displayName = SelectPrimitive.Item.displayName; + </SelectPrimitive.Item> + ); +} -const SelectSeparator: React.FC< - React.ComponentPropsWithRef<typeof SelectPrimitive.Separator> -> = ({ className, ...props }) => ( - <SelectPrimitive.Separator - className={cn('bg-muted -mx-1 my-1 h-px', className)} - {...props} - /> -); -SelectSeparator.displayName = SelectPrimitive.Separator.displayName; +function SelectSeparator({ + className, + ...props +}: SelectPrimitive.Separator.Props) { + return ( + <SelectPrimitive.Separator + data-slot="select-separator" + className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)} + {...props} + /> + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) { + return ( + <SelectPrimitive.ScrollUpArrow + data-slot="select-scroll-up-button" + className={cn( + "bg-popover top-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + <ChevronUpIcon /> + </SelectPrimitive.ScrollUpArrow> + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) { + return ( + <SelectPrimitive.ScrollDownArrow + data-slot="select-scroll-down-button" + className={cn( + "bg-popover bottom-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + <ChevronDownIcon /> + </SelectPrimitive.ScrollDownArrow> + ); +} export { Select, - SelectGroup, - SelectValue, - SelectTrigger, SelectContent, - SelectLabel, + SelectGroup, SelectItem, - SelectSeparator, - SelectScrollUpButton, + SelectLabel, SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, }; diff --git a/packages/ui/src/shadcn/separator.tsx b/packages/ui/src/shadcn/separator.tsx index 40aaa2a6e..a0600d287 100644 --- a/packages/ui/src/shadcn/separator.tsx +++ b/packages/ui/src/shadcn/separator.tsx @@ -1,31 +1,24 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'; -import { Separator as SeparatorPrimitive } from 'radix-ui'; - -import { cn } from '../lib/utils'; - -const Separator: React.FC< - React.ComponentPropsWithRef<typeof SeparatorPrimitive.Root> -> = ({ +function Separator({ className, orientation = 'horizontal', - decorative = true, ...props -}) => ( - <SeparatorPrimitive.Root - decorative={decorative} - orientation={orientation} - className={cn( - 'bg-border shrink-0', - orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', - className, - )} - {...props} - /> -); - -Separator.displayName = SeparatorPrimitive.Root.displayName; +}: SeparatorPrimitive.Props) { + return ( + <SeparatorPrimitive + data-slot="separator" + orientation={orientation} + className={cn( + 'bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch', + className, + )} + {...props} + /> + ); +} export { Separator }; diff --git a/packages/ui/src/shadcn/sheet.tsx b/packages/ui/src/shadcn/sheet.tsx index 9907c318e..a58e886b7 100644 --- a/packages/ui/src/shadcn/sheet.tsx +++ b/packages/ui/src/shadcn/sheet.tsx @@ -2,128 +2,129 @@ import * as React from 'react'; -import { Cross2Icon } from '@radix-ui/react-icons'; -import { type VariantProps, cva } from 'class-variance-authority'; -import { Dialog as SheetPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'; +import { XIcon } from 'lucide-react'; -import { cn } from '../lib/utils'; +import { Button } from './button'; -const Sheet = SheetPrimitive.Root; +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return <SheetPrimitive.Root data-slot="sheet" {...props} />; +} -const SheetTrigger = SheetPrimitive.Trigger; +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />; +} -const SheetClose = SheetPrimitive.Close; +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return <SheetPrimitive.Close data-slot="sheet-close" {...props} />; +} -const SheetPortal = SheetPrimitive.Portal; +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />; +} -const SheetOverlay: React.FC< - React.ComponentPropsWithRef<typeof SheetPrimitive.Overlay> -> = ({ className, ...props }) => ( - <SheetPrimitive.Overlay - className={cn( - 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', - className, - )} - {...props} - /> -); -SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + <SheetPrimitive.Backdrop + data-slot="sheet-overlay" + className={cn( + 'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs', + className, + )} + {...props} + /> + ); +} -const sheetVariants = cva( - 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 gap-4 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', - { - variants: { - side: { - top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b', - bottom: - 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t', - left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', - right: - 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', - }, - }, - defaultVariants: { - side: 'right', - }, - }, -); - -interface SheetContentProps - extends - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, - VariantProps<typeof sheetVariants> {} - -const SheetContent: React.FC<SheetContentProps> = ({ - side = 'right', +function SheetContent({ className, children, + side = 'right', + showCloseButton = true, ...props -}) => ( - <SheetPortal> - <SheetOverlay /> - <SheetPrimitive.Content - className={cn(sheetVariants({ side }), className)} +}: SheetPrimitive.Popup.Props & { + side?: 'top' | 'right' | 'bottom' | 'left'; + showCloseButton?: boolean; +}) { + return ( + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Popup + data-slot="sheet-content" + data-side={side} + className={cn( + 'bg-background data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm', + className, + )} + {...props} + > + {children} + {showCloseButton && ( + <SheetPrimitive.Close + data-slot="sheet-close" + render={ + <Button + variant="ghost" + className="absolute top-3 right-3" + size="icon-sm" + /> + } + > + <XIcon /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + )} + </SheetPrimitive.Popup> + </SheetPortal> + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="sheet-header" + className={cn('flex flex-col gap-0.5 p-4', className)} {...props} - > - <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> - <Cross2Icon className="h-4 w-4" /> - <span className="sr-only">Close</span> - </SheetPrimitive.Close> - {children} - </SheetPrimitive.Content> - </SheetPortal> -); -SheetContent.displayName = SheetPrimitive.Content.displayName; + /> + ); +} -const SheetHeader = ({ +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="sheet-footer" + className={cn('mt-auto flex flex-col gap-2 p-4', className)} + {...props} + /> + ); +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + <SheetPrimitive.Title + data-slot="sheet-title" + className={cn('text-foreground text-base font-medium', className)} + {...props} + /> + ); +} + +function SheetDescription({ className, ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn('flex flex-col gap-y-3 text-center sm:text-left', className)} - {...props} - /> -); -SheetHeader.displayName = 'SheetHeader'; - -const SheetFooter = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', - className, - )} - {...props} - /> -); -SheetFooter.displayName = 'SheetFooter'; - -const SheetTitle: React.FC< - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> -> = ({ className, ...props }) => ( - <SheetPrimitive.Title - className={cn('text-foreground text-lg font-semibold', className)} - {...props} - /> -); -SheetTitle.displayName = SheetPrimitive.Title.displayName; - -const SheetDescription: React.FC< - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> -> = ({ className, ...props }) => ( - <SheetPrimitive.Description - className={cn('text-muted-foreground text-sm', className)} - {...props} - /> -); -SheetDescription.displayName = SheetPrimitive.Description.displayName; +}: SheetPrimitive.Description.Props) { + return ( + <SheetPrimitive.Description + data-slot="sheet-description" + className={cn('text-muted-foreground text-sm', className)} + {...props} + /> + ); +} export { Sheet, - SheetPortal, - SheetOverlay, SheetTrigger, SheetClose, SheetContent, diff --git a/packages/ui/src/shadcn/sidebar.tsx b/packages/ui/src/shadcn/sidebar.tsx index e67b44733..23f0ec0bc 100644 --- a/packages/ui/src/shadcn/sidebar.tsx +++ b/packages/ui/src/shadcn/sidebar.tsx @@ -2,45 +2,34 @@ import * as React from 'react'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { VariantProps, cva } from 'class-variance-authority'; -import { ChevronDown, PanelLeft } from 'lucide-react'; -import { Slot } from 'radix-ui'; -import { useTranslation } from 'react-i18next'; +import { cn } from '#lib/utils'; +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; +import { type VariantProps, cva } from 'class-variance-authority'; +import { PanelLeftIcon } from 'lucide-react'; import { useIsMobile } from '../hooks/use-mobile'; -import { cn, isRouteActive } from '../lib/utils'; -import { If } from '../makerkit/if'; -import type { SidebarConfig } from '../makerkit/sidebar'; -import { Trans } from '../makerkit/trans'; import { Button } from './button'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from './collapsible'; import { Input } from './input'; import { Separator } from './separator'; -import { Sheet, SheetContent } from './sheet'; -import { Skeleton } from './skeleton'; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from './tooltip'; + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from './sheet'; +import { Skeleton } from './skeleton'; +import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'; -const SIDEBAR_COOKIE_NAME = 'sidebar:state'; +const SIDEBAR_COOKIE_NAME = 'sidebar_state'; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = '16rem'; const SIDEBAR_WIDTH_MOBILE = '18rem'; -const SIDEBAR_WIDTH_ICON = '4rem'; +const SIDEBAR_WIDTH_ICON = '3rem'; const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; -const SIDEBAR_MINIMIZED_WIDTH = SIDEBAR_WIDTH_ICON; -type SidebarContext = { +type SidebarContextProps = { state: 'expanded' | 'collapsed'; open: boolean; setOpen: (open: boolean) => void; @@ -50,11 +39,10 @@ type SidebarContext = { toggleSidebar: () => void; }; -export const SidebarContext = React.createContext<SidebarContext | null>(null); +const SidebarContext = React.createContext<SidebarContextProps | null>(null); function useSidebar() { const context = React.useContext(SidebarContext); - if (!context) { throw new Error('useSidebar must be used within a SidebarProvider.'); } @@ -62,14 +50,7 @@ function useSidebar() { return context; } -const SidebarProvider: React.FC< - React.ComponentProps<'div'> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; - } -> = ({ - ref, +function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, @@ -77,28 +58,29 @@ const SidebarProvider: React.FC< style, children, ...props -}) => { +}: React.ComponentProps<'div'> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); - const collapsibleStyle = process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE; // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen); const open = openProp ?? _open; - const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value; if (setOpenProp) { - return setOpenProp?.(typeof value === 'function' ? value(open) : value); + setOpenProp(openState); + } else { + _setOpen(openState); } - _setOpen(value); - // This sets the cookie to keep the sidebar state. - const secure = - typeof window !== 'undefined' && window.location.protocol === 'https:'; - document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secure ? '; Secure' : ''}`; + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open], ); @@ -128,7 +110,7 @@ const SidebarProvider: React.FC< // This makes it easier to style the sidebar with Tailwind classes. const state = open ? 'expanded' : 'collapsed'; - const contextValue = React.useMemo<SidebarContext>( + const contextValue = React.useMemo<SidebarContextProps>( () => ({ state, open, @@ -141,68 +123,52 @@ const SidebarProvider: React.FC< [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], ); - const sidebarWidth = !open - ? collapsibleStyle === 'icon' - ? SIDEBAR_WIDTH_ICON - : collapsibleStyle === 'offcanvas' - ? 0 - : SIDEBAR_MINIMIZED_WIDTH - : SIDEBAR_WIDTH; - return ( <SidebarContext.Provider value={contextValue}> <div + data-slot="sidebar-wrapper" style={ { - '--sidebar-width': sidebarWidth, + '--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, ...style, } as React.CSSProperties } - data-minimized={!open} className={cn( - 'group/sidebar text-sidebar-foreground has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', + 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', className, )} - ref={ref} {...props} > {children} </div> </SidebarContext.Provider> ); -}; +} -SidebarProvider.displayName = 'SidebarProvider'; - -const Sidebar: React.FC< - React.ComponentPropsWithRef<'div'> & { - side?: 'left' | 'right'; - variant?: 'sidebar' | 'floating' | 'inset' | 'ghost'; - collapsible?: 'offcanvas' | 'icon' | 'none'; - } -> = ({ +function Sidebar({ side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, - ref, + dir, ...props -}) => { +}: React.ComponentProps<'div'> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; +}) { const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); if (collapsible === 'none') { return ( <div + data-slot="sidebar" className={cn( 'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', className, - { - [SIDEBAR_MINIMIZED_WIDTH]: !open, - }, )} - ref={ref} {...props} > {children} @@ -214,15 +180,11 @@ const Sidebar: React.FC< return ( <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> <SheetContent + dir={dir} data-sidebar="sidebar" + data-slot="sidebar" data-mobile="true" - className={cn( - 'text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden', - { - 'bg-background': variant === 'ghost', - 'bg-sidebar': variant !== 'ghost', - }, - )} + className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" style={ { '--sidebar-width': SIDEBAR_WIDTH_MOBILE, @@ -230,6 +192,10 @@ const Sidebar: React.FC< } side={side} > + <SheetHeader className="sr-only"> + <SheetTitle>Sidebar</SheetTitle> + <SheetDescription>Displays the mobile sidebar.</SheetDescription> + </SheetHeader> <div className="flex h-full w-full flex-col">{children}</div> </SheetContent> </Sheet> @@ -238,15 +204,16 @@ const Sidebar: React.FC< return ( <div - ref={ref} - className="group peer hidden md:block" + className="group peer text-sidebar-foreground hidden md:block" data-state={state} data-collapsible={state === 'collapsed' ? collapsible : ''} data-variant={variant} data-side={side} + data-slot="sidebar" > {/* This is what handles the sidebar gap on desktop */} <div + data-slot="sidebar-gap" className={cn( 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', 'group-data-[collapsible=offcanvas]:w-0', @@ -254,17 +221,13 @@ const Sidebar: React.FC< variant === 'floating' || variant === 'inset' ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', - { - 'h-svh': variant !== 'ghost', - }, )} /> <div + data-slot="sidebar-container" + data-side={side} className={cn( - 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', - side === 'left' - ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' - : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', + 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex', // Adjust the padding for floating and inset variants. variant === 'floating' || variant === 'inset' ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' @@ -275,69 +238,55 @@ const Sidebar: React.FC< > <div data-sidebar="sidebar" - className={cn( - 'bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm', - { - 'bg-transparent': variant === 'ghost', - }, - )} + data-slot="sidebar-inner" + className="bg-sidebar group-data-[variant=floating]:ring-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1" > {children} </div> </div> </div> ); -}; +} -Sidebar.displayName = 'Sidebar'; - -const SidebarTrigger: React.FC<React.ComponentProps<typeof Button>> = ({ +function SidebarTrigger({ className, onClick, ...props -}) => { - const context = React.useContext(SidebarContext); - - if (!context) { - return null; - } - - const { toggleSidebar } = context; +}: React.ComponentProps<typeof Button>) { + const { toggleSidebar } = useSidebar(); return ( <Button data-sidebar="trigger" + data-slot="sidebar-trigger" variant="ghost" - size="icon" - className={cn('h-7 w-7', className)} + size="icon-sm" + className={cn(className)} onClick={(event) => { onClick?.(event); toggleSidebar(); }} {...props} > - <PanelLeft /> + <PanelLeftIcon /> <span className="sr-only">Toggle Sidebar</span> </Button> ); -}; -SidebarTrigger.displayName = 'SidebarTrigger'; +} -const SidebarRail: React.FC<React.ComponentProps<'button'>> = ({ - className, - ...props -}) => { +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { const { toggleSidebar } = useSidebar(); return ( <button data-sidebar="rail" + data-slot="sidebar-rail" aria-label="Toggle Sidebar" tabIndex={-1} onClick={toggleSidebar} title="Toggle Sidebar" className={cn( - 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex', + 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2', 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full', @@ -348,203 +297,182 @@ const SidebarRail: React.FC<React.ComponentProps<'button'>> = ({ {...props} /> ); -}; -SidebarRail.displayName = 'SidebarRail'; +} -const SidebarInset: React.FC<React.ComponentProps<'main'>> = ({ - className, - ...props -}) => { +function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) { return ( <main + data-slot="sidebar-inset" className={cn( - 'bg-background relative flex min-h-svh flex-1 flex-col', - 'peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', + 'bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', className, )} {...props} /> ); -}; -SidebarInset.displayName = 'SidebarInset'; +} -const SidebarInput: React.FC<React.ComponentPropsWithRef<typeof Input>> = ({ +function SidebarInput({ className, ...props -}) => { +}: React.ComponentProps<typeof Input>) { return ( <Input + data-slot="sidebar-input" data-sidebar="input" - className={cn( - 'bg-background focus-visible:ring-sidebar-ring h-8 w-full shadow-none focus-visible:ring-2', - className, - )} + className={cn('bg-background h-8 w-full shadow-none', className)} {...props} /> ); -}; -SidebarInput.displayName = 'SidebarInput'; +} -const SidebarHeader: React.FC<React.ComponentPropsWithRef<'div'>> = ({ - className, - ...props -}) => { +function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) { return ( <div + data-slot="sidebar-header" data-sidebar="header" - className={cn( - 'flex flex-col gap-2 p-2 group-data-[state=collapsed]:group-data-[collapsible=offcanvas]:hidden', - className, - )} + className={cn('flex flex-col gap-2 p-2', className)} {...props} /> ); -}; -SidebarHeader.displayName = 'SidebarHeader'; +} -const SidebarFooter: React.FC<React.ComponentProps<'div'>> = ({ - className, - ...props -}) => { +function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) { return ( <div + data-slot="sidebar-footer" data-sidebar="footer" className={cn('flex flex-col gap-2 p-2', className)} {...props} /> ); -}; -SidebarFooter.displayName = 'SidebarFooter'; +} -const SidebarSeparator: React.FC<React.ComponentProps<typeof Separator>> = ({ +function SidebarSeparator({ className, ...props -}) => { +}: React.ComponentProps<typeof Separator>) { return ( <Separator + data-slot="sidebar-separator" data-sidebar="separator" className={cn('bg-sidebar-border mx-2 w-auto', className)} {...props} /> ); -}; -SidebarSeparator.displayName = 'SidebarSeparator'; +} -const SidebarContent: React.FC<React.ComponentProps<'div'>> = ({ - className, - ...props -}) => { +function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) { return ( <div + data-slot="sidebar-content" data-sidebar="content" className={cn( - 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', + 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden', className, )} {...props} /> ); -}; -SidebarContent.displayName = 'SidebarContent'; +} -const SidebarGroup: React.FC<React.ComponentProps<'div'>> = ({ - className, - ...props -}) => { +function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) { return ( <div + data-slot="sidebar-group" data-sidebar="group" className={cn('relative flex w-full min-w-0 flex-col p-2', className)} {...props} /> ); -}; -SidebarGroup.displayName = 'SidebarGroup'; +} -const SidebarGroupLabel: React.FC< - React.ComponentProps<'div'> & { asChild?: boolean } -> = ({ className, asChild = false, ...props }) => { - const Comp = asChild ? Slot.Root : 'div'; +function SidebarGroupLabel({ + className, + render, + ...props +}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) { + return useRender({ + defaultTagName: 'div', + props: mergeProps<'div'>( + { + className: cn( + 'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + className, + ), + }, + props, + ), + render, + state: { + slot: 'sidebar-group-label', + sidebar: 'group-label', + }, + }); +} +function SidebarGroupAction({ + className, + render, + ...props +}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) { + return useRender({ + defaultTagName: 'button', + props: mergeProps<'button'>( + { + className: cn( + 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0', + className, + ), + }, + props, + ), + render, + state: { + slot: 'sidebar-group-action', + sidebar: 'group-action', + }, + }); +} + +function SidebarGroupContent({ + className, + ...props +}: React.ComponentProps<'div'>) { return ( - <Comp - data-sidebar="group-label" - className={cn( - 'text-muted-foreground ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', - 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', - className, - )} + <div + data-slot="sidebar-group-content" + data-sidebar="group-content" + className={cn('w-full text-sm', className)} {...props} /> ); -}; -SidebarGroupLabel.displayName = 'SidebarGroupLabel'; - -const SidebarGroupAction: React.FC< - React.ComponentProps<'button'> & { asChild?: boolean } -> = ({ className, asChild = false, ...props }) => { - const Comp = asChild ? Slot.Root : 'button'; +} +function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) { return ( - <Comp - data-sidebar="group-action" - className={cn( - 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', - // Increases the hit area of the button on mobile. - 'after:absolute after:-inset-2 md:after:hidden', - 'group-data-[collapsible=icon]:hidden', - className, - )} + <ul + data-slot="sidebar-menu" + data-sidebar="menu" + className={cn('flex w-full min-w-0 flex-col gap-0', className)} {...props} /> ); -}; -SidebarGroupAction.displayName = 'SidebarGroupAction'; +} -const SidebarGroupContent: React.FC<React.ComponentProps<'div'>> = ({ - className, - ...props -}) => ( - <div - data-sidebar="group-content" - className={cn('w-full text-sm', className)} - {...props} - /> -); -SidebarGroupContent.displayName = 'SidebarGroupContent'; - -const SidebarMenu: React.FC<React.ComponentProps<'ul'>> = ({ - className, - ...props -}) => ( - <ul - data-sidebar="menu" - className={cn( - 'flex w-full min-w-0 flex-col gap-1 group-data-[minimized=true]/sidebar:items-center', - className, - )} - {...props} - /> -); -SidebarMenu.displayName = 'SidebarMenu'; - -const SidebarMenuItem: React.FC<React.ComponentProps<'li'>> = ({ - className, - ...props -}) => ( - <li - data-sidebar="menu-item" - className={cn( - 'group/menu-item relative group-data-[collapsible=icon]:justify-center', - className, - )} - {...props} - /> -); -SidebarMenuItem.displayName = 'SidebarMenuItem'; +function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) { + return ( + <li + data-slot="sidebar-menu-item" + data-sidebar="menu-item" + className={cn('group/menu-item relative', className)} + {...props} + /> + ); +} const sidebarMenuButtonVariants = cva( - 'peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus:ring-primary active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + 'peer/menu-button group/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:font-medium [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate', { variants: { variant: { @@ -565,126 +493,122 @@ const sidebarMenuButtonVariants = cva( }, ); -const SidebarMenuButton: React.FC< - React.ComponentProps<'button'> & { - asChild?: boolean; - isActive?: boolean; - tooltip?: string | React.ComponentProps<typeof TooltipContent>; - } & VariantProps<typeof sidebarMenuButtonVariants> -> = ({ - asChild = false, +function SidebarMenuButton({ + render, isActive = false, variant = 'default', size = 'default', tooltip, className, ...props -}) => { - const Comp = asChild ? Slot.Root : 'button'; - const { isMobile, open } = useSidebar(); - const { t } = useTranslation(); - - const button = ( - <Comp - data-sidebar="menu-button" - data-size={size} - data-active={isActive} - className={cn(sidebarMenuButtonVariants({ variant, size }), className)} - {...props} - /> - ); +}: useRender.ComponentProps<'button'> & + React.ComponentProps<'button'> & { + isActive?: boolean; + tooltip?: string | React.ComponentProps<typeof TooltipContent>; + } & VariantProps<typeof sidebarMenuButtonVariants>) { + const { isMobile, state } = useSidebar(); + const comp = useRender({ + defaultTagName: 'button', + props: mergeProps<'button'>( + { + className: cn(sidebarMenuButtonVariants({ variant, size }), className), + }, + props, + ), + render: !tooltip ? render : <TooltipTrigger render={render} />, + state: { + slot: 'sidebar-menu-button', + sidebar: 'menu-button', + size, + active: isActive, + }, + }); if (!tooltip) { - return button; + return comp; } if (typeof tooltip === 'string') { tooltip = { - children: t(tooltip, { - defaultValue: tooltip, - }), + children: tooltip, }; } return ( - <TooltipProvider delayDuration={0}> - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent - side="right" - align="center" - hidden={isMobile || open} - {...tooltip} - /> - </Tooltip> - </TooltipProvider> + <Tooltip> + {comp} + <TooltipContent + side="right" + align="center" + hidden={state !== 'collapsed' || isMobile} + {...tooltip} + /> + </Tooltip> ); -}; +} -SidebarMenuButton.displayName = 'SidebarMenuButton'; - -const SidebarMenuAction: React.FC< +function SidebarMenuAction({ + className, + render, + showOnHover = false, + ...props +}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'> & { - asChild?: boolean; showOnHover?: boolean; - } -> = ({ className, asChild = false, showOnHover = false, ...props }) => { - const Comp = asChild ? Slot.Root : 'button'; + }) { + return useRender({ + defaultTagName: 'button', + props: mergeProps<'button'>( + { + className: cn( + 'text-sidebar-foreground ring-sidebar-ring peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0', + showOnHover && + 'peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded:opacity-100 md:opacity-0', + className, + ), + }, + props, + ), + render, + state: { + slot: 'sidebar-menu-action', + sidebar: 'menu-action', + }, + }); +} +function SidebarMenuBadge({ + className, + ...props +}: React.ComponentProps<'div'>) { return ( - <Comp - data-sidebar="menu-action" + <div + data-slot="sidebar-menu-badge" + data-sidebar="menu-badge" className={cn( - 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', - // Increases the hit area of the button on mobile. - 'after:absolute after:-inset-2 md:after:hidden', - 'peer-data-[size=sm]/menu-button:top-1', - 'peer-data-[size=default]/menu-button:top-1.5', - 'peer-data-[size=lg]/menu-button:top-2.5', - 'group-data-[collapsible=icon]:hidden', - showOnHover && - 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0', + 'text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1', className, )} {...props} /> ); -}; -SidebarMenuAction.displayName = 'SidebarMenuAction'; +} -const SidebarMenuBadge: React.FC<React.ComponentProps<'div'>> = ({ +function SidebarMenuSkeleton({ className, + showIcon = false, ...props -}) => ( - <div - data-sidebar="menu-badge" - className={cn( - 'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none', - 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', - 'peer-data-[size=sm]/menu-button:top-1', - 'peer-data-[size=default]/menu-button:top-1.5', - 'peer-data-[size=lg]/menu-button:top-2.5', - 'group-data-[collapsible=icon]:hidden', - className, - )} - {...props} - /> -); -SidebarMenuBadge.displayName = 'SidebarMenuBadge'; - -const SidebarMenuSkeleton: React.FC< - React.ComponentProps<'div'> & { - showIcon?: boolean; - } -> = ({ className, showIcon = false, ...props }) => { +}: React.ComponentProps<'div'> & { + showIcon?: boolean; +}) { // Random width between 50 to 90%. - const width = React.useMemo(() => { - // eslint-disable-next-line react-hooks/purity + const [width] = React.useState(() => { return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + }); return ( <div + data-slot="sidebar-menu-skeleton" data-sidebar="menu-skeleton" className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)} {...props} @@ -706,326 +630,68 @@ const SidebarMenuSkeleton: React.FC< /> </div> ); -}; -SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton'; - -const SidebarMenuSub: React.FC<React.ComponentProps<'ul'>> = ({ - className, - ...props -}) => ( - <ul - data-sidebar="menu-sub" - className={cn( - 'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5', - 'group-data-[collapsible=icon]:hidden', - className, - )} - {...props} - /> -); -SidebarMenuSub.displayName = 'SidebarMenuSub'; - -const SidebarMenuSubItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<'li'> ->(({ ...props }, ref) => <li ref={ref} {...props} />); -SidebarMenuSubItem.displayName = 'SidebarMenuSubItem'; - -const SidebarMenuSubButton: React.FC< - React.ComponentProps<'a'> & { - asChild?: boolean; - size?: 'sm' | 'md'; - isActive?: boolean; - } -> = ({ asChild = false, size = 'md', isActive, className, ...props }) => { - const Comp = asChild ? Slot.Root : 'a'; +} +function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) { return ( - <Comp - data-sidebar="menu-sub-button" - data-size={size} - data-active={isActive} + <ul + data-slot="sidebar-menu-sub" + data-sidebar="menu-sub" className={cn( - 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', - 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', - size === 'sm' && 'text-xs', - size === 'md' && 'text-sm', - 'group-data-[collapsible=icon]:hidden', + 'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden', className, )} {...props} /> ); -}; -SidebarMenuSubButton.displayName = 'SidebarMenuSubButton'; - -export function SidebarNavigation({ - config, -}: React.PropsWithChildren<{ - config: SidebarConfig; -}>) { - const currentPath = usePathname() ?? ''; - const { open } = useSidebar(); +} +function SidebarMenuSubItem({ + className, + ...props +}: React.ComponentProps<'li'>) { return ( - <> - {config.routes.map((item, index) => { - const isLast = index === config.routes.length - 1; - - if ('divider' in item) { - return <SidebarSeparator key={`divider-${index}`} />; - } - - if ('children' in item) { - const Container = (props: React.PropsWithChildren) => { - if (item.collapsible) { - return ( - <Collapsible - defaultOpen={!item.collapsed} - className={'group/collapsible'} - > - {props.children} - </Collapsible> - ); - } - - return props.children; - }; - - const ContentContainer = (props: React.PropsWithChildren) => { - if (item.collapsible) { - return <CollapsibleContent>{props.children}</CollapsibleContent>; - } - - return props.children; - }; - - return ( - <Container key={`collapsible-${index}`}> - <SidebarGroup key={item.label}> - <If - condition={item.collapsible} - fallback={ - <SidebarGroupLabel className={cn({ hidden: !open })}> - <Trans i18nKey={item.label} defaults={item.label} /> - </SidebarGroupLabel> - } - > - <SidebarGroupLabel className={cn({ hidden: !open })} asChild> - <CollapsibleTrigger> - <Trans i18nKey={item.label} defaults={item.label} /> - <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" /> - </CollapsibleTrigger> - </SidebarGroupLabel> - </If> - - <If condition={item.renderAction}> - <SidebarGroupAction title={item.label}> - {item.renderAction} - </SidebarGroupAction> - </If> - - <SidebarGroupContent> - <SidebarMenu> - <ContentContainer> - {item.children.map((child, childIndex) => { - const Container = (props: React.PropsWithChildren) => { - if ('collapsible' in child && child.collapsible) { - return ( - <Collapsible - defaultOpen={!child.collapsed} - className={'group/collapsible'} - > - {props.children} - </Collapsible> - ); - } - - return props.children; - }; - - const ContentContainer = ( - props: React.PropsWithChildren, - ) => { - if ('collapsible' in child && child.collapsible) { - return ( - <CollapsibleContent> - {props.children} - </CollapsibleContent> - ); - } - - return props.children; - }; - - const TriggerItem = () => { - if ('collapsible' in child && child.collapsible) { - return ( - <CollapsibleTrigger asChild> - <SidebarMenuButton tooltip={child.label}> - <div - className={cn('flex items-center gap-2', { - 'mx-auto w-full gap-0 [&>svg]:flex-1 [&>svg]:shrink-0': - !open, - })} - > - {child.Icon} - <span - className={cn( - 'transition-width w-auto transition-opacity duration-500', - { - 'w-0 opacity-0': !open, - }, - )} - > - <Trans - i18nKey={child.label} - defaults={child.label} - /> - </span> - - <ChevronDown - className={cn( - 'ml-auto size-4 transition-transform group-data-[state=open]/collapsible:rotate-180', - { - 'hidden size-0': !open, - }, - )} - /> - </div> - </SidebarMenuButton> - </CollapsibleTrigger> - ); - } - - const path = 'path' in child ? child.path : ''; - const end = 'end' in child ? child.end : false; - - const isActive = isRouteActive( - path, - currentPath, - end, - ); - - return ( - <SidebarMenuButton - asChild - isActive={isActive} - tooltip={child.label} - > - <Link - className={cn('flex items-center', { - 'mx-auto w-full gap-0! [&>svg]:flex-1': !open, - })} - href={path} - > - {child.Icon} - <span - className={cn( - 'w-auto transition-opacity duration-300', - { - 'w-0 opacity-0': !open, - }, - )} - > - <Trans - i18nKey={child.label} - defaults={child.label} - /> - </span> - </Link> - </SidebarMenuButton> - ); - }; - - return ( - <Container key={`group-${index}-${childIndex}`}> - <SidebarMenuItem> - <TriggerItem /> - - <ContentContainer> - <If condition={child.children}> - {(children) => ( - <SidebarMenuSub - className={cn({ - 'mx-0 px-1.5': !open, - })} - > - {children.map((child) => { - const isActive = isRouteActive( - child.path, - currentPath, - child.end, - ); - - const linkClassName = cn( - 'flex items-center', - { - 'mx-auto w-full gap-0! [&>svg]:flex-1': - !open, - }, - ); - - const spanClassName = cn( - 'w-auto transition-opacity duration-300', - { - 'w-0 opacity-0': !open, - }, - ); - - return ( - <SidebarMenuSubItem key={child.path}> - <SidebarMenuSubButton - isActive={isActive} - asChild - > - <Link - className={linkClassName} - href={child.path} - > - {child.Icon} - - <span className={spanClassName}> - <Trans - i18nKey={child.label} - defaults={child.label} - /> - </span> - </Link> - </SidebarMenuSubButton> - </SidebarMenuSubItem> - ); - })} - </SidebarMenuSub> - )} - </If> - </ContentContainer> - - <If condition={child.renderAction}> - <SidebarMenuAction> - {child.renderAction} - </SidebarMenuAction> - </If> - </SidebarMenuItem> - </Container> - ); - })} - </ContentContainer> - </SidebarMenu> - </SidebarGroupContent> - </SidebarGroup> - - <If condition={!open && !isLast}> - <SidebarSeparator /> - </If> - </Container> - ); - } - })} - </> + <li + data-slot="sidebar-menu-sub-item" + data-sidebar="menu-sub-item" + className={cn('group/menu-sub-item relative', className)} + {...props} + /> ); } +function SidebarMenuSubButton({ + render, + size = 'md', + isActive = false, + className, + ...props +}: useRender.ComponentProps<'a'> & + React.ComponentProps<'a'> & { + size?: 'sm' | 'md'; + isActive?: boolean; + }) { + return useRender({ + defaultTagName: 'a', + props: mergeProps<'a'>( + { + className: cn( + 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden group-data-[collapsible=icon]:hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + className, + ), + }, + props, + ), + render, + state: { + slot: 'sidebar-menu-sub-button', + sidebar: 'menu-sub-button', + size, + active: isActive, + }, + }); +} + export { Sidebar, SidebarContent, @@ -1050,5 +716,6 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, + SidebarContext, useSidebar, }; diff --git a/packages/ui/src/shadcn/skeleton.tsx b/packages/ui/src/shadcn/skeleton.tsx index 9f09b6c77..70bf64867 100644 --- a/packages/ui/src/shadcn/skeleton.tsx +++ b/packages/ui/src/shadcn/skeleton.tsx @@ -1,12 +1,10 @@ -import { cn } from '../lib/utils'; +import { cn } from '#lib/utils'; -function Skeleton({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) { +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { return ( <div - className={cn('bg-primary/10 animate-pulse rounded-md', className)} + data-slot="skeleton" + className={cn('bg-muted animate-pulse rounded-md', className)} {...props} /> ); diff --git a/packages/ui/src/shadcn/slider.tsx b/packages/ui/src/shadcn/slider.tsx index f3eb2aaac..d7c369f84 100644 --- a/packages/ui/src/shadcn/slider.tsx +++ b/packages/ui/src/shadcn/slider.tsx @@ -2,28 +2,58 @@ import * as React from 'react'; -import { Slider as SliderPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { Slider as SliderPrimitive } from '@base-ui/react/slider'; -import { cn } from '../lib/utils'; +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: SliderPrimitive.Root.Props) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max], + ); -const Slider = React.forwardRef< - React.ElementRef<typeof SliderPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> ->(({ className, ...props }, ref) => ( - <SliderPrimitive.Root - ref={ref} - className={cn( - 'relative flex w-full touch-none items-center select-none', - className, - )} - {...props} - > - <SliderPrimitive.Track className="bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full"> - <SliderPrimitive.Range className="bg-primary absolute h-full" /> - </SliderPrimitive.Track> - <SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block h-4 w-4 rounded-full border shadow transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" /> - </SliderPrimitive.Root> -)); -Slider.displayName = SliderPrimitive.Root.displayName; + return ( + <SliderPrimitive.Root + className={cn('data-horizontal:w-full data-vertical:h-full', className)} + data-slot="slider" + defaultValue={defaultValue} + value={value} + min={min} + max={max} + thumbAlignment="edge" + {...props} + > + <SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col"> + <SliderPrimitive.Track + data-slot="slider-track" + className="bg-muted relative grow overflow-hidden rounded-full select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1" + > + <SliderPrimitive.Indicator + data-slot="slider-range" + className="bg-primary select-none data-horizontal:h-full data-vertical:w-full" + /> + </SliderPrimitive.Track> + {Array.from({ length: _values.length }, (_, index) => ( + <SliderPrimitive.Thumb + data-slot="slider-thumb" + key={index} + className="border-ring ring-ring/50 relative block size-3 shrink-0 rounded-full border bg-white transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50" + /> + ))} + </SliderPrimitive.Control> + </SliderPrimitive.Root> + ); +} export { Slider }; diff --git a/packages/ui/src/shadcn/sonner.tsx b/packages/ui/src/shadcn/sonner.tsx index fdfc02118..93dce5734 100644 --- a/packages/ui/src/shadcn/sonner.tsx +++ b/packages/ui/src/shadcn/sonner.tsx @@ -1,9 +1,14 @@ 'use client'; +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from 'lucide-react'; import { useTheme } from 'next-themes'; -import { Toaster as Sonner, toast } from 'sonner'; - -type ToasterProps = React.ComponentProps<typeof Sonner>; +import { Toaster as Sonner, type ToasterProps, toast } from 'sonner'; const Toaster = ({ ...props }: ToasterProps) => { const { theme = 'system' } = useTheme(); @@ -12,15 +17,24 @@ const Toaster = ({ ...props }: ToasterProps) => { <Sonner theme={theme as ToasterProps['theme']} className="toaster group" + icons={{ + success: <CircleCheckIcon className="size-4" />, + info: <InfoIcon className="size-4" />, + warning: <TriangleAlertIcon className="size-4" />, + error: <OctagonXIcon className="size-4" />, + loading: <Loader2Icon className="size-4 animate-spin" />, + }} + style={ + { + '--normal-bg': 'var(--popover)', + '--normal-text': 'var(--popover-foreground)', + '--normal-border': 'var(--border)', + '--border-radius': 'var(--radius)', + } as React.CSSProperties + } toastOptions={{ classNames: { - toast: - 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', - description: 'group-[.toast]:text-muted-foreground', - actionButton: - 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', - cancelButton: - 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground', + toast: 'cn-toast', }, }} {...props} diff --git a/packages/ui/src/shadcn/spinner.tsx b/packages/ui/src/shadcn/spinner.tsx new file mode 100644 index 000000000..ee82dc025 --- /dev/null +++ b/packages/ui/src/shadcn/spinner.tsx @@ -0,0 +1,15 @@ +import { cn } from '#lib/utils'; +import { Loader2Icon } from 'lucide-react'; + +function Spinner({ className, ...props }: React.ComponentProps<'svg'>) { + return ( + <Loader2Icon + role="status" + aria-label="Loading" + className={cn('size-4 animate-spin', className)} + {...props} + /> + ); +} + +export { Spinner }; diff --git a/packages/ui/src/shadcn/switch.tsx b/packages/ui/src/shadcn/switch.tsx index c6721cc8e..dba4b5f5b 100644 --- a/packages/ui/src/shadcn/switch.tsx +++ b/packages/ui/src/shadcn/switch.tsx @@ -1,28 +1,31 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Switch as SwitchPrimitive } from '@base-ui/react/switch'; -import { Switch as SwitchPrimitives } from 'radix-ui'; - -import { cn } from '../lib/utils'; - -const Switch: React.FC< - React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> -> = ({ className, ...props }) => ( - <SwitchPrimitives.Root - className={cn( - 'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50', - className, - )} - {...props} - > - <SwitchPrimitives.Thumb +function Switch({ + className, + size = 'default', + ...props +}: SwitchPrimitive.Root.Props & { + size?: 'sm' | 'default'; +}) { + return ( + <SwitchPrimitive.Root + data-slot="switch" + data-size={size} className={cn( - 'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0', + 'peer group/switch focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 aria-invalid:ring-3 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]', + className, )} - /> - </SwitchPrimitives.Root> -); -Switch.displayName = SwitchPrimitives.Root.displayName; + {...props} + > + <SwitchPrimitive.Thumb + data-slot="switch-thumb" + className="bg-background dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0" + /> + </SwitchPrimitive.Root> + ); +} export { Switch }; diff --git a/packages/ui/src/shadcn/table.tsx b/packages/ui/src/shadcn/table.tsx index 372d5c5c8..611a24721 100644 --- a/packages/ui/src/shadcn/table.tsx +++ b/packages/ui/src/shadcn/table.tsx @@ -1,94 +1,108 @@ +'use client'; + import * as React from 'react'; -import { cn } from '../lib/utils'; +import { cn } from '#lib/utils'; -const Table: React.FC<React.HTMLAttributes<HTMLTableElement>> = ({ - className, - ...props -}) => ( - <div - className={cn('bg-background relative flex flex-1 flex-col overflow-auto')} - > - <table - className={cn('w-full caption-bottom text-sm', className)} +function Table({ className, ...props }: React.ComponentProps<'table'>) { + return ( + <div + data-slot="table-container" + className="relative w-full overflow-x-auto" + > + <table + data-slot="table" + className={cn('w-full caption-bottom text-sm', className)} + {...props} + /> + </div> + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return ( + <thead + data-slot="table-header" + className={cn('[&_tr]:border-b', className)} {...props} /> - </div> -); + ); +} -const TableHeader: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({ +function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return ( + <tbody + data-slot="table-body" + className={cn('[&_tr:last-child]:border-0', className)} + {...props} + /> + ); +} + +function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { + return ( + <tfoot + data-slot="table-footer" + className={cn( + 'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', + className, + )} + {...props} + /> + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + <tr + data-slot="table-row" + className={cn( + 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', + className, + )} + {...props} + /> + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( + <th + data-slot="table-head" + className={cn( + 'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0', + className, + )} + {...props} + /> + ); +} + +function TableCell({ className, ...props }: React.ComponentProps<'td'>) { + return ( + <td + data-slot="table-cell" + className={cn( + 'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', + className, + )} + {...props} + /> + ); +} + +function TableCaption({ className, ...props -}) => <thead className={cn('[&_tr]:border-b', className)} {...props} />; - -const TableBody: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({ - className, - ...props -}) => ( - <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} /> -); - -const TableFooter: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({ - className, - ...props -}) => ( - <tfoot - className={cn( - 'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', - className, - )} - {...props} - /> -); - -const TableRow: React.FC<React.HTMLAttributes<HTMLTableRowElement>> = ({ - className, - ...props -}) => ( - <tr - className={cn( - 'hover:bg-muted/50 data-[state=selected]:bg-muted group/row border-b transition-colors', - className, - )} - {...props} - /> -); - -const TableHead: React.FC<React.ThHTMLAttributes<HTMLTableCellElement>> = ({ - className, - ...props -}) => ( - <th - className={cn( - 'text-muted-foreground h-8 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', - className, - )} - {...props} - /> -); - -const TableCell: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>> = ({ - className, - ...props -}) => ( - <td - className={cn( - 'px-2 py-1.5 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', - className, - )} - {...props} - /> -); - -const TableCaption: React.FC<React.HTMLAttributes<HTMLTableCaptionElement>> = ({ - className, - ...props -}) => ( - <caption - className={cn('text-muted-foreground mt-4 text-sm', className)} - {...props} - /> -); +}: React.ComponentProps<'caption'>) { + return ( + <caption + data-slot="table-caption" + className={cn('text-muted-foreground mt-4 text-sm', className)} + {...props} + /> + ); +} export { Table, diff --git a/packages/ui/src/shadcn/tabs.tsx b/packages/ui/src/shadcn/tabs.tsx index beb65e971..9e9bb8ea5 100644 --- a/packages/ui/src/shadcn/tabs.tsx +++ b/packages/ui/src/shadcn/tabs.tsx @@ -1,50 +1,81 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'; +import { type VariantProps, cva } from 'class-variance-authority'; -import { Tabs as TabsPrimitive } from 'radix-ui'; +function Tabs({ + className, + orientation = 'horizontal', + ...props +}: TabsPrimitive.Root.Props) { + return ( + <TabsPrimitive.Root + data-slot="tabs" + data-orientation={orientation} + className={cn( + 'group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', + className, + )} + {...props} + /> + ); +} -import { cn } from '../lib/utils'; - -const Tabs = TabsPrimitive.Root; - -const TabsList: React.FC< - React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> -> = ({ className, ...props }) => ( - <TabsPrimitive.List - className={cn( - 'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1', - className, - )} - {...props} - /> +const tabsListVariants = cva( + 'group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none', + { + variants: { + variant: { + default: 'bg-muted', + line: 'gap-1 bg-transparent', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, ); -TabsList.displayName = TabsPrimitive.List.displayName; -const TabsTrigger: React.FC< - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> -> = ({ className, ...props }) => ( - <TabsPrimitive.Trigger - className={cn( - 'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-xs px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-xs', - className, - )} - {...props} - /> -); -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; +function TabsList({ + className, + variant = 'default', + ...props +}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) { + return ( + <TabsPrimitive.List + data-slot="tabs-list" + data-variant={variant} + className={cn(tabsListVariants({ variant }), className)} + {...props} + /> + ); +} -const TabsContent: React.FC< - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> -> = ({ className, ...props }) => ( - <TabsPrimitive.Content - className={cn( - 'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden', - className, - )} - {...props} - /> -); -TabsContent.displayName = TabsPrimitive.Content.displayName; +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + <TabsPrimitive.Tab + data-slot="tabs-trigger" + className={cn( + "text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + 'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent', + 'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground', + 'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100', + className, + )} + {...props} + /> + ); +} -export { Tabs, TabsList, TabsTrigger, TabsContent }; +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + <TabsPrimitive.Panel + data-slot="tabs-content" + className={cn('flex-1 text-sm outline-none', className)} + {...props} + /> + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; diff --git a/packages/ui/src/shadcn/textarea.tsx b/packages/ui/src/shadcn/textarea.tsx index fc978cf41..18cbe1209 100644 --- a/packages/ui/src/shadcn/textarea.tsx +++ b/packages/ui/src/shadcn/textarea.tsx @@ -1,21 +1,18 @@ import * as React from 'react'; -import { cn } from '../lib/utils'; +import { cn } from '#lib/utils'; -export type TextareaProps = React.ComponentPropsWithRef<'textarea'>; - -const Textarea: React.FC<TextareaProps> = ({ className, ...props }) => { +function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { return ( <textarea + data-slot="textarea" className={cn( - 'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50', + 'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm', className, )} {...props} /> ); -}; - -Textarea.displayName = 'Textarea'; +} export { Textarea }; diff --git a/packages/ui/src/shadcn/toggle-group.tsx b/packages/ui/src/shadcn/toggle-group.tsx new file mode 100644 index 000000000..627afca05 --- /dev/null +++ b/packages/ui/src/shadcn/toggle-group.tsx @@ -0,0 +1,90 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import { Toggle as TogglePrimitive } from '@base-ui/react/toggle'; +import { ToggleGroup as ToggleGroupPrimitive } from '@base-ui/react/toggle-group'; +import { type VariantProps } from 'class-variance-authority'; + +import { toggleVariants } from './toggle'; + +const ToggleGroupContext = React.createContext< + VariantProps<typeof toggleVariants> & { + spacing?: number; + orientation?: 'horizontal' | 'vertical'; + } +>({ + size: 'default', + variant: 'default', + spacing: 0, + orientation: 'horizontal', +}); + +function ToggleGroup({ + className, + variant, + size, + spacing = 0, + orientation = 'horizontal', + children, + ...props +}: ToggleGroupPrimitive.Props & + VariantProps<typeof toggleVariants> & { + spacing?: number; + orientation?: 'horizontal' | 'vertical'; + }) { + return ( + <ToggleGroupPrimitive + data-slot="toggle-group" + data-variant={variant} + data-size={size} + data-spacing={spacing} + data-orientation={orientation} + style={{ '--gap': spacing } as React.CSSProperties} + className={cn( + 'group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-vertical:flex-col data-vertical:items-stretch data-[size=sm]:rounded-[min(var(--radius-md),10px)]', + className, + )} + {...props} + > + <ToggleGroupContext.Provider + value={{ variant, size, spacing, orientation }} + > + {children} + </ToggleGroupContext.Provider> + </ToggleGroupPrimitive> + ); +} + +function ToggleGroupItem({ + className, + children, + variant = 'default', + size = 'default', + ...props +}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { + const context = React.useContext(ToggleGroupContext); + + return ( + <TogglePrimitive + data-slot="toggle-group-item" + data-variant={context.variant || variant} + data-size={context.size || size} + data-spacing={context.spacing} + className={cn( + 'shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t', + toggleVariants({ + variant: context.variant || variant, + size: context.size || size, + }), + className, + )} + {...props} + > + {children} + </TogglePrimitive> + ); +} + +export { ToggleGroup, ToggleGroupItem }; diff --git a/packages/ui/src/shadcn/toggle.tsx b/packages/ui/src/shadcn/toggle.tsx new file mode 100644 index 000000000..dabeb9109 --- /dev/null +++ b/packages/ui/src/shadcn/toggle.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { cn } from '#lib/utils'; +import { Toggle as TogglePrimitive } from '@base-ui/react/toggle'; +import { type VariantProps, cva } from 'class-variance-authority'; + +const toggleVariants = cva( + "group/toggle hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-transparent', + outline: 'border-input hover:bg-muted border bg-transparent', + }, + size: { + default: 'h-8 min-w-8 px-2', + sm: 'h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]', + lg: 'h-9 min-w-9 px-2.5', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Toggle({ + className, + variant = 'default', + size = 'default', + ...props +}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { + return ( + <TogglePrimitive + data-slot="toggle" + className={cn(toggleVariants({ variant, size, className }))} + {...props} + /> + ); +} + +export { Toggle, toggleVariants }; diff --git a/packages/ui/src/shadcn/tooltip.tsx b/packages/ui/src/shadcn/tooltip.tsx index b2997f8bd..c78c2c9c8 100644 --- a/packages/ui/src/shadcn/tooltip.tsx +++ b/packages/ui/src/shadcn/tooltip.tsx @@ -1,29 +1,65 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; -import { Tooltip as TooltipPrimitive } from 'radix-ui'; +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( + <TooltipPrimitive.Provider + data-slot="tooltip-provider" + delay={delay} + {...props} + /> + ); +} -import { cn } from '../lib/utils'; +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return <TooltipPrimitive.Root data-slot="tooltip" {...props} />; +} -const TooltipProvider = TooltipPrimitive.Provider; +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; +} -const Tooltip = TooltipPrimitive.Root; - -const TooltipTrigger = TooltipPrimitive.Trigger; - -const TooltipContent: React.FC< - React.ComponentPropsWithRef<typeof TooltipPrimitive.Content> -> = ({ className, sideOffset = 4, ...props }) => ( - <TooltipPrimitive.Content - sideOffset={sideOffset} - className={cn( - 'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md px-3 py-1.5 text-xs', - className, - )} - {...props} - /> -); -TooltipContent.displayName = TooltipPrimitive.Content.displayName; +function TooltipContent({ + className, + side = 'top', + sideOffset = 4, + align = 'center', + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'side' | 'sideOffset' + >) { + return ( + <TooltipPrimitive.Portal> + <TooltipPrimitive.Positioner + align={align} + alignOffset={alignOffset} + side={side} + sideOffset={sideOffset} + className="isolate z-50" + > + <TooltipPrimitive.Popup + data-slot="tooltip-content" + className={cn( + 'bg-foreground text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm', + className, + )} + {...props} + > + {children} + <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" /> + </TooltipPrimitive.Popup> + </TooltipPrimitive.Positioner> + </TooltipPrimitive.Portal> + ); +} export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 6e298a922..a2fa12c1c 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -11,6 +11,6 @@ "~/ui/*": ["./src/shadcn/*"] } }, - "include": ["*.ts", "src"], + "include": ["src"], "exclude": ["node_modules"] -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e8aed27e..a28a737dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,45 +6,81 @@ settings: catalogs: default: - '@eslint/js': - specifier: 10.0.1 - version: 10.0.1 + '@faker-js/faker': + specifier: ^10.4.0 + version: 10.4.0 + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2 + '@keystatic/core': + specifier: 0.5.49 + version: 0.5.49 + '@keystatic/next': + specifier: ^5.0.4 + version: 5.0.4 + '@lemonsqueezy/lemonsqueezy.js': + specifier: 4.0.0 + version: 4.0.0 + '@makerkit/data-loader-supabase-core': + specifier: ^0.0.10 + version: 0.0.10 + '@makerkit/data-loader-supabase-nextjs': + specifier: ^1.2.5 + version: 1.2.5 + '@manypkg/cli': + specifier: ^0.25.1 + version: 0.25.1 + '@markdoc/markdoc': + specifier: ^0.5.6 + version: 0.5.6 '@marsidev/react-turnstile': - specifier: 1.4.2 + specifier: ^1.4.2 version: 1.4.2 + '@modelcontextprotocol/sdk': + specifier: 1.27.1 + version: 1.27.1 '@next/bundle-analyzer': - specifier: 16.1.6 - version: 16.1.6 - '@next/eslint-plugin-next': - specifier: 16.1.6 - version: 16.1.6 + specifier: 16.2.1 + version: 16.2.1 + '@nosecone/next': + specifier: 1.3.0 + version: 1.3.0 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@react-email/components': - specifier: 1.0.8 - version: 1.0.8 + specifier: 1.0.10 + version: 1.0.10 '@sentry/nextjs': - specifier: 10.40.0 - version: 10.40.0 + specifier: 10.45.0 + version: 10.45.0 '@stripe/react-stripe-js': - specifier: 5.6.0 - version: 5.6.0 + specifier: 5.6.1 + version: 5.6.1 '@stripe/stripe-js': - specifier: 8.8.0 - version: 8.8.0 + specifier: 8.11.0 + version: 8.11.0 + '@supabase/ssr': + specifier: ^0.9.0 + version: 0.9.0 '@supabase/supabase-js': - specifier: 2.97.0 - version: 2.97.0 + specifier: 2.100.0 + version: 2.100.0 '@tailwindcss/postcss': - specifier: 4.2.1 - version: 4.2.1 + specifier: ^4.2.2 + version: 4.2.2 '@tanstack/react-query': - specifier: 5.90.21 - version: 5.90.21 - '@types/eslint': - specifier: 9.6.1 - version: 9.6.1 + specifier: 5.95.2 + version: 5.95.2 + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3 + '@turbo/gen': + specifier: ^2.8.20 + version: 2.8.20 '@types/node': - specifier: 25.3.1 - version: 25.3.1 + specifier: 25.5.0 + version: 25.5.0 '@types/nodemailer': specifier: 7.0.11 version: 7.0.11 @@ -54,36 +90,69 @@ catalogs: '@types/react-dom': specifier: 19.2.3 version: 19.2.3 - eslint: - specifier: 10.0.1 - version: 10.0.1 - eslint-config-next: - specifier: 16.1.6 - version: 16.1.6 - eslint-config-turbo: - specifier: 2.8.11 - version: 2.8.11 - i18next: - specifier: 25.8.13 - version: 25.8.13 - i18next-browser-languagedetector: - specifier: 8.2.1 - version: 8.2.1 - i18next-resources-to-backend: - specifier: 1.2.1 - version: 1.2.1 + babel-plugin-react-compiler: + specifier: 1.0.0 + version: 1.0.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + cross-env: + specifier: ^10.0.0 + version: 10.1.0 + cssnano: + specifier: ^7.1.3 + version: 7.1.3 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + dotenv: + specifier: 17.3.1 + version: 17.3.1 lucide-react: - specifier: 0.575.0 - version: 0.575.0 + specifier: 1.0.1 + version: 1.0.1 + nanoid: + specifier: ^5.1.7 + version: 5.1.7 next: - specifier: 16.1.6 - version: 16.1.6 + specifier: 16.2.1 + version: 16.2.1 + next-intl: + specifier: ^4.8.3 + version: 4.8.3 + next-runtime-env: + specifier: 3.3.0 + version: 3.3.0 + next-safe-action: + specifier: ^8.1.8 + version: 8.1.8 + next-sitemap: + specifier: ^4.2.3 + version: 4.2.3 + next-themes: + specifier: 0.4.6 + version: 0.4.6 + node-html-parser: + specifier: ^7.1.0 + version: 7.1.0 nodemailer: - specifier: 8.0.1 - version: 8.0.1 + specifier: 8.0.3 + version: 8.0.3 + oxfmt: + specifier: ^0.41.0 + version: 0.41.0 + oxlint: + specifier: ^1.56.0 + version: 1.56.0 pino: specifier: 10.3.1 version: 10.3.1 + pino-pretty: + specifier: 13.0.0 + version: 13.0.0 + postgres: + specifier: 3.4.8 + version: 3.4.8 react: specifier: 19.2.4 version: 19.2.4 @@ -91,73 +160,121 @@ catalogs: specifier: 19.2.4 version: 19.2.4 react-hook-form: - specifier: 7.71.2 - version: 7.71.2 - react-i18next: - specifier: 16.5.4 - version: 16.5.4 + specifier: 7.72.0 + version: 7.72.0 + react-resizable-panels: + specifier: ^4.7.5 + version: 4.7.5 + recharts: + specifier: 3.7.0 + version: 3.7.0 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + server-only: + specifier: ^0.0.1 + version: 0.0.1 + shadcn: + specifier: 4.1.0 + version: 4.1.0 + sonner: + specifier: ^2.0.7 + version: 2.0.7 stripe: - specifier: 20.4.0 - version: 20.4.0 + specifier: 20.4.1 + version: 20.4.1 supabase: - specifier: 2.76.15 - version: 2.76.15 + specifier: 2.83.0 + version: 2.83.0 + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 tailwindcss: - specifier: 4.2.1 - version: 4.2.1 + specifier: 4.2.2 + version: 4.2.2 + totp-generator: + specifier: ^2.0.1 + version: 2.0.1 tsup: specifier: 8.5.1 version: 8.5.1 + turbo: + specifier: 2.8.20 + version: 2.8.20 tw-animate-css: specifier: 1.4.0 version: 1.4.0 - -overrides: - zod: 3.25.76 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + urlpattern-polyfill: + specifier: ^10.1.0 + version: 10.1.0 + vitest: + specifier: ^4.1.1 + version: 4.1.1 + wp-types: + specifier: ^4.69.0 + version: 4.69.0 + zod: + specifier: 4.3.6 + version: 4.3.6 importers: .: devDependencies: '@manypkg/cli': - specifier: ^0.25.1 + specifier: 'catalog:' version: 0.25.1 '@turbo/gen': - specifier: ^2.8.11 - version: 2.8.11(@types/node@25.3.1) + specifier: 'catalog:' + version: 2.8.20(@types/node@25.5.0) + '@types/node': + specifier: 'catalog:' + version: 25.5.0 cross-env: - specifier: ^10.0.0 + specifier: 'catalog:' version: 10.1.0 - prettier: - specifier: ^3.8.1 - version: 3.8.1 + oxfmt: + specifier: 'catalog:' + version: 0.41.0 + oxlint: + specifier: 'catalog:' + version: 1.56.0 + server-only: + specifier: 'catalog:' + version: 0.0.1 turbo: - specifier: 2.8.11 - version: 2.8.11 + specifier: 'catalog:' + version: 2.8.20 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: 'catalog:' + version: 6.0.2 apps/dev-tool: dependencies: '@faker-js/faker': - specifier: ^10.2.0 - version: 10.3.0 + specifier: 'catalog:' + version: 10.4.0 '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) + specifier: 'catalog:' + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) + version: 5.95.2(react@19.2.4) lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) + version: 1.0.1(react@19.2.4) next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl: + specifier: 'catalog:' + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@6.0.2) nodemailer: specifier: 'catalog:' - version: 8.0.1 + version: 8.0.3 react: specifier: 'catalog:' version: 19.2.4 @@ -165,7 +282,7 @@ importers: specifier: 'catalog:' version: 19.2.4(react@19.2.4) rxjs: - specifier: ^7.8.2 + specifier: 'catalog:' version: 7.8.2 devDependencies: '@kit/email-templates': @@ -180,9 +297,6 @@ importers: '@kit/next': specifier: workspace:* version: link:../../packages/next - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../../packages/shared @@ -193,11 +307,8 @@ importers: specifier: workspace:* version: link:../../packages/ui '@tailwindcss/postcss': - specifier: ^4.2.1 - version: 4.2.1 - '@types/node': specifier: 'catalog:' - version: 25.3.1 + version: 4.2.2 '@types/nodemailer': specifier: 'catalog:' version: 7.0.11 @@ -208,59 +319,53 @@ importers: specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) babel-plugin-react-compiler: - specifier: 1.0.0 + specifier: 'catalog:' version: 1.0.0 pino-pretty: - specifier: 13.0.0 + specifier: 'catalog:' version: 13.0.0 react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) + version: 7.72.0(react@19.2.4) recharts: - specifier: 2.15.3 - version: 2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 'catalog:' + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) tailwindcss: specifier: 'catalog:' - version: 4.2.1 + version: 4.2.2 tw-animate-css: specifier: 'catalog:' version: 1.4.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: 'catalog:' + version: 6.0.2 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 apps/e2e: devDependencies: '@playwright/test': - specifier: ^1.58.2 + specifier: 'catalog:' version: 1.58.2 '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 - '@types/node': - specifier: 'catalog:' - version: 25.3.1 + version: 2.100.0 dotenv: - specifier: 17.3.1 + specifier: 'catalog:' version: 17.3.1 node-html-parser: - specifier: ^7.0.2 - version: 7.0.2 + specifier: 'catalog:' + version: 7.1.0 totp-generator: - specifier: ^2.0.1 + specifier: 'catalog:' version: 2.0.1 apps/web: dependencies: - '@edge-csrf/nextjs': - specifier: 2.5.3-cloudflare-rc1 - version: 2.5.3-cloudflare-rc1(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) + specifier: 'catalog:' + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) '@kit/accounts': specifier: workspace:* version: link:../../packages/features/accounts @@ -316,43 +421,49 @@ importers: specifier: workspace:* version: link:../../packages/ui '@makerkit/data-loader-supabase-core': - specifier: ^0.0.10 - version: 0.0.10(@supabase/postgrest-js@2.97.0)(@supabase/supabase-js@2.97.0) + specifier: 'catalog:' + version: 0.0.10(@supabase/postgrest-js@2.100.0)(@supabase/supabase-js@2.100.0) '@makerkit/data-loader-supabase-nextjs': - specifier: ^1.2.5 - version: 1.2.5(@supabase/postgrest-js@2.97.0)(@supabase/supabase-js@2.97.0)(@tanstack/react-query@5.90.21(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + specifier: 'catalog:' + version: 1.2.5(@supabase/postgrest-js@2.100.0)(@supabase/supabase-js@2.100.0)(@tanstack/react-query@5.95.2(react@19.2.4))(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@marsidev/react-turnstile': - specifier: ^1.4.2 + specifier: 'catalog:' version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@nosecone/next': - specifier: 1.1.0 - version: 1.1.0(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) - '@radix-ui/react-icons': - specifier: ^1.3.2 - version: 1.3.2(react@19.2.4) + specifier: 'catalog:' + version: 1.3.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) + version: 5.95.2(react@19.2.4) '@tanstack/react-table': - specifier: ^8.21.3 + specifier: 'catalog:' version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) date-fns: - specifier: ^4.1.0 + specifier: 'catalog:' version: 4.1.0 lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) + version: 1.0.1(react@19.2.4) next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl: + specifier: 'catalog:' + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@6.0.2) + next-runtime-env: + specifier: 'catalog:' + version: 3.3.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + next-safe-action: + specifier: 'catalog:' + version: 8.1.8(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-sitemap: - specifier: ^4.2.3 - version: 4.2.3(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + specifier: 'catalog:' + version: 4.2.3(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) next-themes: - specifier: 0.4.6 + specifier: 'catalog:' version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' @@ -362,44 +473,32 @@ importers: version: 19.2.4(react@19.2.4) react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) - react-i18next: - specifier: 'catalog:' - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 7.72.0(react@19.2.4) recharts: - specifier: 2.15.3 - version: 2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 'catalog:' + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) tailwind-merge: - specifier: ^3.5.0 + specifier: 'catalog:' version: 3.5.0 tw-animate-css: specifier: 'catalog:' version: 1.4.0 urlpattern-polyfill: - specifier: ^10.1.0 + specifier: 'catalog:' version: 10.1.0 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../tooling/typescript '@next/bundle-analyzer': specifier: 'catalog:' - version: 16.1.6 + version: 16.2.1 '@tailwindcss/postcss': specifier: 'catalog:' - version: 4.2.1 - '@types/node': - specifier: 'catalog:' - version: 25.3.1 + version: 4.2.2 '@types/react': specifier: 'catalog:' version: 19.2.14 @@ -407,50 +506,35 @@ importers: specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) babel-plugin-react-compiler: - specifier: 1.0.0 + specifier: 'catalog:' version: 1.0.0 cssnano: - specifier: ^7.1.2 - version: 7.1.2(postcss@8.5.6) + specifier: 'catalog:' + version: 7.1.3(postcss@8.5.8) pino-pretty: - specifier: 13.0.0 + specifier: 'catalog:' version: 13.0.0 - prettier: - specifier: ^3.8.1 - version: 3.8.1 supabase: specifier: 'catalog:' - version: 2.76.15 + version: 2.83.0 tailwindcss: specifier: 'catalog:' - version: 4.2.1 + version: 4.2.2 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: 'catalog:' + version: 6.0.2 packages/analytics: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../tooling/typescript '@types/node': specifier: 'catalog:' - version: 25.3.1 + version: 25.5.0 packages/billing/core: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/supabase': specifier: workspace:* version: link:../../supabase @@ -461,26 +545,20 @@ importers: specifier: workspace:* version: link:../../ui zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/billing/gateway: devDependencies: '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) + specifier: 'catalog:' + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) '@kit/billing': specifier: workspace:* version: link:../core - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint '@kit/lemon-squeezy': specifier: workspace:* version: link:../lemon-squeezy - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../../shared @@ -498,47 +576,41 @@ importers: version: link:../../ui '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@types/react': specifier: 'catalog:' version: 19.2.14 date-fns: - specifier: ^4.1.0 + specifier: 'catalog:' version: 4.1.0 lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) + version: 1.0.1(react@19.2.4) next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl: + specifier: 'catalog:' + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: specifier: 'catalog:' version: 19.2.4 react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) - react-i18next: - specifier: 'catalog:' - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 7.72.0(react@19.2.4) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/billing/lemon-squeezy: dependencies: '@lemonsqueezy/lemonsqueezy.js': - specifier: 4.0.0 + specifier: 'catalog:' version: 4.0.0 devDependencies: '@kit/billing': specifier: workspace:* version: link:../core - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../../shared @@ -556,35 +628,29 @@ importers: version: 19.2.14 next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' version: 19.2.4 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/billing/stripe: dependencies: '@stripe/react-stripe-js': specifier: 'catalog:' - version: 5.6.0(@stripe/stripe-js@8.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 5.6.1(@stripe/stripe-js@8.11.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@stripe/stripe-js': specifier: 'catalog:' - version: 8.8.0 + version: 8.11.0 stripe: specifier: 'catalog:' - version: 20.4.0(@types/node@25.3.1) + version: 20.4.1(@types/node@25.5.0) devDependencies: '@kit/billing': specifier: workspace:* version: link:../core - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../../shared @@ -601,32 +667,26 @@ importers: specifier: 'catalog:' version: 19.2.14 date-fns: - specifier: ^4.1.0 + specifier: 'catalog:' version: 4.1.0 next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' version: 19.2.4 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/cms/core: devDependencies: '@kit/cms-types': specifier: workspace:* version: link:../types - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint '@kit/keystatic': specifier: workspace:* version: link:../keystatic - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../../shared @@ -638,38 +698,29 @@ importers: version: link:../wordpress '@types/node': specifier: 'catalog:' - version: 25.3.1 + version: 25.5.0 packages/cms/keystatic: dependencies: '@keystatic/core': - specifier: 0.5.48 - version: 0.5.48(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 'catalog:' + version: 0.5.49(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@keystatic/next': - specifier: ^5.0.4 - version: 5.0.4(@keystatic/core@0.5.48(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 'catalog:' + version: 5.0.4(@keystatic/core@0.5.49(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@markdoc/markdoc': - specifier: ^0.5.4 - version: 0.5.4(@types/react@19.2.14)(react@19.2.4) + specifier: 'catalog:' + version: 0.5.6(@types/react@19.2.14)(react@19.2.4) devDependencies: '@kit/cms-types': specifier: workspace:* version: link:../types - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript '@kit/ui': specifier: workspace:* version: link:../../ui - '@types/node': - specifier: 'catalog:' - version: 25.3.1 '@types/react': specifier: 'catalog:' version: 19.2.14 @@ -677,17 +728,11 @@ importers: specifier: 'catalog:' version: 19.2.4 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/cms/types: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript @@ -697,26 +742,17 @@ importers: '@kit/cms-types': specifier: workspace:* version: link:../types - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript '@kit/ui': specifier: workspace:* version: link:../../ui - '@types/node': - specifier: 'catalog:' - version: 25.3.1 '@types/react': specifier: 'catalog:' version: 19.2.14 wp-types: - specifier: ^4.69.0 + specifier: 'catalog:' version: 4.69.0 packages/database-webhooks: @@ -727,12 +763,6 @@ importers: '@kit/billing-gateway': specifier: workspace:* version: link:../billing/gateway - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../shared @@ -747,35 +777,26 @@ importers: version: link:../../tooling/typescript '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/email-templates: dependencies: '@react-email/components': specifier: 'catalog:' - version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.0.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@kit/i18n': - specifier: workspace:* - version: link:../i18n - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../tooling/typescript - '@types/node': - specifier: 'catalog:' - version: 25.3.1 '@types/react': specifier: 'catalog:' version: 19.2.14 + next-intl: + specifier: 'catalog:' + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: specifier: 'catalog:' version: 19.2.4 @@ -786,21 +807,21 @@ importers: packages/features/accounts: dependencies: nanoid: - specifier: ^5.1.6 - version: 5.1.6 + specifier: 'catalog:' + version: 5.1.7 devDependencies: '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) + specifier: 'catalog:' + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) '@kit/billing-gateway': specifier: workspace:* version: link:../../billing/gateway '@kit/email-templates': specifier: workspace:* version: link:../../email-templates - '@kit/eslint-config': + '@kit/i18n': specifier: workspace:* - version: link:../../../tooling/eslint + version: link:../../i18n '@kit/mailers': specifier: workspace:* version: link:../../mailers/core @@ -813,9 +834,6 @@ importers: '@kit/otp': specifier: workspace:* version: link:../../otp - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../../shared @@ -828,15 +846,12 @@ importers: '@kit/ui': specifier: workspace:* version: link:../../ui - '@radix-ui/react-icons': - specifier: ^1.3.2 - version: 1.3.2(react@19.2.4) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) + version: 5.95.2(react@19.2.4) '@types/react': specifier: 'catalog:' version: 19.2.14 @@ -845,12 +860,18 @@ importers: version: 19.2.3(@types/react@19.2.14) lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) + version: 1.0.1(react@19.2.4) next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl: + specifier: 'catalog:' + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + next-safe-action: + specifier: 'catalog:' + version: 8.1.8(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: - specifier: 0.4.6 + specifier: 'catalog:' version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' @@ -860,28 +881,19 @@ importers: version: 19.2.4(react@19.2.4) react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) - react-i18next: - specifier: 'catalog:' - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 7.72.0(react@19.2.4) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/features/admin: devDependencies: '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint + specifier: 'catalog:' + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) '@kit/next': specifier: workspace:* version: link:../../next - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../../shared @@ -895,29 +907,32 @@ importers: specifier: workspace:* version: link:../../ui '@makerkit/data-loader-supabase-core': - specifier: ^0.0.10 - version: 0.0.10(@supabase/postgrest-js@2.97.0)(@supabase/supabase-js@2.97.0) + specifier: 'catalog:' + version: 0.0.10(@supabase/postgrest-js@2.100.0)(@supabase/supabase-js@2.100.0) '@makerkit/data-loader-supabase-nextjs': - specifier: ^1.2.5 - version: 1.2.5(@supabase/postgrest-js@2.97.0)(@supabase/supabase-js@2.97.0)(@tanstack/react-query@5.90.21(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + specifier: 'catalog:' + version: 1.2.5(@supabase/postgrest-js@2.100.0)(@supabase/supabase-js@2.100.0)(@tanstack/react-query@5.95.2(react@19.2.4))(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) + version: 5.95.2(react@19.2.4) '@tanstack/react-table': - specifier: ^8.21.3 + specifier: 'catalog:' version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/react': specifier: 'catalog:' version: 19.2.14 lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) + version: 1.0.1(react@19.2.4) next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-safe-action: + specifier: 'catalog:' + version: 8.1.8(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' version: 19.2.4 @@ -926,22 +941,16 @@ importers: version: 19.2.4(react@19.2.4) react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) + version: 7.72.0(react@19.2.4) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/features/auth: devDependencies: '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier + specifier: 'catalog:' + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) '@kit/shared': specifier: workspace:* version: link:../../shared @@ -957,52 +966,40 @@ importers: '@marsidev/react-turnstile': specifier: 'catalog:' version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-icons': - specifier: ^1.3.2 - version: 1.3.2(react@19.2.4) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) - '@types/node': - specifier: 'catalog:' - version: 25.3.1 + version: 5.95.2(react@19.2.4) '@types/react': specifier: 'catalog:' version: 19.2.14 lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) + version: 1.0.1(react@19.2.4) next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl: + specifier: 'catalog:' + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) - react-i18next: - specifier: 'catalog:' - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 7.72.0(react@19.2.4) sonner: - specifier: ^2.0.7 + specifier: 'catalog:' version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/features/notifications: dependencies: '@types/node': specifier: 'catalog:' - version: 25.3.1 + version: 25.5.0 devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/supabase': specifier: workspace:* version: link:../../supabase @@ -1014,35 +1011,35 @@ importers: version: link:../../ui '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) + version: 5.95.2(react@19.2.4) '@types/react': specifier: 'catalog:' version: 19.2.14 lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) + version: 1.0.1(react@19.2.4) + next-intl: + specifier: 'catalog:' + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: specifier: 'catalog:' version: 19.2.4 react-dom: specifier: 'catalog:' version: 19.2.4(react@19.2.4) - react-i18next: - specifier: 'catalog:' - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) packages/features/team-accounts: dependencies: nanoid: - specifier: ^5.1.6 - version: 5.1.6 + specifier: 'catalog:' + version: 5.1.7 devDependencies: '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) + specifier: 'catalog:' + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) '@kit/accounts': specifier: workspace:* version: link:../accounts @@ -1052,9 +1049,6 @@ importers: '@kit/email-templates': specifier: workspace:* version: link:../../email-templates - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint '@kit/mailers': specifier: workspace:* version: link:../../mailers/core @@ -1070,9 +1064,6 @@ importers: '@kit/policies': specifier: workspace:* version: link:../../policies - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../../shared @@ -1087,12 +1078,12 @@ importers: version: link:../../ui '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) + version: 5.95.2(react@19.2.4) '@tanstack/react-table': - specifier: ^8.21.3 + specifier: 'catalog:' version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/react': specifier: 'catalog:' @@ -1101,17 +1092,23 @@ importers: specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) class-variance-authority: - specifier: ^0.7.1 + specifier: 'catalog:' version: 0.7.1 date-fns: - specifier: ^4.1.0 + specifier: 'catalog:' version: 4.1.0 lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) + version: 1.0.1(react@19.2.4) next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl: + specifier: 'catalog:' + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + next-safe-action: + specifier: 'catalog:' + version: 8.1.8(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' version: 19.2.4 @@ -1120,32 +1117,17 @@ importers: version: 19.2.4(react@19.2.4) react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) - react-i18next: - specifier: 'catalog:' - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 7.72.0(react@19.2.4) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/i18n: dependencies: - i18next: + next-intl: specifier: 'catalog:' - version: 25.8.13(typescript@5.9.3) - i18next-browser-languagedetector: - specifier: 'catalog:' - version: 8.2.1 - i18next-resources-to-backend: - specifier: 'catalog:' - version: 1.2.1 + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../shared @@ -1154,34 +1136,28 @@ importers: version: link:../../tooling/typescript '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) + version: 5.95.2(react@19.2.4) + '@types/react': + specifier: 'catalog:' + version: 19.2.14 next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' version: 19.2.4 react-dom: specifier: 'catalog:' version: 19.2.4(react@19.2.4) - react-i18next: - specifier: 'catalog:' - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) packages/mailers/core: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint '@kit/mailers-shared': specifier: workspace:* version: link:../shared '@kit/nodemailer': specifier: workspace:* version: link:../nodemailer - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/resend': specifier: workspace:* version: link:../resend @@ -1191,28 +1167,19 @@ importers: '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript - '@types/node': - specifier: 'catalog:' - version: 25.3.1 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/mailers/nodemailer: dependencies: nodemailer: specifier: 'catalog:' - version: 8.0.1 + version: 8.0.3 devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint '@kit/mailers-shared': specifier: workspace:* version: link:../shared - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript @@ -1220,89 +1187,62 @@ importers: specifier: 'catalog:' version: 7.0.11 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/mailers/resend: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint '@kit/mailers-shared': specifier: workspace:* version: link:../shared - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript - '@types/node': - specifier: 'catalog:' - version: 25.3.1 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/mailers/shared: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/mcp-server: devDependencies: '@kit/email-templates': specifier: workspace:* version: link:../email-templates - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../tooling/typescript '@modelcontextprotocol/sdk': - specifier: 1.27.1 - version: 1.27.1(zod@3.25.76) - '@types/node': specifier: 'catalog:' - version: 25.3.1 + version: 1.27.1(zod@4.3.6) postgres: - specifier: 3.4.8 + specifier: 'catalog:' version: 3.4.8 tsup: specifier: 'catalog:' - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.19))(jiti@2.6.1)(postcss@8.5.8)(typescript@6.0.2) typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: 'catalog:' + version: 6.0.2 vitest: - specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0) + specifier: 'catalog:' + version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/monitoring/api: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint '@kit/monitoring-core': specifier: workspace:* version: link:../core - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/sentry': specifier: workspace:* version: link:../sentry @@ -1319,17 +1259,11 @@ importers: specifier: 'catalog:' version: 19.2.4 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/monitoring/core: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript @@ -1344,17 +1278,11 @@ importers: dependencies: '@sentry/nextjs': specifier: 'catalog:' - version: 10.40.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.1) + version: 10.45.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4) devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../../tooling/eslint '@kit/monitoring-core': specifier: workspace:* version: link:../core - '@kit/prettier-config': - specifier: workspace:* - version: link:../../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript @@ -1366,19 +1294,17 @@ importers: version: 19.2.4 packages/next: + dependencies: + next-safe-action: + specifier: 'catalog:' + version: 8.1.8(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: '@kit/auth': specifier: workspace:* version: link:../features/auth - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint '@kit/monitoring': specifier: workspace:* version: link:../monitoring/api - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/supabase': specifier: workspace:* version: link:../supabase @@ -1387,37 +1313,28 @@ importers: version: link:../../tooling/typescript '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 - '@types/node': - specifier: 'catalog:' - version: 25.3.1 + version: 2.100.0 next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/otp: devDependencies: '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) + specifier: 'catalog:' + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) '@kit/email-templates': specifier: workspace:* version: link:../email-templates - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint '@kit/mailers': specifier: workspace:* version: link:../mailers/core '@kit/next': specifier: workspace:* version: link:../next - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../shared @@ -1430,18 +1347,21 @@ importers: '@kit/ui': specifier: workspace:* version: link:../ui - '@radix-ui/react-icons': - specifier: ^1.3.2 - version: 1.3.2(react@19.2.4) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@types/react': specifier: 'catalog:' version: 19.2.14 '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + lucide-react: + specifier: 'catalog:' + version: 1.0.1(react@19.2.4) + next-safe-action: + specifier: 'catalog:' + version: 8.1.8(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' version: 19.2.4 @@ -1450,19 +1370,13 @@ importers: version: 19.2.4(react@19.2.4) react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) + version: 7.72.0(react@19.2.4) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/policies: devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/shared': specifier: workspace:* version: link:../shared @@ -1470,27 +1384,21 @@ importers: specifier: workspace:* version: link:../../tooling/typescript zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/shared: dependencies: + next-runtime-env: + specifier: 'catalog:' + version: 3.3.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) pino: specifier: 'catalog:' version: 10.3.1 devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../tooling/typescript - '@types/node': - specifier: 'catalog:' - version: 25.3.1 '@types/react': specifier: 'catalog:' version: 19.2.14 @@ -1501,97 +1409,88 @@ importers: specifier: workspace:* version: link:../shared devDependencies: - '@kit/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier '@kit/tsconfig': specifier: workspace:* version: link:../../tooling/typescript '@supabase/ssr': - specifier: ^0.8.0 - version: 0.8.0(@supabase/supabase-js@2.97.0) + specifier: 'catalog:' + version: 0.9.0(@supabase/supabase-js@2.100.0) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) - '@types/node': - specifier: 'catalog:' - version: 25.3.1 + version: 5.95.2(react@19.2.4) '@types/react': specifier: 'catalog:' version: 19.2.14 next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' version: 19.2.4 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 'catalog:' + version: 4.3.6 packages/ui: dependencies: + '@base-ui/react': + specifier: ^1.3.0 + version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@hookform/resolvers': specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) - '@radix-ui/react-icons': - specifier: ^1.3.2 - version: 1.3.2(react@19.2.4) + version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) + '@kit/shared': + specifier: workspace:* + version: link:../shared clsx: specifier: ^2.1.1 version: 2.1.1 cmdk: - specifier: 1.1.1 + specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@19.2.4) input-otp: - specifier: 1.4.2 + specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) lucide-react: specifier: 'catalog:' - version: 0.575.0(react@19.2.4) - radix-ui: - specifier: 1.4.3 - version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.0.1(react@19.2.4) react-dropzone: specifier: ^15.0.0 version: 15.0.0(react@19.2.4) + react-resizable-panels: + specifier: 'catalog:' + version: 4.7.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-top-loading-bar: - specifier: 3.0.2 + specifier: ^3.0.2 version: 3.0.2(react@19.2.4) recharts: - specifier: 2.15.3 - version: 2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 3.7.0 + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 devDependencies: - '@kit/eslint-config': + '@kit/i18n': specifier: workspace:* - version: link:../../tooling/eslint - '@kit/prettier-config': - specifier: workspace:* - version: link:../../tooling/prettier + version: link:../i18n '@kit/tsconfig': specifier: workspace:* version: link:../../tooling/typescript '@supabase/supabase-js': specifier: 'catalog:' - version: 2.97.0 + version: 2.100.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.90.21(react@19.2.4) + version: 5.95.2(react@19.2.4) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@types/node': - specifier: 'catalog:' - version: 25.3.1 '@types/react': specifier: 'catalog:' version: 19.2.14 @@ -1606,78 +1505,40 @@ importers: version: 4.1.0 next: specifier: 'catalog:' - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl: + specifier: ^4.8.3 + version: 4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + next-safe-action: + specifier: ^8.1.8 + version: 8.1.8(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - prettier: - specifier: ^3.8.1 - version: 3.8.1 react-day-picker: - specifier: ^9.13.2 - version: 9.13.2(react@19.2.4) + specifier: ^9.14.0 + version: 9.14.0(react@19.2.4) react-hook-form: specifier: 'catalog:' - version: 7.71.2(react@19.2.4) - react-i18next: + version: 7.72.0(react@19.2.4) + shadcn: specifier: 'catalog:' - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 4.1.0(@types/node@25.5.0)(babel-plugin-macros@3.1.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwindcss: specifier: 'catalog:' - version: 4.2.1 - typescript: - specifier: ^5.9.3 - version: 5.9.3 + version: 4.2.2 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vitest: + specifier: 'catalog:' + version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)) zod: - specifier: 3.25.76 - version: 3.25.76 - - tooling/eslint: - dependencies: - '@eslint/js': specifier: 'catalog:' - version: 10.0.1(eslint@10.0.1(jiti@2.6.1)) - '@next/eslint-plugin-next': - specifier: 'catalog:' - version: 16.1.6 - '@types/eslint': - specifier: 'catalog:' - version: 9.6.1 - eslint-config-next: - specifier: 'catalog:' - version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint-config-turbo: - specifier: 'catalog:' - version: 2.8.11(eslint@10.0.1(jiti@2.6.1))(turbo@2.8.11) - devDependencies: - '@kit/prettier-config': - specifier: workspace:* - version: link:../prettier - eslint: - specifier: 'catalog:' - version: 10.0.1(jiti@2.6.1) - - tooling/prettier: - dependencies: - '@trivago/prettier-plugin-sort-imports': - specifier: 6.0.2 - version: 6.0.2(prettier@3.8.1) - prettier: - specifier: ^3.8.1 - version: 3.8.1 - prettier-plugin-tailwindcss: - specifier: ^0.7.2 - version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1) - devDependencies: - '@kit/tsconfig': - specifier: workspace:* - version: link:../typescript - typescript: - specifier: ^5.9.3 - version: 5.9.3 + version: 4.3.6 tooling/scripts: {} @@ -1713,14 +1574,28 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} @@ -1731,6 +1606,24 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -1743,8 +1636,8 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.0': @@ -1752,14 +1645,49 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/runtime-corejs3@7.29.0': - resolution: {integrity: sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==} + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1772,6 +1700,27 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.3.0': + resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui/utils@0.2.6': + resolution: {integrity: sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@braintree/sanitize-url@6.0.4': resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} @@ -1785,20 +1734,18 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@edge-csrf/nextjs@2.5.3-cloudflare-rc1': - resolution: {integrity: sha512-sH8HKl2s/zFkIgXcVzODljHsBhKW7LN2gXeUNEQTSP31Chy40ryjR7iTf0MA/DtPhOpXUkd6IBUMMF820sK+rA==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + '@dotenvx/dotenvx@1.57.2': + resolution: {integrity: sha512-lv9+UZPnl/KOvShepevLWm3+/wc1It5kgO5Q580evnvOFMZcgKVEYFwxlL7Ohl9my1yjTsWo28N3PJYUEO8wFQ==} + hasBin: true + + '@ecies/ciphers@0.2.5': + resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} peerDependencies: - next: ^13.0.0 || ^14.0.0 || ^15.0.0 + '@noble/ciphers': ^1.0.0 - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -1836,218 +1783,491 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.2': - resolution: {integrity: sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.5.2': - resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.1.0': - resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.2': - resolution: {integrity: sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.6.0': - resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@faker-js/faker@10.3.0': - resolution: {integrity: sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==} + '@faker-js/faker@10.4.0': + resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} - '@fastify/otel@0.16.0': - resolution: {integrity: sha512-2304BdM5Q/kUvQC9qJO1KZq3Zn1WWsw+WWkVmFEaj1UE2hEIiuFqrPeglQOwEtw/ftngisqfQ3v70TWMmwhhHA==} + '@fastify/otel@0.17.1': + resolution: {integrity: sha512-K4wyxfUZx2ux5o+b6BtTqouYFVILohLZmSbA2tKUueJstNcBnoGPVhllCaOvbQ3ZrXdUxUC/fyrSWSCqHhdOPg==} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@floating-ui/core@1.7.4': - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/dom@1.7.5': - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -2058,31 +2278,46 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} '@formatjs/ecma402-abstract@2.3.6': resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + '@formatjs/ecma402-abstract@3.1.1': + resolution: {integrity: sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==} + '@formatjs/fast-memoize@2.2.7': resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + '@formatjs/fast-memoize@3.1.0': + resolution: {integrity: sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==} + '@formatjs/icu-messageformat-parser@2.11.4': resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} + '@formatjs/icu-messageformat-parser@3.5.1': + resolution: {integrity: sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==} + '@formatjs/icu-skeleton-parser@1.8.16': resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + '@formatjs/icu-skeleton-parser@2.1.1': + resolution: {integrity: sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==} + '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@formatjs/intl-localematcher@0.8.1': + resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==} + '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@hono/node-server@1.19.9': - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -2092,24 +2327,8 @@ packages: peerDependencies: react-hook-form: ^7.55.0 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} '@img/sharp-darwin-arm64@0.34.5': @@ -2138,89 +2357,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2379,8 +2614,8 @@ packages: '@types/node': optional: true - '@internationalized/date@3.11.0': - resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==} + '@internationalized/date@3.12.0': + resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==} '@internationalized/message@3.1.8': resolution: {integrity: sha512-Rwk3j/TlYZhn3HQ6PyXUV0XP9Uv42jqZGNegt0BXlxjE6G3+LwHjbQZAGHhCnCPdaA6Tvd3ma/7QzLlLkJxAWA==} @@ -2417,8 +2652,8 @@ packages: '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} - '@keystar/ui@0.7.19': - resolution: {integrity: sha512-8yXXli2w6rzK4wYQ1LNjznV2v08SkKHdLDWCOaCffuQkULZRGU7oRZJIYTEqCRy8b6K3frbIuhqyRcKzEv+xgA==} + '@keystar/ui@0.7.20': + resolution: {integrity: sha512-TOxAdbUsHKt0ssd7O32LRdYkIoYVnVeUx2M2NBzgOGgO11+xY9vQavQrZz/48ust1t4zNrsYKcdHPA2Yvtauew==} peerDependencies: next: '>=14' react: ^18.2.0 || ^19.0.0 @@ -2427,8 +2662,8 @@ packages: next: optional: true - '@keystatic/core@0.5.48': - resolution: {integrity: sha512-ar4zHMsG+YpQjIj2JT8utvAyyUN57eXTz0ibAr1MPkhTo75vQvgEzVHfBR65RZHJzmEix6iGbUYq3iJwPcVNfQ==} + '@keystatic/core@0.5.49': + resolution: {integrity: sha512-joEggE0CWj7+G96CW7hpam1A0jHb+Fw773UOF9sRT2UBx22nQg7YgO/dOA8ZhKeAycqCwCR4suOu2hDnQgE1IA==} peerDependencies: react: ^18.2.0 || ^19.0.0 react-dom: ^18.2.0 || ^19.0.0 @@ -2488,8 +2723,8 @@ packages: react: optional: true - '@markdoc/markdoc@0.5.4': - resolution: {integrity: sha512-36YFNlqFk//gVNGm5xZaTWVwbAVF2AOmVjf1tiUrS6tCoD/YSkVy2E3CkAfhc5MlKcjparL/QFHCopxL4zRyaQ==} + '@markdoc/markdoc@0.5.6': + resolution: {integrity: sha512-Qs7L5YKYHADWhpOP1JthE3E5ut4Mby/UsJKsGla3g8OXBLWDImTsG5H+Mzq5h3J6a8CNnGtp+Kz9JcGtHRpoKA==} engines: {node: '>=14.7.0'} peerDependencies: '@types/react': '*' @@ -2511,74 +2746,88 @@ packages: engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 - zod: 3.25.76 + zod: ^3.25 || ^4.0 peerDependenciesMeta: '@cfworker/json-schema': optional: true - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} - '@next/bundle-analyzer@16.1.6': - resolution: {integrity: sha512-ee2kagdTaeEWPlotgdTOqFHYcD3e2m2bbE3I9Rq2i6ABYi5OgopmtEUe8NM23viaYxLV2tDH/2nd5+qKoEr6cw==} + '@next/bundle-analyzer@16.2.1': + resolution: {integrity: sha512-fbj2WE6dnCyG8CvQnrBfpHyxdOIyZ4aEHJY0bSqAmamRiIXDqunFQPDvuSOPo24mJE9zQHw7TY6d+sGrXO98TQ==} '@next/env@13.5.11': resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==} - '@next/env@16.1.6': - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + '@next/env@16.2.1': + resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} - '@next/eslint-plugin-next@16.1.6': - resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} - - '@next/swc-darwin-arm64@16.1.6': - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + '@next/swc-darwin-arm64@16.2.1': + resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + '@next/swc-darwin-x64@16.2.1': + resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + '@next/swc-linux-arm64-gnu@16.2.1': + resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + '@next/swc-linux-arm64-musl@16.2.1': + resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + '@next/swc-linux-x64-gnu@16.2.1': + resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + '@next/swc-linux-x64-musl@16.2.1': + resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + '@next/swc-win32-arm64-msvc@16.2.1': + resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + '@next/swc-win32-x64-msvc@16.2.1': + resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2591,178 +2840,177 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nolyfill/is-core-module@1.0.39': - resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} - engines: {node: '>=12.4.0'} - - '@nosecone/next@1.1.0': - resolution: {integrity: sha512-dc9gXjKnIQAssS2WA8GJSkVTCvCmyERP5qNaf0JHs+WOwzEp182jlyTcLPzmG/dgbyhdyW4htdmZJjDy53wfhA==} + '@nosecone/next@1.3.0': + resolution: {integrity: sha512-/vk7V4c9CRNdFyrG2Lr4VsImUYSIUzSFOZwXsUIOIIE+Hr8iMaysjZ+/8Yodhk/tbM89bwxNj5uwWN4Y4jOW3w==} engines: {node: '>=20'} peerDependencies: next: '>=14' + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api-logs@0.207.0': resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} engines: {node: '>=8.0.0'} - '@opentelemetry/api-logs@0.208.0': - resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} engines: {node: '>=8.0.0'} - '@opentelemetry/api-logs@0.211.0': - resolution: {integrity: sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==} + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} engines: {node: '>=8.0.0'} '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/context-async-hooks@2.5.1': - resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + '@opentelemetry/context-async-hooks@2.6.0': + resolution: {integrity: sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.5.0': - resolution: {integrity: sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==} + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.5.1': - resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/instrumentation-amqplib@0.58.0': - resolution: {integrity: sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==} + '@opentelemetry/instrumentation-amqplib@0.60.0': + resolution: {integrity: sha512-q/B2IvoVXRm1M00MvhnzpMN6rKYOszPXVsALi6u0ss4AYHe+TidZEtLW9N1ZhrobI1dSriHnBqqtAOZVAv07sg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-connect@0.54.0': - resolution: {integrity: sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==} + '@opentelemetry/instrumentation-connect@0.56.0': + resolution: {integrity: sha512-PKp+sSZ7AfzMvGgO3VCyo1inwNu+q7A1k9X88WK4PQ+S6Hp7eFk8pie+sWHDTaARovmqq5V2osav3lQej2B0nw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-dataloader@0.28.0': - resolution: {integrity: sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==} + '@opentelemetry/instrumentation-dataloader@0.30.0': + resolution: {integrity: sha512-MXHP2Q38cd2OhzEBKAIXUi9uBlPEYzF6BNJbyjUXBQ6kLaf93kRC41vNMIz0Nl5mnuwK7fDvKT+/lpx7BXRwdg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-express@0.59.0': - resolution: {integrity: sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==} + '@opentelemetry/instrumentation-express@0.61.0': + resolution: {integrity: sha512-Xdmqo9RZuZlL29Flg8QdwrrX7eW1CZ7wFQPKHyXljNymgKhN1MCsYuqQ/7uxavhSKwAl7WxkTzKhnqpUApLMvQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-fs@0.30.0': - resolution: {integrity: sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==} + '@opentelemetry/instrumentation-fs@0.32.0': + resolution: {integrity: sha512-koR6apx0g0wX6RRiPpjA4AFQUQUbXrK16kq4/SZjVp7u5cffJhNkY4TnITxcGA4acGSPYAfx3NHRIv4Khn1axQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-generic-pool@0.54.0': - resolution: {integrity: sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==} + '@opentelemetry/instrumentation-generic-pool@0.56.0': + resolution: {integrity: sha512-fg+Jffs6fqrf0uQS0hom7qBFKsbtpBiBl8+Vkc63Gx8xh6pVh+FhagmiO6oM0m3vyb683t1lP7yGYq22SiDnqg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-graphql@0.58.0': - resolution: {integrity: sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==} + '@opentelemetry/instrumentation-graphql@0.61.0': + resolution: {integrity: sha512-pUiVASv6nh2XrerTvlbVHh7vKFzscpgwiQ/xvnZuAIzQ5lRjWVdRPUuXbvZJ/Yq79QsE81TZdJ7z9YsXiss1ew==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-hapi@0.57.0': - resolution: {integrity: sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==} + '@opentelemetry/instrumentation-hapi@0.59.0': + resolution: {integrity: sha512-33wa4mEr+9+ztwdgLor1SeBu4Opz4IsmpcLETXAd3VmBrOjez8uQtrsOhPCa5Vhbm5gzDlMYTgFRLQzf8/YHFA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-http@0.211.0': - resolution: {integrity: sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==} + '@opentelemetry/instrumentation-http@0.213.0': + resolution: {integrity: sha512-B978Xsm5XEPGhm1P07grDoaOFLHapJPkOG9h016cJsyWWxmiLnPu2M/4Nrm7UCkHSiLnkXgC+zVGUAIahy8EEA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-ioredis@0.59.0': - resolution: {integrity: sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==} + '@opentelemetry/instrumentation-ioredis@0.61.0': + resolution: {integrity: sha512-hsHDadUtAFbws1YSDc1XW0svGFKiUbqv2td1Cby+UAiwvojm1NyBo/taifH0t8CuFZ0x/2SDm0iuTwrM5pnVOg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-kafkajs@0.20.0': - resolution: {integrity: sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==} + '@opentelemetry/instrumentation-kafkajs@0.22.0': + resolution: {integrity: sha512-wJU4IBQMUikdJAcTChLFqK5lo+flo7pahqd8DSLv7uMxsdOdAHj6RzKYAm8pPfUS6ItKYutYyuicwKaFwQKsoA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-knex@0.55.0': - resolution: {integrity: sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==} + '@opentelemetry/instrumentation-knex@0.57.0': + resolution: {integrity: sha512-vMCSh8kolEm5rRsc+FZeTZymWmIJwc40hjIKnXH4O0Dv/gAkJJIRXCsPX5cPbe0c0j/34+PsENd0HqKruwhVYw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-koa@0.59.0': - resolution: {integrity: sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==} + '@opentelemetry/instrumentation-koa@0.61.0': + resolution: {integrity: sha512-lvrfWe9ShK/D2X4brmx8ZqqeWPfRl8xekU0FCn7C1dHm5k6+rTOOi36+4fnaHAP8lig9Ux6XQ1D4RNIpPCt1WQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@opentelemetry/instrumentation-lru-memoizer@0.55.0': - resolution: {integrity: sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==} + '@opentelemetry/instrumentation-lru-memoizer@0.57.0': + resolution: {integrity: sha512-cEqpUocSKJfwDtLYTTJehRLWzkZ2eoePCxfVIgGkGkb83fMB71O+y4MvRHJPbeV2bdoWdOVrl8uO0+EynWhTEA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mongodb@0.64.0': - resolution: {integrity: sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==} + '@opentelemetry/instrumentation-mongodb@0.66.0': + resolution: {integrity: sha512-d7m9QnAY+4TCWI4q1QRkfrc6fo/92VwssaB1DzQfXNRvu51b78P+HJlWP7Qg6N6nkwdb9faMZNBCZJfftmszkw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mongoose@0.57.0': - resolution: {integrity: sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==} + '@opentelemetry/instrumentation-mongoose@0.59.0': + resolution: {integrity: sha512-6/jWU+c1NgznkVLDU/2y0bXV2nJo3o9FWZ9mZ9nN6T/JBNRoMnVXZl2FdBmgH+a5MwaWLs5kmRJTP5oUVGIkPw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mysql2@0.57.0': - resolution: {integrity: sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==} + '@opentelemetry/instrumentation-mysql2@0.59.0': + resolution: {integrity: sha512-n9/xrVCRBfG9egVbffnlU1uhr+HX0vF4GgtAB/Bvm48wpFgRidqD8msBMiym1kRYzmpWvJqTxNT47u1MkgBEdw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mysql@0.57.0': - resolution: {integrity: sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==} + '@opentelemetry/instrumentation-mysql@0.59.0': + resolution: {integrity: sha512-r+V/Fh0sm7Ga8/zk/TI5H5FQRAjwr0RrpfPf8kNIehlsKf12XnvIaZi8ViZkpX0gyPEpLXqzqWD6QHlgObgzZw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-pg@0.63.0': - resolution: {integrity: sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==} + '@opentelemetry/instrumentation-pg@0.65.0': + resolution: {integrity: sha512-W0zpHEIEuyZ8zvb3njaX9AAbHgPYOsSWVOoWmv1sjVRSF6ZpBqtlxBWbU+6hhq1TFWBeWJOXZ8nZS/PUFpLJYQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-redis@0.59.0': - resolution: {integrity: sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==} + '@opentelemetry/instrumentation-redis@0.61.0': + resolution: {integrity: sha512-JnPexA034/0UJRsvH96B0erQoNOqKJZjE2ZRSw9hiTSC23LzE0nJE/u6D+xqOhgUhRnhhcPHq4MdYtmUdYTF+Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-tedious@0.30.0': - resolution: {integrity: sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==} + '@opentelemetry/instrumentation-tedious@0.32.0': + resolution: {integrity: sha512-BQS6gG8RJ1foEqfEZ+wxoqlwfCAzb1ZVG0ad8Gfe4x8T658HJCLGLd4E4NaoQd8EvPfLqOXgzGaE/2U4ytDSWA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-undici@0.21.0': - resolution: {integrity: sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==} + '@opentelemetry/instrumentation-undici@0.23.0': + resolution: {integrity: sha512-LL0VySzKVR2cJSFVZaTYpZl1XTpBGnfzoQPe2W7McS2267ldsaEIqtQY6VXs2KCXN0poFjze5110PIpxHDaDGg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.7.0 @@ -2773,14 +3021,14 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.208.0': - resolution: {integrity: sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==} + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.211.0': - resolution: {integrity: sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==} + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -2789,20 +3037,20 @@ packages: resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} engines: {node: ^18.19.0 || >=20.6.0} - '@opentelemetry/resources@2.5.1': - resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.5.1': - resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.39.0': - resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} '@opentelemetry/sql-common@0.41.2': @@ -2811,6 +3059,338 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@oxfmt/binding-android-arm-eabi@0.41.0': + resolution: {integrity: sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.41.0': + resolution: {integrity: sha512-s0b1dxNgb2KomspFV2LfogC2XtSJB42POXF4bMCLJyvQmAGos4ZtjGPfQreToQEaY0FQFjz3030ggI36rF1q5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.41.0': + resolution: {integrity: sha512-EGXGualADbv/ZmamE7/2DbsrYmjoPlAmHEpTL4vapLF4EfVD6fr8/uQDFnPJkUBjiSWFJZtFNsGeN1B6V3owmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.41.0': + resolution: {integrity: sha512-WxySJEvdQQYMmyvISH3qDpTvoS0ebnIP63IMxLLWowJyPp/AAH0hdWtlo+iGNK5y3eVfa5jZguwNaQkDKWpGSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.41.0': + resolution: {integrity: sha512-Y2kzMkv3U3oyuYaR4wTfGjOTYTXiFC/hXmG0yVASKkbh02BJkvD98Ij8bIevr45hNZ0DmZEgqiXF+9buD4yMYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.41.0': + resolution: {integrity: sha512-ptazDjdUyhket01IjPTT6ULS1KFuBfTUU97osTP96X5y/0oso+AgAaJzuH81oP0+XXyrWIHbRzozSAuQm4p48g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.41.0': + resolution: {integrity: sha512-UkoL2OKxFD+56bPEBcdGn+4juTW4HRv/T6w1dIDLnvKKWr6DbarB/mtHXlADKlFiJubJz8pRkttOR7qjYR6lTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.41.0': + resolution: {integrity: sha512-gofu0PuumSOHYczD8p62CPY4UF6ee+rSLZJdUXkpwxg6pILiwSDBIouPskjF/5nF3A7QZTz2O9KFNkNxxFN9tA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.41.0': + resolution: {integrity: sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.41.0': + resolution: {integrity: sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.41.0': + resolution: {integrity: sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.41.0': + resolution: {integrity: sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.41.0': + resolution: {integrity: sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.41.0': + resolution: {integrity: sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.41.0': + resolution: {integrity: sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.41.0': + resolution: {integrity: sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.41.0': + resolution: {integrity: sha512-Z7NAtu/RN8kjCQ1y5oDD0nTAeRswh3GJ93qwcW51srmidP7XPBmZbLlwERu1W5veCevQJtPS9xmkpcDTYsGIwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.41.0': + resolution: {integrity: sha512-uNxxP3l4bJ6VyzIeRqCmBU2Q0SkCFgIhvx9/9dJ9V8t/v+jP1IBsuaLwCXGR8JPHtkj4tFp+RHtUmU2ZYAUpMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.41.0': + resolution: {integrity: sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.56.0': + resolution: {integrity: sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.56.0': + resolution: {integrity: sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.56.0': + resolution: {integrity: sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.56.0': + resolution: {integrity: sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.56.0': + resolution: {integrity: sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.56.0': + resolution: {integrity: sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.56.0': + resolution: {integrity: sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.56.0': + resolution: {integrity: sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.56.0': + resolution: {integrity: sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.56.0': + resolution: {integrity: sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.56.0': + resolution: {integrity: sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.56.0': + resolution: {integrity: sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.56.0': + resolution: {integrity: sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.56.0': + resolution: {integrity: sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.56.0': + resolution: {integrity: sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.56.0': + resolution: {integrity: sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.56.0': + resolution: {integrity: sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.56.0': + resolution: {integrity: sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.56.0': + resolution: {integrity: sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2834,134 +3414,14 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@prisma/instrumentation@7.2.0': - resolution: {integrity: sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==} + '@prisma/instrumentation@7.4.2': + resolution: {integrity: sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg==} peerDependencies: '@opentelemetry/api': ^1.8 - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/react-accessible-icon@1.1.7': - resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-accordion@1.2.12': - resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-alert-dialog@1.1.15': - resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-aspect-ratio@1.1.7': - resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-avatar@1.1.10': - resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-checkbox@1.3.3': - resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collapsible@1.1.12': - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -2971,19 +3431,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-context-menu@2.2.16': - resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -3006,15 +3453,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -3028,19 +3466,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -3063,37 +3488,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-form@0.1.8': - resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-hover-card@1.1.15': - resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-icons@1.3.2': - resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} - peerDependencies: - react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc - '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: @@ -3103,110 +3497,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-label@2.1.7': - resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-menubar@1.1.16': - resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-navigation-menu@1.2.14': - resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-one-time-password-field@0.1.8': - resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-password-toggle-field@0.1.3': - resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-portal@1.1.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: @@ -3259,97 +3549,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.7': - resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-radio-group@1.3.8': - resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-select@2.2.6': - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-separator@1.1.7': - resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slider@1.3.6': - resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -3368,97 +3567,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-switch@1.2.6': - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toast@1.2.15': - resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toggle-group@1.1.11': - resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toggle@1.1.10': - resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toolbar@1.1.11': - resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -3495,15 +3603,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-is-hydrated@0.1.0': - resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -3513,159 +3612,116 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - - '@react-aria/actiongroup@3.7.23': - resolution: {integrity: sha512-CQyswtH0CWCJPYlAz39TUS3H0eA1Xplx4GalF71BYcFG6U6mbawgtiUHPNj9sXdUSwY3Pb2K2FBk3cEtk/fySQ==} + '@react-aria/actiongroup@3.7.24': + resolution: {integrity: sha512-OyoNHZTxAcdKAoolWT3anEPCXilsWw65YiyCnV+8Vr9JjP2ON1w1cyVv7pnfRots4E1luydsWbRS/CMER4Za7w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/breadcrumbs@3.5.31': - resolution: {integrity: sha512-j8F2NMHFGT/n3alfFKdO4bvrY/ymtdL04GdclY7Vc6zOmCnWoEZ2UA0sFuV7Rk9dOL8fAtYV1kMD1ZRO/EMcGA==} + '@react-aria/breadcrumbs@3.5.32': + resolution: {integrity: sha512-S61vh5DJ2PXiXUwD7gk+pvS/b4VPrc3ZJOUZ0yVRLHkVESr5LhIZH+SAVgZkm1lzKyMRG+BH+fiRH/DZRSs7SA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/button@3.14.4': - resolution: {integrity: sha512-6mTPiSSQhELnWlnYJ1Tm1B0VL1GGKAs2PGAY3ZGbPGQPPDc6Wu82yIhuAO8TTFJrXkwAiqjQawgDLil/yB0V7Q==} + '@react-aria/button@3.14.5': + resolution: {integrity: sha512-ZuLx+wQj9VQhH9BYe7t0JowmKnns2XrFHFNvIVBb5RwxL+CIycIOL7brhWKg2rGdxvlOom7jhVbcjSmtAaSyaQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/calendar@3.9.4': - resolution: {integrity: sha512-0BvU8cj6uHn622Vp8Xd21XxXtvp3Bh4Yk1pHloqDNmUvvdBN+ol3Xsm5gG3XKKkZ+6CCEi6asCbLaEg3SZSbyg==} + '@react-aria/calendar@3.9.5': + resolution: {integrity: sha512-k0kvceYdZZu+DoeqephtlmIvh1CxqdFyoN52iqVzTz9O0pe5Xfhq7zxPGbeCp4pC61xzp8Lu/6uFA/YNfQQNag==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/checkbox@3.16.4': - resolution: {integrity: sha512-FcZj6/f27mNp2+G5yxyOMRZbZQjJ1cuWvo0PPnnZ4ybSPUmSzI4uUZBk1wvsJVP9F9n+J2hZuYVCaN8pyzLweA==} + '@react-aria/checkbox@3.16.5': + resolution: {integrity: sha512-ZhUT7ELuD52hb+Zpzw0ElLQiVOd5sKYahrh+PK3vq13Wk5TedBscALpjuXetI4pwFfdmAM1Lhgcsrd8+6AmyvA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/combobox@3.14.2': - resolution: {integrity: sha512-qwBeb8cMgK3xwrvXYHPtcphduD/k+oTcU18JHPvEO2kmR32knB33H81C2/Zoh4x86zTDJXaEtPscXBWuQ/M7AQ==} + '@react-aria/combobox@3.15.0': + resolution: {integrity: sha512-qSjQTFwKl3x1jCP2NRSJ6doZqAp6c2GTfoiFwWjaWg1IewwLsglaW6NnzqRDFiqFbDGgXPn4MqtC1VYEJ3NEjA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/datepicker@3.16.0': - resolution: {integrity: sha512-QynYHIHE+wvuGopl/k05tphmDpykpfZ3l3eKnUfGrqvAYJEeCOyS0qoMlw7Vq3NscMLFbJI6ajqBmlmtgFNiSA==} + '@react-aria/datepicker@3.16.1': + resolution: {integrity: sha512-6BltCVWt09yefTkGjb2gViGCwoddx9HKJiZbY9u6Es/Q+VhwNJQRtczbnZ3K32p262hIknukNf/5nZaCOI1AKA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/dialog@3.5.33': - resolution: {integrity: sha512-C5FpLAMJU6gQU8gztWKlEJ2A0k/JKl0YijNOv3Lizk+vUdF5njROSrmFs16bY5Hd6ycmsK9x/Pqkq3m/OpNFXA==} + '@react-aria/dialog@3.5.34': + resolution: {integrity: sha512-/x53Q5ynpW5Kv9637WYu7SrDfj3woSp6jJRj8l6teGnWW/iNZWYJETgzHfbxx+HPKYATCZesRoIeO2LnYIXyEA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/dnd@3.11.5': - resolution: {integrity: sha512-3IGrABfK8Cf6/b/uEmGEDGeubWKMUK3umWunF/tdkWBnIaxpdj4gRkWFMw7siWQYnqir6AN567nrWXtHFcLKsA==} + '@react-aria/dnd@3.11.6': + resolution: {integrity: sha512-4YLHUeYJleF+moAYaYt8UZqujudPvpoaHR+QMkWIFzhfridVUhCr6ZjGWrzpSZY3r68k46TG7YCsi4IEiNnysw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/focus@3.21.4': - resolution: {integrity: sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q==} + '@react-aria/focus@3.21.5': + resolution: {integrity: sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/form@3.1.4': - resolution: {integrity: sha512-GjPS85cE/34zal3vs6MOi7FxUsXwbxN4y6l1LFor2g92UK97gVobp238f3xdMW2T8IuaWGcnHeYFg+cjiZ51pQ==} + '@react-aria/form@3.1.5': + resolution: {integrity: sha512-BWlONgHn8hmaMkcS6AgMSLQeNqVBwqPNLhdqjDO/PCfzvV7O8NZw/dFeIzJwfG4aBfSpbHHRdXGdfrk3d8dylQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/grid@3.14.7': - resolution: {integrity: sha512-8eaJThNHUs75Xf4+FQC2NKQtTOVYkkDdA8VbfbqG06oYDAn7ETb1yhbwoqh1jOv7MezCNkYjyFe4ADsz2rBVcw==} + '@react-aria/grid@3.14.8': + resolution: {integrity: sha512-X6rRFKDu/Kh6Sv8FBap3vjcb+z4jXkSOwkYnexIJp5kMTo5/Dqo55cCBio5B70Tanfv32Ev/6SpzYG7ryxnM9w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/gridlist@3.14.3': - resolution: {integrity: sha512-t3nr29nU5jRG9MdWe9aiMd02V8o0pmidLU/7c4muWAu7hEH+IYdeDthGDdXL9tXAom/oQ+6yt6sOfLxpsVNmGA==} + '@react-aria/gridlist@3.14.4': + resolution: {integrity: sha512-C/SbwC0qagZatoBrCjx8iZUex9apaJ8o8iRJ9eVHz0cpj7mXg6HuuotYGmDy9q67A2hve4I693RM1Cuwqwm+PQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/i18n@3.12.15': - resolution: {integrity: sha512-3CrAN7ORVHrckvTmbPq76jFZabqq+rScosGT5+ElircJ5rF5+JcdT99Hp5Xg6R10jk74e8G3xiqdYsUd+7iJMA==} + '@react-aria/i18n@3.12.16': + resolution: {integrity: sha512-Km2CAz6MFQOUEaattaW+2jBdWOHUF8WX7VQoNbjlqElCP58nSaqi9yxTWUDRhAcn8/xFUnkFh4MFweNgtrHuEA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/interactions@3.27.0': - resolution: {integrity: sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==} + '@react-aria/interactions@3.27.1': + resolution: {integrity: sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/label@3.7.24': - resolution: {integrity: sha512-lcJbUy6xyicWKNgzfrXksrJ2CeCST2rDxGAvHOmUxSbFOm26kK710DjaFvtO4tICWh/TKW5mC3sm77soNcVUGA==} + '@react-aria/label@3.7.25': + resolution: {integrity: sha512-oNK3Pqj4LDPwEbQaoM/uCip4QvQmmwGOh08VeW+vzSi6TAwf+KoWTyH/tiAeB0CHWNDK0k3e1iTygTAt4wzBmg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/landmark@3.0.9': - resolution: {integrity: sha512-YYyluDBCXupnMh91ccE5g27fczjYmzPebHqTkVYjH4B6k45pOoqsMmWBCMnOTl0qOCeioI+daT8W0MamAZzoSw==} + '@react-aria/landmark@3.0.10': + resolution: {integrity: sha512-GpNjJaI8/a6WxYDZgzTCLYSzPM6xp2pxCIQ4udiGbTCtxx13Trmm0cPABvPtzELidgolCf05em9Phr+3G0eE8A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/link@3.8.8': - resolution: {integrity: sha512-hxQEvo5rrn2C0GOSwB/tROe+y//dyhmyXGbm8arDy6WF5Mj0wcjjrAu0/dhGYBqoltJa16iIEvs52xgzOC+f+Q==} + '@react-aria/link@3.8.9': + resolution: {integrity: sha512-UaAFBfs84/Qq6TxlMWkREqqNY6SFLukot+z2Aa1kC+VyStv1kWG6sE5QLjm4SBn1Q3CGRsefhB/5+taaIbB4Pw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/listbox@3.15.2': - resolution: {integrity: sha512-xcrgSediV8MaVmsuDrDPmWywF82/HOv+H+Y/dgr6GLCWl0XDj5Q7PyAhDzUsYdZNIne3B9muGh6IQc3HdkgWqg==} + '@react-aria/listbox@3.15.3': + resolution: {integrity: sha512-C6YgiyrHS5sbS5UBdxGMhEs+EKJYotJgGVtl9l0ySXpBUXERiHJWLOyV7a8PwkUOmepbB4FaLD7Y9EUzGkrGlw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -3673,68 +3729,68 @@ packages: '@react-aria/live-announcer@3.4.4': resolution: {integrity: sha512-PTTBIjNRnrdJOIRTDGNifY2d//kA7GUAwRFJNOEwSNG4FW+Bq9awqLiflw0JkpyB0VNIwou6lqKPHZVLsGWOXA==} - '@react-aria/menu@3.20.0': - resolution: {integrity: sha512-BAsHuf7kTVmawNUkTUd5RB3ZvL6DQQT7hgZ2cYKd/1ZwYq4KO2wWGYdzyTOtK1qimZL0eyHyQwDYv4dNKBH4gw==} + '@react-aria/menu@3.21.0': + resolution: {integrity: sha512-CKTVZ4izSE1eKIti6TbTtzJAUo+WT8O4JC0XZCYDBpa0f++lD19Kz9aY+iY1buv5xGI20gAfpO474E9oEd4aQA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/meter@3.4.29': - resolution: {integrity: sha512-XAhJf8LlYQl+QQXqtpWvzjlrT8MZKEG6c8N3apC5DONgSKlCwfmDm4laGEJPqtuz3QGiOopsfSfyTFYHjWsfZw==} + '@react-aria/meter@3.4.30': + resolution: {integrity: sha512-ZmANKW7s/Z4QGylHi46nhwtQ47T1bfMsU9MysBu7ViXXNJ03F4b6JXCJlKL5o2goQ3NbfZ68GeWamIT0BWSgtw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/numberfield@3.12.4': - resolution: {integrity: sha512-TgKBjKOjyURzbqNR2wF4tSFmQKNK5DqE4QZSlQxpYYo1T6zuztkh+oTOUZ4IWCJymL5qLtuPfGHCZbR7B+DN2w==} + '@react-aria/numberfield@3.12.5': + resolution: {integrity: sha512-Fi41IUWXEHLFIeJ/LHuZ9Azs8J/P563fZi37GSBkIq5P1pNt1rPgJJng5CNn4KsHxwqadTRUlbbZwbZraWDtRg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/overlays@3.31.1': - resolution: {integrity: sha512-U5BedzcXU97U5PWm4kIPnNoVpAs9KjTYfbkGx33vapmTVpGYhQyYW9eg6zW2E8ZKsyFJtQ/jkQnbWGen97aHSQ==} + '@react-aria/overlays@3.31.2': + resolution: {integrity: sha512-78HYI08r6LvcfD34gyv19ArRIjy1qxOKuXl/jYnjLDyQzD4pVb634IQWcm0zt10RdKgyuH6HTqvuDOgZTLet7Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/progress@3.4.29': - resolution: {integrity: sha512-orSaaFLX5LdD9UyxgBrmP1J/ivyEFX+5v4ENPQM5RH5+Hl+0OJa+8ozI0AfVKBqCYc89BOZfG7kzi7wFHACZcQ==} + '@react-aria/progress@3.4.30': + resolution: {integrity: sha512-S6OWVGgluSWYSd/A6O8CVjz83eeMUfkuWSra0ewAV9bmxZ7TP9pUmD3bGdqHZEl97nt5vHGjZ3eq/x8eCmzKhA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/radio@3.12.4': - resolution: {integrity: sha512-2sjBAE8++EtAAfjwPdrqEVswbzR4Mvcy4n8SvwUxTo02yESa9nolBzCSdAUFUmhrNj3MiMA+zLxQ+KACfUjJOg==} + '@react-aria/radio@3.12.5': + resolution: {integrity: sha512-8CCJKJzfozEiWBPO9QAATG1rBGJEJ+xoqvHf9LKU2sPFGsA2/SRnLs6LB9fCG5R3spvaK1xz0any1fjWPl7x8A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/searchfield@3.8.11': - resolution: {integrity: sha512-5R0prEC+jRFwPeJsK6G4RN8QG3V/+EaIuw9p79G1gFD+1dY81ZakiZIIJaLWRyO7AzYBGyC/QFHtz0m3KGQT/Q==} + '@react-aria/searchfield@3.8.12': + resolution: {integrity: sha512-kYlUHD/+mWzNroHoR8ojUxYBoMviRZn134WaKPFjfNUGZDOEuh4XzOoj+cjdJfe6N3mwTaYu6rJQtunSHIAfhA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/select@3.17.2': - resolution: {integrity: sha512-oMpHStyMluRf67qxrzH5Qfcvw6ETQgZT1Qw2xvAxQVRd5IBb0PfzZS7TGiULOcMLqXAUOC28O/ycUGrGRKLarg==} + '@react-aria/select@3.17.3': + resolution: {integrity: sha512-u0UFWw0S7q9oiSbjetDpRoLLIcC+L89uYlm+YfCrdT8ntbQgABNiJRxdVvxnhR0fR6MC9ASTTvuQnNHNn52+1A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/selection@3.27.1': - resolution: {integrity: sha512-8WQ4AtWiBnk9UEeYkqpH12dd8KQW2aFbNZvM4sDfLtz7K7HWyY/MkqMe/snk9IcoSa7t4zr0bnoZJcWSGgn2PQ==} + '@react-aria/selection@3.27.2': + resolution: {integrity: sha512-GbUSSLX/ciXix95KW1g+SLM9np7iXpIZrFDSXkC6oNx1uhy18eAcuTkeZE25+SY5USVUmEzjI3m/3JoSUcebbg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/separator@3.4.15': - resolution: {integrity: sha512-A1aPQhCaE8XeelNJYPjHtA2uh921ROh8PNiZI4o62x80wcziRoctN5PAtNHJAx7VKvX66A8ZVGbOqb7iqS3J5Q==} + '@react-aria/separator@3.4.16': + resolution: {integrity: sha512-RCUtQhDGnPxKzyG8KM79yOB0fSiEf8r/rxShidOVnGLiBW2KFmBa22/Gfc4jnqg/keN3dxvkSGoqmeXgctyp6g==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/spinbutton@3.7.1': - resolution: {integrity: sha512-Nisah6yzxOC6983u/5ck0w+OQoa3sRKmpDvWpTEX0g2+ZIABOl8ttdSd65XKtxXmXHdK8X1zmrfeGOBfBR3sKA==} + '@react-aria/spinbutton@3.7.2': + resolution: {integrity: sha512-adjE1wNCWlugvAtVXlXWPtIG9JWurEgYVn1Eeyh19x038+oXGvOsOAoKCXM+SnGleTWQ9J7pEZITFoEI3cVfAw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -3745,32 +3801,32 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/switch@3.7.10': - resolution: {integrity: sha512-j7nrYnqX6H9J8GuqD0kdMECUozeqxeG19A2nsvfaTx3//Q7RhgIR9fqhQdVHW/wgraTlEHNH6AhDzmomBg0TNw==} + '@react-aria/switch@3.7.11': + resolution: {integrity: sha512-dYVX71HiepBsKyeMaQgHbhqI+MQ3MVoTd5EnTbUjefIBnmQZavYj1/e4NUiUI4Ix+/C0HxL8ibDAv4NlSW3eLQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/table@3.17.10': - resolution: {integrity: sha512-xdEeyOzuETkOfAHhZrX7HOIwMUsCUr4rbPvHqdcNqg7Ngla2ck9iulZNAyvOPfFwELuBEd2rz1I9TYRQ2OzSQQ==} + '@react-aria/table@3.17.11': + resolution: {integrity: sha512-GkYmWPiW3OM+FUZxdS33teHXHXde7TjHuYgDDaG9phvg6cQTQjGilJozrzA3OfftTOq5VB8XcKTIQW3c0tpYsQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/tabs@3.11.0': - resolution: {integrity: sha512-9Gwo118GHrMXSyteCZL1L/LHLVlGSYkhGgiTL3e/UgnYjHfEfDJVTkV2JikuE2O/4uig52gQRlq5E99axLeE9Q==} + '@react-aria/tabs@3.11.1': + resolution: {integrity: sha512-3Ppz7yaEDW9L7p9PE9yNOl5caLwNnnLQqI+MX/dwbWlw9HluHS7uIjb21oswNl6UbSxAWyENOka45+KN4Fkh7A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/tag@3.8.0': - resolution: {integrity: sha512-sTV6uRKFIFU1aljKb0QjM6fPPnzBuitrbkkCUZCJ0w0RIX1JinZPh96NknNtjFwWmqoROjVNCq51EUd0Hh2SQw==} + '@react-aria/tag@3.8.1': + resolution: {integrity: sha512-VonpO++F8afXGDWc9VUxAc2wefyJpp1n9OGpbnB7zmqWiuPwO/RixjUdcH7iJkiC4vADwx9uLnhyD6kcwGV2ig==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/textfield@3.18.4': - resolution: {integrity: sha512-ts3Vdy2qNOzjCVeO+4RH8FSgTYN2USAMcYFeGbHOriCukVOrvgRsqcDniW7xaT60LgFdlWMJsCusvltSIyo6xw==} + '@react-aria/textfield@3.18.5': + resolution: {integrity: sha512-ttwVSuwoV3RPaG2k2QzEXKeQNQ3mbdl/2yy6I4Tjrn1ZNkYHfVyJJ26AjenfSmj1kkTQoSAfZ8p+7rZp4n0xoQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -3781,44 +3837,44 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/toggle@3.12.4': - resolution: {integrity: sha512-yVcl8kEFLsV47aCA22EMPcd/KWoYqPIPSzoKjRD/iWmxcP6iGzSxDjdUgMQojNGY8Q6wL8lUxfRqKBjvl/uezQ==} + '@react-aria/toggle@3.12.5': + resolution: {integrity: sha512-XXVFLzcV8fr9mz7y/wfxEAhWvaBZ9jSfhCMuxH2bsivO7nTcMJ1jb4g2xJNwZgne17bMWNc7mKvW5dbsdlI6BA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/toolbar@3.0.0-beta.23': - resolution: {integrity: sha512-FzvNf2hWtjEwk8F2MBf4qSs6AAR/p2WFSws6kJ4f0SrWXl4wR9VDEwBEUQcIPbWCK2aUsyOjubCh55Cl4t3MoQ==} + '@react-aria/toolbar@3.0.0-beta.24': + resolution: {integrity: sha512-B2Rmpko7Ghi2RbNfsGdbR7I+RQBDhPGVE4bU3/EwHz+P/vNe5LyGPTeSwqaOMsQTF9lKNCkY8424dVTCr6RUMg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/tooltip@3.9.1': - resolution: {integrity: sha512-mvEhqpvF4v/wj9zw3a8bsAEnySutGbxKXXt39s6WvF6dkVfaXfsmV9ahuMCHH//UGh/yidZGLrXX4YVdrgS8lA==} + '@react-aria/tooltip@3.9.2': + resolution: {integrity: sha512-VrgkPwHiEnAnBhoQ4W7kfry/RfVuRWrUPaJSp0+wKM6u0gg2tmn7OFRDXTxBAm/omQUguIdIjRWg7sf3zHH82A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/utils@3.33.0': - resolution: {integrity: sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==} + '@react-aria/utils@3.33.1': + resolution: {integrity: sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/virtualizer@4.1.12': - resolution: {integrity: sha512-va0VAD28nq7rk1vHZvnkq591EbWuDKBwh2NzAEn+zz9JjMtpg4utcihNXECJ1DwMRkpaT6q+KpOE7dSdzTxPBQ==} + '@react-aria/virtualizer@4.1.13': + resolution: {integrity: sha512-d5KS+p8GXGNRbGPRE/N6jtth3et3KssQIz52h2+CAoAh7C3vvR64kkTaGdeywClvM+fSo8FxJuBrdfQvqC2ktQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/visually-hidden@3.8.30': - resolution: {integrity: sha512-iY44USEU8sJy0NOJ/sTDn3YlspbhHuVG3nx2YYrzfmxbS3i+lNwkCfG8kJ77dtmbuDLIdBGKENjGkbcwz3kiJg==} + '@react-aria/visually-hidden@3.8.31': + resolution: {integrity: sha512-RTOHHa4n56a9A3criThqFHBifvZoV71+MCkSuNP2cKO662SUWjqKkd0tJt/mBRMEJPkys8K7Eirp6T8Wt5FFRA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-email/body@0.2.1': - resolution: {integrity: sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==} + '@react-email/body@0.3.0': + resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==} engines: {node: '>=20.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -3847,8 +3903,8 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/components@1.0.8': - resolution: {integrity: sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==} + '@react-email/components@1.0.10': + resolution: {integrity: sha512-r/BnqfAjr3apcvn/NDx2DqNRD5BP5wZLRdjn2IVHXjt4KmQ5RHWSCAvFiXAzRHys1BWQ2zgIc7cpWePUcAl+nw==} engines: {node: '>=20.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -3932,21 +3988,21 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/tailwind@2.0.5': - resolution: {integrity: sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==} + '@react-email/tailwind@2.0.6': + resolution: {integrity: sha512-3PgL/GYWmgS+puLPQ2aLlsplHSOFztRl70fowBkbLIb8ZUIgvx5YId6zYCCHeM2+DQ/EG3iXXqLNTahVztuMqQ==} engines: {node: '>=20.0.0'} peerDependencies: - '@react-email/body': 0.2.1 - '@react-email/button': 0.2.1 - '@react-email/code-block': 0.2.1 - '@react-email/code-inline': 0.0.6 - '@react-email/container': 0.0.16 - '@react-email/heading': 0.0.16 - '@react-email/hr': 0.0.12 - '@react-email/img': 0.0.12 - '@react-email/link': 0.0.13 - '@react-email/preview': 0.0.14 - '@react-email/text': 0.1.6 + '@react-email/body': '>=0' + '@react-email/button': '>=0' + '@react-email/code-block': '>=0' + '@react-email/code-inline': '>=0' + '@react-email/container': '>=0' + '@react-email/heading': '>=0' + '@react-email/hr': '>=0' + '@react-email/img': '>=0' + '@react-email/link': '>=0' + '@react-email/preview': '>=0' + '@react-email/text': '>=0' react: ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@react-email/body': @@ -3976,107 +4032,107 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-stately/calendar@3.9.2': - resolution: {integrity: sha512-AQj8/izwb7eY+KFqKcMLI2ygvnbAIwLuQG5KPHgJsMygFqnN4yzXKz5orGqVJnxEXLKiLPteVztx7b5EQobrtw==} + '@react-stately/calendar@3.9.3': + resolution: {integrity: sha512-uw7fCZXoypSBBUsVkbNvJMQWTihZReRbyLIGG3o/ZM630N3OCZhb/h4Uxke4pNu7n527H0V1bAnZgAldIzOYqg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/checkbox@3.7.4': - resolution: {integrity: sha512-oXHMkK22CWLcmNlunDuu4p52QXYmkpx6es9AjWx/xlh3XLZdJzo/5SANioOH1QvBtwPA/c2KQy+ZBqC21NtMHw==} + '@react-stately/checkbox@3.7.5': + resolution: {integrity: sha512-K5R5ted7AxLB3sDkuVAazUdyRMraFT1imVqij2GuAiOUFvsZvbuocnDuFkBVKojyV3GpqLBvViV8IaCMc4hNIw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/collections@3.12.9': - resolution: {integrity: sha512-2jywPMhVgMOh0XtutxPqIxFCIiLOnL/GXIrRKoBEo8M3Q24NoMRBavUrn9RTvjqNnec1i/8w1/8sq8cmCKEohA==} + '@react-stately/collections@3.12.10': + resolution: {integrity: sha512-wmF9VxJDyBujBuQ76vXj2g/+bnnj8fx5DdXgRmyfkkYhPB46+g2qnjbVGEvipo7bJuGxDftCUC4SN7l7xqUWfg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/combobox@3.12.2': - resolution: {integrity: sha512-h4YRmzA+s3aMwUrXm6jyWLN0BWWXUNiodArB1wC24xNdeI7S8O3mxz6G2r3Ne8AE02FXmZXs9SD30Mx5vVVuqQ==} + '@react-stately/combobox@3.13.0': + resolution: {integrity: sha512-dX9g/cK1hjLRjcbWVF6keHxTQDGhKGB2QAgPhWcBmOK3qJv+2dQqsJ6YCGWn/Y2N2acoEseLrAA7+Qe4HWV9cg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/data@3.15.1': - resolution: {integrity: sha512-lchubLxCWg1Yswpe9yRYJAjmzP0eTYZe+AQyFJQRIT6axRi9Gs92RIZ7zhwLXxI0vcWpnAWADB9kD4bsos7xww==} + '@react-stately/data@3.15.2': + resolution: {integrity: sha512-BsmeeGgFwOGwo0g9Waprdyt+846n3KhKggZfpEnp5+sC4dE4uW1VIYpdyupMfr3bQcmX123q6TegfNP3eszrUA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/datepicker@3.16.0': - resolution: {integrity: sha512-mYtzKXufFVivrHjmxys3ryJFMPIQNhVqaSItmGnWv3ehxw+0HKBrROf3BFiEN4zP20euoP149ZaR4uNx90kMYw==} + '@react-stately/datepicker@3.16.1': + resolution: {integrity: sha512-BtAMDvxd1OZxkxjqq5tN5TYmp6Hm8+o3+IDA4qmem2/pfQfVbOZeWS2WitcPBImj4n4T+W1A5+PI7mT/6DUBVg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/dnd@3.7.3': - resolution: {integrity: sha512-yBtzAimyYvJWnzP80Scx7l559+43TVSyjaMpUR6/s2IjqD3XoPKgPsv7KaFUmygBTkCBGBFJn404rYgMCOsu3g==} + '@react-stately/dnd@3.7.4': + resolution: {integrity: sha512-YD0TVR5JkvTqskc1ouBpVKs6t/QS4RYCIyu8Ug8RgO122iIizuf2pfKnRLjYMdu5lXzBXGaIgd49dvnLzEXHIw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 '@react-stately/flags@3.1.2': resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} - '@react-stately/form@3.2.3': - resolution: {integrity: sha512-NPvjJtns1Pq9uvqeRJCf8HIdVmOm2ARLYQ2F/sqXj1w5IChJ4oWL4Xzvj29/zBitgE1vVjDhnrnwSfNlHZGX0g==} + '@react-stately/form@3.2.4': + resolution: {integrity: sha512-qNBzun8SbLdgahryhKLqL1eqP+MXY6as82sVXYOOvUYLzgU5uuN8mObxYlxJgMI5akSdQJQV3RzyfVobPRE7Kw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/grid@3.11.8': - resolution: {integrity: sha512-tCabR5U7ype+uEElS5Chv5n6ntUv3drXa9DwebjO05cFevUmjTkEfYPJWixpgX4UlCCvjdUFgzeQlJF+gCiozg==} + '@react-stately/grid@3.11.9': + resolution: {integrity: sha512-qQY6F+27iZRn30dt0ZOrSetUmbmNJ0pLe9Weuqw3+XDVSuWT+2O/rO1UUYeK+mO0Acjzdv+IWiYbu9RKf2wS9w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/layout@4.5.3': - resolution: {integrity: sha512-BDYnvO2AKzvWfxxVM96kif3qCynsA+XcNoQC+T77exH+LLT8zlK9oOdarZXTlok/eZmjs6+5wmjq51PeL6eM5w==} + '@react-stately/layout@4.6.0': + resolution: {integrity: sha512-kBenEsP03nh5rKgfqlVMPcoKTJv0v92CTvrAb5gYY8t9g8LOwzdL89Yannq7f5xv8LFck/MmRQlotpMt2InETg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/list@3.13.3': - resolution: {integrity: sha512-xN0v7rzhIKshhcshOzx+ZgVngXnGCtMPRdhoDLGaHzQy5YfxvKBMNLCnr5Lm4T1U/kIvHbyzxmr5uwmH8WxoIg==} + '@react-stately/list@3.13.4': + resolution: {integrity: sha512-HHYSjA9VG7FPSAtpXAjQyM/V7qFHWGg88WmMrDt5QDlTBexwPuH0oFLnW0qaVZpAIxuWIsutZfxRAnme/NhhAA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/menu@3.9.10': - resolution: {integrity: sha512-dY9FzjQ+6iNInVujZPyMklDGoSbaoO0yguUnALAY+yfkPAyStEElfm4aXZgRfNKOTNHe9E34oV7qefSYsclvTg==} + '@react-stately/menu@3.9.11': + resolution: {integrity: sha512-vYkpO9uV2OUecsIkrOc+Urdl/s1xw/ibNH/UXsp4PtjMnS6mK9q2kXZTM3WvMAKoh12iveUO+YkYCZQshmFLHQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/numberfield@3.10.4': - resolution: {integrity: sha512-EniHHwXOw/Ta0x5j61OvldDAvLoi/8xOo//bzrqwnDvf2/1IKGFMD9CHs7HYhQw+9oNl3Q2V1meOTNPc4PvoMQ==} + '@react-stately/numberfield@3.11.0': + resolution: {integrity: sha512-rxfC047vL0LP4tanjinfjKAriAvdVL57Um5RUL5nHML8IOWCB3TBxegQkJ6to6goScC/oZhd0/Y2LSaiRuKbNw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/overlays@3.6.22': - resolution: {integrity: sha512-sWBnuy5dqVp8d+1e+ABTRVB3YBcOW86/90pF5PWY44au3bUFXVSUBO2QMdR/6JtojDoPRmrjufonI19/Zs/20w==} + '@react-stately/overlays@3.6.23': + resolution: {integrity: sha512-RzWxots9A6gAzQMP4s8hOAHV7SbJRTFSlQbb6ly1nkWQXacOSZSFNGsKOaS0eIatfNPlNnW4NIkgtGws5UYzfw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/radio@3.11.4': - resolution: {integrity: sha512-3svsW5VxJA5/p1vO+Qlxv+7Jq9g7f4rqX9Rbqdfd+pH7ykHaV0CUKkSRMaWfcY8Vgaf2xmcc6dvusPRqKX8T1A==} + '@react-stately/radio@3.11.5': + resolution: {integrity: sha512-QxA779S4ea5icQ0ja7CeiNzY1cj7c9G9TN0m7maAIGiTSinZl2Ia8naZJ0XcbRRp+LBll7RFEdekne15TjvS/w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/searchfield@3.5.18': - resolution: {integrity: sha512-C3/1wOON5oK0QBljj0vSbHm/IWgd29NxB+7zT1JjZcxtbcFxCj4HOxKdnPCT/d8Pojb0YS26QgKzatLZ0NnhgQ==} + '@react-stately/searchfield@3.5.19': + resolution: {integrity: sha512-URllgjbtTQEaOCfddbHpJSPKOzG3pE3ajQHJ7Df8qCoHTjKfL6hnm/vp7X5sxPaZaN7VLZ5kAQxTE8hpo6s0+A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/select@3.9.1': - resolution: {integrity: sha512-CJQRqv8Dg+0RRvcig3a2YfY6POJIscDINvidRF31yK6J72rsP01dY3ria9aJjizNDHR9Q5dWFp/z+ii0cOTWIQ==} + '@react-stately/select@3.9.2': + resolution: {integrity: sha512-oWn0bijuusp8YI7FRM/wgtPVqiIrgU/ZUfLKe/qJUmT8D+JFaMAJnyrAzKpx98TrgamgtXynF78ccpopPhgrKQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/selection@3.20.8': - resolution: {integrity: sha512-V1kRN1NLW+i/3Xv+Q0pN9OzuM0zFEW9mdXOOOq7l+YL6hFjqIjttT2/q4KoyiNV3W0hfoRFSTQ7XCgqnqtwEng==} + '@react-stately/selection@3.20.9': + resolution: {integrity: sha512-RhxRR5Wovg9EVi3pq7gBPK2BoKmP59tOXDMh2r1PbnGevg/7TNdR67DCEblcmXwHuBNS46ELfKdd0XGHqmS8nQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/table@3.15.3': - resolution: {integrity: sha512-W1wR0O/PmdD8hCUFIAelHICjUX/Ii6ZldPlH6EILr9olyGpoCaY7XmnyG7kii1aANuQGBeskjJdXvS6LX/gyDw==} + '@react-stately/table@3.15.4': + resolution: {integrity: sha512-fGaNyw3wv7JgRCNzgyDzpaaTFuSy5f4Qekch4UheMXDJX7dOeaMhUXeOfvnXCVg+BGM4ey/D82RvDOGvPy1Nww==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/tabs@3.8.8': - resolution: {integrity: sha512-BZImWT+pHZitImRQkoL7jVhTtpGPSra1Rhh4pi8epzwogeqseEIEpuWpQebjQP74r1kfNi/iT2p5Qb31eWfh1Q==} + '@react-stately/tabs@3.8.9': + resolution: {integrity: sha512-AQ4Xrn6YzIolaVShCV9cnwOjBKPAOGP/PTp7wpSEtQbQ0HZzUDG2RG/M4baMeUB2jZ33b7ifXyPcK78o0uOftg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -4085,18 +4141,18 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/toggle@3.9.4': - resolution: {integrity: sha512-tjWsshRJtHC+PI5NYMlnDlV/BTo1eWq6fmR6x1mXlQfKuKGTJRzhgJyaQ2mc5K+LkifD7fchOhfapHCrRlzwMg==} + '@react-stately/toggle@3.9.5': + resolution: {integrity: sha512-PVzXc788q3jH98Kvw1LYDL+wpVC14dCEKjOku8cSaqhEof6AJGaLR9yq+EF1yYSL2dxI6z8ghc0OozY8WrcFcA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/tooltip@3.5.10': - resolution: {integrity: sha512-GauUdc6Of08Np2iUw4xx/DdgpvszS9CxJWYcRnNyAAGPLQrmniVrpJvb0EUKQTP9sUSci1SlmpvJh4SNZx26Bw==} + '@react-stately/tooltip@3.5.11': + resolution: {integrity: sha512-o8PnFXbvDCuVZ4Ht9ahfS6KHwIZjXopvoQ2vUPxv920irdgWEeC+4omgDOnJ/xFvcpmmJAmSsrQsTQrTguDUQA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/tree@3.9.5': - resolution: {integrity: sha512-UpvBlzL/MpFdOepDg+cohI/zvw8DEVM8cXY/OZ8tKUXWpew1HpUglwnAI3ivm0L2k9laUIB9siW0g04ZWiH9Lg==} + '@react-stately/tree@3.9.6': + resolution: {integrity: sha512-JCuhGyX2A+PAMsx2pRSwArfqNFZJ9JSPkDaOQJS8MFPAsBe5HemvXsdmv9aBIMzlbCYcVq6EsrFnzbVVTBt/6w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -4105,142 +4161,153 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-stately/virtualizer@4.4.5': - resolution: {integrity: sha512-MP33zys3nRYTk/+3BPchxlil9GrwbMksc3XuvNACeZqYEA/oEidsHffgPL+LY0iitKCmQE6pg49MI5HvBuOd2w==} + '@react-stately/virtualizer@4.4.6': + resolution: {integrity: sha512-9SfXgLFB61/8SXNLfg5ARx9jAK4m03Aw6/Cg8mdZN24SYarL4TKNRpfw8K/HHVU/bi6WHSJypk6Z/z19o/ztrg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/actionbar@3.1.20': - resolution: {integrity: sha512-K3fajms4FmeVnUAXuJ7iwhtS2Lh8hHFZCqCrvHdHqzRyIsx7vMRvITXyhHnIEuIXDCHb2k7cSzFJzn9kvJLosw==} + '@react-types/actionbar@3.1.21': + resolution: {integrity: sha512-X2PZrYxkFow4M0nnAG16aDoErMFoI+qHGB5n4ytLdPF3+ea4PsxvL1Z9KrpiB2ELuOCx77RaGnuXcfyXK/HaKQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/actiongroup@3.4.22': - resolution: {integrity: sha512-J3MIpK3a+AXmyUTd2+bHn3J2XcZV1oJpZxFxmWonitiIvbyEZbiZHeMrBPxHST60BxVGzQK/J+mROZNjxIRWpg==} + '@react-types/actiongroup@3.4.23': + resolution: {integrity: sha512-FPd/2N3nfxVVP535JnMBu5jw40UrTAaQKLF+1dD+KQ3eHs2mNLfnPvJcY1HLj0mD/FhEFSbY8rJEDbcKCnBuhw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/breadcrumbs@3.7.18': - resolution: {integrity: sha512-zwltqx2XSELBRQeuCraxrdfT4fpIOVu6eQXsZ4RhWlsT7DLhzj3pUGkxdPDAMfYaVdyNBqc+nhiAnCwz6tUJ8A==} + '@react-types/breadcrumbs@3.7.19': + resolution: {integrity: sha512-AnkyYYmzaM2QFi/N0P/kQLM8tHOyFi7p397B/jEMucXDfwMw5Ny1ObCXeIEqbh8KrIa2Xp8SxmQlCV+8FPs4LA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/button@3.15.0': - resolution: {integrity: sha512-X/K2/Oeuq7Hi8nMIzx4/YlZuvWFiSOHZt27p4HmThCnNO/9IDFPmvPrpkYjWN5eN9Nuk+P5vZUb4A7QJgYpvGA==} + '@react-types/button@3.15.1': + resolution: {integrity: sha512-M1HtsKreJkigCnqceuIT22hDJBSStbPimnpmQmsl7SNyqCFY3+DHS7y/Sl3GvqCkzxF7j9UTL0dG38lGQ3K4xQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/calendar@3.8.2': - resolution: {integrity: sha512-QbPFhvBQfrsz3x1Nnatr5SL+8XtbxvP4obESFuDrKmsqaaAv+jG5vwLiPTKp6Z3L+MWkCvKavBPuW+byhq+69A==} + '@react-types/calendar@3.8.3': + resolution: {integrity: sha512-fpH6WNXotzH0TlKHXXxtjeLZ7ko0sbyHmwDAwmDFyP7T0Iwn1YQZ+lhceLifvynlxuOgX6oBItyUKmkHQ0FouQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/checkbox@3.10.3': - resolution: {integrity: sha512-Xw4jHG7uK352Wc18XXzdzmtr3Xjg8d2tPoBGNgsw39f92EY2UpoDAPHxYR0BaDe04lGfAn6YwVivI4OGVbjXIg==} + '@react-types/checkbox@3.10.4': + resolution: {integrity: sha512-tYCG0Pd1usEz5hjvBEYcqcA0youx930Rss1QBIse9TgMekA1c2WmPDNupYV8phpO8Zuej3DL1WfBeXcgavK8aw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/combobox@3.13.11': - resolution: {integrity: sha512-5/tdmTAvqPpiWzEeaV7uLLSbSTkkoQ1mVz6NfKMPuw4ZBkY3lPc9JDkkQjY/JrquZao+KY4Dx8ZIoS0NqkrFrw==} + '@react-types/combobox@3.14.0': + resolution: {integrity: sha512-zmSSS7BcCOD8rGT8eGbVy7UlL5qq1vm88fFn4WgFe+lfK33ne+E7yTzTxcPY2TCGSo5fY6xMj3OG79FfVNGbSg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/datepicker@3.13.4': - resolution: {integrity: sha512-B5sAPoYZfluDBpgVK3ADlHbXBKRkFCQFO18Bs091IvRRwqzfoO/uf+/9UpXMw+BEF4pciLf0/kdiVQTvI3MzlA==} + '@react-types/datepicker@3.13.5': + resolution: {integrity: sha512-j28Vz+xvbb4bj7+9Xbpc4WTvSitlBvt7YEaEGM/8ZQ5g4Jr85H2KwkmDwjzmMN2r6VMQMMYq9JEcemq5wWpfUQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/dialog@3.5.23': - resolution: {integrity: sha512-3tMzweYuaDOaufF5tZPMgXSA0pPFJNgdg89YRITh0wMXMG0pm+tAKVQJL1TSLLhOiLCEL08V8M/AK67dBdr2IA==} + '@react-types/dialog@3.5.24': + resolution: {integrity: sha512-NFurEP/zV0dA/41422lV1t+0oh6f/13n+VmLHZG8R13m1J3ql/kAXZ49zBSqkqANBO1ojyugWebk99IiR4pYOw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/grid@3.3.7': - resolution: {integrity: sha512-riET3xeKPTcRWQy6hYCMxdbdL3yubPY5Ow66b2GA2rEqoYvmDBniYXAM2Oh+q9s+YgnAP7qJK++ym8NljvHiLA==} + '@react-types/grid@3.3.8': + resolution: {integrity: sha512-zJvXH8gc1e1VH2H3LRnHH/W2HIkLkZMH3Cu5pLcj0vDuLBSWpcr3Ikh3jZ+VUOZF0G1Jt1lO8pKIaqFzDLNmLQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/link@3.6.6': - resolution: {integrity: sha512-M6WXxUJFmiF6GNu7xUH0uHj0jsorFBN6npkfSCNM4puStC8NbUT2+ZPySQyZXCoHMQ89g6qZ6vCc8QduVkTE7Q==} + '@react-types/link@3.6.7': + resolution: {integrity: sha512-1apXCFJgMC1uydc2KNENrps1qR642FqDpwlNWe254UTpRZn/hEZhA6ImVr8WhomfLJu672WyWA0rUOv4HT+/pQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/listbox@3.7.5': - resolution: {integrity: sha512-Cn+yNip+YZBaGzu+z5xPNgmfSupnLl+li7uG5hRc+EArkk8/G42myRXz6M8wPrLM1bFAq3r85tAbyoXVmKG5Jw==} + '@react-types/listbox@3.7.6': + resolution: {integrity: sha512-335NYElKEByXMalAmeRPyulKIDd2cjOCQhLwvv2BtxO5zaJfZnBbhZs+XPd9zwU6YomyOxODKSHrwbNDx+Jf3w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/menu@3.10.6': - resolution: {integrity: sha512-OJTznQ4xE/VddBJU+HO4x5tceSOdyQhiHA1bREE1aHl+PcgHOUZLdMjXp1zFaGF16HhItHJaxpifJ4hzf4hWQA==} + '@react-types/menu@3.10.7': + resolution: {integrity: sha512-+p7ixZdvPDJZhisqdtWiiuJ9pteNfK5i19NB6wzAw5XkljbEzodNhwLv6rI96DY5XpbFso2kcjw7IWi+rAAGGQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/meter@3.4.14': - resolution: {integrity: sha512-rNw0Do2AM3zLGZ0pSWweViuddg1uW99PWzE6RQXE8nsTHTeiwDZt9SYGdObEnjd+nJ3YzemqekG0Kqt93iNBcA==} + '@react-types/meter@3.4.15': + resolution: {integrity: sha512-9WjNphhLLM+TA4Ev1y2MkpugJ5JjTXseHh7ZWWx2veq5DrXMZYclkRpfUrUdLVKvaBIPQCgpQIj0TcQi+quR9A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/numberfield@3.8.17': - resolution: {integrity: sha512-Q9n24OaSMXrebMowbtowmHLNclknN3XkcBIaYMwA2BIGIl+fZFnI8MERM0pG87W+wki6FepDExsDW9YxQF4pnw==} + '@react-types/numberfield@3.8.18': + resolution: {integrity: sha512-nLzk7YAG9yAUtSv+9R8LgCHsu8hJq8/A+m1KsKxvc8WmNJjIujSFgWvT21MWBiUgPBzJKGzAqpMDDa087mltJQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/overlays@3.9.3': - resolution: {integrity: sha512-LzetThNNk8T26pQRbs1I7+isuFhdFYREy7wJCsZmbB0FnZgCukGTfOtThZWv+ry11veyVJiX68jfl4SV6ACTWA==} + '@react-types/overlays@3.9.4': + resolution: {integrity: sha512-7Z9HaebMFyYBqtv3XVNHEmVkm7AiYviV7gv0c98elEN2Co+eQcKFGvwBM9Gy/lV57zlTqFX1EX/SAqkMEbCLOA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/progress@3.5.17': - resolution: {integrity: sha512-JtiGlek6QS04bFrRj1WfChjPNr7+3/+pd6yZayXGUkQUPHt1Z/cFnv3QZ/tSQTdUt1XXmjnCak9ZH9JQBqe64Q==} + '@react-types/progress@3.5.18': + resolution: {integrity: sha512-mKeQn+KrHr1y0/k7KtrbeDGDaERH6i4f6yBwj/ZtYDCTNKMO3tPHJY6nzF0w/KKZLplIO+BjUbHXc2RVm8ovwQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/radio@3.9.3': - resolution: {integrity: sha512-w2BrMGIiZxYXPCnnB2NQyifwE/rRFMIW87MyawrKO9zPSbnDkqLIHAAtqmlNk2zkz1ZEWjk9opNsuztjP7D4sA==} + '@react-types/radio@3.9.4': + resolution: {integrity: sha512-TkMRY3sA1PcFZhhclu4IUzUTIir6MzNJj8h6WT8vO6Nug2kXJ72qigugVFBWJSE472mltduOErEAo0rtAYWbQA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/searchfield@3.6.7': - resolution: {integrity: sha512-POo3spZcYD14aqo0f4eNbymJ8w9EKrlu0pOOjYYWI2P0GUSRmib9cBA9xZFhvRGHuNlHo3ePjeFitYQI7L3g1g==} + '@react-types/searchfield@3.6.8': + resolution: {integrity: sha512-M2p7OVdMTMDmlBcHd4N2uCBwg3uJSNM4lmEyf09YD44N5wDAI0yogk52QBwsnhpe+i2s65UwCYgunB+QltRX8A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/select@3.12.1': - resolution: {integrity: sha512-PtIUymvQNIIzgr+piJtK/8gbH7akWtbswIbfoADPSxtZEd1/vfUIO0s8c750s3XYNlmx/4DrhugQsLYwgC35yg==} + '@react-types/select@3.12.2': + resolution: {integrity: sha512-AseOjfr3qM1W1qIWcbAe6NFpwZluVeQX/dmu9BYxjcnVvtoBLPMbE5zX/BPbv+N5eFYjoMyj7Ug9dqnI+LrlGw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/shared@3.33.0': - resolution: {integrity: sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==} + '@react-types/shared@3.33.1': + resolution: {integrity: sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/switch@3.5.16': - resolution: {integrity: sha512-6fynclkyg0wGHo3f1bwk4Z+gZZEg0Z63iP5TFhgHWdZ8W+Uq6F3u7V4IgQpuJ2NleL1c2jy2/CKdS9v06ac2Og==} + '@react-types/switch@3.5.17': + resolution: {integrity: sha512-2GTPJvBCYI8YZ3oerHtXg+qikabIXCMJ6C2wcIJ5Xn0k9XOovowghfJi10OPB2GGyOiLBU74CczP5nx8adG90Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/table@3.13.5': - resolution: {integrity: sha512-4/CixlNmXSuJuX2IKuUlgNd/dEgNh3WvfE/bdwuI1t5JBdShP9tHIzSkgZbrzE2xX46NeA2xq4vXNO5kBv+QDA==} + '@react-types/table@3.13.6': + resolution: {integrity: sha512-eluL+iFfnVmFm7OSZrrFG9AUjw+tcv898zbv+NsZACa8oXG1v9AimhZfd+Mo8q/5+sX/9hguWNXFkSvmTjuVPQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/tabs@3.3.21': - resolution: {integrity: sha512-Dq9bKI62rHoI4LGGcBGlZ5s0aSwB0G4Y8o0r7hQZvf1eZWc9fmqdAdTTaGG/RUyhMIGRYWl5RRUBUuC5RmaO6w==} + '@react-types/tabs@3.3.22': + resolution: {integrity: sha512-HGwLD9dA3k3AGfRKGFBhNgxU9/LyRmxN0kxVj1ghA4L9S/qTOzS6GhrGNkGzsGxyVLV4JN8MLxjWN2o9QHnLEg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/textfield@3.12.7': - resolution: {integrity: sha512-ddiacsS6sLFtAn2/fym7lR8nbdsLgPfelNDcsDqHiu6XUHh5TCNe8ItXHFaIiyfnKTH8uJqZrSli4wfAYNfMsw==} + '@react-types/textfield@3.12.8': + resolution: {integrity: sha512-wt6FcuE5AyntxsnPika/h3nf/DPmeAVbI018L9o6h+B/IL4sMWWdx663wx2KOOeHH8ejKGZQNPLhUKs4s1mVQA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/tooltip@3.5.1': - resolution: {integrity: sha512-h6xOAWbWUJKs9CzcCyzSPATLHq7W5dS866HkXLrtCrRDShLuzQnojZnctD2tKtNt17990hjnOhl36GUBuO5kyw==} + '@react-types/tooltip@3.5.2': + resolution: {integrity: sha512-FvSuZ2WP08NEWefrpCdBYpEEZh/5TvqvGjq0wqGzWg2OPwpc14HjD8aE7I3MOuylXkD4MSlMjl7J4DlvlcCs3Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rollup/plugin-commonjs@28.0.1': resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -4259,19 +4326,14 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} - cpu: [arm64] + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] os: [android] '@rollup/rollup-android-arm64@4.59.0': @@ -4279,19 +4341,19 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} cpu: [arm64] - os: [darwin] + os: [android] '@rollup/rollup-darwin-arm64@4.59.0': resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} - cpu: [x64] + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] os: [darwin] '@rollup/rollup-darwin-x64@4.59.0': @@ -4299,19 +4361,19 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} - cpu: [arm64] - os: [freebsd] + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] '@rollup/rollup-freebsd-arm64@4.59.0': resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} - cpu: [x64] + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] os: [freebsd] '@rollup/rollup-freebsd-x64@4.59.0': @@ -4319,169 +4381,195 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} - cpu: [arm] - os: [linux] + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] '@rollup/rollup-linux-arm-gnueabihf@4.59.0': resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} - cpu: [arm64] + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} - cpu: [loong64] + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} - cpu: [ppc64] + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} - cpu: [riscv64] + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} - cpu: [s390x] + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} - cpu: [x64] + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] - os: [openbsd] + os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} - cpu: [arm64] - os: [openharmony] + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] '@rollup/rollup-openharmony-arm64@4.59.0': resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] - os: [win32] + os: [openharmony] '@rollup/rollup-win32-arm64-msvc@4.59.0': resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} - cpu: [ia32] + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] os: [win32] '@rollup/rollup-win32-ia32-msvc@4.59.0': @@ -4489,9 +4577,9 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} - cpu: [x64] + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] os: [win32] '@rollup/rollup-win32-x64-gnu@4.59.0': @@ -4499,8 +4587,8 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} cpu: [x64] os: [win32] @@ -4509,39 +4597,47 @@ packages: cpu: [x64] os: [win32] - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@sentry-internal/browser-utils@10.40.0': - resolution: {integrity: sha512-3CDeVNBXYOIvBVdT0SOdMZx5LzYDLuhGK/z7A14sYZz4Cd2+f4mSeFDaEOoH/g2SaY2CKR5KGkAADy8IyjZ21w==} + '@sentry-internal/browser-utils@10.45.0': + resolution: {integrity: sha512-ZPZpeIarXKScvquGx2AfNKcYiVNDA4wegMmjyGVsTA2JPmP0TrJoO3UybJS6KGDeee8V3I3EfD/ruauMm7jOFQ==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.40.0': - resolution: {integrity: sha512-V/ixkcdCNMo04KgsCEeNEu966xUUTD6czKT2LOAO5siZACqFjT/Rp9VR1n7QQrVo3sL7P3QNiTHtX0jaeWbwzg==} + '@sentry-internal/feedback@10.45.0': + resolution: {integrity: sha512-vCSurazFVq7RUeYiM5X326jA5gOVrWYD6lYX2fbjBOMcyCEhDnveNxMT62zKkZDyNT/jyD194nz/cjntBUkyWA==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.40.0': - resolution: {integrity: sha512-wzQwilFHO2baeCt0dTMf0eW+rgK8O+mkisf9sQzPXzG3Krr/iVtFg1T5T1Th3YsCsEdn6yQ3hcBPLEXjMSvccg==} + '@sentry-internal/replay-canvas@10.45.0': + resolution: {integrity: sha512-nvq/AocdZTuD7y0KSiWi3gVaY0s5HOFy86mC/v1kDZmT/jsBAzN5LDkk/f1FvsWma1peqQmpUqxvhC+YIW294Q==} engines: {node: '>=18'} - '@sentry-internal/replay@10.40.0': - resolution: {integrity: sha512-vsH2Ut0KIIQIHNdS3zzEGLJ2C9btbpvJIWAVk7l7oft66JzlUNC89qNaQ5SAypjLQx4Ln2V/ZTqfEoNzXOAsoQ==} + '@sentry-internal/replay@10.45.0': + resolution: {integrity: sha512-vjosRoGA1bzhVAEO1oce+CsRdd70quzBeo7WvYqpcUnoLe/Rv8qpOMqWX3j26z7XfFHMExWQNQeLxmtYOArvlw==} engines: {node: '>=18'} - '@sentry/babel-plugin-component-annotate@5.1.0': - resolution: {integrity: sha512-deEZGTxPMiVNcHXzYMcKEp2uGGU3Q+055nVH6vPHnzuxGoRNZRe2YZ5B1yP9gFD+LJGku8dJ4y3bs1iJrLGPtQ==} - engines: {node: '>= 14'} + '@sentry/babel-plugin-component-annotate@5.1.1': + resolution: {integrity: sha512-x2wEpBHwsTyTF2rWsLKJlzrRF1TTIGOfX+ngdE+Yd5DBkoS58HwQv824QOviPGQRla4/ypISqAXzjdDPL/zalg==} + engines: {node: '>= 18'} - '@sentry/browser@10.40.0': - resolution: {integrity: sha512-nCt3FKUMFad0C6xl5wCK0Jz+qT4Vev4fv6HJRn0YoNRRDQCfsUVxAz7pNyyiPNGM/WCDp9wJpGJsRvbBRd2anw==} + '@sentry/browser@10.45.0': + resolution: {integrity: sha512-e/a8UMiQhqqv706McSIcG6XK+AoQf9INthi2pD+giZfNRTzXTdqHzUT5OIO5hg8Am6eF63nDJc+vrYNPhzs51Q==} engines: {node: '>=18'} - '@sentry/bundler-plugin-core@5.1.0': - resolution: {integrity: sha512-/GDzz+UbT7fO3AbvquHDWuqYXWKv2tzCQZddzMYNv36P9wpof5SFELGG6HnfqFb5l2PeHNrVTtp2rrPBQO/OXw==} - engines: {node: '>= 14'} + '@sentry/bundler-plugin-core@5.1.1': + resolution: {integrity: sha512-F+itpwR9DyQR7gEkrXd2tigREPTvtF5lC8qu6e4anxXYRTui1+dVR0fXNwjpyAZMhIesLfXRN7WY7ggdj7hi0Q==} + engines: {node: '>= 18'} '@sentry/cli-darwin@2.58.5': resolution: {integrity: sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==} @@ -4595,18 +4691,18 @@ packages: engines: {node: '>= 10'} hasBin: true - '@sentry/core@10.40.0': - resolution: {integrity: sha512-/wrcHPp9Avmgl6WBimPjS4gj810a1wU5oX9fF1bzJfeIIbF3jTsAbv0oMbgDp0cSDnkwv2+NvcPnn3+c5J6pBA==} + '@sentry/core@10.45.0': + resolution: {integrity: sha512-s69UXxvefeQxuZ5nY7/THtTrIEvJxNVCp3ns4kwoCw1qMpgpvn/296WCKVmM7MiwnaAdzEKnAvLAwaxZc2nM7Q==} engines: {node: '>=18'} - '@sentry/nextjs@10.40.0': - resolution: {integrity: sha512-0aID+iQ/8oEfmB2j8RRnQqio0AQcxTMiuEV+ev8K64UqJOb64cXNGBYP7fAankd0/jQOvIOuHvZhoZi9pwiRbg==} + '@sentry/nextjs@10.45.0': + resolution: {integrity: sha512-4LE+UvnfdOYyG8YEb/9TWaJQzMPuGLlph/iqowvsMdxaW6la+mvADiuzNTXly4QfsjeD3KIb7dKlGTqiVV0Ttw==} engines: {node: '>=18'} peerDependencies: next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0 - '@sentry/node-core@10.40.0': - resolution: {integrity: sha512-ciZGOF54rJH9Fkg7V3v4gmWVufnJRqQQOrn0KStuo49vfPQAJLGePDx+crQv0iNVoLc6Hmrr6E7ebNHSb4NSAw==} + '@sentry/node-core@10.45.0': + resolution: {integrity: sha512-KQZEvLKM344+EqXiA9HIzWbW5hzq6/9nnFUQ8niaBPoOgR9AiJhrccfIscfgb8vjkriiEtzE03OW/4h1CTgZ3Q==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -4632,12 +4728,12 @@ packages: '@opentelemetry/semantic-conventions': optional: true - '@sentry/node@10.40.0': - resolution: {integrity: sha512-HQETLoNZTUUM8PBxFPT4X0qepzk5NcyWg3jyKUmF7Hh/19KSJItBXXZXxx+8l3PC2eASXUn70utXi65PoXEHWA==} + '@sentry/node@10.45.0': + resolution: {integrity: sha512-Kpiq9lRGnJc1ex8SwxOBl+FLQNl4Y137BydVooP7AFiAYZ6ftwHsIEF1bcYXaipHMT1YHS2bdhC2UQaaB2jkuQ==} engines: {node: '>=18'} - '@sentry/opentelemetry@10.40.0': - resolution: {integrity: sha512-Zx6T258qlEhQfdghIlazSTbK7uRO0pXWw4/4/VPR8pMOiRPh8dAoJg8AB0L55PYPMpVdXxNf7L9X0EZoDYibJw==} + '@sentry/opentelemetry@10.45.0': + resolution: {integrity: sha512-PmuGO+p/gC3ZQ8ddOeJ5P9ApnTTm35i12Bpuyb13AckCbNSJFvG2ggZda35JQOmiFU0kKYiwkoFAa8Mvj9od3Q==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -4646,22 +4742,26 @@ packages: '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 '@opentelemetry/semantic-conventions': ^1.39.0 - '@sentry/react@10.40.0': - resolution: {integrity: sha512-3T5W/e3QJMimXRIOx8xMEZbxeIuFiKlXvHLcMTLGygGBYnxQGeb8Oz/8heov+3zF1JoCIxeVQNFW0woySApfyA==} + '@sentry/react@10.45.0': + resolution: {integrity: sha512-jLezuxi4BUIU3raKyAPR5xMbQG/nhwnWmKo5p11NCbLmWzkS+lxoyDTUB4B8TAKZLfdtdkKLOn1S0tFc8vbUHw==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - '@sentry/vercel-edge@10.40.0': - resolution: {integrity: sha512-DdW8F5NE69Wm1CdKTaElFBtTsEzZZlYWs6tkHPY6GapQ97XY+71zu73cx7jFJgCGG/W4l0Em/BQlzNcw4U0V9A==} + '@sentry/vercel-edge@10.45.0': + resolution: {integrity: sha512-sSF+Ex5NwT60gMinLcP/JNZb3cDaIv0mL1cRjfvN6zN2ZNEw0C9rhdgxa0EdD4G6PCHQ0XnCuAMDsfJ6gnRmfA==} engines: {node: '>=18'} - '@sentry/webpack-plugin@5.1.0': - resolution: {integrity: sha512-GTLnr32ZIu6Gliy0z1J8N2S+WgWl5V1QeQj2BCpqA04hBOG1KK+dOX9z8yUKv2e5jvSQzpoyNNg3fBn08952cg==} - engines: {node: '>= 14'} + '@sentry/webpack-plugin@5.1.1': + resolution: {integrity: sha512-XgQg+t2aVrlQDfIiAEizqR/bsy6GtBygwgR+Kw11P/cYczj4W9PZ2IYqQEStBzHqnRTh5DbpyMcUNW2CujdA9A==} + engines: {node: '>= 18'} peerDependencies: webpack: '>=5.0.0' + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@sindresorhus/slugify@1.1.2': resolution: {integrity: sha512-V9nR/W0Xd9TSGXpZ4iFUcFGhuOJtZX82Fzxj1YISlbSgKvIiNa7eLEZrT0vAraPOt++KHauIVNYgGRgjc13dXA==} engines: {node: '>=10'} @@ -4676,111 +4776,201 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@stripe/react-stripe-js@5.6.0': - resolution: {integrity: sha512-tucu/vTGc+5NXbo2pUiaVjA4ENdRBET8qGS00BM4BAU8J4Pi3eY6BHollsP2+VSuzzlvXwMg0it3ZLhbCj2fPg==} + '@stripe/react-stripe-js@5.6.1': + resolution: {integrity: sha512-5xBrjkGmFvKvpMod6VvpOaFaa67eRbmieKeFTePZyOr/sUXzm7A3YY91l330pS0usUst5PxTZDUZHWfOc0v1GA==} peerDependencies: '@stripe/stripe-js': '>=8.0.0 <9.0.0' react: '>=16.8.0 <20.0.0' react-dom: '>=16.8.0 <20.0.0' - '@stripe/stripe-js@8.8.0': - resolution: {integrity: sha512-NNYuyW8qmLjyHnpyFgs/23wUrjB8k0xN9YIZFOMLewCa/pIkIji9e9aY/EgdNryEDDRptc6TcPIHRvG1R0ClFw==} + '@stripe/stripe-js@8.11.0': + resolution: {integrity: sha512-3fVF4z3efsgwgyj64nFK+6F4/vMw0mUXD2TBbOfftJtKVNx4JNv3CSfe1fY4DCtCk0JFp8/YPNcRkzgV0HJ8cg==} engines: {node: '>=12.16'} - '@supabase/auth-js@2.97.0': - resolution: {integrity: sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==} + '@supabase/auth-js@2.100.0': + resolution: {integrity: sha512-pdT3ye3UVRN1Cg0wom6BmyY+XTtp5DiJaYnPi6j8ht5i8Lq8kfqxJMJz9GI9YDKk3w1nhGOPnh6Qz5qpyYm+1w==} engines: {node: '>=20.0.0'} - '@supabase/functions-js@2.97.0': - resolution: {integrity: sha512-fSaA0ZeBUS9hMgpGZt5shIZvfs3Mvx2ZdajQT4kv/whubqDBAp3GU5W8iIXy21MRvKmO2NpAj8/Q6y+ZkZyF/w==} + '@supabase/functions-js@2.100.0': + resolution: {integrity: sha512-keLg79RPwP+uiwHuxFPTFgDRxPV46LM4j/swjyR2GKJgWniTVSsgiBHfbIBDcrQwehLepy09b/9QSHUywtKRWQ==} engines: {node: '>=20.0.0'} - '@supabase/postgrest-js@2.97.0': - resolution: {integrity: sha512-g4Ps0eaxZZurvfv/KGoo2XPZNpyNtjth9aW8eho9LZWM0bUuBtxPZw3ZQ6ERSpEGogshR+XNgwlSPIwcuHCNww==} + '@supabase/phoenix@0.4.0': + resolution: {integrity: sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==} + + '@supabase/postgrest-js@2.100.0': + resolution: {integrity: sha512-xYNvNbBJaXOGcrZ44wxwp5830uo1okMHGS8h8dm3u4f0xcZ39yzbryUsubTJW41MG2gbL/6U57cA4Pi6YMZ9pA==} engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.97.0': - resolution: {integrity: sha512-37Jw0NLaFP0CZd7qCan97D1zWutPrTSpgWxAw6Yok59JZoxp4IIKMrPeftJ3LZHmf+ILQOPy3i0pRDHM9FY36Q==} + '@supabase/realtime-js@2.100.0': + resolution: {integrity: sha512-2AZs00zzEF0HuCKY8grz5eCYlwEfVi5HONLZFoNR6aDfxQivl8zdQYNjyFoqN2MZiVhQHD7u6XV/xHwM8mCEHw==} engines: {node: '>=20.0.0'} - '@supabase/ssr@0.8.0': - resolution: {integrity: sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==} + '@supabase/ssr@0.9.0': + resolution: {integrity: sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q==} peerDependencies: - '@supabase/supabase-js': ^2.76.1 + '@supabase/supabase-js': ^2.97.0 - '@supabase/storage-js@2.97.0': - resolution: {integrity: sha512-9f6NniSBfuMxOWKwEFb+RjJzkfMdJUwv9oHuFJKfe/5VJR8cd90qw68m6Hn0ImGtwG37TUO+QHtoOechxRJ1Yg==} + '@supabase/storage-js@2.100.0': + resolution: {integrity: sha512-d4EeuK6RNIgYNA2MU9kj8lQrLm5AzZ+WwpWjGkii6SADQNIGTC/uiaTRu02XJ5AmFALQfo8fLl9xuCkO6Xw+iQ==} engines: {node: '>=20.0.0'} - '@supabase/supabase-js@2.97.0': - resolution: {integrity: sha512-kTD91rZNO4LvRUHv4x3/4hNmsEd2ofkYhuba2VMUPRVef1RCmnHtm7rIws38Fg0yQnOSZOplQzafn0GSiy6GVg==} + '@supabase/supabase-js@2.100.0': + resolution: {integrity: sha512-r0tlcukejJXJ1m/2eG/Ya5eYs4W8AC7oZfShpG3+SIo/eIU9uIt76ZeYI1SoUwUmcmzlAbgch+HDZDR/toVQPQ==} engines: {node: '>=20.0.0'} + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-x64-gnu@1.15.18': + resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.18': + resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.18': + resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.18': + resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.18': + resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/helpers@0.5.18': - resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} - '@tailwindcss/node@4.2.1': - resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@tailwindcss/oxide-android-arm64@4.2.1': - resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + '@tabby_ai/hijri-converter@1.0.5': + resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} + engines: {node: '>=16.0.0'} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.1': - resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.1': - resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.1': - resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -4791,30 +4981,30 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.1': - resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} engines: {node: '>= 20'} - '@tailwindcss/postcss@4.2.1': - resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} + '@tailwindcss/postcss@4.2.2': + resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} - '@tanstack/query-core@5.90.20': - resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + '@tanstack/query-core@5.95.2': + resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} - '@tanstack/react-query@5.90.21': - resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + '@tanstack/react-query@5.95.2': + resolution: {integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==} peerDependencies: react: ^18 || ^19 @@ -4834,61 +5024,47 @@ packages: peerDependencies: yjs: ^13 - '@trivago/prettier-plugin-sort-imports@6.0.2': - resolution: {integrity: sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==} - engines: {node: '>= 20'} - peerDependencies: - '@vue/compiler-sfc': 3.x - prettier: 2.x - 3.x - prettier-plugin-ember-template-tag: '>= 2.0.0' - prettier-plugin-svelte: 3.x - svelte: 4.x || 5.x - peerDependenciesMeta: - '@vue/compiler-sfc': - optional: true - prettier-plugin-ember-template-tag: - optional: true - prettier-plugin-svelte: - optional: true - svelte: - optional: true - '@ts-gql/tag@0.7.3': resolution: {integrity: sha512-qWBoe5TGXs7l6lrdSfqAhsZP1aW9vEoZvjy5hPsiMwQ7VB8PyK2TFmLCijLmdeKSiY7BSzff20xZZrLIMB+IKQ==} peerDependencies: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || 14 || 15 || 16 - '@turbo/gen-darwin-64@2.8.11': - resolution: {integrity: sha512-xrx2diQfiZOQ//BTDDjBfT8GG5gKUQ15QlvHFn/9kSyZSBZUkgDeuCbk12K15/kZfjfa6e0Il0guVCKld2pzhg==} + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@turbo/darwin-64@2.8.20': + resolution: {integrity: sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ==} cpu: [x64] os: [darwin] - '@turbo/gen-darwin-arm64@2.8.11': - resolution: {integrity: sha512-D7sSxQLRU4pHWvz9o+SXtfGgRgQ/UEi4DKXNZViSIvCctq5rSzQFioD7M0BigUBLYTxxb7cAbY9PFNmPbe8rkw==} + '@turbo/darwin-arm64@2.8.20': + resolution: {integrity: sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw==} cpu: [arm64] os: [darwin] - '@turbo/gen-linux-64@2.8.11': - resolution: {integrity: sha512-NmzZ+GVs2DuJFBsTXlViOcANA4xWmOl1VN8UfmbYuZzkaH9oIB4L5ehg88PZkzO12yn8qULnyzqGxNYa/YzAKw==} + '@turbo/gen@2.8.20': + resolution: {integrity: sha512-SazKn5Pc9mitpc3uc6Pmf+QhkNtvF5t6Ro0V1cuc0QFhblbfw4KwWqFnnfTEmGzgDtb2CZJB3BK8LFMBX52eLg==} + hasBin: true + + '@turbo/linux-64@2.8.20': + resolution: {integrity: sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA==} cpu: [x64] os: [linux] - '@turbo/gen-linux-arm64@2.8.11': - resolution: {integrity: sha512-Y4yqldcNZXdIYWfm/iByOxYjeXYw53gHU0NRlbwD4hqW5v/Hi/846ehVtEGH4hzmy4r94k4jrEu+ftxK/oJSUw==} + '@turbo/linux-arm64@2.8.20': + resolution: {integrity: sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA==} cpu: [arm64] os: [linux] - '@turbo/gen-windows-64@2.8.11': - resolution: {integrity: sha512-qHGQum/Xae5BThd0A/a8w3yjq5EndJj4szjXxZlzTQEddzMCAFSO5DfJpRWkSZ/g8ei/6JebFiIAxZMkuQ1SAw==} + '@turbo/windows-64@2.8.20': + resolution: {integrity: sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw==} cpu: [x64] os: [win32] - '@turbo/gen@2.8.11': - resolution: {integrity: sha512-WgTEEwq1NOmDjDRUu+F1qLJtyIDnguWX/GxML6Q+MnOQDk9va+pkkdWsscIy/CqOputOgCE5wOlEgefE+frydg==} - hasBin: true - - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@turbo/windows-arm64@2.8.20': + resolution: {integrity: sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw==} + cpu: [arm64] + os: [win32] '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -4923,8 +5099,8 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -4935,38 +5111,26 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/glob@7.2.0': - resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/inquirer@6.5.0': - resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} - '@types/is-hotkey@0.1.10': resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/linkify-it@3.0.5': resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} - '@types/lodash@4.17.23': - resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} '@types/markdown-it@12.2.3': resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} @@ -4977,18 +5141,14 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/minimatch@6.0.0': - resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} - deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} '@types/mysql@2.15.27': resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} - '@types/node@25.3.1': - resolution: {integrity: sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==} + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} '@types/nodemailer@7.0.11': resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} @@ -5002,9 +5162,6 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} - '@types/phoenix@1.6.7': - resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -5013,175 +5170,27 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} - '@types/through@0.0.33': - resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} - '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.55.0': - resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.55.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.55.0': - resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.55.0': - resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.55.0': - resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.55.0': - resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.55.0': - resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.55.0': - resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.55.0': - resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.55.0': - resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.55.0': - resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} - cpu: [arm] - os: [android] - - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} - cpu: [arm64] - os: [android] - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} - cpu: [arm64] - os: [darwin] - - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} - cpu: [x64] - os: [darwin] - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} - cpu: [x64] - os: [freebsd] - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} - cpu: [arm64] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} - cpu: [arm64] - os: [linux] - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} - cpu: [ppc64] - os: [linux] - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} - cpu: [riscv64] - os: [linux] - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} - cpu: [riscv64] - os: [linux] - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} - cpu: [s390x] - os: [linux] - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} - cpu: [x64] - os: [linux] - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} - cpu: [x64] - os: [linux] - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} - cpu: [arm64] - os: [win32] - - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} - cpu: [ia32] - os: [win32] - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} - cpu: [x64] - os: [win32] - '@urql/core@5.2.0': resolution: {integrity: sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==} @@ -5200,34 +5209,34 @@ packages: peerDependencies: '@urql/core': ^5.0.0 - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.1': + resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.1': + resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.1': + resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.1': + resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.1': + resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.1': + resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.1': + resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5300,15 +5309,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -5322,10 +5326,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -5347,20 +5347,17 @@ packages: peerDependencies: ajv: ^8.8.2 - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -5379,56 +5376,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} @@ -5438,18 +5392,6 @@ packages: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axe-core@4.11.1: - resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} - engines: {node: '>=4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -5460,16 +5402,18 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.3: - resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} - engines: {node: 20 || >=22} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.10.10: + resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + baseline-browser-mapping@2.10.7: + resolution: {integrity: sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==} + engines: {node: '>=6.0.0'} hasBin: true bin-links@6.0.0: @@ -5487,18 +5431,11 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} - - brace-expansion@5.0.3: - resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -5513,6 +5450,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5531,10 +5472,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -5543,14 +5480,14 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camel-case@3.0.0: - resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} - caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001778: + resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} + + caniuse-lite@1.0.30001780: + resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -5559,12 +5496,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - change-case@3.1.0: - resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -5578,9 +5512,6 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -5606,18 +5537,14 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-width@3.0.0: - resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} - engines: {node: '>= 10'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -5625,6 +5552,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -5639,6 +5570,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -5656,6 +5590,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -5676,9 +5614,6 @@ packages: compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -5689,9 +5624,6 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - constant-case@2.0.0: - resolution: {integrity: sha512-eS0N9WwmjTqrOmR3o83F5vW8Z+9R1HnVz3xmzT2PMFug9ly+Au/fxRWlEBSb6LcZwspSsEn9Xs1uw9YgzAg1EQ==} - content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -5718,9 +5650,6 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - core-js-pure@3.48.0: - resolution: {integrity: sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==} - cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -5729,6 +5658,15 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -5751,8 +5689,8 @@ packages: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} css-what@6.2.2: @@ -5764,8 +5702,8 @@ packages: engines: {node: '>=4'} hasBin: true - cssnano-preset-default@7.0.10: - resolution: {integrity: sha512-6ZBjW0Lf1K1Z+0OKUAUpEN62tSXmYChXWi2NAA0afxEVsj9a+MbcB1l5qel6BHJHmULai2fCGRthCeKSFbScpA==} + cssnano-preset-default@7.0.11: + resolution: {integrity: sha512-waWlAMuCakP7//UCY+JPrQS1z0OSLeOXk2sKWJximKWGupVxre50bzPlvpbUwZIDylhf/ptf0Pk+Yf7C+hoa3g==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -5776,8 +5714,8 @@ packages: peerDependencies: postcss: ^8.4.32 - cssnano@7.1.2: - resolution: {integrity: sha512-HYOPBsNvoiFeR1eghKD5C3ASm64v9YVyJB4Ivnl2gqKoQYvjjN/G0rztvKQq8OxocUtC6sjqY8jwYngIB4AByA==} + cssnano@7.1.3: + resolution: {integrity: sha512-mLFHQAzyapMVFLiJIn7Ef4C2UCEvtlTlbyILR6B5ZsUAV3D/Pa761R5uC1YPhyBkRd3eqaDm2ncaNrD7R4mTRg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -5833,25 +5771,10 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} @@ -5864,14 +5787,6 @@ packages: debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5890,28 +5805,37 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} - del@5.1.0: - resolution: {integrity: sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==} - engines: {node: '>=8'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} @@ -5935,21 +5859,14 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} direction@1.0.4: resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} hasBin: true - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -5963,13 +5880,6 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dot-case@2.1.1: - resolution: {integrity: sha512-HnM6ZlFqcajLsyudHq7LeeLDr2rFAVYtDv/hV5qchQEidSck8j9OPUsXY9KwJv/lHMtYlX4DjRQqwFYa+0r8Ug==} - - dotenv@16.0.3: - resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} - engines: {node: '>=12'} - dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -5985,21 +5895,38 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + eciesjs@0.4.18: + resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-to-chromium@1.5.313: + resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} emery@1.4.4: resolution: {integrity: sha512-mMoO3uGDoiw/DmZ/YekT9gEoC0IFAXNWzYVukY8+/j0Wt8un1IDraIYGx+cMbRh+fHaCDE6Ui7zFAN8ezZSsAA==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -6007,21 +5934,21 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -6030,13 +5957,6 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.2.2: - resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -6044,23 +5964,24 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -6068,10 +5989,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -6084,129 +6001,14 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.1.6: - resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} - peerDependencies: - eslint: '>=9.0.0' - typescript: '>=3.3.1' - peerDependenciesMeta: - typescript: - optional: true - - eslint-config-turbo@2.8.11: - resolution: {integrity: sha512-LMV1ZUNmzPxUEYf61N5SCtGxCx8+ZNnrHQhYshDdQCbqUUAdpCYq65foRzCTnHz9Ge57/ll9mWXllZHrsLyNAg==} - peerDependencies: - eslint: '>6.6.0' - turbo: '>2.0.0' - - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - - eslint-import-resolver-typescript@3.10.1: - resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - eslint: '*' - eslint-plugin-import: '*' - eslint-plugin-import-x: '*' - peerDependenciesMeta: - eslint-plugin-import: - optional: true - eslint-plugin-import-x: - optional: true - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-jsx-a11y@6.10.2: - resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - - eslint-plugin-turbo@2.8.11: - resolution: {integrity: sha512-DTrc61/Ppvq5xt7tAukmmcL3o8aAKFi5SPTLZF2w5UeFpFuEM7ZptFdoTsdNZfQpOWKJ+sF7quv7usO11IP/kQ==} - peerDependencies: - eslint: '>6.6.0' - turbo: '>2.0.0' - eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} - eslint-scope@9.1.1: - resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.0.1: - resolution: {integrity: sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@11.1.1: - resolution: {integrity: sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -6232,10 +6034,6 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -6244,8 +6042,8 @@ packages: resolution: {integrity: sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==} engines: {node: '>=10.13.0'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} @@ -6259,12 +6057,20 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -6273,10 +6079,6 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} - facepaint@1.2.1: resolution: {integrity: sha512-oNvBekbhsm/0PNSOWca5raHNAi6dG960Bx6LJgxDPNF59WpuspgQ17bN5MKwOr7JcFdQYc7StW3VZ28DBZLavQ==} @@ -6286,24 +6088,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@5.4.0: - resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} - engines: {node: '>=6.0.0'} - - fast-glob@3.3.1: - resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} - engines: {node: '>=8.6.0'} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -6326,13 +6114,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - figures@3.2.0: - resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} - engines: {node: '>=8'} - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} file-selector@2.1.2: resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} @@ -6356,17 +6140,6 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -6382,8 +6155,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} @@ -6398,21 +6172,21 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -6421,25 +6195,26 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} @@ -6447,22 +6222,6 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - globals@16.4.0: - resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} - engines: {node: '>=18'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - globby@10.0.2: - resolution: {integrity: sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==} - engines: {node: '>=8'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -6479,42 +6238,22 @@ packages: peerDependencies: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + graphql@16.13.1: + resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -6523,28 +6262,19 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - header-case@1.0.1: - resolution: {integrity: sha512-i0q9mkOeSuhXw6bGgiQCCBgY/jlZuV/7dZXyZ9c6LcBrqwvT8eT719E9uxE5LiZftdl+z81Ugbg/VvXV4OJOeQ==} + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - - hono@4.12.2: - resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-parse-stringify@3.0.1: - resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -6564,32 +6294,25 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - i18next-browser-languagedetector@8.2.1: - resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} - i18next-resources-to-backend@1.2.1: - resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} - - i18next@25.8.13: - resolution: {integrity: sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==} - peerDependencies: - typescript: ^5 - peerDependenciesMeta: - typescript: - optional: true + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} iceberg-js@0.8.1: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + icu-minify@4.8.3: + resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==} + idb-keyval@6.2.2: resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} @@ -6600,9 +6323,11 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} immer@9.0.21: resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} @@ -6614,17 +6339,9 @@ packages: import-in-the-middle@2.0.6: resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -6638,14 +6355,6 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - inquirer@7.3.3: - resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==} - engines: {node: '>=8.0.0'} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -6653,8 +6362,11 @@ packages: intl-messageformat@10.7.18: resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + intl-messageformat@11.1.2: + resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -6667,67 +6379,33 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-bun-module@2.0.0: - resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -6741,32 +6419,33 @@ packages: is-hotkey@0.2.0: resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} - is-lower-case@1.1.3: - resolution: {integrity: sha512-+5A1e/WJpLLXZEDlgz4G//WYSHyQBD32qa4Jd3Lw06qQlv3fJHnp3YIHjTQSGzHMgzmVKz2ZP3rBxTHkPw/lxA==} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-cwd@2.2.0: - resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} - engines: {node: '>=6'} + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} @@ -6778,65 +6457,40 @@ packages: is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-upper-case@1.1.2: - resolution: {integrity: sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw==} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isbinaryfile@4.0.10: - resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} - engines: {node: '>= 8.0.0'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} - iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} - engines: {node: '>= 0.4'} - - javascript-natural-sort@0.7.1: - resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} - jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -6848,8 +6502,8 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jose@6.2.1: + resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -6867,134 +6521,118 @@ packages: engines: {node: '>=6'} hasBin: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jssha@3.3.1: resolution: {integrity: sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==} - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} ky@1.14.3: resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} engines: {node: '>=18'} - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - lib0@0.2.117: resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} engines: {node: '>=16'} hasBin: true - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} lilconfig@3.1.3: @@ -7016,16 +6654,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.23: - resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} - lodash.deburr@4.1.0: resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==} - lodash.get@4.4.2: - resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} - deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. - lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -7035,6 +6666,10 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -7042,34 +6677,24 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lower-case-first@1.0.2: - resolution: {integrity: sha512-UuxaYakO7XeONbKrZf5FEgkantPf5DUqDayzP5VXZrtRPdH86s4kN47I8B3TW10S4QKiE3ziHNf3kRN//okHjA==} - - lower-case@1.1.4: - resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} - lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.575.0: - resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==} + lucide-react@1.0.1: + resolution: {integrity: sha512-lih7tKEczCYOQjVEzpFuxEuNzlwf+1yhvlMlEkGWJM3va8Pugv8bYXc/pRtcjPncaP7k84X0Pt/71ufxvqEPtQ==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@0.30.8: - resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} - engines: {node: '>=12'} - markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -7088,8 +6713,8 @@ packages: mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} mdast-util-gfm-autolink-literal@2.0.1: resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} @@ -7124,8 +6749,8 @@ packages: mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} @@ -7259,26 +6884,16 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} - engines: {node: 18 || 20 || >=22} - - minimatch@10.2.3: - resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} - engines: {node: 18 || 20 || >=22} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -7292,12 +6907,8 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -7309,8 +6920,15 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mute-stream@0.0.8: - resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + msw@2.12.14: + resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} @@ -7324,19 +6942,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.6: - resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + nanoid@5.1.7: + resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} engines: {node: ^18 || >=20} hasBin: true - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -7344,6 +6954,33 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next-intl-swc-plugin-extractor@4.8.3: + resolution: {integrity: sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg==} + + next-intl@4.8.3: + resolution: {integrity: sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + next-runtime-env@3.3.0: + resolution: {integrity: sha512-JgKVnog9mNbjbjH9csVpMnz2tB2cT5sLF+7O47i6Ze/s/GoiKdV7dHhJHk1gwXpo6h5qPj5PTzryldtSjvrHuQ==} + peerDependencies: + next: ^14 + react: ^18 + + next-safe-action@8.1.8: + resolution: {integrity: sha512-e/HZ886xsKtaBr/+rL1ULq6RHlaZoLJR2PMLqkxFES0VKmhMBY/Ov/sOgoBcOqLAE8N9q3swW+95gyiZxoOIKA==} + engines: {node: '>=18.17'} + peerDependencies: + next: '>= 14.0.0' + react: '>= 18.2.0' + react-dom: '>= 18.2.0' + next-sitemap@4.2.3: resolution: {integrity: sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==} engines: {node: '>=14.18'} @@ -7357,8 +6994,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + next@16.2.1: + resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -7378,8 +7015,8 @@ packages: sass: optional: true - no-case@2.3.2: - resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} @@ -7399,32 +7036,36 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-html-parser@7.0.2: - resolution: {integrity: sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ==} + node-html-parser@7.1.0: + resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} - node-plop@0.26.3: - resolution: {integrity: sha512-Cov028YhBZ5aB7MdMWJEmwyBig43aGL5WT4vdoB28Oitau1zZAcHUn8Sgfk9HM33TqhtLJ9PlM/O0Mv+QpV/4Q==} - engines: {node: '>=8.9.4'} + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - - nodemailer@8.0.1: - resolution: {integrity: sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==} + nodemailer@8.0.3: + resolution: {integrity: sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==} engines: {node: '>=6.0.0'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - nosecone@1.1.0: - resolution: {integrity: sha512-h5ohM73+elGG/I8KmkIiTUQ4IbIx0Q0k4ng67YJfWo6NfOVX1FMW2q2ipwmZEmB+B7K/ZuXdi5lAiaHXTJ2gHg==} + nosecone@1.3.0: + resolution: {integrity: sha512-AYEacOpXmpbBX+GheA3Lbp8CxxsWtNanMjINFi9mt0pvPUaDCJc3VSffU21O0QDRtBM8hrBWhRVyvTqShaIHdw==} engines: {node: '>=20'} npm-normalize-package-bin@5.0.0: resolution: {integrity: sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==} engines: {node: ^20.17.0 || >=22.9.0} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -7436,29 +7077,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -7478,24 +7099,42 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} + oxfmt@0.41.0: + resolution: {integrity: sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + oxlint@1.56.0: + resolution: {integrity: sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.15.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} @@ -7509,17 +7148,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@3.0.0: - resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==} - engines: {node: '>=8'} - package-json@10.0.1: resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} engines: {node: '>=18'} - param-case@2.1.1: - resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7532,15 +7164,13 @@ packages: engines: {node: '>= 0.10'} hasBin: true - parse-imports-exports@0.2.4: - resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} - parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} - parse-statements@1.0.11: - resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -7552,24 +7182,21 @@ packages: partysocket@0.0.22: resolution: {integrity: sha512-HmFJoVA48vfU5VaQ539YnQt+/QncV5wdlN7vEW//m8eCnOV2PKB8X08c7hI4VLrqntajaWovHhprWHgXbXgR1A==} - pascal-case@2.0.1: - resolution: {integrity: sha512-qjS4s8rBOJa2Xm0jmxXiyh1+OFf6ekCWOvUaRgAQSktzlTbMotS0nmG9gyYAybCWBcuP4fsBeRCKNwGBnMe2OQ==} - - path-case@2.1.1: - resolution: {integrity: sha512-Ou0N05MioItesaLr9q8TtHVWmJ6fxWdqKB2RohFmNWVyJ+2zeKIeDNWAN6B/Pe7wpzWChhZX6nONYmOnMeJQ/Q==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -7577,6 +7204,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -7594,8 +7224,8 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-protocol@1.11.0: - resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} @@ -7612,6 +7242,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} @@ -7650,9 +7284,8 @@ packages: engines: {node: '>=18'} hasBin: true - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} + po-parser@2.1.1: + resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} postcss-calc@10.1.1: resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} @@ -7660,20 +7293,20 @@ packages: peerDependencies: postcss: ^8.4.38 - postcss-colormin@7.0.5: - resolution: {integrity: sha512-ekIBP/nwzRWhEMmIxHHbXHcMdzd1HIUzBECaj5KEdLz9DVP2HzT065sEhvOx1dkLjYW7jyD0CngThx6bpFi2fA==} + postcss-colormin@7.0.6: + resolution: {integrity: sha512-oXM2mdx6IBTRm39797QguYzVEWzbdlFiMNfq88fCCN1Wepw3CYmJ/1/Ifa/KjWo+j5ZURDl2NTldLJIw51IeNQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 - postcss-convert-values@7.0.8: - resolution: {integrity: sha512-+XNKuPfkHTCEo499VzLMYn94TiL3r9YqRE3Ty+jP7UX4qjewUONey1t7CG21lrlTLN07GtGM8MqFVp86D4uKJg==} + postcss-convert-values@7.0.9: + resolution: {integrity: sha512-l6uATQATZaCa0bckHV+r6dLXfWtUBKXxO3jK+AtxxJJtgMPD+VhhPCCx51I4/5w8U5uHV67g3w7PXj+V3wlMlg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 - postcss-discard-comments@7.0.5: - resolution: {integrity: sha512-IR2Eja8WfYgN5n32vEGSctVQ1+JARfu4UH8M7bgGh1bC+xI/obsPJXaBpQF7MAByvgwZinhpHpdrmXtvVVlKcQ==} + postcss-discard-comments@7.0.6: + resolution: {integrity: sha512-Sq+Fzj1Eg5/CPf1ERb0wS1Im5cvE2gDXCE+si4HCn1sf+jpQZxDI4DXEp8t77B/ImzDceWE2ebJQFXdqZ6GRJw==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -7720,8 +7353,8 @@ packages: peerDependencies: postcss: ^8.4.32 - postcss-merge-rules@7.0.7: - resolution: {integrity: sha512-njWJrd/Ms6XViwowaaCc+/vqhPG3SmXn725AGrnl+BgTuRPEacjiLEaGq16J6XirMJbtKkTwnt67SS+e2WGoew==} + postcss-merge-rules@7.0.8: + resolution: {integrity: sha512-BOR1iAM8jnr7zoQSlpeBmCsWV5Uudi/+5j7k05D0O/WP3+OFMPD86c1j/20xiuRtyt45bhxw/7hnhZNhW2mNFA==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -7738,14 +7371,14 @@ packages: peerDependencies: postcss: ^8.4.32 - postcss-minify-params@7.0.5: - resolution: {integrity: sha512-FGK9ky02h6Ighn3UihsyeAH5XmLEE2MSGH5Tc4tXMFtEDx7B+zTG6hD/+/cT+fbF7PbYojsmmWjyTwFwW1JKQQ==} + postcss-minify-params@7.0.6: + resolution: {integrity: sha512-YOn02gC68JijlaXVuKvFSCvQOhTpblkcfDre2hb/Aaa58r2BIaK4AtE/cyZf2wV7YKAG+UlP9DT+By0ry1E4VQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 - postcss-minify-selectors@7.0.5: - resolution: {integrity: sha512-x2/IvofHcdIrAm9Q+p06ZD1h6FPcQ32WtCRVodJLDR+WMn8EVHI1kvLxZuGKz/9EY5nAmI6lIQIrpo4tBy5+ug==} + postcss-minify-selectors@7.0.6: + resolution: {integrity: sha512-lIbC0jy3AAwDxEgciZlBullDiMBeBCT+fz5G8RcA9MWqh/hfUkpOI3vNDUNEZHgokaoiv0juB9Y8fGcON7rU/A==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -7786,8 +7419,8 @@ packages: peerDependencies: postcss: ^8.4.32 - postcss-normalize-unicode@7.0.5: - resolution: {integrity: sha512-X6BBwiRxVaFHrb2WyBMddIeB5HBjJcAaUHyhLrM2FsxSq5TFqcHSsK7Zu1otag+o0ZphQGJewGH1tAyrD0zX1Q==} + postcss-normalize-unicode@7.0.6: + resolution: {integrity: sha512-z6bwTV84YW6ZvvNoaNLuzRW4/uWxDKYI1iIDrzk6D2YTL7hICApy+Q1LP6vBEsljX8FM7YSuV9qI79XESd4ddQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -7810,8 +7443,8 @@ packages: peerDependencies: postcss: ^8.4.32 - postcss-reduce-initial@7.0.5: - resolution: {integrity: sha512-RHagHLidG8hTZcnr4FpyMB2jtgd/OcyAazjMhoy5qmWJOx1uxKh4ntk0Pb46ajKM0rkf32lRH4C8c9qQiPR6IA==} + postcss-reduce-initial@7.0.6: + resolution: {integrity: sha512-G6ZyK68AmrPdMB6wyeA37ejnnRG2S8xinJrZJnOv+IaRKf6koPAVbQsiC7MfkmXaGmF1UO+QCijb27wfpxuRNg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -7826,14 +7459,14 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss-svgo@7.1.0: - resolution: {integrity: sha512-KnAlfmhtoLz6IuU3Sij2ycusNs4jPW+QoFE5kuuUOK8awR6tMxZQrs5Ey3BUz7nFCzT3eqyFgqkyrHiaU2xx3w==} + postcss-svgo@7.1.1: + resolution: {integrity: sha512-zU9H9oEDrUFKa0JB7w+IYL7Qs9ey1mZyjhbf0KLxwJDdDRtoPvCmaEfknzqfHj44QS9VD6c5sJnBAVYTLRg/Sg==} engines: {node: ^18.12.0 || ^20.9.0 || >= 18} peerDependencies: postcss: ^8.4.32 - postcss-unique-selectors@7.0.4: - resolution: {integrity: sha512-pmlZjsmEAG7cHd7uK3ZiNSW6otSZ13RHuZ/4cDN/bVglS5EpF2r2oxY99SuOHa8m7AWoBCelTS3JPpzsIs8skQ==} + postcss-unique-selectors@7.0.5: + resolution: {integrity: sha512-3QoYmEt4qg/rUWDn6Tc8+ZVPmbp4G1hXDtCNWDx0st8SjtCbRcxRXDDM1QrEiXGG3A45zscSJFb4QH90LViyxg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -7845,8 +7478,8 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -7869,70 +7502,19 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier-plugin-tailwindcss@0.7.2: - resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} - engines: {node: '>=20.19'} - peerDependencies: - '@ianvs/prettier-plugin-sort-imports': '*' - '@prettier/plugin-hermes': '*' - '@prettier/plugin-oxc': '*' - '@prettier/plugin-pug': '*' - '@shopify/prettier-plugin-liquid': '*' - '@trivago/prettier-plugin-sort-imports': '*' - '@zackad/prettier-plugin-twig': '*' - prettier: ^3.0 - prettier-plugin-astro: '*' - prettier-plugin-css-order: '*' - prettier-plugin-jsdoc: '*' - prettier-plugin-marko: '*' - prettier-plugin-multiline-arrays: '*' - prettier-plugin-organize-attributes: '*' - prettier-plugin-organize-imports: '*' - prettier-plugin-sort-imports: '*' - prettier-plugin-svelte: '*' - peerDependenciesMeta: - '@ianvs/prettier-plugin-sort-imports': - optional: true - '@prettier/plugin-hermes': - optional: true - '@prettier/plugin-oxc': - optional: true - '@prettier/plugin-pug': - optional: true - '@shopify/prettier-plugin-liquid': - optional: true - '@trivago/prettier-plugin-sort-imports': - optional: true - '@zackad/prettier-plugin-twig': - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-css-order: - optional: true - prettier-plugin-jsdoc: - optional: true - prettier-plugin-marko: - optional: true - prettier-plugin-multiline-arrays: - optional: true - prettier-plugin-organize-attributes: - optional: true - prettier-plugin-organize-imports: - optional: true - prettier-plugin-sort-imports: - optional: true - prettier-plugin-svelte: - optional: true + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -7948,6 +7530,10 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -7972,8 +7558,8 @@ packages: prosemirror-transform@1.11.0: resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} - prosemirror-view@1.41.6: - resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} + prosemirror-view@1.41.7: + resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==} proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -7985,12 +7571,8 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} @@ -8002,22 +7584,6 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - radix-ui@1.4.3: - resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -8030,8 +7596,8 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-day-picker@9.13.2: - resolution: {integrity: sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==} + react-day-picker@9.14.0: + resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} engines: {node: '>=18'} peerDependencies: react: '>=16.8.0' @@ -8047,33 +7613,26 @@ packages: peerDependencies: react: '>= 16.8 || 18.0.0' - react-hook-form@7.71.2: - resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==} + react-hook-form@7.72.0: + resolution: {integrity: sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - react-i18next@16.5.4: - resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} - peerDependencies: - i18next: '>= 25.6.2' - react: '>= 16.8.0' - react-dom: '*' - react-native: '*' - typescript: ^5 - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - typescript: - optional: true - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} @@ -8095,11 +7654,11 @@ packages: '@types/react': optional: true - react-smooth@4.0.4: - resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + react-resizable-panels@4.7.5: + resolution: {integrity: sha512-ma22FpbUolymMK6xIwZOzzNxszi59kZdJiw805byxuGBrjAs8HngpQrrgEp5dj1OOV2jVFBCJxhVult6G+2KaQ==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} @@ -8117,12 +7676,6 @@ packages: peerDependencies: react: ^16 || ^17 || ^18 || ^19 - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -8143,23 +7696,25 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} - recharts-scale@0.4.5: - resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} - recharts@2.15.3: - resolution: {integrity: sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==} - engines: {node: '>=14'} + recharts@3.7.0: + resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} + engines: {node: '>=18'} peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} registry-auth-token@5.1.1: resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} @@ -8172,6 +7727,10 @@ packages: remove-accents@0.5.0: resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -8180,6 +7739,9 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -8188,38 +7750,29 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} - hasBin: true + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -8230,35 +7783,16 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - run-async@2.4.1: - resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} - engines: {node: '>=0.12.0'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@6.6.7: - resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} - engines: {npm: '>=2.0.0'} - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -8266,8 +7800,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sax@1.4.4: - resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + sax@1.5.0: + resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} engines: {node: '>=11.0.0'} scheduler@0.27.0: @@ -8305,12 +7839,6 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - sentence-case@2.1.1: - resolution: {integrity: sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ==} - - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -8318,21 +7846,13 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shadcn@4.1.0: + resolution: {integrity: sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==} + hasBin: true + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -8375,9 +7895,8 @@ packages: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} slate-history@0.86.0: resolution: {integrity: sha512-OxObL9tbhgwvSlnKSCpGIh7wnuaqvOj5jRExGjEyCU2Ke8ctf22HjT+jw7GEi9ttLzNTUmTEU3YIzqKGeqN+og==} @@ -8394,9 +7913,6 @@ packages: slate@0.91.4: resolution: {integrity: sha512-aUJ3rpjrdi5SbJ5G1Qjr3arytfRkEStTmHjBfWq2A2Q8MybacIzkScSvGJjQkdTk3djCK9C9SEOt39sSeZFwTw==} - snake-case@2.1.0: - resolution: {integrity: sha512-FMR5YoPFwOLuh4rRz92dywJjyKYZNLpMn1R5ujVpIYkbA9p01fq8RMg0FkO4M+Yobt4MjHeLTJVm5xFFBHSV2Q==} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -8429,9 +7945,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - stable-hash@0.0.5: - resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -8443,51 +7956,51 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} - - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} - - string.prototype.repeat@1.0.0: - resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -8496,8 +8009,8 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - stripe@20.4.0: - resolution: {integrity: sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==} + stripe@20.4.1: + resolution: {integrity: sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==} engines: {node: '>=16'} peerDependencies: '@types/node': '>=16' @@ -8518,8 +8031,8 @@ packages: babel-plugin-macros: optional: true - stylehacks@7.0.7: - resolution: {integrity: sha512-bJkD0JkEtbRrMFtwgpJyBbFIwfDDONQ1Ov3sDLZQP8HuJ73kBOyx66H4bOcAbVWmnfLdvQ0AJwXxOMkpujcO6g==} + stylehacks@7.0.8: + resolution: {integrity: sha512-I3f053GBLIiS5Fg6OMFhq/c+yW+5Hc2+1fgq7gElDMMSqwlRb3tBf2ef6ucLStYRpId4q//bQO1FjcyNyy4yDQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.4.32 @@ -8532,8 +8045,8 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true - supabase@2.76.15: - resolution: {integrity: sha512-m69o1XPAzZaIWfQiEeT+KY/Ci3OSA663RyoH9xECbXSxhr7dsipLCpCqT1E4MCob0mMhHh/7A+Eltx4y1qSwiQ==} + supabase@2.83.0: + resolution: {integrity: sha512-80j5YeYMkJqVc5gZGySMxiUJSUcwES6mNqi1nCa9q40qnPftff+LevcdZGLct4xtpaefkTtgmZArPSdq+H2RZQ==} engines: {npm: '>=8'} hasBin: true @@ -8541,10 +8054,6 @@ packages: resolution: {integrity: sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==} engines: {node: '>=14.0.0'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -8553,33 +8062,41 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - svgo@4.0.0: - resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==} + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} hasBin: true - swap-case@1.1.2: - resolution: {integrity: sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ==} - tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.1: - resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tar@7.5.9: - resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tar@7.5.11: + resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} engines: {node: '>=18'} - terser-webpack-plugin@5.3.16: - resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} + terser-webpack-plugin@5.4.0: + resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -8594,8 +8111,8 @@ packages: uglify-js: optional: true - terser@5.46.0: - resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + terser@5.46.1: + resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} engines: {node: '>=10'} hasBin: true @@ -8610,9 +8127,6 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} - through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - tiny-invariant@1.0.6: resolution: {integrity: sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==} @@ -8632,20 +8146,28 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - title-case@2.1.1: - resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==} + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -8662,6 +8184,10 @@ packages: totp-generator@2.0.1: resolution: {integrity: sha512-50DiKmv9zKTPzCgWOqQYVBMvxh+tpL9O3IUFIqzGUlFXzJyb/IQZac8bonXudvLbfuDY8laZ9qTDX+yAvTBNSQ==} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -8669,23 +8195,18 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - ts-case-convert@2.1.0: resolution: {integrity: sha512-Ye79el/pHYXfoew6kqhMwCoxp4NWjKNcm2kBzpmEMIU9dd9aBmHNNFtZ+WTm0rz1ngyDmfqDXDlyUnBXayiD0w==} ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -8709,107 +8230,45 @@ packages: typescript: optional: true - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - - turbo-darwin-64@2.8.11: - resolution: {integrity: sha512-XKaCWaz4OCt77oYYvGCIRpvYD4c/aNaKjRkUpv+e8rN3RZb+5Xsyew4yRO+gaHdMIUhQznXNXfHlhs+/p7lIhA==} - cpu: [x64] - os: [darwin] - - turbo-darwin-arm64@2.8.11: - resolution: {integrity: sha512-VvynLHGUNvQ9k7GZjRPSsRcK4VkioTfFb7O7liAk4nHKjEcMdls7GqxzjVWgJiKz3hWmQGaP9hRa9UUnhVWCxA==} - cpu: [arm64] - os: [darwin] - - turbo-linux-64@2.8.11: - resolution: {integrity: sha512-cbSn37dcm+EmkQ7DD0euy7xV7o2el4GAOr1XujvkAyKjjNvQ+6QIUeDgQcwAx3D17zPpDvfDMJY2dLQadWnkmQ==} - cpu: [x64] - os: [linux] - - turbo-linux-arm64@2.8.11: - resolution: {integrity: sha512-+trymp2s2aBrhS04l6qFxcExzZ8ffndevuUB9c5RCeqsVpZeiWuGQlWNm5XjOmzoMayxRARZ5ma7yiWbGMiLqQ==} - cpu: [arm64] - os: [linux] - - turbo-windows-64@2.8.11: - resolution: {integrity: sha512-3kJjFSM4yw1n9Uzmi+XkAUgCae19l/bH6RJ442xo7mnZm0tpOjo33F+FYHoSVpIWVMd0HG0LDccyafPSdylQbA==} - cpu: [x64] - os: [win32] - - turbo-windows-arm64@2.8.11: - resolution: {integrity: sha512-JOM4uF2vuLsJUvibdR6X9QqdZr6BhC6Nhlrw4LKFPsXZZI/9HHLoqAiYRpE4MuzIwldCH/jVySnWXrI1SKto0g==} - cpu: [arm64] - os: [win32] - - turbo@2.8.11: - resolution: {integrity: sha512-H+rwSHHPLoyPOSoHdmI1zY0zy0GGj1Dmr7SeJW+nZiWLz2nex8EJ+fkdVabxXFMNEux+aywI4Sae8EqhmnOv4A==} + turbo@2.8.20: + resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==} hasBin: true tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - type-fest@0.7.1: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@5.5.0: + resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - typescript-eslint@8.55.0: - resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -8825,12 +8284,16 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} @@ -8838,15 +8301,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - upper-case-first@1.1.2: - resolution: {integrity: sha512-wINKYvI3Db8dtjikdAqoBbZoP6Q+PZUyfMR7pmwHzjC2quzSkUq5DmPrTtPEqHaz8AGtmsB4TqwapMTM1QAQOQ==} - - upper-case@1.1.3: - resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} @@ -8866,6 +8320,11 @@ packages: '@types/react': optional: true + use-intl@4.8.3: + resolution: {integrity: sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -8892,15 +8351,25 @@ packages: resolution: {integrity: sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==} engines: {node: ^18.17.0 || >=20.5.0} + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - victory-vendor@36.9.2: - resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} @@ -8942,20 +8411,21 @@ packages: yaml: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.1: + resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.1 + '@vitest/browser-preview': 4.1.1 + '@vitest/browser-webdriverio': 4.1.1 + '@vitest/ui': 4.1.1 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -8976,10 +8446,6 @@ packages: jsdom: optional: true - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -9003,8 +8469,8 @@ packages: resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} - webpack@5.105.1: - resolution: {integrity: sha512-Gdj3X74CLJJ8zy4URmK42W7wTZUJrqL+z8nyGEr4dTN0kb3nVs+ZvjbTOqRYPD7qX4tUmwyHL9Q9K6T1seW6Yw==} + webpack@5.105.4: + resolution: {integrity: sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -9016,27 +8482,16 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -9045,13 +8500,6 @@ packages: wonka@6.3.5: resolution: {integrity: sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==} - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wp-types@4.69.0: resolution: {integrity: sha512-2w0i2ygylpbYpqFskg1NlvH/1DM8thZuhxjihFRHdvjgFkmzJ2cHl2kq9cBnxYWHyLHzRiLI2TupKbq3yl2STQ==} @@ -9059,11 +8507,15 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - write-file-atomic@7.0.0: - resolution: {integrity: sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==} + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} ws@7.5.10: @@ -9078,8 +8530,8 @@ packages: utf-8-validate: optional: true - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -9090,6 +8542,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -9115,6 +8571,10 @@ packages: peerDependencies: yjs: ^13 + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -9122,12 +8582,20 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} - yjs@13.6.29: - resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yjs@13.6.30: + resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} yocto-queue@0.1.0: @@ -9142,28 +8610,29 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: - zod: 3.25.76 - - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: 3.25.76 + zod: ^3.25 || ^4 zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@0no-co/graphql.web@1.2.0(graphql@16.12.0)': + '@0no-co/graphql.web@1.2.0(graphql@16.13.1)': optionalDependencies: - graphql: 16.12.0 + graphql: 16.13.1 '@alloc/quick-lru@5.2.0': {} @@ -9181,8 +8650,8 @@ snapshots: '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -9203,6 +8672,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -9211,8 +8684,28 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -9229,13 +8722,35 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.6': + '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.29.0 @@ -9244,12 +8759,54 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/runtime-corejs3@7.29.0': + '@babel/parser@7.29.2': dependencies: - core-js-pure: 3.48.0 + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -9273,6 +8830,30 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tabbable: 6.4.0 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@braintree/sanitize-url@6.0.4': {} '@corex/deepmerge@4.0.43': {} @@ -9281,22 +8862,23 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@edge-csrf/nextjs@2.5.3-cloudflare-rc1(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@dotenvx/dotenvx@1.57.2': dependencies: - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + commander: 11.1.0 + dotenv: 17.3.1 + eciesjs: 0.4.18 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.3) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.3 + which: 4.0.0 - '@emnapi/core@1.8.1': + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true + '@noble/ciphers': 1.3.0 - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.1.0': + '@emnapi/runtime@1.9.1': dependencies: tslib: 2.8.1 optional: true @@ -9304,7 +8886,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.28.6 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -9359,154 +8941,276 @@ snapshots: '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.1(jiti@2.6.1))': - dependencies: - eslint: 10.0.1(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 + '@esbuild/win32-x64@0.27.4': + optional: true - '@eslint-community/regexpp@4.12.2': {} + '@faker-js/faker@10.4.0': {} - '@eslint/config-array@0.23.2': - dependencies: - '@eslint/object-schema': 3.0.2 - debug: 4.4.3 - minimatch: 10.2.2 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.5.2': - dependencies: - '@eslint/core': 1.1.0 - - '@eslint/core@1.1.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.0.1(jiti@2.6.1))': - optionalDependencies: - eslint: 10.0.1(jiti@2.6.1) - - '@eslint/object-schema@3.0.2': {} - - '@eslint/plugin-kit@0.6.0': - dependencies: - '@eslint/core': 1.1.0 - levn: 0.4.1 - - '@faker-js/faker@10.3.0': {} - - '@fastify/otel@0.16.0(@opentelemetry/api@1.9.0)': + '@fastify/otel@0.17.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 - minimatch: 10.2.3 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color - '@floating-ui/core@1.7.4': + '@floating-ui/core@1.7.5': dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.7.5': + '@floating-ui/dom@1.7.6': dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/dom': 1.7.5 + '@floating-ui/dom': 1.7.6 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) '@floating-ui/react@0.24.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) aria-hidden: 1.2.6 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) tabbable: 6.4.0 - '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.11': {} '@formatjs/ecma402-abstract@2.3.6': dependencies: @@ -9515,50 +9219,66 @@ snapshots: decimal.js: 10.6.0 tslib: 2.8.1 + '@formatjs/ecma402-abstract@3.1.1': + dependencies: + '@formatjs/fast-memoize': 3.1.0 + '@formatjs/intl-localematcher': 0.8.1 + decimal.js: 10.6.0 + tslib: 2.8.1 + '@formatjs/fast-memoize@2.2.7': dependencies: tslib: 2.8.1 + '@formatjs/fast-memoize@3.1.0': + dependencies: + tslib: 2.8.1 + '@formatjs/icu-messageformat-parser@2.11.4': dependencies: '@formatjs/ecma402-abstract': 2.3.6 '@formatjs/icu-skeleton-parser': 1.8.16 tslib: 2.8.1 + '@formatjs/icu-messageformat-parser@3.5.1': + dependencies: + '@formatjs/ecma402-abstract': 3.1.1 + '@formatjs/icu-skeleton-parser': 2.1.1 + tslib: 2.8.1 + '@formatjs/icu-skeleton-parser@1.8.16': dependencies: '@formatjs/ecma402-abstract': 2.3.6 tslib: 2.8.1 + '@formatjs/icu-skeleton-parser@2.1.1': + dependencies: + '@formatjs/ecma402-abstract': 3.1.1 + tslib: 2.8.1 + '@formatjs/intl-localematcher@0.6.2': dependencies: tslib: 2.8.1 - '@graphql-typed-document-node/core@3.2.0(graphql@16.12.0)': + '@formatjs/intl-localematcher@0.8.1': dependencies: - graphql: 16.12.0 + '@formatjs/fast-memoize': 3.1.0 + tslib: 2.8.1 - '@hono/node-server@1.19.9(hono@4.12.2)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.13.1)': dependencies: - hono: 4.12.2 + graphql: 16.13.1 - '@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))': + '@hono/node-server@1.19.11(hono@4.12.7)': + dependencies: + hono: 4.12.7 + + '@hookform/resolvers@5.2.2(react-hook-form@7.72.0(react@19.2.4))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.71.2(react@19.2.4) + react-hook-form: 7.72.0(react@19.2.4) - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@img/colour@1.0.0': + '@img/colour@1.1.0': optional: true '@img/sharp-darwin-arm64@0.34.5': @@ -9643,7 +9363,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.9.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -9657,145 +9377,145 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@25.3.1)': + '@inquirer/checkbox@4.3.2(@types/node@25.5.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/type': 3.0.10(@types/node@25.5.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/confirm@5.1.21(@types/node@25.3.1)': + '@inquirer/confirm@5.1.21(@types/node@25.5.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.1) - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/core@10.3.2(@types/node@25.3.1)': + '@inquirer/core@10.3.2(@types/node@25.5.0)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/type': 3.0.10(@types/node@25.5.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/editor@4.2.23(@types/node@25.3.1)': + '@inquirer/editor@4.2.23(@types/node@25.5.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.1) - '@inquirer/external-editor': 1.0.3(@types/node@25.3.1) - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) + '@inquirer/external-editor': 1.0.3(@types/node@25.5.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/expand@4.0.23(@types/node@25.3.1)': + '@inquirer/expand@4.0.23(@types/node@25.5.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.1) - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/external-editor@1.0.3(@types/node@25.3.1)': + '@inquirer/external-editor@1.0.3(@types/node@25.5.0)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@25.3.1)': + '@inquirer/input@4.3.1(@types/node@25.5.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.1) - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/number@3.0.23(@types/node@25.3.1)': + '@inquirer/number@3.0.23(@types/node@25.5.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.1) - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/password@4.0.23(@types/node@25.3.1)': + '@inquirer/password@4.0.23(@types/node@25.5.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.3.1) - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/prompts@7.10.1(@types/node@25.3.1)': + '@inquirer/prompts@7.10.1(@types/node@25.5.0)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@25.3.1) - '@inquirer/confirm': 5.1.21(@types/node@25.3.1) - '@inquirer/editor': 4.2.23(@types/node@25.3.1) - '@inquirer/expand': 4.0.23(@types/node@25.3.1) - '@inquirer/input': 4.3.1(@types/node@25.3.1) - '@inquirer/number': 3.0.23(@types/node@25.3.1) - '@inquirer/password': 4.0.23(@types/node@25.3.1) - '@inquirer/rawlist': 4.1.11(@types/node@25.3.1) - '@inquirer/search': 3.2.2(@types/node@25.3.1) - '@inquirer/select': 4.4.2(@types/node@25.3.1) + '@inquirer/checkbox': 4.3.2(@types/node@25.5.0) + '@inquirer/confirm': 5.1.21(@types/node@25.5.0) + '@inquirer/editor': 4.2.23(@types/node@25.5.0) + '@inquirer/expand': 4.0.23(@types/node@25.5.0) + '@inquirer/input': 4.3.1(@types/node@25.5.0) + '@inquirer/number': 3.0.23(@types/node@25.5.0) + '@inquirer/password': 4.0.23(@types/node@25.5.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.5.0) + '@inquirer/search': 3.2.2(@types/node@25.5.0) + '@inquirer/select': 4.4.2(@types/node@25.5.0) optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/rawlist@4.1.11(@types/node@25.3.1)': + '@inquirer/rawlist@4.1.11(@types/node@25.5.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.1) - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) + '@inquirer/type': 3.0.10(@types/node@25.5.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/search@3.2.2(@types/node@25.3.1)': + '@inquirer/search@3.2.2(@types/node@25.5.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/type': 3.0.10(@types/node@25.5.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/select@4.4.2(@types/node@25.3.1)': + '@inquirer/select@4.4.2(@types/node@25.5.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.3.1) + '@inquirer/core': 10.3.2(@types/node@25.5.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.3.1) + '@inquirer/type': 3.0.10(@types/node@25.5.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@inquirer/type@3.0.10(@types/node@25.3.1)': + '@inquirer/type@3.0.10(@types/node@25.5.0)': optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@internationalized/date@3.11.0': + '@internationalized/date@3.12.0': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 '@internationalized/message@3.1.8': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 intl-messageformat: 10.7.18 '@internationalized/number@3.6.5': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 '@internationalized/string@3.2.7': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 '@isaacs/fs-minipass@4.0.1': dependencies: @@ -9827,138 +9547,138 @@ snapshots: '@juggle/resize-observer@3.4.0': {} - '@keystar/ui@0.7.19(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@keystar/ui@0.7.20(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@emotion/css': 11.13.5 '@floating-ui/react': 0.24.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@internationalized/date': 3.11.0 + '@internationalized/date': 3.12.0 '@internationalized/string': 3.2.7 - '@react-aria/actiongroup': 3.7.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/breadcrumbs': 3.5.31(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/button': 3.14.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/calendar': 3.9.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/checkbox': 3.16.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/combobox': 3.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/datepicker': 3.16.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/dialog': 3.5.33(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/dnd': 3.11.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/gridlist': 3.14.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/link': 3.8.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/listbox': 3.15.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/actiongroup': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/breadcrumbs': 3.5.32(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/button': 3.14.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/calendar': 3.9.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/checkbox': 3.16.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/combobox': 3.15.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/datepicker': 3.16.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/dialog': 3.5.34(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/dnd': 3.11.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/gridlist': 3.14.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/link': 3.8.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/listbox': 3.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/live-announcer': 3.4.4 - '@react-aria/menu': 3.20.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/meter': 3.4.29(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/numberfield': 3.12.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/overlays': 3.31.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/progress': 3.4.29(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/radio': 3.12.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/searchfield': 3.8.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/select': 3.17.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/separator': 3.4.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/menu': 3.21.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/meter': 3.4.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/numberfield': 3.12.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/overlays': 3.31.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/progress': 3.4.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/radio': 3.12.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/searchfield': 3.8.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/select': 3.17.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/separator': 3.4.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/ssr': 3.9.10(react@19.2.4) - '@react-aria/switch': 3.7.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/table': 3.17.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/tabs': 3.11.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/tag': 3.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/textfield': 3.18.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/switch': 3.7.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/table': 3.17.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/tabs': 3.11.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/tag': 3.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/textfield': 3.18.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/toast': 3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/tooltip': 3.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/virtualizer': 4.1.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/visually-hidden': 3.8.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/calendar': 3.9.2(react@19.2.4) - '@react-stately/checkbox': 3.7.4(react@19.2.4) - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/combobox': 3.12.2(react@19.2.4) - '@react-stately/data': 3.15.1(react@19.2.4) - '@react-stately/datepicker': 3.16.0(react@19.2.4) - '@react-stately/dnd': 3.7.3(react@19.2.4) - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-stately/layout': 4.5.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-stately/menu': 3.9.10(react@19.2.4) - '@react-stately/numberfield': 3.10.4(react@19.2.4) - '@react-stately/overlays': 3.6.22(react@19.2.4) - '@react-stately/radio': 3.11.4(react@19.2.4) - '@react-stately/searchfield': 3.5.18(react@19.2.4) - '@react-stately/select': 3.9.1(react@19.2.4) - '@react-stately/selection': 3.20.8(react@19.2.4) - '@react-stately/table': 3.15.3(react@19.2.4) - '@react-stately/tabs': 3.8.8(react@19.2.4) + '@react-aria/tooltip': 3.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/virtualizer': 4.1.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/visually-hidden': 3.8.31(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/calendar': 3.9.3(react@19.2.4) + '@react-stately/checkbox': 3.7.5(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/combobox': 3.13.0(react@19.2.4) + '@react-stately/data': 3.15.2(react@19.2.4) + '@react-stately/datepicker': 3.16.1(react@19.2.4) + '@react-stately/dnd': 3.7.4(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-stately/layout': 4.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-stately/menu': 3.9.11(react@19.2.4) + '@react-stately/numberfield': 3.11.0(react@19.2.4) + '@react-stately/overlays': 3.6.23(react@19.2.4) + '@react-stately/radio': 3.11.5(react@19.2.4) + '@react-stately/searchfield': 3.5.19(react@19.2.4) + '@react-stately/select': 3.9.2(react@19.2.4) + '@react-stately/selection': 3.20.9(react@19.2.4) + '@react-stately/table': 3.15.4(react@19.2.4) + '@react-stately/tabs': 3.8.9(react@19.2.4) '@react-stately/toast': 3.1.0(react@19.2.4) - '@react-stately/toggle': 3.9.4(react@19.2.4) - '@react-stately/tooltip': 3.5.10(react@19.2.4) - '@react-stately/tree': 3.9.5(react@19.2.4) + '@react-stately/toggle': 3.9.5(react@19.2.4) + '@react-stately/tooltip': 3.5.11(react@19.2.4) + '@react-stately/tree': 3.9.6(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-stately/virtualizer': 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/actionbar': 3.1.20(react@19.2.4) - '@react-types/actiongroup': 3.4.22(react@19.2.4) - '@react-types/breadcrumbs': 3.7.18(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/calendar': 3.8.2(react@19.2.4) - '@react-types/combobox': 3.13.11(react@19.2.4) - '@react-types/datepicker': 3.13.4(react@19.2.4) - '@react-types/grid': 3.3.7(react@19.2.4) - '@react-types/menu': 3.10.6(react@19.2.4) - '@react-types/numberfield': 3.8.17(react@19.2.4) - '@react-types/overlays': 3.9.3(react@19.2.4) - '@react-types/radio': 3.9.3(react@19.2.4) - '@react-types/select': 3.12.1(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/switch': 3.5.16(react@19.2.4) - '@react-types/table': 3.13.5(react@19.2.4) - '@react-types/tabs': 3.3.21(react@19.2.4) + '@react-stately/virtualizer': 4.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/actionbar': 3.1.21(react@19.2.4) + '@react-types/actiongroup': 3.4.23(react@19.2.4) + '@react-types/breadcrumbs': 3.7.19(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/calendar': 3.8.3(react@19.2.4) + '@react-types/combobox': 3.14.0(react@19.2.4) + '@react-types/datepicker': 3.13.5(react@19.2.4) + '@react-types/grid': 3.3.8(react@19.2.4) + '@react-types/menu': 3.10.7(react@19.2.4) + '@react-types/numberfield': 3.8.18(react@19.2.4) + '@react-types/overlays': 3.9.4(react@19.2.4) + '@react-types/radio': 3.9.4(react@19.2.4) + '@react-types/select': 3.12.2(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/switch': 3.5.17(react@19.2.4) + '@react-types/table': 3.13.6(react@19.2.4) + '@react-types/tabs': 3.3.22(react@19.2.4) '@types/react': 19.2.14 emery: 1.4.4 facepaint: 1.2.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - supports-color - '@keystatic/core@0.5.48(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@keystatic/core@0.5.49(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@braintree/sanitize-url': 6.0.4 '@emotion/weak-memoize': 0.3.1 '@floating-ui/react': 0.24.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@internationalized/string': 3.2.7 - '@keystar/ui': 0.7.19(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@keystar/ui': 0.7.20(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@markdoc/markdoc': 0.4.0(@types/react@19.2.14)(react@19.2.4) - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/overlays': 3.31.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/visually-hidden': 3.8.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-stately/overlays': 3.6.22(react@19.2.4) + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/overlays': 3.31.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/visually-hidden': 3.8.31(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-stately/overlays': 3.6.23(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) '@sindresorhus/slugify': 1.1.2 - '@toeverything/y-indexeddb': 0.10.0-canary.9(yjs@13.6.29) - '@ts-gql/tag': 0.7.3(graphql@16.12.0) + '@toeverything/y-indexeddb': 0.10.0-canary.9(yjs@13.6.30) + '@ts-gql/tag': 0.7.3(graphql@16.13.1) '@types/react': 19.2.14 - '@urql/core': 5.2.0(graphql@16.12.0) - '@urql/exchange-auth': 2.2.1(@urql/core@5.2.0(graphql@16.12.0)) - '@urql/exchange-graphcache': 7.2.4(@urql/core@5.2.0(graphql@16.12.0))(graphql@16.12.0) - '@urql/exchange-persisted': 4.3.1(@urql/core@5.2.0(graphql@16.12.0)) + '@urql/core': 5.2.0(graphql@16.13.1) + '@urql/exchange-auth': 2.2.1(@urql/core@5.2.0(graphql@16.13.1)) + '@urql/exchange-graphcache': 7.2.4(@urql/core@5.2.0(graphql@16.13.1))(graphql@16.13.1) + '@urql/exchange-persisted': 4.3.1(@urql/core@5.2.0(graphql@16.13.1)) cookie: 1.1.1 emery: 1.4.4 escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 - graphql: 16.12.0 + graphql: 16.13.1 idb-keyval: 6.2.2 ignore: 5.3.2 is-hotkey: 0.2.0 @@ -9966,7 +9686,7 @@ snapshots: lib0: 0.2.117 lru-cache: 10.4.3 match-sorter: 6.3.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 @@ -9976,7 +9696,7 @@ snapshots: micromark-extension-gfm-strikethrough: 2.1.0 micromark-extension-gfm-table: 2.1.1 micromark-extension-mdxjs: 3.0.0 - minimatch: 9.0.5 + minimatch: 9.0.9 partysocket: 0.0.22 prosemirror-commands: 1.7.1 prosemirror-history: 1.5.0 @@ -9985,7 +9705,7 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.6 + prosemirror-view: 1.41.7 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) scroll-into-view-if-needed: 3.1.0 @@ -9994,39 +9714,39 @@ snapshots: slate-react: 0.91.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(slate@0.91.4) superstruct: 1.0.4 unist-util-visit: 5.1.0 - urql: 4.2.2(@urql/core@5.2.0(graphql@16.12.0))(react@19.2.4) - y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) - y-protocols: 1.0.7(yjs@13.6.29) - yjs: 13.6.29 + urql: 4.2.2(@urql/core@5.2.0(graphql@16.13.1))(react@19.2.4) + y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + y-protocols: 1.0.7(yjs@13.6.30) + yjs: 13.6.30 transitivePeerDependencies: - next - supports-color - '@keystatic/next@5.0.4(@keystatic/core@0.5.48(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@keystatic/next@5.0.4(@keystatic/core@0.5.49(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@babel/runtime': 7.28.6 - '@keystatic/core': 0.5.48(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@keystatic/core': 0.5.49(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/react': 19.2.14 chokidar: 3.6.0 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) server-only: 0.0.1 '@lemonsqueezy/lemonsqueezy.js@4.0.0': {} - '@makerkit/data-loader-supabase-core@0.0.10(@supabase/postgrest-js@2.97.0)(@supabase/supabase-js@2.97.0)': + '@makerkit/data-loader-supabase-core@0.0.10(@supabase/postgrest-js@2.100.0)(@supabase/supabase-js@2.100.0)': dependencies: - '@supabase/postgrest-js': 2.97.0 - '@supabase/supabase-js': 2.97.0 + '@supabase/postgrest-js': 2.100.0 + '@supabase/supabase-js': 2.100.0 ts-case-convert: 2.1.0 - '@makerkit/data-loader-supabase-nextjs@1.2.5(@supabase/postgrest-js@2.97.0)(@supabase/supabase-js@2.97.0)(@tanstack/react-query@5.90.21(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@makerkit/data-loader-supabase-nextjs@1.2.5(@supabase/postgrest-js@2.100.0)(@supabase/supabase-js@2.100.0)(@tanstack/react-query@5.95.2(react@19.2.4))(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': dependencies: - '@makerkit/data-loader-supabase-core': 0.0.10(@supabase/postgrest-js@2.97.0)(@supabase/supabase-js@2.97.0) - '@supabase/supabase-js': 2.97.0 - '@tanstack/react-query': 5.90.21(react@19.2.4) - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@makerkit/data-loader-supabase-core': 0.0.10(@supabase/postgrest-js@2.100.0)(@supabase/supabase-js@2.100.0) + '@supabase/supabase-js': 2.100.0 + '@tanstack/react-query': 5.95.2(react@19.2.4) + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - '@supabase/postgrest-js' @@ -10066,7 +9786,7 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 - '@markdoc/markdoc@0.5.4(@types/react@19.2.14)(react@19.2.4)': + '@markdoc/markdoc@0.5.6(@types/react@19.2.14)(react@19.2.4)': optionalDependencies: '@types/linkify-it': 3.0.5 '@types/markdown-it': 12.2.3 @@ -10080,7 +9800,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.12.2) + '@hono/node-server': 1.19.11(hono@4.12.7) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -10089,9 +9809,9 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.12.2 - jose: 6.1.3 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.7 + jose: 6.2.1 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -10100,14 +9820,38 @@ snapshots: transitivePeerDependencies: - supports-color - '@napi-rs/wasm-runtime@0.2.12': + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 - optional: true + '@hono/node-server': 1.19.11(hono@4.12.7) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.7 + jose: 6.2.1 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color - '@next/bundle-analyzer@16.1.6': + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@next/bundle-analyzer@16.2.1': dependencies: webpack-bundle-analyzer: 4.10.1 transitivePeerDependencies: @@ -10116,35 +9860,39 @@ snapshots: '@next/env@13.5.11': {} - '@next/env@16.1.6': {} + '@next/env@16.2.1': {} - '@next/eslint-plugin-next@16.1.6': + '@next/swc-darwin-arm64@16.2.1': + optional: true + + '@next/swc-darwin-x64@16.2.1': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.1': + optional: true + + '@next/swc-linux-arm64-musl@16.2.1': + optional: true + + '@next/swc-linux-x64-gnu@16.2.1': + optional: true + + '@next/swc-linux-x64-musl@16.2.1': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.1': + optional: true + + '@next/swc-win32-x64-msvc@16.2.1': + optional: true + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': dependencies: - fast-glob: 3.3.1 + '@noble/hashes': 1.8.0 - '@next/swc-darwin-arm64@16.1.6': - optional: true - - '@next/swc-darwin-x64@16.1.6': - optional: true - - '@next/swc-linux-arm64-gnu@16.1.6': - optional: true - - '@next/swc-linux-arm64-musl@16.1.6': - optional: true - - '@next/swc-linux-x64-gnu@16.1.6': - optional: true - - '@next/swc-linux-x64-musl@16.1.6': - optional: true - - '@next/swc-win32-arm64-msvc@16.1.6': - optional: true - - '@next/swc-win32-x64-msvc@16.1.6': - optional: true + '@noble/hashes@1.8.0': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -10158,229 +9906,231 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nolyfill/is-core-module@1.0.39': {} - - '@nosecone/next@1.1.0(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@nosecone/next@1.3.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - nosecone: 1.1.0 + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nosecone: 1.3.0 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} '@opentelemetry/api-logs@0.207.0': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs@0.208.0': + '@opentelemetry/api-logs@0.212.0': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs@0.211.0': + '@opentelemetry/api-logs@0.213.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api@1.9.0': {} - '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-amqplib@0.60.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 - - '@opentelemetry/instrumentation-amqplib@0.58.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-connect@0.54.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-connect@0.56.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 '@types/connect': 3.4.38 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-dataloader@0.28.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-dataloader@0.30.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-express@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-express@0.61.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-fs@0.30.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-fs@0.32.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-generic-pool@0.54.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-generic-pool@0.56.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-graphql@0.58.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-graphql@0.61.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-hapi@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-hapi@0.59.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-http@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-ioredis@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-ioredis@0.61.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common': 0.38.2 - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-kafkajs@0.20.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-kafkajs@0.22.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-knex@0.55.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-knex@0.57.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-koa@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-koa@0.61.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-lru-memoizer@0.55.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-lru-memoizer@0.57.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mongodb@0.64.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mongodb@0.66.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mongoose@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mongoose@0.59.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mysql2@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mysql2@0.59.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mysql@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mysql@0.59.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 '@types/mysql': 2.15.27 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pg@0.63.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pg@0.65.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) '@types/pg': 8.15.6 '@types/pg-pool': 2.0.7 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-redis@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-redis@0.61.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common': 0.38.2 - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-tedious@0.30.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-tedious@0.32.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 '@types/tedious': 4.0.14 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-undici@0.21.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-undici@0.23.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color @@ -10393,45 +10143,219 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/api-logs': 0.212.0 import-in-the-middle: 2.0.6 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.211.0 - import-in-the-middle: 2.0.6 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color '@opentelemetry/redis-common@0.38.2': {} - '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/semantic-conventions@1.39.0': {} + '@opentelemetry/semantic-conventions@1.40.0': {} '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + + '@oxfmt/binding-android-arm-eabi@0.41.0': + optional: true + + '@oxfmt/binding-android-arm64@0.41.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.41.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.41.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.41.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.41.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.41.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.41.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.41.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.41.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.41.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.41.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.41.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.41.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.41.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.41.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.41.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.41.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.41.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.56.0': + optional: true + + '@oxlint/binding-android-arm64@1.56.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.56.0': + optional: true + + '@oxlint/binding-darwin-x64@1.56.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.56.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.56.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.56.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.56.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.56.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.56.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.56.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.56.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.56.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.56.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.56.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.56.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.56.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.56.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.56.0': + optional: true + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.3 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 '@pinojs/redact@0.4.0': {} @@ -10453,152 +10377,21 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@prisma/instrumentation@7.2.0(@opentelemetry/api@1.9.0)': + '@prisma/instrumentation@7.4.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.207.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@radix-ui/number@1.1.1': {} - '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -10627,12 +10420,6 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -10646,21 +10433,6 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -10678,41 +10450,6 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-icons@1.3.2(react@19.2.4)': - dependencies: - react: 19.2.4 - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) @@ -10720,158 +10457,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/rect': 1.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -10910,125 +10495,6 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -11043,118 +10509,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -11183,612 +10537,577 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@react-aria/actiongroup@3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/rect': 1.1.1 - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/rect@1.1.1': {} - - '@react-aria/actiongroup@3.7.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-types/actiongroup': 3.4.22(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-types/actiongroup': 3.4.23(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/breadcrumbs@3.5.31(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/breadcrumbs@3.5.32(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/link': 3.8.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/breadcrumbs': 3.7.18(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/link': 3.8.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/breadcrumbs': 3.7.19(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/button@3.14.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/button@3.14.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/toolbar': 3.0.0-beta.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/toggle': 3.9.4(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/toolbar': 3.0.0-beta.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/toggle': 3.9.5(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/calendar@3.9.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/calendar@3.9.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@internationalized/date': 3.11.0 - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@internationalized/date': 3.12.0 + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/live-announcer': 3.4.4 - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/calendar': 3.9.2(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/calendar': 3.8.2(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/calendar': 3.9.3(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/calendar': 3.8.3(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/checkbox@3.16.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/checkbox@3.16.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/form': 3.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/toggle': 3.12.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/checkbox': 3.7.4(react@19.2.4) - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-stately/toggle': 3.9.4(react@19.2.4) - '@react-types/checkbox': 3.10.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/form': 3.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/toggle': 3.12.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/checkbox': 3.7.5(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-stately/toggle': 3.9.5(react@19.2.4) + '@react-types/checkbox': 3.10.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/combobox@3.14.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/combobox@3.15.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/listbox': 3.15.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/listbox': 3.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/live-announcer': 3.4.4 - '@react-aria/menu': 3.20.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/overlays': 3.31.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/textfield': 3.18.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/combobox': 3.12.2(react@19.2.4) - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/combobox': 3.13.11(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/menu': 3.21.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/overlays': 3.31.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/textfield': 3.18.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/combobox': 3.13.0(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/combobox': 3.14.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/datepicker@3.16.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/datepicker@3.16.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@internationalized/date': 3.11.0 + '@internationalized/date': 3.12.0 '@internationalized/number': 3.6.5 '@internationalized/string': 3.2.7 - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/form': 3.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/spinbutton': 3.7.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/datepicker': 3.16.0(react@19.2.4) - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/calendar': 3.8.2(react@19.2.4) - '@react-types/datepicker': 3.13.4(react@19.2.4) - '@react-types/dialog': 3.5.23(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/form': 3.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/spinbutton': 3.7.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/datepicker': 3.16.1(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/calendar': 3.8.3(react@19.2.4) + '@react-types/datepicker': 3.13.5(react@19.2.4) + '@react-types/dialog': 3.5.24(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/dialog@3.5.33(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/dialog@3.5.34(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/overlays': 3.31.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/dialog': 3.5.23(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/overlays': 3.31.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/dialog': 3.5.24(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/dnd@3.11.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/dnd@3.11.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@internationalized/string': 3.2.7 - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/live-announcer': 3.4.4 - '@react-aria/overlays': 3.31.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/dnd': 3.7.3(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/overlays': 3.31.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/dnd': 3.7.4(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/focus@3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/focus@3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 clsx: 2.1.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/form@3.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/form@3.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/grid@3.14.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/grid@3.14.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/live-announcer': 3.4.4 - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/grid': 3.11.8(react@19.2.4) - '@react-stately/selection': 3.20.8(react@19.2.4) - '@react-types/checkbox': 3.10.3(react@19.2.4) - '@react-types/grid': 3.3.7(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/grid': 3.11.9(react@19.2.4) + '@react-stately/selection': 3.20.9(react@19.2.4) + '@react-types/checkbox': 3.10.4(react@19.2.4) + '@react-types/grid': 3.3.8(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/gridlist@3.14.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/gridlist@3.14.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/grid': 3.14.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-stately/tree': 3.9.5(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/grid': 3.14.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-stately/tree': 3.9.6(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/i18n@3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/i18n@3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@internationalized/date': 3.11.0 + '@internationalized/date': 3.12.0 '@internationalized/message': 3.1.8 '@internationalized/number': 3.6.5 '@internationalized/string': 3.2.7 '@react-aria/ssr': 3.9.10(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/interactions@3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/interactions@3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@react-aria/ssr': 3.9.10(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/label@3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/label@3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/landmark@3.0.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/landmark@3.0.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) - '@react-aria/link@3.8.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/link@3.8.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/link': 3.6.6(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/link': 3.6.7(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/listbox@3.15.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/listbox@3.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-types/listbox': 3.7.5(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-types/listbox': 3.7.6(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) '@react-aria/live-announcer@3.4.4': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 - '@react-aria/menu@3.20.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/menu@3.21.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/overlays': 3.31.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/menu': 3.9.10(react@19.2.4) - '@react-stately/selection': 3.20.8(react@19.2.4) - '@react-stately/tree': 3.9.5(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/menu': 3.10.6(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/overlays': 3.31.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/menu': 3.9.11(react@19.2.4) + '@react-stately/selection': 3.20.9(react@19.2.4) + '@react-stately/tree': 3.9.6(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/menu': 3.10.7(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/meter@3.4.29(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/meter@3.4.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/progress': 3.4.29(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/meter': 3.4.14(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/progress': 3.4.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/meter': 3.4.15(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/numberfield@3.12.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/numberfield@3.12.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/spinbutton': 3.7.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/textfield': 3.18.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-stately/numberfield': 3.10.4(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/numberfield': 3.8.17(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/overlays@3.31.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/ssr': 3.9.10(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/visually-hidden': 3.8.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/overlays': 3.6.22(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/overlays': 3.9.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/progress@3.4.29(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/progress': 3.5.17(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/radio@3.12.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/form': 3.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/radio': 3.11.4(react@19.2.4) - '@react-types/radio': 3.9.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/searchfield@3.8.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/textfield': 3.18.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/searchfield': 3.5.18(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/searchfield': 3.6.7(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/select@3.17.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/form': 3.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/listbox': 3.15.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/menu': 3.20.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/visually-hidden': 3.8.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/select': 3.9.1(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/select': 3.12.1(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/selection@3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/selection': 3.20.8(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/separator@3.4.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/spinbutton@3.7.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/live-announcer': 3.4.4 - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/spinbutton': 3.7.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/textfield': 3.18.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-stately/numberfield': 3.11.0(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/numberfield': 3.8.18(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/overlays@3.31.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/visually-hidden': 3.8.31(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/flags': 3.1.2 + '@react-stately/overlays': 3.6.23(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/overlays': 3.9.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/progress@3.4.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/progress': 3.5.18(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/radio@3.12.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/form': 3.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/radio': 3.11.5(react@19.2.4) + '@react-types/radio': 3.9.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/searchfield@3.8.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/textfield': 3.18.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/searchfield': 3.5.19(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/searchfield': 3.6.8(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/select@3.17.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/form': 3.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/listbox': 3.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/menu': 3.21.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/visually-hidden': 3.8.31(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/select': 3.9.2(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/select': 3.12.2(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/selection@3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/selection': 3.20.9(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/separator@3.4.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/spinbutton@3.7.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/live-announcer': 3.4.4 + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) '@react-aria/ssr@3.9.10(react@19.2.4)': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-aria/switch@3.7.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/switch@3.7.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/toggle': 3.12.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/toggle': 3.9.4(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/switch': 3.5.16(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/toggle': 3.12.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/toggle': 3.9.5(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/switch': 3.5.17(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/table@3.17.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/table@3.17.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/grid': 3.14.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/grid': 3.14.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/live-announcer': 3.4.4 - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/visually-hidden': 3.8.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/collections': 3.12.9(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/visually-hidden': 3.8.31(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) '@react-stately/flags': 3.1.2 - '@react-stately/table': 3.15.3(react@19.2.4) - '@react-types/checkbox': 3.10.3(react@19.2.4) - '@react-types/grid': 3.3.7(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/table': 3.13.5(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-stately/table': 3.15.4(react@19.2.4) + '@react-types/checkbox': 3.10.4(react@19.2.4) + '@react-types/grid': 3.3.8(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/table': 3.13.6(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/tabs@3.11.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/tabs@3.11.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/tabs': 3.8.8(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/tabs': 3.3.21(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/tabs': 3.8.9(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/tabs': 3.3.22(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/tag@3.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/tag@3.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/gridlist': 3.14.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/selection': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/gridlist': 3.14.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/selection': 3.27.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/textfield@3.18.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/textfield@3.18.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/form': 3.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/label': 3.7.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/form': 3.2.3(react@19.2.4) + '@react-aria/form': 3.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/label': 3.7.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/textfield': 3.12.7(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/textfield': 3.12.8(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) '@react-aria/toast@3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/landmark': 3.0.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/landmark': 3.0.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-stately/toast': 3.1.0(react@19.2.4) - '@react-types/button': 3.15.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/button': 3.15.1(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/toggle@3.12.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/toggle@3.12.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/toggle': 3.9.4(react@19.2.4) - '@react-types/checkbox': 3.10.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/toggle': 3.9.5(react@19.2.4) + '@react-types/checkbox': 3.10.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/toolbar@3.0.0-beta.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/toolbar@3.0.0-beta.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/tooltip@3.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/tooltip@3.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/tooltip': 3.5.10(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/tooltip': 3.5.1(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/tooltip': 3.5.11(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/tooltip': 3.5.2(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/utils@3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/utils@3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@react-aria/ssr': 3.9.10(react@19.2.4) '@react-stately/flags': 3.1.2 '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 clsx: 2.1.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/virtualizer@4.1.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/virtualizer@4.1.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/i18n': 3.12.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/virtualizer': 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/i18n': 3.12.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/virtualizer': 4.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-aria/visually-hidden@3.8.30(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/visually-hidden@3.8.31(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-email/body@0.2.1(react@19.2.4)': + '@react-email/body@0.3.0(react@19.2.4)': dependencies: react: 19.2.4 @@ -11809,9 +11128,9 @@ snapshots: dependencies: react: 19.2.4 - '@react-email/components@1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-email/components@1.0.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-email/body': 0.2.1(react@19.2.4) + '@react-email/body': 0.3.0(react@19.2.4) '@react-email/button': 0.2.1(react@19.2.4) '@react-email/code-block': 0.2.1(react@19.2.4) '@react-email/code-inline': 0.0.6(react@19.2.4) @@ -11829,7 +11148,7 @@ snapshots: '@react-email/render': 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-email/row': 0.0.13(react@19.2.4) '@react-email/section': 0.0.17(react@19.2.4) - '@react-email/tailwind': 2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4) + '@react-email/tailwind': 2.0.6(@react-email/body@0.3.0(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4) '@react-email/text': 0.1.6(react@19.2.4) react: 19.2.4 transitivePeerDependencies: @@ -11891,13 +11210,13 @@ snapshots: dependencies: react: 19.2.4 - '@react-email/tailwind@2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)': + '@react-email/tailwind@2.0.6(@react-email/body@0.3.0(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)': dependencies: '@react-email/text': 0.1.6(react@19.2.4) react: 19.2.4 - tailwindcss: 4.2.1 + tailwindcss: 4.1.18 optionalDependencies: - '@react-email/body': 0.2.1(react@19.2.4) + '@react-email/body': 0.3.0(react@19.2.4) '@react-email/button': 0.2.1(react@19.2.4) '@react-email/code-block': 0.2.1(react@19.2.4) '@react-email/code-inline': 0.0.6(react@19.2.4) @@ -11912,372 +11231,384 @@ snapshots: dependencies: react: 19.2.4 - '@react-stately/calendar@3.9.2(react@19.2.4)': + '@react-stately/calendar@3.9.3(react@19.2.4)': dependencies: - '@internationalized/date': 3.11.0 + '@internationalized/date': 3.12.0 '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/calendar': 3.8.2(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/calendar': 3.8.3(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/checkbox@3.7.4(react@19.2.4)': + '@react-stately/checkbox@3.7.5(react@19.2.4)': dependencies: - '@react-stately/form': 3.2.3(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/checkbox': 3.10.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/checkbox': 3.10.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/collections@3.12.9(react@19.2.4)': + '@react-stately/collections@3.12.10(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/combobox@3.12.2(react@19.2.4)': + '@react-stately/combobox@3.13.0(react@19.2.4)': dependencies: - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-stately/overlays': 3.6.22(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-stately/overlays': 3.6.23(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/combobox': 3.13.11(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/combobox': 3.14.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/data@3.15.1(react@19.2.4)': + '@react-stately/data@3.15.2(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/datepicker@3.16.0(react@19.2.4)': + '@react-stately/datepicker@3.16.1(react@19.2.4)': dependencies: - '@internationalized/date': 3.11.0 + '@internationalized/date': 3.12.0 '@internationalized/number': 3.6.5 '@internationalized/string': 3.2.7 - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-stately/overlays': 3.6.22(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-stately/overlays': 3.6.23(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/datepicker': 3.13.4(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/datepicker': 3.13.5(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/dnd@3.7.3(react@19.2.4)': + '@react-stately/dnd@3.7.4(react@19.2.4)': dependencies: - '@react-stately/selection': 3.20.8(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-stately/selection': 3.20.9(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 '@react-stately/flags@3.1.2': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 - '@react-stately/form@3.2.3(react@19.2.4)': + '@react-stately/form@3.2.4(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/grid@3.11.8(react@19.2.4)': + '@react-stately/grid@3.11.9(react@19.2.4)': dependencies: - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/selection': 3.20.8(react@19.2.4) - '@react-types/grid': 3.3.7(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/selection': 3.20.9(react@19.2.4) + '@react-types/grid': 3.3.8(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/layout@4.5.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-stately/layout@4.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/table': 3.15.3(react@19.2.4) - '@react-stately/virtualizer': 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/grid': 3.3.7(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/table': 3.13.5(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/table': 3.15.4(react@19.2.4) + '@react-stately/virtualizer': 4.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/grid': 3.3.8(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/table': 3.13.6(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-stately/list@3.13.3(react@19.2.4)': + '@react-stately/list@3.13.4(react@19.2.4)': dependencies: - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/selection': 3.20.8(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/selection': 3.20.9(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/menu@3.9.10(react@19.2.4)': + '@react-stately/menu@3.9.11(react@19.2.4)': dependencies: - '@react-stately/overlays': 3.6.22(react@19.2.4) - '@react-types/menu': 3.10.6(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-stately/overlays': 3.6.23(react@19.2.4) + '@react-types/menu': 3.10.7(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/numberfield@3.10.4(react@19.2.4)': + '@react-stately/numberfield@3.11.0(react@19.2.4)': dependencies: '@internationalized/number': 3.6.5 - '@react-stately/form': 3.2.3(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/numberfield': 3.8.17(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/numberfield': 3.8.18(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/overlays@3.6.22(react@19.2.4)': + '@react-stately/overlays@3.6.23(react@19.2.4)': dependencies: '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/overlays': 3.9.3(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/overlays': 3.9.4(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/radio@3.11.4(react@19.2.4)': + '@react-stately/radio@3.11.5(react@19.2.4)': dependencies: - '@react-stately/form': 3.2.3(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/radio': 3.9.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/radio': 3.9.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/searchfield@3.5.18(react@19.2.4)': + '@react-stately/searchfield@3.5.19(react@19.2.4)': dependencies: '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/searchfield': 3.6.7(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/searchfield': 3.6.8(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/select@3.9.1(react@19.2.4)': + '@react-stately/select@3.9.2(react@19.2.4)': dependencies: - '@react-stately/form': 3.2.3(react@19.2.4) - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-stately/overlays': 3.6.22(react@19.2.4) + '@react-stately/form': 3.2.4(react@19.2.4) + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-stately/overlays': 3.6.23(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/select': 3.12.1(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/select': 3.12.2(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/selection@3.20.8(react@19.2.4)': + '@react-stately/selection@3.20.9(react@19.2.4)': dependencies: - '@react-stately/collections': 3.12.9(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/table@3.15.3(react@19.2.4)': + '@react-stately/table@3.15.4(react@19.2.4)': dependencies: - '@react-stately/collections': 3.12.9(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) '@react-stately/flags': 3.1.2 - '@react-stately/grid': 3.11.8(react@19.2.4) - '@react-stately/selection': 3.20.8(react@19.2.4) + '@react-stately/grid': 3.11.9(react@19.2.4) + '@react-stately/selection': 3.20.9(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/grid': 3.3.7(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/table': 3.13.5(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/grid': 3.3.8(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/table': 3.13.6(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/tabs@3.8.8(react@19.2.4)': + '@react-stately/tabs@3.8.9(react@19.2.4)': dependencies: - '@react-stately/list': 3.13.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/tabs': 3.3.21(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-stately/list': 3.13.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/tabs': 3.3.22(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 '@react-stately/toast@3.1.0(react@19.2.4)': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) - '@react-stately/toggle@3.9.4(react@19.2.4)': + '@react-stately/toggle@3.9.5(react@19.2.4)': dependencies: '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/checkbox': 3.10.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/checkbox': 3.10.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/tooltip@3.5.10(react@19.2.4)': + '@react-stately/tooltip@3.5.11(react@19.2.4)': dependencies: - '@react-stately/overlays': 3.6.22(react@19.2.4) - '@react-types/tooltip': 3.5.1(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-stately/overlays': 3.6.23(react@19.2.4) + '@react-types/tooltip': 3.5.2(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/tree@3.9.5(react@19.2.4)': + '@react-stately/tree@3.9.6(react@19.2.4)': dependencies: - '@react-stately/collections': 3.12.9(react@19.2.4) - '@react-stately/selection': 3.20.8(react@19.2.4) + '@react-stately/collections': 3.12.10(react@19.2.4) + '@react-stately/selection': 3.20.9(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 '@react-stately/utils@3.11.0(react@19.2.4)': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 react: 19.2.4 - '@react-stately/virtualizer@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-stately/virtualizer@4.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) - '@swc/helpers': 0.5.18 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.19 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@react-types/actionbar@3.1.20(react@19.2.4)': + '@react-types/actionbar@3.1.21(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/actiongroup@3.4.22(react@19.2.4)': + '@react-types/actiongroup@3.4.23(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/breadcrumbs@3.7.18(react@19.2.4)': + '@react-types/breadcrumbs@3.7.19(react@19.2.4)': dependencies: - '@react-types/link': 3.6.6(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/link': 3.6.7(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/button@3.15.0(react@19.2.4)': + '@react-types/button@3.15.1(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/calendar@3.8.2(react@19.2.4)': + '@react-types/calendar@3.8.3(react@19.2.4)': dependencies: - '@internationalized/date': 3.11.0 - '@react-types/shared': 3.33.0(react@19.2.4) + '@internationalized/date': 3.12.0 + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/checkbox@3.10.3(react@19.2.4)': + '@react-types/checkbox@3.10.4(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/combobox@3.13.11(react@19.2.4)': + '@react-types/combobox@3.14.0(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/datepicker@3.13.4(react@19.2.4)': + '@react-types/datepicker@3.13.5(react@19.2.4)': dependencies: - '@internationalized/date': 3.11.0 - '@react-types/calendar': 3.8.2(react@19.2.4) - '@react-types/overlays': 3.9.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) + '@internationalized/date': 3.12.0 + '@react-types/calendar': 3.8.3(react@19.2.4) + '@react-types/overlays': 3.9.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/dialog@3.5.23(react@19.2.4)': + '@react-types/dialog@3.5.24(react@19.2.4)': dependencies: - '@react-types/overlays': 3.9.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/overlays': 3.9.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/grid@3.3.7(react@19.2.4)': + '@react-types/grid@3.3.8(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/link@3.6.6(react@19.2.4)': + '@react-types/link@3.6.7(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/listbox@3.7.5(react@19.2.4)': + '@react-types/listbox@3.7.6(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/menu@3.10.6(react@19.2.4)': + '@react-types/menu@3.10.7(react@19.2.4)': dependencies: - '@react-types/overlays': 3.9.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/overlays': 3.9.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/meter@3.4.14(react@19.2.4)': + '@react-types/meter@3.4.15(react@19.2.4)': dependencies: - '@react-types/progress': 3.5.17(react@19.2.4) + '@react-types/progress': 3.5.18(react@19.2.4) react: 19.2.4 - '@react-types/numberfield@3.8.17(react@19.2.4)': + '@react-types/numberfield@3.8.18(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/overlays@3.9.3(react@19.2.4)': + '@react-types/overlays@3.9.4(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/progress@3.5.17(react@19.2.4)': + '@react-types/progress@3.5.18(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/radio@3.9.3(react@19.2.4)': + '@react-types/radio@3.9.4(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/searchfield@3.6.7(react@19.2.4)': + '@react-types/searchfield@3.6.8(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) - '@react-types/textfield': 3.12.7(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/textfield': 3.12.8(react@19.2.4) react: 19.2.4 - '@react-types/select@3.12.1(react@19.2.4)': + '@react-types/select@3.12.2(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/shared@3.33.0(react@19.2.4)': + '@react-types/shared@3.33.1(react@19.2.4)': dependencies: react: 19.2.4 - '@react-types/switch@3.5.16(react@19.2.4)': + '@react-types/switch@3.5.17(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/table@3.13.5(react@19.2.4)': + '@react-types/table@3.13.6(react@19.2.4)': dependencies: - '@react-types/grid': 3.3.7(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/grid': 3.3.8(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/tabs@3.3.21(react@19.2.4)': + '@react-types/tabs@3.3.22(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/textfield@3.12.7(react@19.2.4)': + '@react-types/textfield@3.12.8(react@19.2.4)': dependencies: - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@react-types/tooltip@3.5.1(react@19.2.4)': + '@react-types/tooltip@3.5.2(react@19.2.4)': dependencies: - '@react-types/overlays': 3.9.3(react@19.2.4) - '@react-types/shared': 3.33.0(react@19.2.4) + '@react-types/overlays': 3.9.4(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) react: 19.2.4 - '@rollup/plugin-commonjs@28.0.1(rollup@4.59.0)': + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + + '@rollup/plugin-commonjs@28.0.1(rollup@4.60.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.0) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.3) @@ -12285,210 +11616,212 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.3 optionalDependencies: - rollup: 4.59.0 + rollup: 4.60.0 - '@rollup/pluginutils@5.3.0(rollup@4.59.0)': + '@rollup/pluginutils@5.3.0(rollup@4.60.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.59.0 - - '@rollup/rollup-android-arm-eabi@4.57.1': - optional: true + rollup: 4.60.0 '@rollup/rollup-android-arm-eabi@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.57.1': + '@rollup/rollup-android-arm-eabi@4.60.0': optional: true '@rollup/rollup-android-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.57.1': + '@rollup/rollup-android-arm64@4.60.0': optional: true '@rollup/rollup-darwin-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.57.1': + '@rollup/rollup-darwin-arm64@4.60.0': optional: true '@rollup/rollup-darwin-x64@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.57.1': + '@rollup/rollup-darwin-x64@4.60.0': optional: true '@rollup/rollup-freebsd-arm64@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.57.1': + '@rollup/rollup-freebsd-arm64@4.60.0': optional: true '@rollup/rollup-freebsd-x64@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + '@rollup/rollup-freebsd-x64@4.60.0': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.57.1': + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.57.1': + '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.57.1': + '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.57.1': + '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.57.1': + '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.57.1': + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.57.1': + '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.57.1': + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.57.1': + '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.57.1': + '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.57.1': + '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.57.1': + '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true '@rollup/rollup-linux-x64-musl@4.59.0': optional: true - '@rollup/rollup-openbsd-x64@4.57.1': + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true '@rollup/rollup-openbsd-x64@4.59.0': optional: true - '@rollup/rollup-openharmony-arm64@4.57.1': + '@rollup/rollup-openbsd-x64@4.60.0': optional: true '@rollup/rollup-openharmony-arm64@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.57.1': + '@rollup/rollup-openharmony-arm64@4.60.0': optional: true '@rollup/rollup-win32-arm64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.57.1': + '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true '@rollup/rollup-win32-ia32-msvc@4.59.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.57.1': + '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true '@rollup/rollup-win32-x64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.57.1': + '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true - '@rtsao/scc@1.1.0': {} + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@schummar/icu-type-parser@1.21.5': {} + + '@sec-ant/readable-stream@0.4.1': {} '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 selderee: 0.11.0 - '@sentry-internal/browser-utils@10.40.0': + '@sentry-internal/browser-utils@10.45.0': dependencies: - '@sentry/core': 10.40.0 + '@sentry/core': 10.45.0 - '@sentry-internal/feedback@10.40.0': + '@sentry-internal/feedback@10.45.0': dependencies: - '@sentry/core': 10.40.0 + '@sentry/core': 10.45.0 - '@sentry-internal/replay-canvas@10.40.0': + '@sentry-internal/replay-canvas@10.45.0': dependencies: - '@sentry-internal/replay': 10.40.0 - '@sentry/core': 10.40.0 + '@sentry-internal/replay': 10.45.0 + '@sentry/core': 10.45.0 - '@sentry-internal/replay@10.40.0': + '@sentry-internal/replay@10.45.0': dependencies: - '@sentry-internal/browser-utils': 10.40.0 - '@sentry/core': 10.40.0 + '@sentry-internal/browser-utils': 10.45.0 + '@sentry/core': 10.45.0 - '@sentry/babel-plugin-component-annotate@5.1.0': {} + '@sentry/babel-plugin-component-annotate@5.1.1': {} - '@sentry/browser@10.40.0': + '@sentry/browser@10.45.0': dependencies: - '@sentry-internal/browser-utils': 10.40.0 - '@sentry-internal/feedback': 10.40.0 - '@sentry-internal/replay': 10.40.0 - '@sentry-internal/replay-canvas': 10.40.0 - '@sentry/core': 10.40.0 + '@sentry-internal/browser-utils': 10.45.0 + '@sentry-internal/feedback': 10.45.0 + '@sentry-internal/replay': 10.45.0 + '@sentry-internal/replay-canvas': 10.45.0 + '@sentry/core': 10.45.0 - '@sentry/bundler-plugin-core@5.1.0': + '@sentry/bundler-plugin-core@5.1.1': dependencies: '@babel/core': 7.29.0 - '@sentry/babel-plugin-component-annotate': 5.1.0 + '@sentry/babel-plugin-component-annotate': 5.1.1 '@sentry/cli': 2.58.5 dotenv: 16.6.1 find-up: 5.0.0 glob: 13.0.6 - magic-string: 0.30.8 + magic-string: 0.30.21 transitivePeerDependencies: - encoding - supports-color @@ -12537,23 +11870,23 @@ snapshots: - encoding - supports-color - '@sentry/core@10.40.0': {} + '@sentry/core@10.45.0': {} - '@sentry/nextjs@10.40.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.1)': + '@sentry/nextjs@10.45.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 - '@rollup/plugin-commonjs': 28.0.1(rollup@4.59.0) - '@sentry-internal/browser-utils': 10.40.0 - '@sentry/bundler-plugin-core': 5.1.0 - '@sentry/core': 10.40.0 - '@sentry/node': 10.40.0 - '@sentry/opentelemetry': 10.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) - '@sentry/react': 10.40.0(react@19.2.4) - '@sentry/vercel-edge': 10.40.0 - '@sentry/webpack-plugin': 5.1.0(webpack@5.105.1) - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - rollup: 4.59.0 + '@opentelemetry/semantic-conventions': 1.40.0 + '@rollup/plugin-commonjs': 28.0.1(rollup@4.60.0) + '@sentry-internal/browser-utils': 10.45.0 + '@sentry/bundler-plugin-core': 5.1.1 + '@sentry/core': 10.45.0 + '@sentry/node': 10.45.0 + '@sentry/opentelemetry': 10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) + '@sentry/react': 10.45.0(react@19.2.4) + '@sentry/vercel-edge': 10.45.0 + '@sentry/webpack-plugin': 5.1.1(webpack@5.105.4) + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + rollup: 4.60.0 stacktrace-parser: 0.1.11 transitivePeerDependencies: - '@opentelemetry/context-async-hooks' @@ -12564,90 +11897,92 @@ snapshots: - supports-color - webpack - '@sentry/node-core@10.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)': + '@sentry/node-core@10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0)': dependencies: - '@sentry/core': 10.40.0 - '@sentry/opentelemetry': 10.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) - import-in-the-middle: 2.0.6 + '@sentry/core': 10.45.0 + '@sentry/opentelemetry': 10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) + import-in-the-middle: 3.0.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@sentry/node@10.40.0': + '@sentry/node@10.45.0': dependencies: - '@fastify/otel': 0.16.0(@opentelemetry/api@1.9.0) + '@fastify/otel': 0.17.1(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-amqplib': 0.58.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-connect': 0.54.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-dataloader': 0.28.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-express': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-fs': 0.30.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-generic-pool': 0.54.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-graphql': 0.58.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-hapi': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-http': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-ioredis': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-kafkajs': 0.20.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-knex': 0.55.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-koa': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-lru-memoizer': 0.55.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongodb': 0.64.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongoose': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql2': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-pg': 0.63.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-redis': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-tedious': 0.30.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-undici': 0.21.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 - '@prisma/instrumentation': 7.2.0(@opentelemetry/api@1.9.0) - '@sentry/core': 10.40.0 - '@sentry/node-core': 10.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) - '@sentry/opentelemetry': 10.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) - import-in-the-middle: 2.0.6 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib': 0.60.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.30.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fs': 0.32.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-generic-pool': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.22.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-knex': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-lru-memoizer': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.66.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.65.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-tedious': 0.32.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': 0.23.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@prisma/instrumentation': 7.4.2(@opentelemetry/api@1.9.0) + '@sentry/core': 10.45.0 + '@sentry/node-core': 10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) + '@sentry/opentelemetry': 10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) + import-in-the-middle: 3.0.0 transitivePeerDependencies: - supports-color - '@sentry/opentelemetry@10.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)': + '@sentry/opentelemetry@10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 - '@sentry/core': 10.40.0 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@sentry/core': 10.45.0 - '@sentry/react@10.40.0(react@19.2.4)': + '@sentry/react@10.45.0(react@19.2.4)': dependencies: - '@sentry/browser': 10.40.0 - '@sentry/core': 10.40.0 + '@sentry/browser': 10.45.0 + '@sentry/core': 10.45.0 react: 19.2.4 - '@sentry/vercel-edge@10.40.0': + '@sentry/vercel-edge@10.45.0': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@sentry/core': 10.40.0 + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@sentry/core': 10.45.0 - '@sentry/webpack-plugin@5.1.0(webpack@5.105.1)': + '@sentry/webpack-plugin@5.1.1(webpack@5.105.4)': dependencies: - '@sentry/bundler-plugin-core': 5.1.0 + '@sentry/bundler-plugin-core': 5.1.1 uuid: 9.0.1 - webpack: 5.105.1 + webpack: 5.105.4 transitivePeerDependencies: - encoding - supports-color + '@sindresorhus/merge-streams@4.0.0': {} + '@sindresorhus/slugify@1.1.2': dependencies: '@sindresorhus/transliterate': 0.1.2 @@ -12662,140 +11997,197 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@stripe/react-stripe-js@5.6.0(@stripe/stripe-js@8.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@stripe/react-stripe-js@5.6.1(@stripe/stripe-js@8.11.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@stripe/stripe-js': 8.8.0 + '@stripe/stripe-js': 8.11.0 prop-types: 15.8.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@stripe/stripe-js@8.8.0': {} + '@stripe/stripe-js@8.11.0': {} - '@supabase/auth-js@2.97.0': + '@supabase/auth-js@2.100.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.97.0': + '@supabase/functions-js@2.100.0': dependencies: tslib: 2.8.1 - '@supabase/postgrest-js@2.97.0': + '@supabase/phoenix@0.4.0': {} + + '@supabase/postgrest-js@2.100.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.97.0': + '@supabase/realtime-js@2.100.0': dependencies: - '@types/phoenix': 1.6.7 + '@supabase/phoenix': 0.4.0 '@types/ws': 8.18.1 tslib: 2.8.1 - ws: 8.19.0 + ws: 8.20.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@supabase/ssr@0.8.0(@supabase/supabase-js@2.97.0)': + '@supabase/ssr@0.9.0(@supabase/supabase-js@2.100.0)': dependencies: - '@supabase/supabase-js': 2.97.0 + '@supabase/supabase-js': 2.100.0 cookie: 1.1.1 - '@supabase/storage-js@2.97.0': + '@supabase/storage-js@2.100.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.97.0': + '@supabase/supabase-js@2.100.0': dependencies: - '@supabase/auth-js': 2.97.0 - '@supabase/functions-js': 2.97.0 - '@supabase/postgrest-js': 2.97.0 - '@supabase/realtime-js': 2.97.0 - '@supabase/storage-js': 2.97.0 + '@supabase/auth-js': 2.100.0 + '@supabase/functions-js': 2.100.0 + '@supabase/postgrest-js': 2.100.0 + '@supabase/realtime-js': 2.100.0 + '@supabase/storage-js': 2.100.0 transitivePeerDependencies: - bufferutil - utf-8-validate + '@swc/core-darwin-arm64@1.15.18': + optional: true + + '@swc/core-darwin-x64@1.15.18': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.18': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.18': + optional: true + + '@swc/core-linux-arm64-musl@1.15.18': + optional: true + + '@swc/core-linux-x64-gnu@1.15.18': + optional: true + + '@swc/core-linux-x64-musl@1.15.18': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.18': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.18': + optional: true + + '@swc/core-win32-x64-msvc@1.15.18': + optional: true + + '@swc/core@1.15.18(@swc/helpers@0.5.19)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.18 + '@swc/core-darwin-x64': 1.15.18 + '@swc/core-linux-arm-gnueabihf': 1.15.18 + '@swc/core-linux-arm64-gnu': 1.15.18 + '@swc/core-linux-arm64-musl': 1.15.18 + '@swc/core-linux-x64-gnu': 1.15.18 + '@swc/core-linux-x64-musl': 1.15.18 + '@swc/core-win32-arm64-msvc': 1.15.18 + '@swc/core-win32-ia32-msvc': 1.15.18 + '@swc/core-win32-x64-msvc': 1.15.18 + '@swc/helpers': 0.5.19 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@swc/helpers@0.5.18': + '@swc/helpers@0.5.19': dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.2.1': + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + + '@tabby_ai/hijri-converter@1.0.5': {} + + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.1 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.1 + tailwindcss: 4.2.2 - '@tailwindcss/oxide-android-arm64@4.2.1': + '@tailwindcss/oxide-android-arm64@4.2.2': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.1': + '@tailwindcss/oxide-darwin-arm64@4.2.2': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.1': + '@tailwindcss/oxide-darwin-x64@4.2.2': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.1': + '@tailwindcss/oxide-freebsd-x64@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.1': + '@tailwindcss/oxide-linux-x64-musl@4.2.2': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.1': + '@tailwindcss/oxide-wasm32-wasi@4.2.2': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': optional: true - '@tailwindcss/oxide@4.2.1': + '@tailwindcss/oxide@4.2.2': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-x64': 4.2.1 - '@tailwindcss/oxide-freebsd-x64': 4.2.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-x64-musl': 4.2.1 - '@tailwindcss/oxide-wasm32-wasi': 4.2.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/postcss@4.2.1': + '@tailwindcss/postcss@4.2.2': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 - postcss: 8.5.6 - tailwindcss: 4.2.1 + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + postcss: 8.5.8 + tailwindcss: 4.2.2 - '@tanstack/query-core@5.90.20': {} + '@tanstack/query-core@5.95.2': {} - '@tanstack/react-query@5.90.21(react@19.2.4)': + '@tanstack/react-query@5.95.2(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.90.20 + '@tanstack/query-core': 5.95.2 react: 19.2.4 '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -12806,64 +12198,48 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@toeverything/y-indexeddb@0.10.0-canary.9(yjs@13.6.29)': + '@toeverything/y-indexeddb@0.10.0-canary.9(yjs@13.6.30)': dependencies: idb: 7.1.1 - nanoid: 5.1.6 - y-provider: 0.10.0-canary.9(yjs@13.6.29) - yjs: 13.6.29 + nanoid: 5.1.7 + y-provider: 0.10.0-canary.9(yjs@13.6.30) + yjs: 13.6.30 - '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': + '@ts-gql/tag@0.7.3(graphql@16.13.1)': dependencies: - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.0 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - javascript-natural-sort: 0.7.1 - lodash-es: 4.17.23 - minimatch: 9.0.5 - parse-imports-exports: 0.2.4 - prettier: 3.8.1 - transitivePeerDependencies: - - supports-color + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.1) + graphql: 16.13.1 + graphql-tag: 2.12.6(graphql@16.13.1) - '@ts-gql/tag@0.7.3(graphql@16.12.0)': + '@ts-morph/common@0.27.0': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - graphql: 16.12.0 - graphql-tag: 2.12.6(graphql@16.12.0) + fast-glob: 3.3.3 + minimatch: 10.2.4 + path-browserify: 1.0.1 - '@turbo/gen-darwin-64@2.8.11': + '@turbo/darwin-64@2.8.20': optional: true - '@turbo/gen-darwin-arm64@2.8.11': + '@turbo/darwin-arm64@2.8.20': optional: true - '@turbo/gen-linux-64@2.8.11': - optional: true - - '@turbo/gen-linux-arm64@2.8.11': - optional: true - - '@turbo/gen-windows-64@2.8.11': - optional: true - - '@turbo/gen@2.8.11(@types/node@25.3.1)': + '@turbo/gen@2.8.20(@types/node@25.5.0)': dependencies: - '@inquirer/prompts': 7.10.1(@types/node@25.3.1) - node-plop: 0.26.3 - optionalDependencies: - '@turbo/gen-darwin-64': 2.8.11 - '@turbo/gen-darwin-arm64': 2.8.11 - '@turbo/gen-linux-64': 2.8.11 - '@turbo/gen-linux-arm64': 2.8.11 - '@turbo/gen-windows-64': 2.8.11 + '@inquirer/prompts': 7.10.1(@types/node@25.5.0) + esbuild: 0.25.12 transitivePeerDependencies: - '@types/node' - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 + '@turbo/linux-64@2.8.20': + optional: true + + '@turbo/linux-arm64@2.8.20': + optional: true + + '@turbo/windows-64@2.8.20': + optional: true + + '@turbo/windows-arm64@2.8.20': optional: true '@types/chai@5.2.3': @@ -12873,7 +12249,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 '@types/d3-array@3.2.2': {} @@ -12899,7 +12275,7 @@ snapshots: '@types/d3-timer@3.0.2': {} - '@types/debug@4.1.12': + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -12915,38 +12291,24 @@ snapshots: '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 - '@types/esrecurse@4.3.1': {} - '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 '@types/estree@1.0.8': {} - '@types/glob@7.2.0': - dependencies: - '@types/minimatch': 6.0.0 - '@types/node': 25.3.1 - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 - '@types/inquirer@6.5.0': - dependencies: - '@types/through': 0.0.33 - rxjs: 6.6.7 - '@types/is-hotkey@0.1.10': {} '@types/json-schema@7.0.15': {} - '@types/json5@0.0.29': {} - '@types/linkify-it@3.0.5': optional: true - '@types/lodash@4.17.23': {} + '@types/lodash@4.17.24': {} '@types/markdown-it@12.2.3': dependencies: @@ -12961,23 +12323,19 @@ snapshots: '@types/mdurl@2.0.0': optional: true - '@types/minimatch@6.0.0': - dependencies: - minimatch: 10.2.4 - '@types/ms@2.1.0': {} '@types/mysql@2.15.27': dependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@types/node@25.3.1': + '@types/node@25.5.0': dependencies: undici-types: 7.18.2 '@types/nodemailer@7.0.11': dependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 '@types/parse-json@4.0.2': {} @@ -12987,12 +12345,10 @@ snapshots: '@types/pg@8.15.6': dependencies: - '@types/node': 25.3.1 - pg-protocol: 1.11.0 + '@types/node': 25.5.0 + pg-protocol: 1.13.0 pg-types: 2.2.0 - '@types/phoenix@1.6.7': {} - '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -13001,235 +12357,99 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/statuses@2.0.6': {} + '@types/tedious@4.0.14': dependencies: - '@types/node': 25.3.1 - - '@types/through@0.0.33': - dependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + + '@types/validate-npm-package-name@4.0.2': {} + '@types/ws@8.18.1': dependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@urql/core@5.2.0(graphql@16.13.1)': dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 - eslint: 10.0.1(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.55.0': - dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - - '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.55.0': {} - - '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.55.0': - dependencies: - '@typescript-eslint/types': 8.55.0 - eslint-visitor-keys: 4.2.1 - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - optional: true - - '@unrs/resolver-binding-android-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - dependencies: - '@napi-rs/wasm-runtime': 0.2.12 - optional: true - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - optional: true - - '@urql/core@5.2.0(graphql@16.12.0)': - dependencies: - '@0no-co/graphql.web': 1.2.0(graphql@16.12.0) + '@0no-co/graphql.web': 1.2.0(graphql@16.13.1) wonka: 6.3.5 transitivePeerDependencies: - graphql - '@urql/exchange-auth@2.2.1(@urql/core@5.2.0(graphql@16.12.0))': + '@urql/exchange-auth@2.2.1(@urql/core@5.2.0(graphql@16.13.1))': dependencies: - '@urql/core': 5.2.0(graphql@16.12.0) + '@urql/core': 5.2.0(graphql@16.13.1) wonka: 6.3.5 - '@urql/exchange-graphcache@7.2.4(@urql/core@5.2.0(graphql@16.12.0))(graphql@16.12.0)': + '@urql/exchange-graphcache@7.2.4(@urql/core@5.2.0(graphql@16.13.1))(graphql@16.13.1)': dependencies: - '@0no-co/graphql.web': 1.2.0(graphql@16.12.0) - '@urql/core': 5.2.0(graphql@16.12.0) + '@0no-co/graphql.web': 1.2.0(graphql@16.13.1) + '@urql/core': 5.2.0(graphql@16.13.1) wonka: 6.3.5 transitivePeerDependencies: - graphql - '@urql/exchange-persisted@4.3.1(@urql/core@5.2.0(graphql@16.12.0))': + '@urql/exchange-persisted@4.3.1(@urql/core@5.2.0(graphql@16.13.1))': dependencies: - '@urql/core': 5.2.0(graphql@16.12.0) + '@urql/core': 5.2.0(graphql@16.13.1) wonka: 6.3.5 - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.1': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0))': + '@vitest/mocker@4.1.1(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.1 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0) + msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1) - '@vitest/pretty-format@4.0.18': + '@vitest/mocker@4.1.1(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1))': dependencies: - tinyrainbow: 3.0.3 + '@vitest/spy': 4.1.1 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.14(@types/node@25.5.0)(typescript@6.0.2) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1) - '@vitest/runner@4.0.18': + '@vitest/pretty-format@4.1.1': dependencies: - '@vitest/utils': 4.0.18 + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.1': + dependencies: + '@vitest/utils': 4.1.1 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.1': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.1 + '@vitest/utils': 4.1.1 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.1': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.1': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.1 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@webassemblyjs/ast@1.14.1': dependencies: @@ -13324,19 +12544,13 @@ snapshots: dependencies: acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 - acorn-walk@8.3.4: + acorn-walk@8.3.5: dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} + acorn: 8.16.0 acorn@8.16.0: {} @@ -13348,11 +12562,6 @@ snapshots: agent-base@7.1.4: {} - aggregate-error@3.1.0: - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -13366,13 +12575,6 @@ snapshots: ajv: 8.18.0 fast-deep-equal: 3.1.3 - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -13380,12 +12582,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -13403,98 +12603,19 @@ snapshots: dependencies: tslib: 2.8.1 - aria-query@5.3.2: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array-union@2.1.0: {} - - array.prototype.findlast@1.2.5: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.tosorted@1.1.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - assertion-error@2.0.1: {} - ast-types-flow@0.0.8: {} - - async-function@1.0.0: {} + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 atomic-sleep@1.0.0: {} attr-accept@2.2.5: {} - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - axe-core@4.11.1: {} - - axobject-query@4.1.0: {} - babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 cosmiconfig: 7.1.0 resolve: 1.22.11 @@ -13504,11 +12625,11 @@ snapshots: balanced-match@1.0.2: {} - balanced-match@4.0.3: {} - balanced-match@4.0.4: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.10.10: {} + + baseline-browser-mapping@2.10.7: {} bin-links@6.0.0: dependencies: @@ -13516,7 +12637,7 @@ snapshots: npm-normalize-package-bin: 5.0.0 proc-log: 6.1.0 read-cmd-shim: 6.0.0 - write-file-atomic: 7.0.0 + write-file-atomic: 7.0.1 binary-extensions@2.3.0: {} @@ -13536,20 +12657,11 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.2: - dependencies: - balanced-match: 4.0.3 - - brace-expansion@5.0.3: + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -13559,14 +12671,18 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 - electron-to-chromium: 1.5.286 - node-releases: 2.0.27 + baseline-browser-mapping: 2.10.7 + caniuse-lite: 1.0.30001778 + electron-to-chromium: 1.5.313 + node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) buffer-from@1.1.2: {} + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bundle-require@5.1.0(esbuild@0.27.3): dependencies: esbuild: 0.27.3 @@ -13581,13 +12697,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -13595,49 +12704,22 @@ snapshots: callsites@3.1.0: {} - camel-case@3.0.0: - dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 - caniuse-api@3.0.0: dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001778 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001778: {} + + caniuse-lite@1.0.30001780: {} ccount@2.0.1: {} chai@6.2.2: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - change-case@3.1.0: - dependencies: - camel-case: 3.0.0 - constant-case: 2.0.0 - dot-case: 2.1.1 - header-case: 1.0.1 - is-lower-case: 1.1.3 - is-upper-case: 1.1.2 - lower-case: 1.1.4 - lower-case-first: 1.0.2 - no-case: 2.3.2 - param-case: 2.1.1 - pascal-case: 2.0.1 - path-case: 2.1.1 - sentence-case: 2.1.1 - snake-case: 2.1.0 - swap-case: 1.1.2 - title-case: 2.1.1 - upper-case: 1.1.3 - upper-case-first: 1.1.2 + chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -13647,8 +12729,6 @@ snapshots: character-reference-invalid@2.0.1: {} - chardet@0.7.0: {} - chardet@2.1.1: {} chokidar@3.6.0: @@ -13677,18 +12757,22 @@ snapshots: dependencies: clsx: 2.1.1 - clean-stack@2.2.0: {} - - cli-cursor@3.1.0: + cli-cursor@5.0.0: dependencies: - restore-cursor: 3.1.0 + restore-cursor: 5.1.0 - cli-width@3.0.0: {} + cli-spinners@2.9.2: {} cli-width@4.1.0: {} client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cmd-shim@8.0.0: {} @@ -13705,6 +12789,8 @@ snapshots: - '@types/react' - '@types/react-dom' + code-block-writer@13.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -13717,6 +12803,8 @@ snapshots: commander@11.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -13729,8 +12817,6 @@ snapshots: compute-scroll-into-view@3.1.1: {} - concat-map@0.0.1: {} - confbox@0.1.8: {} config-chain@1.1.13: @@ -13740,11 +12826,6 @@ snapshots: consola@3.4.2: {} - constant-case@2.0.0: - dependencies: - snake-case: 2.1.0 - upper-case: 1.1.3 - content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -13759,8 +12840,6 @@ snapshots: cookie@1.1.1: {} - core-js-pure@3.48.0: {} - cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -13772,7 +12851,16 @@ snapshots: import-fresh: 3.3.1 parse-json: 5.2.0 path-type: 4.0.0 - yaml: 1.10.2 + yaml: 1.10.3 + + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 cross-env@10.1.0: dependencies: @@ -13785,9 +12873,9 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-declaration-sorter@7.3.1(postcss@8.5.6): + css-declaration-sorter@7.3.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 css-select@5.2.2: dependencies: @@ -13802,58 +12890,58 @@ snapshots: mdn-data: 2.0.28 source-map-js: 1.2.1 - css-tree@3.1.0: + css-tree@3.2.1: dependencies: - mdn-data: 2.12.2 + mdn-data: 2.27.1 source-map-js: 1.2.1 css-what@6.2.2: {} cssesc@3.0.0: {} - cssnano-preset-default@7.0.10(postcss@8.5.6): + cssnano-preset-default@7.0.11(postcss@8.5.8): dependencies: browserslist: 4.28.1 - css-declaration-sorter: 7.3.1(postcss@8.5.6) - cssnano-utils: 5.0.1(postcss@8.5.6) - postcss: 8.5.6 - postcss-calc: 10.1.1(postcss@8.5.6) - postcss-colormin: 7.0.5(postcss@8.5.6) - postcss-convert-values: 7.0.8(postcss@8.5.6) - postcss-discard-comments: 7.0.5(postcss@8.5.6) - postcss-discard-duplicates: 7.0.2(postcss@8.5.6) - postcss-discard-empty: 7.0.1(postcss@8.5.6) - postcss-discard-overridden: 7.0.1(postcss@8.5.6) - postcss-merge-longhand: 7.0.5(postcss@8.5.6) - postcss-merge-rules: 7.0.7(postcss@8.5.6) - postcss-minify-font-values: 7.0.1(postcss@8.5.6) - postcss-minify-gradients: 7.0.1(postcss@8.5.6) - postcss-minify-params: 7.0.5(postcss@8.5.6) - postcss-minify-selectors: 7.0.5(postcss@8.5.6) - postcss-normalize-charset: 7.0.1(postcss@8.5.6) - postcss-normalize-display-values: 7.0.1(postcss@8.5.6) - postcss-normalize-positions: 7.0.1(postcss@8.5.6) - postcss-normalize-repeat-style: 7.0.1(postcss@8.5.6) - postcss-normalize-string: 7.0.1(postcss@8.5.6) - postcss-normalize-timing-functions: 7.0.1(postcss@8.5.6) - postcss-normalize-unicode: 7.0.5(postcss@8.5.6) - postcss-normalize-url: 7.0.1(postcss@8.5.6) - postcss-normalize-whitespace: 7.0.1(postcss@8.5.6) - postcss-ordered-values: 7.0.2(postcss@8.5.6) - postcss-reduce-initial: 7.0.5(postcss@8.5.6) - postcss-reduce-transforms: 7.0.1(postcss@8.5.6) - postcss-svgo: 7.1.0(postcss@8.5.6) - postcss-unique-selectors: 7.0.4(postcss@8.5.6) + css-declaration-sorter: 7.3.1(postcss@8.5.8) + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-calc: 10.1.1(postcss@8.5.8) + postcss-colormin: 7.0.6(postcss@8.5.8) + postcss-convert-values: 7.0.9(postcss@8.5.8) + postcss-discard-comments: 7.0.6(postcss@8.5.8) + postcss-discard-duplicates: 7.0.2(postcss@8.5.8) + postcss-discard-empty: 7.0.1(postcss@8.5.8) + postcss-discard-overridden: 7.0.1(postcss@8.5.8) + postcss-merge-longhand: 7.0.5(postcss@8.5.8) + postcss-merge-rules: 7.0.8(postcss@8.5.8) + postcss-minify-font-values: 7.0.1(postcss@8.5.8) + postcss-minify-gradients: 7.0.1(postcss@8.5.8) + postcss-minify-params: 7.0.6(postcss@8.5.8) + postcss-minify-selectors: 7.0.6(postcss@8.5.8) + postcss-normalize-charset: 7.0.1(postcss@8.5.8) + postcss-normalize-display-values: 7.0.1(postcss@8.5.8) + postcss-normalize-positions: 7.0.1(postcss@8.5.8) + postcss-normalize-repeat-style: 7.0.1(postcss@8.5.8) + postcss-normalize-string: 7.0.1(postcss@8.5.8) + postcss-normalize-timing-functions: 7.0.1(postcss@8.5.8) + postcss-normalize-unicode: 7.0.6(postcss@8.5.8) + postcss-normalize-url: 7.0.1(postcss@8.5.8) + postcss-normalize-whitespace: 7.0.1(postcss@8.5.8) + postcss-ordered-values: 7.0.2(postcss@8.5.8) + postcss-reduce-initial: 7.0.6(postcss@8.5.8) + postcss-reduce-transforms: 7.0.1(postcss@8.5.8) + postcss-svgo: 7.1.1(postcss@8.5.8) + postcss-unique-selectors: 7.0.5(postcss@8.5.8) - cssnano-utils@5.0.1(postcss@8.5.6): + cssnano-utils@5.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - cssnano@7.1.2(postcss@8.5.6): + cssnano@7.1.3(postcss@8.5.8): dependencies: - cssnano-preset-default: 7.0.10(postcss@8.5.6) + cssnano-preset-default: 7.0.11(postcss@8.5.8) lilconfig: 3.1.3 - postcss: 8.5.6 + postcss: 8.5.8 csso@5.0.5: dependencies: @@ -13899,28 +12987,8 @@ snapshots: d3-timer@3.0.1: {} - damerau-levenshtein@1.0.8: {} - data-uri-to-buffer@4.0.1: {} - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - date-fns-jalali@4.1.0-0: {} date-fns@4.1.0: {} @@ -13929,10 +12997,6 @@ snapshots: debounce@1.2.1: {} - debug@3.2.7: - dependencies: - ms: 2.1.3 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -13945,34 +13009,24 @@ snapshots: dependencies: character-entities: 2.0.2 + dedent@1.7.2(babel-plugin-macros@3.1.0): + optionalDependencies: + babel-plugin-macros: 3.1.0 + deep-extend@0.6.0: {} - deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} deepmerge@4.3.1: {} - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 + default-browser-id@5.0.1: {} - define-properties@1.2.1: + default-browser@5.5.0: dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 + bundle-name: 4.1.0 + default-browser-id: 5.0.1 - del@5.1.0: - dependencies: - globby: 10.0.2 - graceful-fs: 4.2.11 - is-glob: 4.0.3 - is-path-cwd: 2.2.0 - is-path-inside: 3.0.3 - p-map: 3.0.0 - rimraf: 3.0.2 - slash: 3.0.0 + define-lazy-prop@3.0.0: {} depd@2.0.0: {} @@ -13988,21 +13042,10 @@ snapshots: dependencies: dequal: 2.0.3 - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 + diff@8.0.3: {} direction@1.0.4: {} - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.28.6 - csstype: 3.2.3 - dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -14021,12 +13064,6 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - dot-case@2.1.1: - dependencies: - no-case: 2.3.2 - - dotenv@16.0.3: {} - dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -14039,15 +13076,34 @@ snapshots: duplexer@0.1.2: {} + eciesjs@0.4.18: + dependencies: + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + ee-first@1.1.1: {} - electron-to-chromium@1.5.286: {} + electron-to-chromium@1.5.313: {} + + embla-carousel-react@8.6.0(react@19.2.4): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.2.4 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} emery@1.4.4: {} - emoji-regex@8.0.0: {} + emoji-regex@10.6.0: {} - emoji-regex@9.2.2: {} + emoji-regex@8.0.0: {} encodeurl@2.0.0: {} @@ -14055,121 +13111,59 @@ snapshots: dependencies: once: 1.4.0 - enhanced-resolve@5.19.0: + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 entities@4.5.0: {} + env-paths@2.2.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 - es-abstract@1.24.1: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.2.2: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-set-tostringtag: 2.1.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - iterator.prototype: 1.1.5 - safe-array-concat: 1.1.3 - - es-module-lexer@1.7.0: {} - es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 + es-toolkit@1.45.1: {} - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 esbuild@0.27.3: optionalDependencies: @@ -14200,229 +13194,51 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + escalade@3.2.0: {} escape-html@1.0.3: {} - escape-string-regexp@1.0.5: {} - escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} - eslint-config-next@16.1.6(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.1.6 - eslint: 10.0.1(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@10.0.1(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@10.0.1(jiti@2.6.1)) - globals: 16.4.0 - typescript-eslint: 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - - eslint-config-turbo@2.8.11(eslint@10.0.1(jiti@2.6.1))(turbo@2.8.11): - dependencies: - eslint: 10.0.1(jiti@2.6.1) - eslint-plugin-turbo: 2.8.11(eslint@10.0.1(jiti@2.6.1))(turbo@2.8.11) - turbo: 2.8.11 - - eslint-import-resolver-node@0.3.9: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) - get-tsconfig: 4.13.6 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 10.0.1(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)))(eslint@10.0.1(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-jsx-a11y@6.10.2(eslint@10.0.1(jiti@2.6.1)): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.1 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 10.0.1(jiti@2.6.1) - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - - eslint-plugin-react-hooks@7.0.1(eslint@10.0.1(jiti@2.6.1)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - eslint: 10.0.1(jiti@2.6.1) - hermes-parser: 0.25.1 - zod: 3.25.76 - zod-validation-error: 4.0.2(zod@3.25.76) - transitivePeerDependencies: - - supports-color - - eslint-plugin-react@7.37.5(eslint@10.0.1(jiti@2.6.1)): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 10.0.1(jiti@2.6.1) - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - - eslint-plugin-turbo@2.8.11(eslint@10.0.1(jiti@2.6.1))(turbo@2.8.11): - dependencies: - dotenv: 16.0.3 - eslint: 10.0.1(jiti@2.6.1) - turbo: 2.8.11 - eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 - eslint-scope@9.1.1: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.0.1(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.2 - '@eslint/config-helpers': 0.5.2 - '@eslint/core': 1.1.0 - '@eslint/plugin-kit': 0.6.0 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.1 - eslint-visitor-keys: 5.0.1 - espree: 11.1.1 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@11.1.1: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 + esprima@4.0.1: {} esrecurse@4.3.0: dependencies: @@ -14445,13 +13261,11 @@ snapshots: dependencies: '@types/estree': 1.0.8 - esutils@2.0.3: {} - etag@1.8.1: {} event-target-shim@6.0.2: {} - eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} events@3.3.0: {} @@ -14461,12 +13275,39 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expect-type@1.3.0: {} - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.1.0 express@5.2.1: dependencies: @@ -14501,28 +13342,12 @@ snapshots: transitivePeerDependencies: - supports-color - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 - facepaint@1.2.1: {} fast-copy@3.0.2: {} fast-deep-equal@3.1.3: {} - fast-equals@5.4.0: {} - - fast-glob@3.3.1: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14531,10 +13356,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - fast-safe-stringify@2.1.1: {} fast-uri@3.1.0: {} @@ -14547,18 +13368,18 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - figures@3.2.0: + figures@6.1.0: dependencies: - escape-string-regexp: 1.0.5 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 + is-unicode-supported: 2.1.0 file-selector@2.1.2: dependencies: @@ -14589,19 +13410,8 @@ snapshots: fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 - mlly: 1.8.0 - rollup: 4.57.1 - - flat-cache@4.0.1: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - - flatted@3.3.3: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 + mlly: 1.8.1 + rollup: 4.59.0 formdata-polyfill@4.0.10: dependencies: @@ -14613,7 +13423,11 @@ snapshots: fresh@2.0.0: {} - fs.realpath@1.0.0: {} + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 fsevents@2.3.2: optional: true @@ -14623,21 +13437,14 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - generator-function@2.0.1: {} + fuzzysort@3.1.0: {} gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -14653,135 +13460,67 @@ snapshots: get-nonce@1.0.1: {} + get-own-enumerable-keys@1.0.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 + get-stream@6.0.1: {} - get-tsconfig@4.13.6: + get-stream@9.0.1: dependencies: - resolve-pkg-maps: 1.0.0 + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} glob@13.0.6: dependencies: - minimatch: 10.2.3 + minimatch: 10.2.4 minipass: 7.1.3 path-scurry: 2.0.2 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - - globals@16.4.0: {} - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - globby@10.0.2: - dependencies: - '@types/glob': 7.2.0 - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - glob: 7.2.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - gopd@1.2.0: {} graceful-fs@4.2.10: {} graceful-fs@4.2.11: {} - graphql-tag@2.12.6(graphql@16.12.0): + graphql-tag@2.12.6(graphql@16.13.1): dependencies: - graphql: 16.12.0 + graphql: 16.13.1 tslib: 2.8.1 - graphql@16.12.0: {} + graphql@16.13.1: {} gzip-size@6.0.0: dependencies: duplexer: 0.1.2 - handlebars@4.7.8: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - has-bigints@1.1.0: {} - has-flag@4.0.0: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - has-symbols@1.1.0: {} - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hasown@2.0.2: dependencies: function-bind: 1.1.2 he@1.2.0: {} - header-case@1.0.1: - dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 + headers-polyfill@4.0.3: {} help-me@5.0.0: {} - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - - hono@4.12.2: {} + hono@4.12.7: {} html-escaper@2.0.2: {} - html-parse-stringify@3.0.1: - dependencies: - void-elements: 3.1.0 - html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -14819,37 +13558,29 @@ snapshots: transitivePeerDependencies: - supports-color - i18next-browser-languagedetector@8.2.1: - dependencies: - '@babel/runtime': 7.28.6 + human-signals@2.1.0: {} - i18next-resources-to-backend@1.2.1: - dependencies: - '@babel/runtime': 7.28.6 - - i18next@25.8.13(typescript@5.9.3): - dependencies: - '@babel/runtime': 7.28.6 - optionalDependencies: - typescript: 5.9.3 + human-signals@8.0.1: {} iceberg-js@0.8.1: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 + icu-minify@4.8.3: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.1 + idb-keyval@6.2.2: {} idb@7.1.1: {} ignore@5.3.2: {} - ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.4: {} immer@9.0.21: {} @@ -14865,14 +13596,12 @@ snapshots: cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 - imurmurhash@0.1.4: {} - - indent-string@4.0.0: {} - - inflight@1.0.6: + import-in-the-middle@3.0.0: dependencies: - once: 1.4.0 - wrappy: 1.0.2 + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 inherits@2.0.4: {} @@ -14883,28 +13612,6 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - inquirer@7.3.3: - dependencies: - ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-width: 3.0.0 - external-editor: 3.1.0 - figures: 3.2.0 - lodash: 4.17.23 - mute-stream: 0.0.8 - run-async: 2.4.1 - rxjs: 6.6.7 - string-width: 4.2.3 - strip-ansi: 6.0.1 - through: 2.3.8 - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - internmap@2.0.3: {} intl-messageformat@10.7.18: @@ -14914,7 +13621,14 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.4 tslib: 2.8.1 - ip-address@10.0.1: {} + intl-messageformat@11.1.2: + dependencies: + '@formatjs/ecma402-abstract': 3.1.1 + '@formatjs/fast-memoize': 3.1.0 + '@formatjs/icu-messageformat-parser': 3.5.1 + tslib: 2.8.1 + + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -14925,74 +13639,24 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-arrayish@0.2.1: {} - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-bun-module@2.0.0: - dependencies: - semver: 7.7.4 - - is-callable@1.2.7: {} - is-core-module@2.16.1: dependencies: hasown: 2.0.2 - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - is-decimal@2.0.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - is-fullwidth-code-point@3.0.0: {} - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -15003,24 +13667,21 @@ snapshots: is-hotkey@0.2.0: {} - is-lower-case@1.1.3: + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: dependencies: - lower-case: 1.1.4 + is-docker: 3.0.0 - is-map@2.0.3: {} + is-interactive@2.0.0: {} - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + is-node-process@1.2.0: {} is-number@7.0.0: {} - is-path-cwd@2.2.0: {} + is-obj@3.0.0: {} - is-path-inside@3.0.3: {} + is-plain-obj@4.1.0: {} is-plain-object@5.0.0: {} @@ -15030,71 +13691,29 @@ snapshots: dependencies: '@types/estree': 1.0.8 - is-regex@1.2.1: + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@3.1.1: dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - - is-upper-case@1.1.2: - dependencies: - upper-case: 1.1.3 - - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@2.0.5: {} - - isbinaryfile@4.0.10: {} + is-inside-container: 1.0.0 isexe@2.0.0: {} + isexe@3.1.5: {} + isomorphic.js@0.2.5: {} - iterator.prototype@1.1.5: - dependencies: - define-data-property: 1.1.4 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - has-symbols: 1.1.0 - set-function-name: 2.0.2 - - javascript-natural-sort@0.7.1: {} - jest-worker@27.5.1: dependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -15102,7 +13721,7 @@ snapshots: jju@1.4.0: {} - jose@6.1.3: {} + jose@6.2.1: {} joycon@3.1.1: {} @@ -15114,104 +13733,82 @@ snapshots: jsesc@3.1.0: {} - json-buffer@3.0.1: {} - json-parse-even-better-errors@2.3.1: {} - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@1.0.2: - dependencies: - minimist: 1.2.8 - json5@2.2.3: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jssha@3.3.1: {} - jsx-ast-utils@3.3.5: - dependencies: - array-includes: 3.1.9 - array.prototype.flat: 1.3.3 - object.assign: 4.1.7 - object.values: 1.2.1 + kleur@3.0.3: {} - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 + kleur@4.1.5: {} ky@1.14.3: {} - language-subtag-registry@0.3.23: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.23 - leac@0.6.0: {} - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - lib0@0.2.117: dependencies: isomorphic.js: 0.2.5 - lightningcss-android-arm64@1.31.1: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.31.1: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.31.1: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.31.1: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.31.1: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.31.1: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.31.1: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.31.1: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.31.1: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.31.1: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.31.1: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.31.1: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 lilconfig@3.1.3: {} @@ -15225,39 +13822,34 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.17.23: {} - lodash.deburr@4.1.0: {} - lodash.get@4.4.2: {} - lodash.memoize@4.1.2: {} lodash.uniq@4.5.0: {} lodash@4.17.23: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + longest-streak@3.1.0: {} loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 - lower-case-first@1.0.2: - dependencies: - lower-case: 1.1.4 - - lower-case@1.1.4: {} - lru-cache@10.4.3: {} - lru-cache@11.2.6: {} + lru-cache@11.2.7: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lucide-react@0.575.0(react@19.2.4): + lucide-react@1.0.1(react@19.2.4): dependencies: react: 19.2.4 @@ -15265,17 +13857,13 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@0.30.8: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - markdown-table@3.0.4: {} marked@15.0.12: {} match-sorter@6.3.4: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 remove-accents: 0.5.0 math-intrinsics@1.1.0: {} @@ -15287,7 +13875,7 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.2: + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 @@ -15315,7 +13903,7 @@ snapshots: mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -15325,7 +13913,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -15336,7 +13924,7 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -15349,7 +13937,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -15360,7 +13948,7 @@ snapshots: mdast-util-mdx@3.0.0: dependencies: - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-mdx-expression: 2.0.1 mdast-util-mdx-jsx: 3.2.0 mdast-util-mdxjs-esm: 2.0.1 @@ -15374,7 +13962,7 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -15402,7 +13990,7 @@ snapshots: mdn-data@2.0.28: {} - mdn-data@2.12.2: {} + mdn-data@2.27.1: {} media-typer@1.1.0: {} @@ -15497,8 +14085,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -15622,7 +14210,7 @@ snapshots: micromark@4.0.2: dependencies: - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 @@ -15661,27 +14249,13 @@ snapshots: mimic-fn@2.1.0: {} - minimatch@10.2.2: - dependencies: - brace-expansion: 5.0.2 - - minimatch@10.2.3: - dependencies: - brace-expansion: 5.0.3 + mimic-function@5.0.1: {} minimatch@10.2.4: dependencies: - brace-expansion: 5.0.3 + brace-expansion: 5.0.4 - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.12 - - minimatch@9.0.5: + minimatch@9.0.9: dependencies: brace-expansion: 2.0.2 @@ -15693,13 +14267,9 @@ snapshots: dependencies: minipass: 7.1.3 - mkdirp@0.5.6: + mlly@1.8.1: dependencies: - minimist: 1.2.8 - - mlly@1.8.0: - dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.3 @@ -15710,7 +14280,56 @@ snapshots: ms@2.1.3: {} - mute-stream@0.0.8: {} + msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@25.5.0) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.1 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.5.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@25.5.0) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.1 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.5.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 6.0.2 + transitivePeerDependencies: + - '@types/node' + optional: true mute-stream@2.0.0: {} @@ -15722,48 +14341,92 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.6: {} - - napi-postinstall@0.3.4: {} - - natural-compare@1.4.0: {} + nanoid@5.1.7: {} negotiator@1.0.0: {} neo-async@2.6.2: {} - next-sitemap@4.2.3(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + next-intl-swc-plugin-extractor@4.8.3: {} + + next-intl@4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@formatjs/intl-localematcher': 0.8.1 + '@parcel/watcher': 2.5.6 + '@swc/core': 1.15.18(@swc/helpers@0.5.19) + icu-minify: 4.8.3 + negotiator: 1.0.0 + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl-swc-plugin-extractor: 4.8.3 + po-parser: 2.1.1 + react: 19.2.4 + use-intl: 4.8.3(react@19.2.4) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/helpers' + + next-intl@4.8.3(@swc/helpers@0.5.19)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@6.0.2): + dependencies: + '@formatjs/intl-localematcher': 0.8.1 + '@parcel/watcher': 2.5.6 + '@swc/core': 1.15.18(@swc/helpers@0.5.19) + icu-minify: 4.8.3 + negotiator: 1.0.0 + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl-swc-plugin-extractor: 4.8.3 + po-parser: 2.1.1 + react: 19.2.4 + use-intl: 4.8.3(react@19.2.4) + optionalDependencies: + typescript: 6.0.2 + transitivePeerDependencies: + - '@swc/helpers' + + next-runtime-env@3.3.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + dependencies: + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + + next-safe-action@8.1.8(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + deepmerge-ts: 7.1.5 + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + next-sitemap@4.2.3(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.1.6 + '@next/env': 16.2.1 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + baseline-browser-mapping: 2.10.10 + caniuse-lite: 1.0.30001780 postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.2.1 + '@next/swc-darwin-x64': 16.2.1 + '@next/swc-linux-arm64-gnu': 16.2.1 + '@next/swc-linux-arm64-musl': 16.2.1 + '@next/swc-linux-x64-gnu': 16.2.1 + '@next/swc-linux-x64-musl': 16.2.1 + '@next/swc-win32-arm64-msvc': 16.2.1 + '@next/swc-win32-x64-msvc': 16.2.1 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.58.2 babel-plugin-react-compiler: 1.0.0 @@ -15772,9 +14435,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - no-case@2.3.2: - dependencies: - lower-case: 1.1.4 + node-addon-api@7.1.1: {} node-domexception@1.0.0: {} @@ -15788,35 +14449,30 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-html-parser@7.0.2: + node-html-parser@7.1.0: dependencies: css-select: 5.2.2 he: 1.2.0 - node-plop@0.26.3: - dependencies: - '@babel/runtime-corejs3': 7.29.0 - '@types/inquirer': 6.5.0 - change-case: 3.1.0 - del: 5.1.0 - globby: 10.0.2 - handlebars: 4.7.8 - inquirer: 7.3.3 - isbinaryfile: 4.0.10 - lodash.get: 4.4.2 - mkdirp: 0.5.6 - resolve: 1.22.11 + node-releases@2.0.36: {} - node-releases@2.0.27: {} - - nodemailer@8.0.1: {} + nodemailer@8.0.3: {} normalize-path@3.0.0: {} - nosecone@1.1.0: {} + nosecone@1.3.0: {} npm-normalize-package-bin@5.0.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -15825,43 +14481,7 @@ snapshots: object-inspect@1.13.4: {} - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.entries@1.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + object-treeify@1.1.33: {} obug@2.1.1: {} @@ -15879,26 +14499,82 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + opener@1.5.2: {} - optionator@0.9.4: + ora@8.2.0: dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 orderedmap@2.1.1: {} - os-tmpdir@1.0.2: {} + outvariant@1.4.3: {} - own-keys@1.0.1: + oxfmt@0.41.0: dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.41.0 + '@oxfmt/binding-android-arm64': 0.41.0 + '@oxfmt/binding-darwin-arm64': 0.41.0 + '@oxfmt/binding-darwin-x64': 0.41.0 + '@oxfmt/binding-freebsd-x64': 0.41.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.41.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.41.0 + '@oxfmt/binding-linux-arm64-gnu': 0.41.0 + '@oxfmt/binding-linux-arm64-musl': 0.41.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.41.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.41.0 + '@oxfmt/binding-linux-riscv64-musl': 0.41.0 + '@oxfmt/binding-linux-s390x-gnu': 0.41.0 + '@oxfmt/binding-linux-x64-gnu': 0.41.0 + '@oxfmt/binding-linux-x64-musl': 0.41.0 + '@oxfmt/binding-openharmony-arm64': 0.41.0 + '@oxfmt/binding-win32-arm64-msvc': 0.41.0 + '@oxfmt/binding-win32-ia32-msvc': 0.41.0 + '@oxfmt/binding-win32-x64-msvc': 0.41.0 + + oxlint@1.56.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.56.0 + '@oxlint/binding-android-arm64': 1.56.0 + '@oxlint/binding-darwin-arm64': 1.56.0 + '@oxlint/binding-darwin-x64': 1.56.0 + '@oxlint/binding-freebsd-x64': 1.56.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.56.0 + '@oxlint/binding-linux-arm-musleabihf': 1.56.0 + '@oxlint/binding-linux-arm64-gnu': 1.56.0 + '@oxlint/binding-linux-arm64-musl': 1.56.0 + '@oxlint/binding-linux-ppc64-gnu': 1.56.0 + '@oxlint/binding-linux-riscv64-gnu': 1.56.0 + '@oxlint/binding-linux-riscv64-musl': 1.56.0 + '@oxlint/binding-linux-s390x-gnu': 1.56.0 + '@oxlint/binding-linux-x64-gnu': 1.56.0 + '@oxlint/binding-linux-x64-musl': 1.56.0 + '@oxlint/binding-openharmony-arm64': 1.56.0 + '@oxlint/binding-win32-arm64-msvc': 1.56.0 + '@oxlint/binding-win32-ia32-msvc': 1.56.0 + '@oxlint/binding-win32-x64-msvc': 1.56.0 p-limit@3.1.0: dependencies: @@ -15912,10 +14588,6 @@ snapshots: dependencies: p-limit: 3.1.0 - p-map@3.0.0: - dependencies: - aggregate-error: 3.1.0 - package-json@10.0.1: dependencies: ky: 1.14.3 @@ -15923,10 +14595,6 @@ snapshots: registry-url: 6.0.1 semver: 7.7.4 - param-case@2.1.1: - dependencies: - no-case: 2.3.2 - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -15943,10 +14611,6 @@ snapshots: parse-github-url@1.0.3: {} - parse-imports-exports@0.2.4: - dependencies: - parse-statements: 1.0.11 - parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -15954,7 +14618,7 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - parse-statements@1.0.11: {} + parse-ms@4.0.0: {} parseley@0.12.1: dependencies: @@ -15967,28 +14631,23 @@ snapshots: dependencies: event-target-shim: 6.0.2 - pascal-case@2.0.1: - dependencies: - camel-case: 3.0.0 - upper-case-first: 1.1.2 - - path-case@2.1.1: - dependencies: - no-case: 2.3.2 + path-browserify@1.0.1: {} path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@2.0.2: dependencies: - lru-cache: 11.2.6 + lru-cache: 11.2.7 minipass: 7.1.3 + path-to-regexp@6.3.0: {} + path-to-regexp@8.3.0: {} path-type@4.0.0: {} @@ -15999,7 +14658,7 @@ snapshots: pg-int8@1.0.1: {} - pg-protocol@1.11.0: {} + pg-protocol@1.13.0: {} pg-types@2.2.0: dependencies: @@ -16015,6 +14674,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pino-abstract-transport@2.0.0: dependencies: split2: 4.2.0 @@ -16034,7 +14695,7 @@ snapshots: minimist: 1.2.8 on-exit-leak-free: 2.1.2 pino-abstract-transport: 2.0.0 - pump: 3.0.3 + pump: 3.0.4 secure-json-parse: 2.7.0 sonic-boom: 4.2.1 strip-json-comments: 3.1.1 @@ -16062,7 +14723,7 @@ snapshots: pkg-types@1.3.1: dependencies: confbox: 0.1.8 - mlly: 1.8.0 + mlly: 1.8.1 pathe: 2.0.3 playwright-core@1.58.2: {} @@ -16073,152 +14734,151 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - possible-typed-array-names@1.1.0: {} + po-parser@2.1.1: {} - postcss-calc@10.1.1(postcss@8.5.6): + postcss-calc@10.1.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - postcss-colormin@7.0.5(postcss@8.5.6): + postcss-colormin@7.0.6(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 colord: 2.9.3 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-convert-values@7.0.8(postcss@8.5.6): + postcss-convert-values@7.0.9(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-discard-comments@7.0.5(postcss@8.5.6): + postcss-discard-comments@7.0.6(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-discard-duplicates@7.0.2(postcss@8.5.6): + postcss-discard-duplicates@7.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-empty@7.0.1(postcss@8.5.6): + postcss-discard-empty@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-overridden@7.0.1(postcss@8.5.6): + postcss-discard-overridden@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 - postcss: 8.5.6 - tsx: 4.21.0 + postcss: 8.5.8 - postcss-merge-longhand@7.0.5(postcss@8.5.6): + postcss-merge-longhand@7.0.5(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - stylehacks: 7.0.7(postcss@8.5.6) + stylehacks: 7.0.8(postcss@8.5.8) - postcss-merge-rules@7.0.7(postcss@8.5.6): + postcss-merge-rules@7.0.8(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 - cssnano-utils: 5.0.1(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-minify-font-values@7.0.1(postcss@8.5.6): + postcss-minify-font-values@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-gradients@7.0.1(postcss@8.5.6): + postcss-minify-gradients@7.0.1(postcss@8.5.8): dependencies: colord: 2.9.3 - cssnano-utils: 5.0.1(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-params@7.0.5(postcss@8.5.6): + postcss-minify-params@7.0.6(postcss@8.5.8): dependencies: browserslist: 4.28.1 - cssnano-utils: 5.0.1(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-selectors@7.0.5(postcss@8.5.6): + postcss-minify-selectors@7.0.6(postcss@8.5.8): dependencies: cssesc: 3.0.0 - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-normalize-charset@7.0.1(postcss@8.5.6): + postcss-normalize-charset@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-normalize-display-values@7.0.1(postcss@8.5.6): + postcss-normalize-display-values@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-positions@7.0.1(postcss@8.5.6): + postcss-normalize-positions@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@7.0.1(postcss@8.5.6): + postcss-normalize-repeat-style@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-string@7.0.1(postcss@8.5.6): + postcss-normalize-string@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@7.0.1(postcss@8.5.6): + postcss-normalize-timing-functions@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@7.0.5(postcss@8.5.6): + postcss-normalize-unicode@7.0.6(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-url@7.0.1(postcss@8.5.6): + postcss-normalize-url@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@7.0.1(postcss@8.5.6): + postcss-normalize-whitespace@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-ordered-values@7.0.2(postcss@8.5.6): + postcss-ordered-values@7.0.2(postcss@8.5.8): dependencies: - cssnano-utils: 5.0.1(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-reduce-initial@7.0.5(postcss@8.5.6): + postcss-reduce-initial@7.0.6(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 - postcss: 8.5.6 + postcss: 8.5.8 - postcss-reduce-transforms@7.0.1(postcss@8.5.6): + postcss-reduce-transforms@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 postcss-selector-parser@7.1.1: @@ -16226,15 +14886,15 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-svgo@7.1.0(postcss@8.5.6): + postcss-svgo@7.1.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - svgo: 4.0.0 + svgo: 4.0.1 - postcss-unique-selectors@7.0.4(postcss@8.5.6): + postcss-unique-selectors@7.0.5(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 postcss-value-parser@4.2.0: {} @@ -16245,7 +14905,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -16263,16 +14923,14 @@ snapshots: postgres@3.4.8: {} - prelude-ls@1.2.1: {} - - prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1): - dependencies: - prettier: 3.8.1 - optionalDependencies: - '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.1) + powershell-utils@0.1.0: {} prettier@3.8.1: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + prismjs@1.30.0: {} proc-log@6.1.0: {} @@ -16281,6 +14939,11 @@ snapshots: progress@2.0.3: {} + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -16297,7 +14960,7 @@ snapshots: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.6 + prosemirror-view: 1.41.7 rope-sequence: 1.3.4 prosemirror-keymap@1.2.3: @@ -16313,7 +14976,7 @@ snapshots: dependencies: prosemirror-model: 1.25.4 prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.6 + prosemirror-view: 1.41.7 prosemirror-tables@1.8.5: dependencies: @@ -16321,13 +14984,13 @@ snapshots: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.6 + prosemirror-view: 1.41.7 prosemirror-transform@1.11.0: dependencies: prosemirror-model: 1.25.4 - prosemirror-view@1.41.6: + prosemirror-view@1.41.7: dependencies: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 @@ -16342,13 +15005,11 @@ snapshots: proxy-from-env@1.1.0: {} - pump@3.0.3: + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 - punycode@2.3.1: {} - qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -16357,73 +15018,6 @@ snapshots: quick-format-unescaped@4.0.4: {} - radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - range-parser@1.2.1: {} raw-body@3.0.2: @@ -16440,9 +15034,10 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-day-picker@9.13.2(react@19.2.4): + react-day-picker@9.14.0(react@19.2.4): dependencies: '@date-fns/tz': 1.4.1 + '@tabby_ai/hijri-converter': 1.0.5 date-fns: 4.1.0 date-fns-jalali: 4.1.0-0 react: 19.2.4 @@ -16459,24 +15054,20 @@ snapshots: prop-types: 15.8.1 react: 19.2.4 - react-hook-form@7.71.2(react@19.2.4): + react-hook-form@7.72.0(react@19.2.4): dependencies: react: 19.2.4 - react-i18next@16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): - dependencies: - '@babel/runtime': 7.28.6 - html-parse-stringify: 3.0.1 - i18next: 25.8.13(typescript@5.9.3) - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) - optionalDependencies: - react-dom: 19.2.4(react@19.2.4) - typescript: 5.9.3 - react-is@16.13.1: {} - react-is@18.3.1: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: @@ -16497,13 +15088,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-smooth@4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-resizable-panels@4.7.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - fast-equals: 5.4.0 - prop-types: 15.8.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): dependencies: @@ -16517,15 +15105,6 @@ snapshots: dependencies: react: 19.2.4 - react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@babel/runtime': 7.28.6 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react@19.2.4: {} read-cmd-shim@6.0.0: {} @@ -16538,42 +15117,39 @@ snapshots: real-require@0.2.0: {} - recharts-scale@0.4.5: + recast@0.23.11: dependencies: - decimal.js-light: 2.5.1 + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 - recharts@2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1): dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) clsx: 2.1.1 - eventemitter3: 4.0.7 - lodash: 4.17.23 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - recharts-scale: 0.4.5 + react-is: 16.13.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 tiny-invariant: 1.3.3 - victory-vendor: 36.9.2 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux - reflect.getprototypeof@1.0.10: + redux-thunk@3.1.0(redux@5.0.1): dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 + redux: 5.0.1 - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 + redux@5.0.1: {} registry-auth-token@5.1.1: dependencies: @@ -16585,6 +15161,8 @@ snapshots: remove-accents@0.5.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} require-in-the-middle@8.0.1: @@ -16594,66 +15172,27 @@ snapshots: transitivePeerDependencies: - supports-color + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} - resolve-pkg-maps@1.0.0: {} - resolve@1.22.11: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.5: + restore-cursor@5.1.0: dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 + onetime: 7.0.0 + signal-exit: 4.1.0 - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 + rettime@0.10.1: {} reusify@1.1.0: {} - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - - rollup@4.57.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 - fsevents: 2.3.3 - rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -16685,6 +15224,37 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + rope-sequence@1.3.4: {} router@2.2.0: @@ -16697,46 +15267,21 @@ snapshots: transitivePeerDependencies: - supports-color - run-async@2.4.1: {} + run-applescript@7.1.0: {} run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - rxjs@6.6.7: - dependencies: - tslib: 1.14.1 - rxjs@7.8.2: dependencies: tslib: 2.8.1 - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-buffer@5.2.1: {} - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} - sax@1.4.4: {} + sax@1.5.0: {} scheduler@0.27.0: {} @@ -16785,15 +15330,6 @@ snapshots: transitivePeerDependencies: - supports-color - sentence-case@2.1.1: - dependencies: - no-case: 2.3.2 - upper-case-first: 1.1.2 - - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -16805,33 +15341,54 @@ snapshots: server-only@0.0.1: {} - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - setprototypeof@1.2.0: {} + shadcn@4.1.0(@types/node@25.5.0)(babel-plugin-macros@3.1.0)(typescript@5.9.3): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@dotenvx/dotenvx': 1.57.2 + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.1 + commander: 14.0.3 + cosmiconfig: 9.0.1(typescript@5.9.3) + dedent: 1.7.2(babel-plugin-macros@3.1.0) + deepmerge: 4.3.1 + diff: 8.0.3 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.4 + fuzzysort: 3.1.0 + https-proxy-agent: 7.0.6 + kleur: 4.1.5 + msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3) + node-fetch: 3.3.2 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + prompts: 2.4.2 + recast: 0.23.11 + stringify-object: 5.0.0 + tailwind-merge: 3.5.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@types/node' + - babel-plugin-macros + - supports-color + - typescript + sharp@0.34.5: dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.7.4 optionalDependencies: @@ -16907,7 +15464,7 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - slash@3.0.0: {} + sisteransi@1.0.5: {} slate-history@0.86.0(slate@0.91.4): dependencies: @@ -16918,7 +15475,7 @@ snapshots: dependencies: '@juggle/resize-observer': 3.4.0 '@types/is-hotkey': 0.1.10 - '@types/lodash': 4.17.23 + '@types/lodash': 4.17.24 direction: 1.0.4 is-hotkey: 0.1.8 is-plain-object: 5.0.0 @@ -16935,10 +15492,6 @@ snapshots: is-plain-object: 5.0.0 tiny-warning: 1.0.3 - snake-case@2.1.0: - dependencies: - no-case: 2.3.2 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -16963,8 +15516,6 @@ snapshots: split2@4.2.0: {} - stable-hash@0.0.5: {} - stackback@0.0.2: {} stacktrace-parser@0.1.11: @@ -16973,12 +15524,11 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.0.0: {} - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 + stdin-discarder@0.2.2: {} + + strict-event-emitter@0.5.1: {} string-width@4.2.3: dependencies: @@ -16986,86 +15536,57 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string.prototype.includes@2.0.1: + string-width@7.2.0: dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - - string.prototype.matchall@4.0.12: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - regexp.prototype.flags: 1.5.4 - set-function-name: 2.0.2 - side-channel: 1.1.0 - - string.prototype.repeat@1.0.0: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.24.1 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} - stripe@20.4.0(@types/node@25.3.1): + stripe@20.4.1(@types/node@25.5.0): optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.4): dependencies: client-only: 0.0.1 react: 19.2.4 optionalDependencies: '@babel/core': 7.29.0 + babel-plugin-macros: 3.1.0 - stylehacks@7.0.7(postcss@8.5.6): + stylehacks@7.0.8(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 stylis@4.2.0: {} @@ -17080,51 +15601,48 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 - supabase@2.76.15: + supabase@2.83.0: dependencies: bin-links: 6.0.0 https-proxy-agent: 7.0.6 node-fetch: 3.3.2 - tar: 7.5.9 + tar: 7.5.11 transitivePeerDependencies: - supports-color superstruct@1.0.4: {} - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - supports-color@8.1.1: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} - svgo@4.0.0: + svgo@4.0.1: dependencies: commander: 11.1.0 css-select: 5.2.2 - css-tree: 3.1.0 + css-tree: 3.2.1 css-what: 6.2.2 csso: 5.0.5 picocolors: 1.1.1 - sax: 1.4.4 - - swap-case@1.1.2: - dependencies: - lower-case: 1.1.4 - upper-case: 1.1.3 + sax: 1.5.0 tabbable@6.4.0: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} - tailwindcss@4.2.1: {} + tailwindcss@4.1.18: {} + + tailwindcss@4.2.2: {} tapable@2.3.0: {} - tar@7.5.9: + tapable@2.3.2: {} + + tar@7.5.11: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -17132,16 +15650,15 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser-webpack-plugin@5.3.16(webpack@5.105.1): + terser-webpack-plugin@5.4.0(webpack@5.105.4): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - terser: 5.46.0 - webpack: 5.105.1 + terser: 5.46.1 + webpack: 5.105.4 - terser@5.46.0: + terser@5.46.1: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.16.0 @@ -17160,8 +15677,6 @@ snapshots: dependencies: real-require: 0.2.0 - through@2.3.8: {} - tiny-invariant@1.0.6: {} tiny-invariant@1.3.3: {} @@ -17174,21 +15689,22 @@ snapshots: tinyexec@1.0.2: {} + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinyrainbow@3.0.3: {} + tinypool@2.1.0: {} - title-case@2.1.1: - dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 + tinyrainbow@3.1.0: {} - tmp@0.0.33: + tldts-core@7.0.27: {} + + tldts@7.0.27: dependencies: - os-tmpdir: 1.0.2 + tldts-core: 7.0.27 to-regex-range@5.0.1: dependencies: @@ -17202,30 +15718,32 @@ snapshots: dependencies: jssha: 3.3.1 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + tr46@0.0.3: {} tree-kill@1.2.2: {} - ts-api-utils@2.4.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - ts-case-convert@2.1.0: {} ts-interface-checker@0.1.13: {} - tsconfig-paths@3.15.0: + ts-morph@26.0.0: dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 minimist: 1.2.8 strip-bom: 3.0.0 - tslib@1.14.1: {} - tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + tsup@8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.19))(jiti@2.6.1)(postcss@8.5.8)(typescript@6.0.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -17236,134 +15754,58 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8) resolve-from: 5.0.0 - rollup: 4.57.1 + rollup: 4.59.0 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 0.3.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.6 - typescript: 5.9.3 + '@swc/core': 1.15.18(@swc/helpers@0.5.19) + postcss: 8.5.8 + typescript: 6.0.2 transitivePeerDependencies: - jiti - supports-color - tsx - yaml - tsx@4.21.0: - dependencies: - esbuild: 0.27.3 - get-tsconfig: 4.13.6 + turbo@2.8.20: optionalDependencies: - fsevents: 2.3.3 - optional: true - - turbo-darwin-64@2.8.11: - optional: true - - turbo-darwin-arm64@2.8.11: - optional: true - - turbo-linux-64@2.8.11: - optional: true - - turbo-linux-arm64@2.8.11: - optional: true - - turbo-windows-64@2.8.11: - optional: true - - turbo-windows-arm64@2.8.11: - optional: true - - turbo@2.8.11: - optionalDependencies: - turbo-darwin-64: 2.8.11 - turbo-darwin-arm64: 2.8.11 - turbo-linux-64: 2.8.11 - turbo-linux-arm64: 2.8.11 - turbo-windows-64: 2.8.11 - turbo-windows-arm64: 2.8.11 + '@turbo/darwin-64': 2.8.20 + '@turbo/darwin-arm64': 2.8.20 + '@turbo/linux-64': 2.8.20 + '@turbo/linux-arm64': 2.8.20 + '@turbo/windows-64': 2.8.20 + '@turbo/windows-arm64': 2.8.20 tw-animate-css@1.4.0: {} - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-fest@0.21.3: {} - type-fest@0.7.1: {} + type-fest@5.5.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 media-typer: 1.1.0 mime-types: 3.0.2 - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 + typescript@5.9.3: + optional: true - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typescript-eslint@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - typescript@5.9.3: {} + typescript@6.0.2: {} ufo@1.6.3: {} - uglify-js@3.19.3: - optional: true - - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - undici-types@7.18.2: {} + unicorn-magic@0.3.0: {} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -17387,31 +15829,11 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} + unpipe@1.0.0: {} - unrs-resolver@1.11.1: - dependencies: - napi-postinstall: 0.3.4 - optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.11.1 - '@unrs/resolver-binding-android-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-x64': 1.11.1 - '@unrs/resolver-binding-freebsd-x64': 1.11.1 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 - '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-musl': 1.11.1 - '@unrs/resolver-binding-wasm32-wasi': 1.11.1 - '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 - '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 - '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + until-async@3.0.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: @@ -17419,21 +15841,11 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - upper-case-first@1.1.2: - dependencies: - upper-case: 1.1.3 - - upper-case@1.1.3: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - urlpattern-polyfill@10.1.0: {} - urql@4.2.2(@urql/core@5.2.0(graphql@16.12.0))(react@19.2.4): + urql@4.2.2(@urql/core@5.2.0(graphql@16.13.1))(react@19.2.4): dependencies: - '@urql/core': 5.2.0(graphql@16.12.0) + '@urql/core': 5.2.0(graphql@16.13.1) react: 19.2.4 wonka: 6.3.5 @@ -17444,6 +15856,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-intl@4.8.3(react@19.2.4): + dependencies: + '@formatjs/fast-memoize': 3.1.0 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.8.3 + intl-messageformat: 11.1.2 + react: 19.2.4 + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): dependencies: detect-node-es: 1.1.0 @@ -17462,14 +15882,25 @@ snapshots: validate-npm-package-name@6.0.2: {} + validate-npm-package-name@7.0.2: {} + vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 - victory-vendor@36.9.2: + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 '@types/d3-ease': 3.0.2 @@ -17486,61 +15917,76 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0): + vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1): dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.1 + '@types/node': 25.5.0 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.31.1 - terser: 5.46.0 - tsx: 4.21.0 + lightningcss: 1.32.0 + terser: 5.46.1 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0): + vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.4 + std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0) + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 25.3.1 + '@types/node': 25.5.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - void-elements@3.1.0: {} + vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)): + dependencies: + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.5.0 + transitivePeerDependencies: + - msw w3c-keyname@2.2.8: {} @@ -17556,8 +16002,8 @@ snapshots: webpack-bundle-analyzer@4.10.1: dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.15.0 - acorn-walk: 8.3.4 + acorn: 8.16.0 + acorn-walk: 8.3.5 commander: 7.2.0 debounce: 1.2.1 escape-string-regexp: 4.0.0 @@ -17574,7 +16020,7 @@ snapshots: webpack-sources@3.3.4: {} - webpack@5.105.1: + webpack@5.105.4: dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -17586,7 +16032,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.1 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -17597,8 +16043,8 @@ snapshots: mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 4.3.3 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(webpack@5.105.1) + tapable: 2.3.2 + terser-webpack-plugin: 5.4.0(webpack@5.105.4) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: @@ -17611,51 +16057,14 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - which@2.0.2: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.5 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -17663,13 +16072,9 @@ snapshots: wonka@6.3.5: {} - word-wrap@1.2.5: {} - - wordwrap@1.0.0: {} - wp-types@4.69.0: dependencies: - typescript: 5.9.3 + typescript: 6.0.2 wrap-ansi@6.2.0: dependencies: @@ -17677,44 +16082,68 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} - write-file-atomic@7.0.0: + write-file-atomic@7.0.1: dependencies: - imurmurhash: 0.1.4 signal-exit: 4.1.0 ws@7.5.10: {} - ws@8.19.0: {} + ws@8.20.0: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 xtend@4.0.2: {} - y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29): + y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30): dependencies: lib0: 0.2.117 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.6 - y-protocols: 1.0.7(yjs@13.6.29) - yjs: 13.6.29 + prosemirror-view: 1.41.7 + y-protocols: 1.0.7(yjs@13.6.30) + yjs: 13.6.30 - y-protocols@1.0.7(yjs@13.6.29): + y-protocols@1.0.7(yjs@13.6.30): dependencies: lib0: 0.2.117 - yjs: 13.6.29 + yjs: 13.6.30 - y-provider@0.10.0-canary.9(yjs@13.6.29): + y-provider@0.10.0-canary.9(yjs@13.6.30): dependencies: - yjs: 13.6.29 + yjs: 13.6.30 + + y18n@5.0.8: {} yallist@3.1.1: {} yallist@5.0.0: {} - yaml@1.10.2: {} + yaml@1.10.3: {} - yjs@13.6.29: + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yjs@13.6.30: dependencies: lib0: 0.2.117 @@ -17724,14 +16153,18 @@ snapshots: yoctocolors-cjs@2.1.3: {} + yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@3.25.76): + zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: - zod: 3.25.76 + zod: 4.3.6 zod@3.25.76: {} + zod@4.3.6: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff17c46c1..03ad99800 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,42 +4,81 @@ packages: - tooling/* catalog: - '@eslint/js': 10.0.1 - '@marsidev/react-turnstile': 1.4.2 - '@next/bundle-analyzer': 16.1.6 - '@next/eslint-plugin-next': 16.1.6 - '@react-email/components': 1.0.8 - '@sentry/nextjs': 10.40.0 - '@stripe/react-stripe-js': 5.6.0 - '@stripe/stripe-js': 8.8.0 - '@supabase/supabase-js': 2.97.0 - '@tailwindcss/postcss': 4.2.1 - '@tanstack/react-query': 5.90.21 - '@types/eslint': 9.6.1 - '@types/node': 25.3.1 + '@faker-js/faker': ^10.4.0 + '@hookform/resolvers': ^5.2.2 + '@keystatic/core': 0.5.49 + '@keystatic/next': ^5.0.4 + '@lemonsqueezy/lemonsqueezy.js': 4.0.0 + '@makerkit/data-loader-supabase-core': ^0.0.10 + '@makerkit/data-loader-supabase-nextjs': ^1.2.5 + '@manypkg/cli': ^0.25.1 + '@markdoc/markdoc': ^0.5.6 + '@marsidev/react-turnstile': ^1.4.2 + '@modelcontextprotocol/sdk': 1.27.1 + '@next/bundle-analyzer': 16.2.1 + '@nosecone/next': 1.3.0 + '@playwright/test': ^1.58.2 + '@react-email/components': 1.0.10 + '@sentry/nextjs': 10.45.0 + '@stripe/react-stripe-js': 5.6.1 + '@stripe/stripe-js': 8.11.0 + '@supabase/ssr': ^0.9.0 + '@supabase/supabase-js': 2.100.0 + '@tailwindcss/postcss': ^4.2.2 + '@tanstack/react-query': 5.95.2 + '@tanstack/react-table': ^8.21.3 + '@turbo/gen': ^2.8.20 + '@types/node': 25.5.0 '@types/nodemailer': 7.0.11 '@types/react': 19.2.14 '@types/react-dom': 19.2.3 - eslint: 10.0.1 - eslint-config-next: 16.1.6 - eslint-config-turbo: 2.8.11 - i18next: 25.8.13 - i18next-browser-languagedetector: 8.2.1 - i18next-resources-to-backend: 1.2.1 - lucide-react: 0.575.0 - next: 16.1.6 - nodemailer: 8.0.1 + babel-plugin-react-compiler: 1.0.0 + class-variance-authority: ^0.7.1 + cross-env: ^10.0.0 + cssnano: ^7.1.3 + date-fns: ^4.1.0 + dotenv: 17.3.1 + lucide-react: 1.0.1 + nanoid: ^5.1.7 + next: 16.2.1 + next-intl: ^4.8.3 + next-runtime-env: 3.3.0 + next-safe-action: ^8.1.8 + next-sitemap: ^4.2.3 + next-themes: 0.4.6 + node-html-parser: ^7.1.0 + nodemailer: 8.0.3 + oxfmt: ^0.41.0 + oxlint: ^1.56.0 pino: 10.3.1 + pino-pretty: 13.0.0 + postgres: 3.4.8 react: 19.2.4 react-dom: 19.2.4 - react-hook-form: 7.71.2 - react-i18next: 16.5.4 - stripe: 20.4.0 - supabase: 2.76.15 - tailwindcss: 4.2.1 + react-hook-form: 7.72.0 + react-resizable-panels: ^4.7.5 + recharts: 3.7.0 + rxjs: ^7.8.2 + server-only: ^0.0.1 + shadcn: 4.1.0 + sonner: ^2.0.7 + stripe: 20.4.1 + supabase: 2.83.0 + tailwind-merge: ^3.5.0 + tailwindcss: 4.2.2 + totp-generator: ^2.0.1 tsup: 8.5.1 + turbo: 2.8.20 tw-animate-css: 1.4.0 - zod: 3.25.76 + typescript: ^6.0.2 + urlpattern-polyfill: ^10.1.0 + vitest: ^4.1.1 + wp-types: ^4.69.0 + zod: 4.3.6 + +catalogMode: prefer + +cleanupUnusedCatalogs: true onlyBuiltDependencies: - '@tailwindcss/oxide' diff --git a/tooling/eslint/apps.js b/tooling/eslint/apps.js deleted file mode 100644 index 93fd477f4..000000000 --- a/tooling/eslint/apps.js +++ /dev/null @@ -1,20 +0,0 @@ -export default [ - { - files: ['app/**/*.{ts,tsx}'], - rules: { - 'no-restricted-imports': [ - 'error', - { - paths: [ - { - name: '@kit/supabase/database', - importNames: ['Database'], - message: - 'Please use the application types from your app "~/lib/database.types" instead', - }, - ], - }, - ], - }, - }, -]; diff --git a/tooling/eslint/base.js b/tooling/eslint/base.js deleted file mode 100644 index 4697ee8ce..000000000 --- a/tooling/eslint/base.js +++ /dev/null @@ -1,79 +0,0 @@ -import { defineConfig } from '@eslint/config-helpers'; -import eslint from '@eslint/js'; -import turbo from 'eslint-config-turbo'; - -import { nextEslintConfig, rules as nextjsEslintRules } from './nextjs.js'; - -export default defineConfig( - eslint.configs.recommended, - ...nextEslintConfig, - { - plugins: { - turbo, - }, - settings: { - react: { - version: '19.2', - }, - }, - languageOptions: { - parserOptions: { - warnOnUnsupportedTypeScriptVersion: false, - }, - }, - }, - { - rules: { - ...nextjsEslintRules, - 'no-undef': 'off', - '@typescript-eslint/triple-slash-reference': 'off', - 'react/react-in-jsx-scope': 'off', - 'import/no-anonymous-default-export': 'off', - 'import/named': 'off', - 'import/namespace': 'off', - 'import/default': 'off', - 'import/no-unresolved': 'off', - 'import/no-named-as-default-member': 'off', - 'import/no-named-as-default': 'off', - 'import/no-cycle': 'off', - 'import/no-unused-modules': 'off', - 'import/no-deprecated': 'off', - 'turbo/no-undeclared-env-vars': 'off', - '@typescript-eslint/array-type': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/consistent-type-definitions': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/non-nullable-type-assertion-style': 'off', - '@typescript-eslint/only-throw-error': 'off', - '@typescript-eslint/prefer-nullish-coalescing': 'off', - 'preserve-caught-error': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, - ], - 'no-restricted-imports': [ - 'error', - { - paths: [ - { - name: 'react-i18next', - importNames: ['Trans'], - message: 'Please use `@kit/ui/trans` instead', - }, - ], - }, - ], - }, - }, - { - ignores: [ - '**/node_modules', - '**/database.types.ts', - '**/.next', - '**/public', - 'dist', - 'pnpm-lock.yaml', - ], - }, -); diff --git a/tooling/eslint/nextjs.js b/tooling/eslint/nextjs.js deleted file mode 100644 index 6ee60aa73..000000000 --- a/tooling/eslint/nextjs.js +++ /dev/null @@ -1,10 +0,0 @@ -import nextCoreVitals from 'eslint-config-next/core-web-vitals'; -import nextTypescript from 'eslint-config-next/typescript'; - -const nextEslintConfig = [...nextCoreVitals, ...nextTypescript]; - -const rules = { - '@next/next/no-html-link-for-pages': 'off', -}; - -export { nextEslintConfig, rules }; diff --git a/tooling/eslint/package.json b/tooling/eslint/package.json deleted file mode 100644 index 1bca9d4c8..000000000 --- a/tooling/eslint/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@kit/eslint-config", - "version": "0.2.0", - "private": true, - "type": "module", - "files": [ - "./apps.js", - "./base.js", - "./nextjs.js" - ], - "scripts": { - "clean": "rm -rf .turbo node_modules", - "format": "prettier --check \"**/*.{js,json}\"" - }, - "dependencies": { - "@eslint/js": "catalog:", - "@next/eslint-plugin-next": "catalog:", - "@types/eslint": "catalog:", - "eslint-config-next": "catalog:", - "eslint-config-turbo": "catalog:" - }, - "devDependencies": { - "@kit/prettier-config": "workspace:*", - "eslint": "catalog:" - }, - "prettier": "@kit/prettier-config" -} diff --git a/tooling/prettier/index.mjs b/tooling/prettier/index.mjs deleted file mode 100644 index c78df7800..000000000 --- a/tooling/prettier/index.mjs +++ /dev/null @@ -1,34 +0,0 @@ -/** @typedef {import("prettier").Config} PrettierConfig */ - -/** @type { PrettierConfig } */ -const config = { - tabWidth: 2, - useTabs: false, - semi: true, - printWidth: 80, - singleQuote: true, - arrowParens: 'always', - importOrder: [ - '/^(?!.*\\.css).*/', - '^server-only$', - '^react$', - '^react-dom$', - '^next$', - '^next/(.*)$', - '^@supabase/supabase-js$', - '^@supabase/gotrue-js$', - '<THIRD_PARTY_MODULES>', - '^@kit/(.*)$', // package imports - '^~/(.*)$', // app-specific imports - '^[./]', // relative imports - ], - tailwindFunctions: ['tw', 'clsx', 'cn', 'cva'], - importOrderSeparation: true, - importOrderSortSpecifiers: true, - plugins: [ - '@trivago/prettier-plugin-sort-imports', - 'prettier-plugin-tailwindcss', - ], -}; - -export default config; diff --git a/tooling/prettier/package.json b/tooling/prettier/package.json deleted file mode 100644 index 3fdeea01c..000000000 --- a/tooling/prettier/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@kit/prettier-config", - "private": true, - "version": "0.1.0", - "main": "index.mjs", - "scripts": { - "clean": "rm -rf .turbo node_modules", - "format": "prettier --check \"**/*.{mjs,json}\"", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@trivago/prettier-plugin-sort-imports": "6.0.2", - "prettier": "^3.8.1", - "prettier-plugin-tailwindcss": "^0.7.2" - }, - "devDependencies": { - "@kit/tsconfig": "workspace:*", - "typescript": "^5.9.3" - }, - "prettier": "./index.mjs" -} diff --git a/tooling/prettier/tsconfig.json b/tooling/prettier/tsconfig.json deleted file mode 100644 index 13128bf4d..000000000 --- a/tooling/prettier/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@kit/tsconfig/base.json", - "compilerOptions": { - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" - }, - "include": ["."], - "exclude": ["node_modules"] -} diff --git a/tooling/scripts/package.json b/tooling/scripts/package.json index 9e76eb792..f99a7944d 100644 --- a/tooling/scripts/package.json +++ b/tooling/scripts/package.json @@ -1,7 +1,7 @@ { "name": "scripts", - "private": true, "version": "0.1.0", + "private": true, "scripts": { "dev": "node ./src/dev.mjs", "checks": "node ./src/checks.mjs", diff --git a/tooling/scripts/src/checks.mjs b/tooling/scripts/src/checks.mjs index 1581e9549..6b4676a0f 100644 --- a/tooling/scripts/src/checks.mjs +++ b/tooling/scripts/src/checks.mjs @@ -49,7 +49,9 @@ function checkEnvFiles(rootPath) { return; } - console.error(`⚠️ Secret key "${secret}" found in ${file} on line ${index + 1}`); + console.error( + `⚠️ Secret key "${secret}" found in ${file} on line ${index + 1}`, + ); hasSecrets = true; } @@ -76,13 +78,15 @@ function checkEnvFiles(rootPath) { } else { const appName = rootPath.split('/').pop(); - console.log(`✅ No secret keys found in staged environment files for the app ${appName}`); + console.log( + `✅ No secret keys found in staged environment files for the app ${appName}`, + ); } } const apps = readdirSync('../../apps'); -apps.forEach(app => { +apps.forEach((app) => { checkEnvFiles(`../../apps/${app}`); }); @@ -98,7 +102,7 @@ function isValueWhitelisted(key, value) { } if (Array.isArray(whiteListedValue)) { - return whiteListedValue.some(allowed => { + return whiteListedValue.some((allowed) => { if (allowed instanceof RegExp) { return allowed.test(value); } @@ -108,4 +112,4 @@ function isValueWhitelisted(key, value) { } return whiteListedValue.trim() === value.trim(); -} \ No newline at end of file +} diff --git a/tooling/scripts/src/dev.mjs b/tooling/scripts/src/dev.mjs index 3879b0a3e..5cddeeb73 100644 --- a/tooling/scripts/src/dev.mjs +++ b/tooling/scripts/src/dev.mjs @@ -1,3 +1,3 @@ import './version.mjs'; import './license.mjs'; -import './requirements.mjs'; \ No newline at end of file +import './requirements.mjs'; diff --git a/tooling/typescript/base.json b/tooling/typescript/base.json index 79c6794b3..b61f58a3a 100644 --- a/tooling/typescript/base.json +++ b/tooling/typescript/base.json @@ -16,7 +16,9 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": false, + "types": ["node"] }, "exclude": ["node_modules", "build", "dist", ".next"] -} \ No newline at end of file +} diff --git a/tooling/typescript/package.json b/tooling/typescript/package.json index 76aa2c7bf..72bd71e94 100644 --- a/tooling/typescript/package.json +++ b/tooling/typescript/package.json @@ -1,7 +1,7 @@ { "name": "@kit/tsconfig", - "private": true, "version": "0.1.0", + "private": true, "files": [ "base.json" ] diff --git a/turbo.json b/turbo.json index 940d1bf86..67da47822 100644 --- a/turbo.json +++ b/turbo.json @@ -56,13 +56,13 @@ "cache": false, "passThroughEnv": ["SSH_AUTH_SOCK", "GIT_SSH"] }, - "format": { - "outputs": ["node_modules/.cache/.prettiercache"], - "outputLogs": "new-only" + "//#lint": {}, + "//#lint:fix": { + "cache": false }, - "lint": { - "dependsOn": ["^topo"], - "outputs": ["node_modules/.cache/.eslintcache"] + "//#format": {}, + "//#format:fix": { + "cache": false }, "typecheck": { "dependsOn": ["^topo"], diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 3350149d0..7592f71a4 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -2,18 +2,14 @@ import type { PlopTypes } from '@turbo/gen'; import { createCloudflareGenerator } from './templates/cloudflare/generator'; import { createDockerGenerator } from './templates/docker/generator'; -import { createEnvironmentVariablesGenerator } from './templates/env/generator'; import { createKeystaticAdminGenerator } from './templates/keystatic/generator'; import { createPackageGenerator } from './templates/package/generator'; import { createSetupGenerator } from './templates/setup/generator'; -import { createEnvironmentVariablesValidatorGenerator } from './templates/validate-env/generator'; // List of generators to be registered const generators = [ createPackageGenerator, createKeystaticAdminGenerator, - createEnvironmentVariablesGenerator, - createEnvironmentVariablesValidatorGenerator, createSetupGenerator, createCloudflareGenerator, createDockerGenerator, diff --git a/turbo/generators/templates/cloudflare/generator.ts b/turbo/generators/templates/cloudflare/generator.ts index 01b7bedd5..0a7d228a7 100644 --- a/turbo/generators/templates/cloudflare/generator.ts +++ b/turbo/generators/templates/cloudflare/generator.ts @@ -1,8 +1,9 @@ import type { PlopTypes } from '@turbo/gen'; -import { execSync } from 'node:child_process'; import packageJson from '../../../../package.json'; +import { execSync } from 'node:child_process'; + export function createCloudflareGenerator(plop: PlopTypes.NodePlopAPI) { plop.setGenerator('cloudflare', { description: 'Cloudflare generator', @@ -78,9 +79,7 @@ export function createCloudflareGenerator(plop: PlopTypes.NodePlopAPI) { stdio: 'inherit', }); - execSync( - `pnpm run format:fix`, - ); + execSync(`pnpm run format:fix`); return 'Package scaffolded'; }, diff --git a/turbo/generators/templates/docker/Dockerfile.hbs b/turbo/generators/templates/docker/Dockerfile.hbs index 4120bbda7..82d9801ff 100644 --- a/turbo/generators/templates/docker/Dockerfile.hbs +++ b/turbo/generators/templates/docker/Dockerfile.hbs @@ -65,7 +65,7 @@ ENV PORT=3000 ENV HOSTNAME="0.0.0.0" HEALTHCHECK --interval=90s --timeout=5s --retries=3 \ -CMD curl -f http://localhost:3000/healthcheck || exit 1 +CMD curl -f http://localhost:3000/api/healthcheck || exit 1 # Start the server CMD ["node", "apps/web/server.js"] \ No newline at end of file diff --git a/turbo/generators/templates/docker/generator.ts b/turbo/generators/templates/docker/generator.ts index 5608eebf8..0858e8bce 100644 --- a/turbo/generators/templates/docker/generator.ts +++ b/turbo/generators/templates/docker/generator.ts @@ -1,4 +1,5 @@ import type { PlopTypes } from '@turbo/gen'; + import { execSync } from 'node:child_process'; import * as os from 'node:os'; @@ -47,7 +48,7 @@ export function createDockerGenerator(plop: PlopTypes.NodePlopAPI) { async () => { execSync('pnpm i', { stdio: 'inherit', - }) + }); execSync('pnpm format:fix', { stdio: 'inherit', diff --git a/turbo/generators/templates/env/generator.ts b/turbo/generators/templates/env/generator.ts deleted file mode 100644 index 097afb05d..000000000 --- a/turbo/generators/templates/env/generator.ts +++ /dev/null @@ -1,340 +0,0 @@ -import type { PlopTypes } from '@turbo/gen'; -import { writeFileSync } from 'node:fs'; - -import { generator } from '../../utils'; - -const DOCS_URL = - 'https://makerkit.dev/docs/next-supabase-turbo/environment-variables'; - -export function createEnvironmentVariablesGenerator( - plop: PlopTypes.NodePlopAPI, -) { - const allVariables = generator.loadAllEnvironmentVariables('apps/web'); - - if (allVariables) { - console.log( - `Loaded ${Object.values(allVariables).length} default environment variables in your env files. We use these as defaults.`, - ); - } - - return plop.setGenerator('env', { - description: 'Generate the environment variables to be used in the app', - actions: [ - async (answers) => { - let env = ''; - - for (const [key, value] of Object.entries( - ( - answers as { - values: Record<string, string>; - } - ).values, - )) { - env += `${key}=${value}\n`; - } - - writeFileSync('turbo/generators/templates/env/.env.local', env); - - return 'Environment variables generated at /turbo/generators/templates/env/.env.local.\nPlease double check and use this file in your hosting provider to set the environment variables. \nNever commit this file, it contains secrets!'; - }, - ], - prompts: [ - { - type: 'input', - name: 'values.NEXT_PUBLIC_SITE_URL', - message: `What is the site URL of you website? (Ex. https://makerkit.dev). \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_SITE_URL')}\n`, - default: allVariables.NEXT_PUBLIC_SITE_URL, - }, - { - type: 'input', - name: 'values.NEXT_PUBLIC_PRODUCT_NAME', - message: `What is the name of your product? (Ex. MakerKit). \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_PRODUCT_NAME')}\n`, - default: allVariables.NEXT_PUBLIC_PRODUCT_NAME, - }, - { - type: 'input', - name: 'values.NEXT_PUBLIC_SITE_TITLE', - message: `What is the title of your website? (Ex. MakerKit - The best way to make things). \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_SITE_TITLE')}\n`, - default: allVariables.NEXT_PUBLIC_SITE_TITLE, - }, - { - type: 'input', - name: 'values.NEXT_PUBLIC_SITE_DESCRIPTION', - message: `What is the description of your website? (Ex. MakerKit is the best way to make things and stuff). \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_SITE_DESCRIPTION')}\n`, - default: allVariables.NEXT_PUBLIC_SITE_DESCRIPTION, - }, - { - type: 'list', - name: 'values.NEXT_PUBLIC_DEFAULT_THEME_MODE', - message: `What is the default theme mode of your website? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_DEFAULT_THEME_MODE')}\n`, - choices: ['light', 'dark', 'system'], - default: allVariables.NEXT_PUBLIC_DEFAULT_THEME_MODE ?? 'light', - }, - { - type: 'input', - name: 'values.NEXT_PUBLIC_DEFAULT_LOCALE', - message: `What is the default locale of your website? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_DEFAULT_LOCALE')}\n`, - default: allVariables.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en', - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_AUTH_PASSWORD', - message: `Do you want to use email/password authentication? If not - we will hide the password login from the UI. \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_AUTH_PASSWORD')}\n`, - default: getBoolean(allVariables.NEXT_PUBLIC_AUTH_PASSWORD, true), - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_AUTH_MAGIC_LINK', - message: `Do you want to use magic link authentication? If not - we will hide the magic link login from the UI. \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_AUTH_MAGIC_LINK')}\n`, - default: getBoolean(allVariables.NEXT_PUBLIC_AUTH_MAGIC_LINK, false), - }, - { - type: 'input', - name: 'values.CONTACT_EMAIL', - message: `What is the contact email you want to receive emails to? \nFor more info: ${getUrlToDocs('CONTACT_EMAIL')}\n`, - default: allVariables.CONTACT_EMAIL, - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_ENABLE_THEME_TOGGLE', - message: `Do you want to enable the theme toggle? If not - we will hide the theme toggle from the UI. \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_THEME_TOGGLE')}\n`, - default: getBoolean(allVariables.NEXT_PUBLIC_ENABLE_THEME_TOGGLE, true), - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', - message: `Do you want to enable personal account deletion? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION, - true, - ), - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', - message: `Do you want to enable personal account billing? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING, - true, - ), - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', - message: `Do you want to enable team accounts? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS, - true, - ), - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION', - message: `Do you want to enable team account deletion? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION, - true, - ), - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', - message: `Do you want to enable team account billing? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING, - true, - ), - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', - message: `Do you want to enable team account creation? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION, - true, - ), - }, - { - type: 'confirm', - name: 'values.NEXT_PUBLIC_ENABLE_NOTIFICATIONS', - message: `Do you want to enable notifications? If not - we will hide the notifications bell from the UI. \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_NOTIFICATIONS')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_ENABLE_NOTIFICATIONS, - true, - ), - }, - { - when: (answers) => answers.values.NEXT_PUBLIC_ENABLE_NOTIFICATIONS, - type: 'confirm', - name: 'values.NEXT_PUBLIC_REALTIME_NOTIFICATIONS', - message: `Do you want to enable realtime notifications? If yes, we will enable the realtime notifications from Supabase. If not - updated will be fetched lazily.\nFor more info: ${getUrlToDocs('NEXT_PUBLIC_REALTIME_NOTIFICATIONS')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_REALTIME_NOTIFICATIONS, - false, - ), - }, - { - type: 'input', - name: 'values.NEXT_PUBLIC_ENABLE_VERSION_UPDATER', - message: `Do you want to enable the version updater popup? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_ENABLE_VERSION_UPDATER')}\n`, - default: getBoolean( - allVariables.NEXT_PUBLIC_ENABLE_VERSION_UPDATER, - false, - ), - }, - { - type: 'input', - name: 'values.NEXT_PUBLIC_SUPABASE_URL', - message: `What is the Supabase URL? (Ex. https://yourapp.supabase.co).\nFor more info: ${getUrlToDocs('NEXT_PUBLIC_SUPABASE_URL')}\n`, - default: allVariables.NEXT_PUBLIC_SUPABASE_URL, - }, - { - type: 'input', - name: 'values.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', - message: `What is the Supabase public key?\nFor more info: ${getUrlToDocs('NEXT_PUBLIC_SUPABASE_PUBLIC_KEY')}\n`, - }, - { - type: 'input', - name: 'values.SUPABASE_SECRET_KEY', - message: `What is the Supabase secret key?\nFor more info: ${getUrlToDocs('SUPABASE_SECRET_KEY')}\n`, - }, - { - type: 'list', - name: 'values.NEXT_PUBLIC_BILLING_PROVIDER', - message: `What is the billing provider you want to use?\nFor more info: ${getUrlToDocs('NEXT_PUBLIC_BILLING_PROVIDER')}\n`, - choices: ['stripe', 'lemon-squeezy'], - default: allVariables.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe', - }, - { - when: (answers) => - answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'stripe', - type: 'input', - name: 'values.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', - message: `What is the Stripe publishable key?\nFor more info: ${getUrlToDocs('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY')}\n`, - default: allVariables.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, - }, - { - when: (answers) => - answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'stripe', - type: 'input', - name: 'values.STRIPE_SECRET_KEY', - message: `What is the Stripe secret key? \nFor more info: ${getUrlToDocs('NEXT_PUBLIC_BILLING_PROVIDER')}\n`, - }, - { - when: (answers) => - answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'stripe', - type: 'input', - name: 'values.STRIPE_WEBHOOK_SECRET', - message: `What is the Stripe webhook secret? \nFor more info: ${getUrlToDocs('STRIPE_WEBHOOK_SECRET')}\n`, - }, - { - when: (answers) => - answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'lemon-squeezy', - type: 'input', - name: 'values.LEMON_SQUEEZY_SECRET_KEY', - message: `What is the Lemon Squeezy secret key? \nFor more info: ${getUrlToDocs('LEMON_SQUEEZY_SECRET_KEY')}\n`, - }, - { - when: (answers) => - answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'lemon-squeezy', - type: 'input', - name: 'values.LEMON_SQUEEZY_STORE_ID', - message: `What is the Lemon Squeezy store ID? \nFor more info: ${getUrlToDocs('LEMON_SQUEEZY_STORE_ID')}\n`, - default: allVariables.LEMON_SQUEEZY_STORE_ID, - }, - { - when: (answers) => - answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'lemon-squeezy', - type: 'input', - name: 'values.LEMON_SQUEEZY_SIGNING_SECRET', - message: `What is the Lemon Squeezy signing secret?\nFor more info: ${getUrlToDocs('LEMON_SQUEEZY_SIGNING_SECRET')}\n`, - }, - { - type: 'input', - name: 'values.SUPABASE_DB_WEBHOOK_SECRET', - message: `What is the DB webhook secret?\nFor more info: ${getUrlToDocs('SUPABASE_DB_WEBHOOK_SECRET')}\n`, - }, - { - type: 'list', - name: 'values.CMS_CLIENT', - message: `What is the CMS client you want to use?\nFor more info: ${getUrlToDocs('CMS_CLIENT')}\n`, - choices: ['keystatic', 'wordpress'], - default: allVariables.CMS_CLIENT ?? 'keystatic', - }, - { - type: 'list', - name: 'values.MAILER_PROVIDER', - message: `What is the mailer provider you want to use?\nFor more info: ${getUrlToDocs('MAILER_PROVIDER')}\n`, - choices: ['nodemailer', 'resend'], - default: allVariables.MAILER_PROVIDER ?? 'nodemailer', - }, - { - when: (answers) => answers.values.MAILER_PROVIDER === 'resend', - type: 'input', - name: 'values.RESEND_API_KEY', - message: `What is the Resend API key?\nFor more info: ${getUrlToDocs('RESEND_API_KEY')}\n`, - }, - { - type: 'input', - name: 'values.EMAIL_SENDER', - message: `What is the email sender? (ex. info@makerkit.dev).\nFor more info: ${getUrlToDocs('EMAIL_SENDER')}\n`, - }, - { - when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', - type: 'input', - name: 'values.EMAIL_HOST', - message: `What is the email host?\nFor more info: ${getUrlToDocs('EMAIL_HOST')}\n`, - }, - { - when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', - type: 'input', - name: 'values.EMAIL_PORT', - message: `What is the email port?\nFor more info: ${getUrlToDocs('EMAIL_PORT')}\n`, - }, - { - when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', - type: 'input', - name: 'values.EMAIL_USER', - message: `What is the email username? (check your email provider).\nFor more info: ${getUrlToDocs('EMAIL_USER')}\n`, - }, - { - when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', - type: 'input', - name: 'values.EMAIL_PASSWORD', - message: `What is the email password? (check your email provider).\nFor more info: ${getUrlToDocs('EMAIL_PASSWORD')}\n`, - }, - { - when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', - type: 'confirm', - name: 'values.EMAIL_TLS', - message: `Do you want to enable TLS for your emails?\nFor more info: ${getUrlToDocs('EMAIL_TLS')}\n`, - default: getBoolean(allVariables.EMAIL_TLS, true), - }, - { - type: 'confirm', - name: 'captcha', - message: `Do you want to enable Cloudflare Captcha protection for the Auth endpoints?`, - }, - { - when: (answers) => answers.captcha, - type: 'input', - name: 'values.NEXT_PUBLIC_CAPTCHA_SITE_KEY', - message: `What is the Cloudflare Captcha site key? NB: this is the PUBLIC key!\nFor more info: ${getUrlToDocs('NEXT_PUBLIC_CAPTCHA_SITE_KEY')}\n`, - }, - { - when: (answers) => answers.captcha, - type: 'input', - name: 'values.CAPTCHA_SECRET_TOKEN', - message: `What is the Cloudflare Captcha secret key? NB: this is the PRIVATE key!\nFor more info: ${getUrlToDocs('CAPTCHA_SECRET_TOKEN')}\n`, - }, - ], - }); -} - -function getBoolean(value: string | undefined, defaultValue: boolean) { - return value === 'true' ? true : defaultValue; -} - -function getUrlToDocs(envVar: string) { - return `${DOCS_URL}#${envVar.toLowerCase()}`; -} diff --git a/turbo/generators/templates/keystatic/generator.ts b/turbo/generators/templates/keystatic/generator.ts index 4e16faee9..9a7f3697e 100644 --- a/turbo/generators/templates/keystatic/generator.ts +++ b/turbo/generators/templates/keystatic/generator.ts @@ -1,4 +1,5 @@ import type { PlopTypes } from '@turbo/gen'; + import { execSync } from 'node:child_process'; export function createKeystaticAdminGenerator(plop: PlopTypes.NodePlopAPI) { diff --git a/turbo/generators/templates/package/eslint.config.mjs.hbs b/turbo/generators/templates/package/eslint.config.mjs.hbs deleted file mode 100644 index f5c4f89fe..000000000 --- a/turbo/generators/templates/package/eslint.config.mjs.hbs +++ /dev/null @@ -1,3 +0,0 @@ -import eslintConfigBase from '@kit/eslint-config/base.js'; - -export default eslintConfigBase; \ No newline at end of file diff --git a/turbo/generators/templates/package/generator.ts b/turbo/generators/templates/package/generator.ts index b7e12be98..bc8e4b2d6 100644 --- a/turbo/generators/templates/package/generator.ts +++ b/turbo/generators/templates/package/generator.ts @@ -1,4 +1,5 @@ import type { PlopTypes } from '@turbo/gen'; + import { execSync } from 'node:child_process'; export function createPackageGenerator(plop: PlopTypes.NodePlopAPI) { @@ -37,11 +38,6 @@ export function createPackageGenerator(plop: PlopTypes.NodePlopAPI) { path: 'packages/{{ name }}/tsconfig.json', templateFile: 'templates/package/tsconfig.json.hbs', }, - { - type: 'add', - path: 'packages/{{ name }}/eslint.config.mjs', - templateFile: 'templates/package/eslint.config.mjs.hbs', - }, { type: 'add', path: 'packages/{{ name }}/index.ts', @@ -78,9 +74,7 @@ export function createPackageGenerator(plop: PlopTypes.NodePlopAPI) { stdio: 'inherit', }); - execSync( - `pnpm run format:fix`, - ); + execSync(`pnpm run format:fix`); return 'Package scaffolded'; }, diff --git a/turbo/generators/templates/package/package.json.hbs b/turbo/generators/templates/package/package.json.hbs index a0494aeb9..39cdbd5b6 100644 --- a/turbo/generators/templates/package/package.json.hbs +++ b/turbo/generators/templates/package/package.json.hbs @@ -5,24 +5,11 @@ "exports": { ".": "./index.ts" }, - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } - }, - "license": "MIT", "scripts": { "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", "typecheck": "tsc --noEmit" }, "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", "@kit/tsconfig": "workspace:*" - }, - "prettier": "@kit/prettier-config" + } } diff --git a/turbo/generators/templates/setup/generator.ts b/turbo/generators/templates/setup/generator.ts index 7e6fca182..d970f8d18 100644 --- a/turbo/generators/templates/setup/generator.ts +++ b/turbo/generators/templates/setup/generator.ts @@ -1,4 +1,5 @@ import type { PlopTypes } from '@turbo/gen'; + import { execSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; @@ -60,7 +61,7 @@ export function createSetupGenerator(plop: PlopTypes.NodePlopAPI) { setupPreCommit({ setupHealthCheck: answers.setupHealthCheck }); return 'Project setup complete. Start developing your project!'; - } catch (error) { + } catch (_error) { console.error('Project setup failed. Aborting package generation.'); process.exit(1); } @@ -76,7 +77,7 @@ function createMakerkitConfig(params: { const config = `{ "projectName": "${params.projectName}", "username": "${params.username}" -}` +}`; writeFileSync('.makerkitrc', config, { encoding: 'utf-8', @@ -105,7 +106,7 @@ function setupPreCommit(params: { setupHealthCheck: boolean }) { execSync(`chmod +x ${filePath}`, { stdio: 'inherit', }); - } catch (error) { + } catch (_error) { console.error('Pre-commit hook setup failed. Aborting package generation.'); process.exit(1); } diff --git a/turbo/generators/templates/validate-env/generator.ts b/turbo/generators/templates/validate-env/generator.ts deleted file mode 100644 index 14ecd68b5..000000000 --- a/turbo/generators/templates/validate-env/generator.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { PlopTypes } from '@turbo/gen'; - -// quick hack to avoid installing zod globally -import { z } from '../../../../apps/web/node_modules/zod'; -import { generator } from '../../utils'; - -const BooleanStringEnum = z.enum(['true', 'false']); - -const Schema: Record<string, z.ZodType> = { - NEXT_PUBLIC_SITE_URL: z - .string({ - description: `This is the URL of your website. It should start with https:// like https://makerkit.dev.`, - }) - .url({ - message: - 'NEXT_PUBLIC_SITE_URL must be a valid URL. Please use HTTPS for production sites, otherwise it will fail.', - }) - .refine( - (url) => { - return url.startsWith('https://'); - }, - { - message: 'NEXT_PUBLIC_SITE_URL must start with https://', - path: ['NEXT_PUBLIC_SITE_URL'], - }, - ), - NEXT_PUBLIC_PRODUCT_NAME: z - .string({ - message: 'Product name must be a string', - description: `This is the name of your product. It should be a short name like MakerKit.`, - }) - .min(1), - NEXT_PUBLIC_SITE_DESCRIPTION: z.string({ - message: 'Site description must be a string', - description: `This is the description of your website. It should be a short sentence or two.`, - }), - NEXT_PUBLIC_DEFAULT_THEME_MODE: z.enum(['light', 'dark', 'system'], { - message: 'Default theme mode must be light, dark or system', - description: `This is the default theme mode for your website. It should be light, dark or system.`, - }), - NEXT_PUBLIC_DEFAULT_LOCALE: z.string({ - message: 'Default locale must be a string', - description: `This is the default locale for your website. It should be a two-letter code like en or fr.`, - }), - CONTACT_EMAIL: z - .string({ - message: 'Contact email must be a valid email', - description: `This is the email address that will receive contact form submissions.`, - }) - .email(), - NEXT_PUBLIC_ENABLE_THEME_TOGGLE: BooleanStringEnum, - NEXT_PUBLIC_AUTH_PASSWORD: BooleanStringEnum, - NEXT_PUBLIC_AUTH_MAGIC_LINK: BooleanStringEnum, - NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION: BooleanStringEnum, - NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: BooleanStringEnum, - NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS: BooleanStringEnum, - NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION: BooleanStringEnum, - NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: BooleanStringEnum, - NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION: BooleanStringEnum, - NEXT_PUBLIC_REALTIME_NOTIFICATIONS: BooleanStringEnum, - NEXT_PUBLIC_ENABLE_NOTIFICATIONS: BooleanStringEnum, - NEXT_PUBLIC_SUPABASE_URL: z - .string({ - description: `This is the URL to your hosted Supabase instance.`, - }) - .url({ - message: 'Supabase URL must be a valid URL', - }), - NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string({ - message: 'Supabase anon key must be a string', - description: `This is the key provided by Supabase. It is a public key used client-side.`, - }), - SUPABASE_SERVICE_ROLE_KEY: z.string({ - message: 'Supabase service role key must be a string', - description: `This is the key provided by Supabase. It is a private key used server-side.`, - }), - NEXT_PUBLIC_BILLING_PROVIDER: z.enum(['stripe', 'lemon-squeezy'], { - message: 'Billing provider must be stripe or lemon-squeezy', - description: `This is the billing provider you want to use. It should be stripe or lemon-squeezy.`, - }), - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z - .string({ - message: 'Stripe publishable key must be a string', - description: `This is the publishable key from your Stripe dashboard. It should start with pk_`, - }) - .refine( - (value) => { - return value.startsWith('pk_'); - }, - { - message: 'Stripe publishable key must start with pk_', - path: ['NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY'], - }, - ), - STRIPE_SECRET_KEY: z - .string({ - message: 'Stripe secret key must be a string', - description: `This is the secret key from your Stripe dashboard. It should start with sk_`, - }) - .refine( - (value) => { - return value.startsWith('sk_'); - }, - { - message: 'Stripe secret key must start with sk_', - path: ['STRIPE_SECRET_KEY'], - }, - ), - STRIPE_WEBHOOK_SECRET: z - .string({ - message: 'Stripe webhook secret must be a string', - description: `This is the signing secret you copy after creating a webhook in your Stripe dashboard.`, - }) - .min(1) - .refine( - (value) => { - return value.startsWith('whsec_'); - }, - { - message: 'Stripe webhook secret must start with whsec_', - path: ['STRIPE_WEBHOOK_SECRET'], - }, - ), - LEMON_SQUEEZY_SECRET_KEY: z - .string({ - message: 'Lemon Squeezy API key must be a string', - description: `This is the API key from your Lemon Squeezy account`, - }) - .min(1), - LEMON_SQUEEZY_STORE_ID: z - .string({ - message: 'Lemon Squeezy store ID must be a string', - description: `This is the store ID of your Lemon Squeezy account`, - }) - .min(1), - LEMON_SQUEEZY_SIGNING_SECRET: z - .string({ - message: 'Lemon Squeezy signing secret must be a string', - description: `This is a shared secret that you must set in your Lemon Squeezy account when you create an API Key`, - }) - .min(1), - MAILER_PROVIDER: z.enum(['nodemailer', 'resend'], { - message: 'Mailer provider must be nodemailer or resend', - description: `This is the mailer provider you want to use for sending emails. nodemailer is a generic SMTP mailer, resend is a service.`, - }), -}; - -export function createEnvironmentVariablesValidatorGenerator( - plop: PlopTypes.NodePlopAPI, -) { - return plop.setGenerator('validate-env', { - description: 'Validate the environment variables to be used in the app', - actions: [ - async (answers) => { - if (!('path' in answers) || !answers.path) { - throw new Error('URL is required'); - } - - const env = generator.loadEnvironmentVariables(answers.path as string); - - for (const key of Object.keys(env)) { - const property = Schema[key]; - const value = env[key]; - - if (property) { - // parse with Zod - const { error } = property.safeParse(value); - - if (error) { - throw new Error( - `Encountered a validation error for key ${key}:${value} \n\n${JSON.stringify(error, null, 2)}`, - ); - } else { - console.log(`Key ${key} is valid!`); - } - } - } - - return 'Environment variables are valid!'; - }, - ], - prompts: [ - { - type: 'input', - name: 'path', - message: - 'Where is the path to the environment variables file? Leave empty to use the generated turbo/generators/templates/env/.env.local', - default: 'turbo/generators/templates/env/.env.local', - }, - ], - }); -}