init commit for iPAL-kit plugin
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'ipal-kit/client'
|
||||
import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'ipal-kit/rsc'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
'ipal-kit/client#BeforeDashboardClient':
|
||||
BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343,
|
||||
'ipal-kit/rsc#BeforeDashboardServer':
|
||||
BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const devUser = {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
email: 'it@intecion.com',
|
||||
password: '[email protected]',
|
||||
}
|
||||
|
||||
+378
-70
@@ -6,38 +6,104 @@
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
users: User;
|
||||
posts: Post;
|
||||
media: Media;
|
||||
'plugin-collection': PluginCollection;
|
||||
users: User;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
'plugin-collection': PluginCollectionSelect<false> | PluginCollectionSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
defaultIDType: number;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
fallbackLocale: ('false' | 'none' | 'null') | false | null | ('pl' | 'en') | ('pl' | 'en')[];
|
||||
globals: {
|
||||
'site-settings': SiteSetting;
|
||||
'site-integrations': SiteIntegration;
|
||||
'cookie-settings': CookieSetting;
|
||||
};
|
||||
globalsSelect: {
|
||||
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
||||
'site-integrations': SiteIntegrationsSelect<false> | SiteIntegrationsSelect<true>;
|
||||
'cookie-settings': CookieSettingsSelect<false> | CookieSettingsSelect<true>;
|
||||
};
|
||||
locale: 'pl' | 'en';
|
||||
widgets: {
|
||||
collections: CollectionsWidget;
|
||||
};
|
||||
user: User;
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
@@ -61,13 +127,49 @@ export interface UserAuthOperations {
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
/**
|
||||
* Role hierarchy: admin > editor > user.
|
||||
*/
|
||||
roles: ('user' | 'editor' | 'admin')[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
sessions?:
|
||||
| {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[]
|
||||
| null;
|
||||
password?: string | null;
|
||||
collection: 'users';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: string;
|
||||
addedByPlugin?: string | null;
|
||||
id: number;
|
||||
meta?: {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
/**
|
||||
* Maximum upload file size: 12MB. Recommended file size for images is <500KB.
|
||||
*/
|
||||
image?: (number | null) | Media;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -76,7 +178,7 @@ export interface Post {
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: string;
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
@@ -91,57 +193,44 @@ export interface Media {
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "plugin-collection".
|
||||
* via the `definition` "payload-kv".
|
||||
*/
|
||||
export interface PluginCollection {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
export interface PayloadKv {
|
||||
id: number;
|
||||
key: string;
|
||||
data:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
password?: string | null;
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: string | Post;
|
||||
value: number | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: string | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'plugin-collection';
|
||||
value: string | PluginCollection;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | Media;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -151,10 +240,10 @@ export interface PayloadLockedDocument {
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: string;
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
@@ -174,18 +263,47 @@ export interface PayloadPreference {
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: string;
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
roles?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
addedByPlugin?: T;
|
||||
meta?:
|
||||
| T
|
||||
| {
|
||||
title?: T;
|
||||
description?: T;
|
||||
image?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
@@ -208,27 +326,11 @@ export interface MediaSelect<T extends boolean = true> {
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "plugin-collection_select".
|
||||
* via the `definition` "payload-kv_select".
|
||||
*/
|
||||
export interface PluginCollectionSelect<T extends boolean = true> {
|
||||
id?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
export interface PayloadKvSelect<T extends boolean = true> {
|
||||
key?: T;
|
||||
data?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -262,6 +364,212 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-settings".
|
||||
*/
|
||||
export interface SiteSetting {
|
||||
id: number;
|
||||
/**
|
||||
* Used in page titles and Open Graph metadata.
|
||||
*/
|
||||
siteName: string;
|
||||
/**
|
||||
* Primary site logo.
|
||||
*/
|
||||
logo?: (number | null) | Media;
|
||||
/**
|
||||
* Fallback Open Graph image when a page has none.
|
||||
*/
|
||||
defaultShareImage?: (number | null) | Media;
|
||||
/**
|
||||
* Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.
|
||||
*/
|
||||
favicon?: (number | null) | Media;
|
||||
/**
|
||||
* Theme applied on a visitor’s first visit.
|
||||
*/
|
||||
defaultTheme: 'light' | 'dark';
|
||||
/**
|
||||
* Show a light/dark switch on the site.
|
||||
*/
|
||||
allowThemeToggle?: boolean | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-integrations".
|
||||
*/
|
||||
export interface SiteIntegration {
|
||||
id: number;
|
||||
/**
|
||||
* Google Analytics 4 Measurement ID.
|
||||
*/
|
||||
ga4MeasurementId?: string | null;
|
||||
/**
|
||||
* Google Tag Manager container ID.
|
||||
*/
|
||||
gtmContainerId?: string | null;
|
||||
/**
|
||||
* Public site key rendered in the Turnstile widget.
|
||||
*/
|
||||
turnstileSiteKey?: string | null;
|
||||
/**
|
||||
* Secret key used for server-side verification.
|
||||
*/
|
||||
turnstileSecretKey?: string | null;
|
||||
smtpHost?: string | null;
|
||||
smtpPort?: number | null;
|
||||
/**
|
||||
* SMTP account username.
|
||||
*/
|
||||
smtpUser?: string | null;
|
||||
/**
|
||||
* SMTP account password.
|
||||
*/
|
||||
smtpPassword?: string | null;
|
||||
/**
|
||||
* Default "from" address for outgoing mail.
|
||||
*/
|
||||
smtpFromAddress?: string | null;
|
||||
/**
|
||||
* Default "from" display name.
|
||||
*/
|
||||
smtpFromName?: string | null;
|
||||
/**
|
||||
* R2 bucket name.
|
||||
*/
|
||||
r2Bucket?: string | null;
|
||||
/**
|
||||
* R2 S3-compatible endpoint URL.
|
||||
*/
|
||||
r2Endpoint?: string | null;
|
||||
/**
|
||||
* R2 access key ID.
|
||||
*/
|
||||
r2AccessKeyId?: string | null;
|
||||
/**
|
||||
* R2 secret access key.
|
||||
*/
|
||||
r2SecretAccessKey?: string | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "cookie-settings".
|
||||
*/
|
||||
export interface CookieSetting {
|
||||
id: number;
|
||||
/**
|
||||
* Main consent message shown in the banner.
|
||||
*/
|
||||
message?: string | null;
|
||||
/**
|
||||
* Heading for the detailed settings panel.
|
||||
*/
|
||||
settingsTitle?: string | null;
|
||||
/**
|
||||
* Button labels.
|
||||
*/
|
||||
buttons?: {
|
||||
acceptAll?: string | null;
|
||||
reject?: string | null;
|
||||
settings?: string | null;
|
||||
save?: string | null;
|
||||
back?: string | null;
|
||||
};
|
||||
/**
|
||||
* Per-category titles and descriptions.
|
||||
*/
|
||||
categories?:
|
||||
| {
|
||||
key: 'necessary' | 'functional' | 'analytics' | 'marketing';
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-settings_select".
|
||||
*/
|
||||
export interface SiteSettingsSelect<T extends boolean = true> {
|
||||
siteName?: T;
|
||||
logo?: T;
|
||||
defaultShareImage?: T;
|
||||
favicon?: T;
|
||||
defaultTheme?: T;
|
||||
allowThemeToggle?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-integrations_select".
|
||||
*/
|
||||
export interface SiteIntegrationsSelect<T extends boolean = true> {
|
||||
ga4MeasurementId?: T;
|
||||
gtmContainerId?: T;
|
||||
turnstileSiteKey?: T;
|
||||
turnstileSecretKey?: T;
|
||||
smtpHost?: T;
|
||||
smtpPort?: T;
|
||||
smtpUser?: T;
|
||||
smtpPassword?: T;
|
||||
smtpFromAddress?: T;
|
||||
smtpFromName?: T;
|
||||
r2Bucket?: T;
|
||||
r2Endpoint?: T;
|
||||
r2AccessKeyId?: T;
|
||||
r2SecretAccessKey?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "cookie-settings_select".
|
||||
*/
|
||||
export interface CookieSettingsSelect<T extends boolean = true> {
|
||||
message?: T;
|
||||
settingsTitle?: T;
|
||||
buttons?:
|
||||
| T
|
||||
| {
|
||||
acceptAll?: T;
|
||||
reject?: T;
|
||||
settings?: T;
|
||||
save?: T;
|
||||
back?: T;
|
||||
};
|
||||
categories?:
|
||||
| T
|
||||
| {
|
||||
key?: T;
|
||||
title?: T;
|
||||
description?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
*/
|
||||
export interface CollectionsWidget {
|
||||
data?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
width: 'full';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
|
||||
+21
-10
@@ -1,9 +1,9 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { ipalKit } from 'ipal-kit'
|
||||
import { MongoMemoryReplSet } from 'mongodb-memory-server'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import { ipalKit } from 'ipal-kit'
|
||||
import sharp from 'sharp'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
@@ -36,6 +36,11 @@ const buildConfigWithMemoryDB = async () => {
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
slug: 'posts',
|
||||
fields: [],
|
||||
@@ -43,14 +48,14 @@ const buildConfigWithMemoryDB = async () => {
|
||||
{
|
||||
slug: 'media',
|
||||
fields: [],
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, 'media'),
|
||||
},
|
||||
upload: { staticDir: path.resolve(dirname, 'media') },
|
||||
},
|
||||
],
|
||||
db: mongooseAdapter({
|
||||
ensureIndexes: true,
|
||||
url: process.env.DATABASE_URL || '',
|
||||
|
||||
db: sqliteAdapter({
|
||||
client: {
|
||||
url: process.env.DATABASE_URI || 'file:./payload.db',
|
||||
},
|
||||
}),
|
||||
editor: lexicalEditor(),
|
||||
email: testEmailAdapter,
|
||||
@@ -59,9 +64,15 @@ const buildConfigWithMemoryDB = async () => {
|
||||
},
|
||||
plugins: [
|
||||
ipalKit({
|
||||
collections: {
|
||||
posts: true,
|
||||
access: { authCollection: 'users' },
|
||||
i18n: {
|
||||
defaultLocale: 'pl',
|
||||
locales: [
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
],
|
||||
},
|
||||
seo: { collections: ['posts', 'pages'] },
|
||||
}),
|
||||
],
|
||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||
|
||||
+5
-1
@@ -15,7 +15,11 @@ export const seed = async (payload: Payload) => {
|
||||
if (!totalDocs) {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: devUser,
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
roles: ['admin'],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# IPAL — Dokumentacja modułów
|
||||
|
||||
**Stawiasz nowy projekt?** → [getting-started.md](./getting-started.md)
|
||||
|
||||
IPAL (Intecion Payload Advanced Library) to plugin do Payload CMS 3, który
|
||||
dostarcza logikę i konfigurację; projekt klienta zawiera tylko komponenty
|
||||
wizualne i podłączenia do Next.js.
|
||||
|
||||
## Zasada
|
||||
|
||||
- **Plugin** = logika, helpery, konfiguracja, globale.
|
||||
- **Klient (projekt)** = komponenty (wygląd), pliki-podłączenia Next.js
|
||||
(jednolinijkowe re-eksporty), konfiguracja front.
|
||||
|
||||
## Instalacja i wpięcie
|
||||
|
||||
### Wymagane zależności
|
||||
|
||||
Projekt klienta musi mieć (poza payloadem):
|
||||
|
||||
```json
|
||||
"dependencies": {
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"@payloadcms/plugin-form-builder": "3.84.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"lucide-react": "^0.400.0",
|
||||
"slugify": "^1.6.6",
|
||||
"server-only": "^0.0.1"
|
||||
}
|
||||
```
|
||||
|
||||
Wersje `@payloadcms/*` **muszą** być identyczne z wersją `payload`. Wymuś
|
||||
spójność przez `pnpm.overrides` (patrz niżej), inaczej Payload odrzuci wpięcie
|
||||
pluginów (pusty tab SEO, brak kolekcji Forms) albo crashuje.
|
||||
|
||||
```json
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"payload": "3.84.1",
|
||||
"@payloadcms/ui": "3.84.1",
|
||||
"@payloadcms/next": "3.84.1",
|
||||
"@payloadcms/db-sqlite": "3.84.1",
|
||||
"@payloadcms/richtext-lexical": "3.84.1",
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"@payloadcms/plugin-form-builder": "3.84.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Po wpięciu — wygeneruj importMap
|
||||
|
||||
Plugin dostarcza komponenty admina (pola SEO). Po dodaniu uruchom:
|
||||
|
||||
```bash
|
||||
pnpm payload generate:importmap
|
||||
```
|
||||
|
||||
Bez tego pola SEO nie wyrenderują się (błąd `PayloadComponent not found in
|
||||
importMap`).
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
import { ipalKit } from 'ipal-kit'
|
||||
|
||||
export default buildConfig({
|
||||
// ...
|
||||
plugins: [
|
||||
ipalKit({
|
||||
i18n: {
|
||||
defaultLocale: 'pl',
|
||||
locales: [
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
],
|
||||
},
|
||||
access: { authCollection: 'users' },
|
||||
pages: { slug: 'pages' },
|
||||
seo: { collections: ['pages', 'posts'] },
|
||||
forms: { redirectRelationships: ['pages'] },
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Entry pointy pakietu
|
||||
|
||||
| Import | Zawiera | Kontekst |
|
||||
|---|---|---|
|
||||
| `ipal-kit` | logika server-safe, plugin, helpery | server / config |
|
||||
| `ipal-kit/server` | runtime server-only (sendEmail, verifyTurnstile, submitForm) | Server Actions / route handlers |
|
||||
| `ipal-kit/client` | komponenty client (consent, Turnstile, Analytics) | `'use client'` |
|
||||
| `ipal-kit/rsc` | RenderBlocks (RSC) | server component |
|
||||
| `ipal-kit/next/middleware` | locale middleware | middleware.ts |
|
||||
|
||||
## Moduły
|
||||
|
||||
| Moduł | Opis | Dok |
|
||||
|---|---|---|
|
||||
| i18n | Lokalizacja, negocjacja locale, ścieżki URL | [i18n.md](./i18n.md) |
|
||||
| pages | System pages (homepage/privacy/cookies) → ścieżki | [pages.md](./pages.md) |
|
||||
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
||||
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
||||
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
|
||||
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
||||
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
||||
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) |
|
||||
| email | SMTP z panelu: adapter Payloada + sendEmail | [email.md](./email.md) |
|
||||
| forms | Form-builder + submitForm (Turnstile + zapis) | [forms.md](./forms.md) |
|
||||
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
|
||||
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
|
||||
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
||||
|
||||
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
|
||||
Referencja wdrożenia frontu: [frontend-setup.md](./frontend-setup.md)
|
||||
|
||||
## Zasady dla wszystkich modułów
|
||||
|
||||
1. **Helpery przyjmują `payload` jako argument** — plugin nigdy nie woła
|
||||
`getPayload` sam.
|
||||
2. **Sekrety w panelu** — SMTP, Turnstile secret, R2 w SiteIntegrations
|
||||
(admin-only). Odczyt server-side przez Local API.
|
||||
3. **Client/server split** — kod z sekretami ma `server-only`; komponenty
|
||||
client w `ipal-kit/client`.
|
||||
4. **Generyki na typy klienta** — helpery przyjmują `<T>` (np. wygenerowany
|
||||
`SiteSetting`), bo plugin nie zna typów projektu.
|
||||
@@ -0,0 +1,79 @@
|
||||
# access
|
||||
|
||||
Kontrola dostępu oparta na rolach: hierarchia **admin > editor > user**.
|
||||
Plugin wstrzykuje sztywne pole `roles` do klienckiej kolekcji auth i dostarcza
|
||||
predykaty oraz gotowe funkcje dostępu (collection-level i field-level).
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
access: {
|
||||
authCollection: 'users', // slug Twojej kolekcji auth
|
||||
// defaultRole: 'user', // rola nowych użytkowników, domyślnie 'user'
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Wstrzykuje pole `roles` (select hasMany, `saveToJWT`, tylko admin może
|
||||
zmieniać role) do wskazanej kolekcji. Kolekcja należy do Ciebie
|
||||
(create-payload-app) — plugin tylko dokłada pole.
|
||||
|
||||
> Po dodaniu: zadbaj, by seed / pierwszy user miał `roles: ['admin']`, inaczej
|
||||
> stracisz dostęp do zasobów admin-only (np. SiteIntegrations).
|
||||
|
||||
## Predykaty (front / hooki / access)
|
||||
|
||||
```ts
|
||||
import { isAdmin, isEditor, hasMinimumRole } from 'ipal-kit'
|
||||
|
||||
isAdmin(user) // admin?
|
||||
isEditor(user) // editor lub admin (hierarchia)
|
||||
hasMinimumRole(user, 'editor') // co najmniej editor
|
||||
```
|
||||
|
||||
Przyjmują luźno typowanego usera (jak `req.user`), czytają `roles` defensywnie.
|
||||
|
||||
## Gotowe funkcje dostępu
|
||||
|
||||
### Collection-level (zwracają boolean | Where)
|
||||
|
||||
```ts
|
||||
import { adminOnly, adminOrEditor, adminOrSelf, requireRole, authenticated } from 'ipal-kit'
|
||||
|
||||
export const Articles = {
|
||||
slug: 'articles',
|
||||
access: {
|
||||
read: authenticated,
|
||||
create: adminOrEditor,
|
||||
update: adminOrEditor,
|
||||
delete: adminOnly,
|
||||
},
|
||||
// ...
|
||||
}
|
||||
|
||||
// Users: każdy widzi siebie, admin widzi wszystkich
|
||||
access: { read: adminOrSelf }
|
||||
|
||||
// dowolny minimalny próg
|
||||
access: { update: requireRole('editor') }
|
||||
```
|
||||
|
||||
### Field-level (zwracają boolean)
|
||||
|
||||
```ts
|
||||
import { adminOnlyField, adminOrEditorField, requireRoleField } from 'ipal-kit'
|
||||
|
||||
{
|
||||
name: 'internalNote',
|
||||
type: 'textarea',
|
||||
access: { read: adminOnlyField, update: adminOnlyField },
|
||||
}
|
||||
```
|
||||
|
||||
## Hierarchia
|
||||
|
||||
```ts
|
||||
import { ROLE_HIERARCHY } from 'ipal-kit'
|
||||
// ['user', 'editor', 'admin'] — wyższa rola spełnia wymóg niższej
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
# blocks
|
||||
|
||||
`RenderBlocks` — generyczny, serwerowy silnik renderowania bloków. Plugin nie
|
||||
zna Twoich bloków: iteruje po danych, mapuje `blockType` → komponent przez
|
||||
registry (prop), obsługuje zagnieżdżanie i rozszerzenia per-blok. Bloki
|
||||
(schemat + komponenty) definiujesz u siebie.
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
|
||||
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
|
||||
|
||||
## Front — RenderBlocks
|
||||
|
||||
Import z `ipal-kit/rsc` (to komponent serwerowy):
|
||||
|
||||
```tsx
|
||||
import { RenderBlocks } from 'ipal-kit/rsc'
|
||||
|
||||
// Twój registry: blockType → komponent (komponenty są Twoje)
|
||||
import { Hero } from '@/blocks/Hero'
|
||||
import { FormBlock } from '@/blocks/FormBlock'
|
||||
|
||||
const registry = { hero: Hero, formBlock: FormBlock }
|
||||
|
||||
export default async function Page() {
|
||||
const page = await payload.findByID({ collection: 'pages', id })
|
||||
return <RenderBlocks blocks={page.layout} components={registry} />
|
||||
}
|
||||
```
|
||||
|
||||
- nieznany `blockType` → pomijany (null), nie crashuje
|
||||
- każdy blok dostaje `_components` (mapę) — do rekursji zagnieżdżonych bloków
|
||||
bez React Context (wymóg RSC)
|
||||
|
||||
## enhanceProps — logika per-blok bez wiedzy pluginu
|
||||
|
||||
Gdy blok potrzebuje danych z innych bloków (np. nawigacja zbierająca kotwice
|
||||
z sekcji), podaj `enhanceProps`. Plugin go wywołuje, nie znając Twoich bloków:
|
||||
|
||||
```tsx
|
||||
const enhanceProps = ({ block, allBlocks }) => {
|
||||
if (block.blockType !== 'sectionNav') return {}
|
||||
const sections = allBlocks
|
||||
.filter((b) => b.blockType === 'anchoredSection')
|
||||
.map((b) => ({ anchor: b.anchor, label: b.navLabel }))
|
||||
return { _sections: sections }
|
||||
}
|
||||
|
||||
<RenderBlocks blocks={page.layout} components={registry} enhanceProps={enhanceProps} />
|
||||
```
|
||||
|
||||
## Komponent bloku
|
||||
|
||||
Każdy komponent dostaje dane bloku jako propsy (plus `_components`, plus to co
|
||||
zwróci `enhanceProps`). Spacing/layout należą do Ciebie — silnik nie owija
|
||||
bloków żadnym markupem.
|
||||
|
||||
```tsx
|
||||
export function Hero(props) {
|
||||
return <section className="py-16">{/* ... */}</section>
|
||||
}
|
||||
```
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# consent
|
||||
|
||||
Banner zgody na cookies (GDPR): 4 kategorie (necessary / functional /
|
||||
analytics / marketing), zapis w cookie z wersjonowaniem, Google Consent Mode,
|
||||
treść z globala CookieSettings. Domyślny wygląd w czystym Tailwind,
|
||||
nadpisywalny.
|
||||
|
||||
## Zależność
|
||||
|
||||
Banner używa ikony z `lucide-react`:
|
||||
|
||||
```json
|
||||
"dependencies": { "lucide-react": "^0.400.0" }
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji — global **CookieSettings** jest zawsze budowany. Edytor zarządza
|
||||
treścią bannera (message, przyciski, kategorie, settingsTitle) w panelu,
|
||||
localized. Link do polityki prywatności bierze się z system pages
|
||||
(`privacyPolicy`), nie z osobnego pola.
|
||||
|
||||
## Front — Provider + banner
|
||||
|
||||
Provider owija aplikację, banner i button renderują się same. Import z
|
||||
`ipal-kit/client`:
|
||||
|
||||
```tsx
|
||||
// app/(frontend)/[locale]/layout.tsx
|
||||
import { ConsentProvider, CookieBanner, CookieButton } from 'ipal-kit/client'
|
||||
import { getConsentTexts } from 'ipal-kit'
|
||||
|
||||
export default async function Layout({ children, params }) {
|
||||
const { locale } = await params
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// teksty z CookieSettings + link do polityki z system pages
|
||||
const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })
|
||||
const texts = await getConsentTexts({
|
||||
payload, config: i18nConfig, locale,
|
||||
privacyPolicy: { label: 'Polityka prywatności', page: settings.privacyPolicy },
|
||||
})
|
||||
|
||||
return (
|
||||
<ConsentProvider texts={texts}>
|
||||
{children}
|
||||
<CookieBanner />
|
||||
<CookieButton />
|
||||
</ConsentProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Nadpisywanie wyglądu (Poziom 2)
|
||||
|
||||
Domyślne klasy Tailwind można nadpisać przez `classNames`:
|
||||
|
||||
```tsx
|
||||
<CookieBanner classNames={{
|
||||
root: 'fixed inset-x-0 bottom-0 ...', // Twój layout
|
||||
primaryButton: 'btn btn-primary', // np. DaisyUI
|
||||
secondaryButton: 'btn btn-ghost',
|
||||
}} />
|
||||
```
|
||||
|
||||
## Gating skryptów wg zgody
|
||||
|
||||
```ts
|
||||
import { updateConsent, setDefaultConsent } from 'ipal-kit'
|
||||
// wysyła sygnały do Google Consent Mode (gtag) na podstawie stanu zgody
|
||||
```
|
||||
|
||||
Logika (kategorie, storage, parsowanie) też jest dostępna server-safe z
|
||||
`ipal-kit`:
|
||||
|
||||
```ts
|
||||
import { parseConsent, CONSENT_COOKIE, CONSENT_CATEGORIES } from 'ipal-kit'
|
||||
// np. gating skryptów server-side na podstawie cookie zgody
|
||||
```
|
||||
|
||||
## Wygląd — nadpisywanie stylów
|
||||
|
||||
Banner i przycisk mają domyślny, neutralny wygląd (light + dark) i działają bez
|
||||
żadnej konfiguracji. Kolory i zaokrąglenia idą przez CSS custom properties z
|
||||
fallbackami — żeby przestylować pod klienta, zadeklaruj zmienne w swoim CSS.
|
||||
Bez importów, bez propsów, bez walki ze specificity:
|
||||
|
||||
```css
|
||||
/* global.css — wszystko opcjonalne, nadpisz tylko to, co chcesz */
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-primary-hover: #15803d;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
Dostępne tokeny (każdy ma odpowiednik `-dark` używany pod `dark:`):
|
||||
|
||||
| Token | Domyślnie | Co koloruje |
|
||||
|---|---|---|
|
||||
| `--ipal-surface` | `#fff` | tło bannera i przycisku |
|
||||
| `--ipal-border` | `#e5e5e5` | obramowania |
|
||||
| `--ipal-text` | `#404040` | tekst treści |
|
||||
| `--ipal-text-strong` | `#171717` | nagłówki, nazwy kategorii |
|
||||
| `--ipal-text-muted` | `#737373` | opisy kategorii |
|
||||
| `--ipal-primary` | `#2563eb` | przycisk główny, ikona, link, checkbox |
|
||||
| `--ipal-primary-hover` | `#1d4ed8` | hover przycisku głównego |
|
||||
| `--ipal-primary-text` | `#fff` | tekst na przycisku głównym |
|
||||
| `--ipal-hover` | `#f5f5f5` | hover przycisków drugorzędnych |
|
||||
| `--ipal-radius` | `0.375rem` | zaokrąglenie przycisków |
|
||||
|
||||
Wymaga `@source` skanującego pakiet (patrz frontend-setup.md) — inaczej Tailwind
|
||||
nie wygeneruje tych klas.
|
||||
|
||||
### Gdy tokeny nie wystarczą
|
||||
|
||||
Układ (odstępy, pozycja, breakpointy) nie jest tokenizowany — to nie jest coś,
|
||||
co zmienia się per brand, a wystawienie go oznaczałoby wymyślanie CSS od nowa,
|
||||
zmienna po zmiennej. Na większe zmiany są `classNames`:
|
||||
|
||||
```tsx
|
||||
<CookieBanner classNames={{
|
||||
root: 'fixed inset-0 z-50 grid place-items-center bg-black/50', // modal zamiast paska
|
||||
primaryButton: 'btn btn-primary', // np. DaisyUI
|
||||
secondaryButton: 'btn btn-ghost',
|
||||
}} />
|
||||
<CookieButton className="fixed bottom-6 right-6 ..." />
|
||||
```
|
||||
|
||||
Sloty: `root`, `primaryButton`, `secondaryButton`. Podany className zastępuje
|
||||
domyślny (nie dokleja się).
|
||||
|
||||
Elementy mają też `data-ipal="banner"` i `data-ipal="cookie-button"` — stabilne
|
||||
uchwyty do CSS albo testów e2e.
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
# content
|
||||
|
||||
Kolekcje, których wpisy żyją pod stroną-archiwum — blog, realizacje, aktualności,
|
||||
cokolwiek z listingiem. Wpisy trafiają pod adres w rodzaju `/pl/artykuly/moj-post`,
|
||||
a segment `artykuly` nie jest osobną konfiguracją — to slug strony, którą edytor
|
||||
wskazał jako archiwum tej kolekcji.
|
||||
|
||||
## Model — kto czym jest
|
||||
|
||||
```
|
||||
kolekcja posts ──(config)──► „to kolekcja archiwalna”
|
||||
strona „Artykuły” ──(System Pages)──► „jestem archiwum kolekcji posts”
|
||||
wpis ──► należy do posts, i tyle
|
||||
```
|
||||
|
||||
Wpis niczego nie wie o swoim adresie. O adresie decyduje przypisanie strony —
|
||||
jedno dla całej kolekcji. Zmiana tytułu strony „Artykuły" na „Wpisy" przenosi
|
||||
całą sekcję na `/pl/wpisy`, per locale, bez deploya. To jak folder: pliki nie
|
||||
tagują się nazwą folderu, folder ma nazwę.
|
||||
|
||||
**Kolekcji nie da się dodać z panelu** — Payload trzyma schemat w kodzie. Nowy
|
||||
typ treści to zawsze zmiana w kodzie (nowa kolekcja + wpis w configu). Edytor
|
||||
zarządza tylko przypisaniem archiwum i jego adresem.
|
||||
|
||||
## Config
|
||||
|
||||
```ts
|
||||
// content.config.ts — współdzielony przez payload.config i front
|
||||
import type { ContentOption } from 'ipal-kit'
|
||||
|
||||
export const contentConfig: ContentOption = {
|
||||
collections: [
|
||||
{ slug: 'posts', label: 'Artykuły', perPage: 10 },
|
||||
{ slug: 'projects', label: 'Realizacje', perPage: 6 },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
ipalKit({
|
||||
pages: { slug: 'pages' }, // wymagane — archiwum jest stroną
|
||||
content: contentConfig,
|
||||
seo: { collections: ['pages', 'posts', 'projects'] }, // wpisy też chcą meta
|
||||
})
|
||||
```
|
||||
|
||||
Każda pozycja dodaje w **Site Settings → System Pages** pole relacji
|
||||
„{label} — archive page". `slug` to kolekcja klienta, `perPage` steruje
|
||||
paginacją listingu.
|
||||
|
||||
Osobny plik `content.config.ts` (jak `i18n.config.ts`) jest potrzebny, bo tę samą
|
||||
deklarację czytają dwa miejsca: payload.config (żeby dodać pola archiwum) i front
|
||||
(router). Jedno źródło prawdy.
|
||||
|
||||
## Rozstrzyganie tras — resolveRoute
|
||||
|
||||
Serce modułu. Catch-all `[[...slug]]` łapie wszystko, a `resolveRoute` mówi, czym
|
||||
dana ścieżka jest:
|
||||
|
||||
```ts
|
||||
import { resolveRoute } from 'ipal-kit'
|
||||
|
||||
const route = await resolveRoute({
|
||||
payload,
|
||||
locale: 'pl',
|
||||
segments: ['artykuly', 'moj-post'],
|
||||
page: 1, // z ?page=
|
||||
content: contentConfig,
|
||||
withEntries: false, // true → dociąga wpisy do archiwum (patrz niżej)
|
||||
})
|
||||
// route.type: 'home' | 'page' | 'archive' | 'entry' | (null gdy 404)
|
||||
```
|
||||
|
||||
Logika: pierwszy segment dopasowywany jest do slugów stron-archiwów z System
|
||||
Pages. Trafienie → wiadomo, której kolekcji dotyczy. `['artykuly']` → listing
|
||||
`posts`; `['artykuly','moj-post']` → wpis w `posts`; brak trafienia → zwykła
|
||||
strona.
|
||||
|
||||
**Archiwum ma pierwszeństwo przed stroną o tym samym slugu** — inaczej strona
|
||||
„artykuly" przesłaniałaby własne wpisy.
|
||||
|
||||
**Głębokość tylko `{archiwum}/{wpis}`** — kategorie w ścieżce robiłyby canonical
|
||||
niejednoznacznym (ten sam wpis pod wieloma URL-ami), więc `/a/b/c` → null.
|
||||
|
||||
**Kolizja slugów:** dwie kolekcje z archiwum o tym samym slugu → wygrywa pierwsza
|
||||
z listy `collections`. Błąd konfiguracji, router nie ostrzega.
|
||||
|
||||
## Helpery frontu — createContentHelpers
|
||||
|
||||
Zamiast pisać cache'owane wrappery w każdym projekcie:
|
||||
|
||||
```ts
|
||||
// src/lib/content.ts
|
||||
import { createContentHelpers } from 'ipal-kit'
|
||||
import config from '@/payload.config'
|
||||
import { contentConfig } from '@/content.config'
|
||||
|
||||
export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute } =
|
||||
createContentHelpers({ config, content: contentConfig })
|
||||
```
|
||||
|
||||
Wszystko owinięte w React `cache()`, a instancja Payloada powstaje **raz** w
|
||||
fabryce i jest współdzielona — dlatego to fabryka, nie luźne funkcje. Bez tego
|
||||
`cache()` nie dedupikowałby między helperami, a Next woła generateMetadata i
|
||||
komponent strony osobno.
|
||||
|
||||
`resolveRoute` z fabryki przyjmuje `(locale, segments, page, withEntries?)`.
|
||||
|
||||
## withEntries — wpisy tylko gdy trzeba
|
||||
|
||||
Archiwum potrzebuje listy wpisów; metadane nie. `withEntries` rozstrzyga:
|
||||
|
||||
```ts
|
||||
// generateMetadata — bez wpisów (nie płaci za zapytanie)
|
||||
const route = await resolveRoute(locale, slug, page)
|
||||
|
||||
// komponent strony — z wpisami
|
||||
const route = await resolveRoute(locale, slug, page, true)
|
||||
// route.entries: { docs, page, totalPages, hasPrevPage, hasNextPage, ... }
|
||||
```
|
||||
|
||||
Metadane i strona wołają z różnymi argumentami, więc `cache()` traktuje je jako
|
||||
osobne wywołania — i słusznie, bo metadane wpisów nie potrzebują.
|
||||
|
||||
## Listing
|
||||
|
||||
Strona-archiwum to zwykła strona z blokami, więc listing jest **blokiem** (Twoim
|
||||
— plugin nie decyduje o wyglądzie listy). Blok dostaje wpisy przez enhanceProps:
|
||||
|
||||
```tsx
|
||||
// w page.tsx
|
||||
const enhanceProps = ({ block }) => {
|
||||
if (block.blockType === 'entriesList' && route.type === 'archive') {
|
||||
return { entries: route.entries, locale, archiveSlug: route.doc.slug }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
```
|
||||
|
||||
Blok nie wie, co listuje — wpisy wstrzykuje trasa na podstawie tego, której
|
||||
kolekcji ta strona jest archiwum. Ten sam blok obsługuje `/pl/artykuly` i
|
||||
`/pl/realizacje`.
|
||||
|
||||
## Ścieżki i paginacja
|
||||
|
||||
```ts
|
||||
import { buildEntryPath, buildArchivePath, parsePageParam } from 'ipal-kit'
|
||||
|
||||
buildEntryPath({ locale: 'pl', archiveSlug: 'artykuly', entrySlug: 'moj-post' })
|
||||
// '/pl/artykuly/moj-post'
|
||||
|
||||
buildArchivePath({ locale: 'pl', archiveSlug: 'artykuly', page: 2 })
|
||||
// '/pl/artykuly?page=2' (strona 1 bez query)
|
||||
|
||||
parsePageParam(searchParams.page) // '2' → 2; śmieci/undefined → 1
|
||||
```
|
||||
|
||||
Paginacja przez `?page=`, nie `/2`. Segment ścieżki gryzłby się z wpisem o slugu
|
||||
„2", a `/strona/2` dodawałby kolejny zlokalizowany segment do konfiguracji.
|
||||
Google rozumie `?page=` od lat, a `rel=next/prev` zostało wycofane.
|
||||
|
||||
Canonical strony 2 wskazuje **na siebie** (`?page=2`), nie na stronę 1 — inaczej
|
||||
Google uznałby, że wpisów z dalszych stron nie ma. Hreflangi niosą ten sam numer
|
||||
(`/en/articles?page=2`). To ogarnia `createPageMetadata` automatycznie, gdy
|
||||
przekażesz mu `page` i `content`.
|
||||
|
||||
## Metadane wpisów
|
||||
|
||||
`createPageMetadata` z opcją `content` sam rozpoznaje wpisy i buduje im poprawny
|
||||
canonical/hreflang z prefiksem (patrz seo.md):
|
||||
|
||||
```ts
|
||||
const pageMetadata = createPageMetadata({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
content: contentConfig, // ← bez tego wpisy dostają zły adres
|
||||
})
|
||||
```
|
||||
|
||||
## Przełącznik języka
|
||||
|
||||
`switchLocalePath` (z i18n) uwzględnia prefiks: polski wpis bez tłumaczenia EN
|
||||
prowadzi do archiwum EN (`/en/articles`), nie na stronę główną — najbliżej tego,
|
||||
czego szuka odwiedzający.
|
||||
|
||||
## Eksport
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveRoute,
|
||||
getArchiveEntries,
|
||||
buildArchivePath,
|
||||
buildEntryPath,
|
||||
parsePageParam,
|
||||
createContentHelpers,
|
||||
archiveFieldName,
|
||||
} from 'ipal-kit'
|
||||
import type { ContentOption, ResolvedRoute, ArchiveEntries } from 'ipal-kit'
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
# email
|
||||
|
||||
Wysyłka maili przez SMTP z SiteIntegrations, w runtime (bez Payload email
|
||||
adaptera). Edytor zmienia SMTP w panelu — następny mail idzie z nowymi
|
||||
ustawieniami, bez restartu.
|
||||
|
||||
## Zależność
|
||||
|
||||
```json
|
||||
"dependencies": { "nodemailer": "^6.9.0" }
|
||||
```
|
||||
(+ `@types/nodemailer` w devDependencies)
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji — SMTP (host, port, user, password, from) jest w SiteIntegrations
|
||||
(tab SMTP). Edytor konfiguruje w panelu.
|
||||
|
||||
## Front — sendEmail (server)
|
||||
|
||||
```ts
|
||||
import { sendEmail } from 'ipal-kit/server'
|
||||
|
||||
const result = await sendEmail({
|
||||
payload,
|
||||
to: '[email protected]',
|
||||
subject: 'Nowa wiadomość',
|
||||
html: '<h1>Cześć</h1><p>Treść…</p>', // gotowy HTML (Twój wygląd)
|
||||
// text: 'wersja plain', from: '...', replyTo: '...'
|
||||
})
|
||||
|
||||
if (result.sent) {
|
||||
// result.messageId
|
||||
} else {
|
||||
// result.error — np. "SMTP is not configured..."
|
||||
}
|
||||
```
|
||||
|
||||
- **HTML to Twój argument** — plugin nie ma templatek, wysyła to co podasz.
|
||||
Wygląd maila składasz na froncie.
|
||||
- Zwraca `{ sent: true, messageId }` albo `{ sent: false, error }` — nie
|
||||
rzuca wyjątku, nie wycieka detali SMTP do klienta.
|
||||
- Port 465 → implicit TLS, inne → STARTTLS.
|
||||
- `server-only` — hasło SMTP nigdy w bundlu przeglądarki.
|
||||
|
||||
## Uwaga: maile systemowe Payloada
|
||||
|
||||
Reset hasła / weryfikacja email idą przez wbudowany mechanizm Payload
|
||||
(`config.email`), którego ten moduł **nie** konfiguruje (celowo — wymagałby
|
||||
SMTP w env). Jeśli ich potrzebujesz, to osobna konfiguracja adaptera przy
|
||||
`buildConfig`.
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# forms
|
||||
|
||||
Wpina `@payloadcms/plugin-form-builder` (kolekcje forms + form-submissions) i
|
||||
dostarcza `submitForm` — wywoływalną z frontu funkcję, która spina: weryfikację
|
||||
Turnstile → zapis zgłoszenia → wysyłkę maili (naszym senderem).
|
||||
|
||||
## Zależność
|
||||
|
||||
```json
|
||||
"dependencies": { "@payloadcms/plugin-form-builder": "3.84.1" }
|
||||
```
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
forms: {
|
||||
redirectRelationships: ['pages'], // formularz może przekierować na Page
|
||||
// fields: { text: true, textarea: true, email: true, ... }, // domyślnie włączone sensowne
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Dodaje kolekcje **Forms** (edytor buduje formularze) i **Form Submissions**
|
||||
(zgłoszenia). Wbudowany email form-buildera jest nieużywany — wysyłką zajmuje
|
||||
się `submitForm` przez nasz sender (SMTP z panelu).
|
||||
|
||||
## Front — submitForm (server)
|
||||
|
||||
Zwykle w Server Action wywoływanej przez formularz. HTML obu maili składasz
|
||||
na froncie (Twój wygląd):
|
||||
|
||||
```ts
|
||||
import { submitForm } from 'ipal-kit/server'
|
||||
|
||||
const result = await submitForm({
|
||||
payload,
|
||||
formId, // z kolekcji Forms
|
||||
data: { name, email, message },
|
||||
turnstileToken, // opcjonalny — jeśli podany, weryfikowany
|
||||
ip,
|
||||
emails: {
|
||||
// wiadomość do klienta/admina (np. "nowe zgłoszenie")
|
||||
notification: {
|
||||
to: '[email protected]',
|
||||
subject: 'Nowe zgłoszenie',
|
||||
html: renderAdminEmail(data), // Twój HTML
|
||||
},
|
||||
// potwierdzenie do wysyłającego
|
||||
confirmation: {
|
||||
to: email,
|
||||
subject: 'Dziękujemy za wiadomość',
|
||||
html: renderUserEmail(data), // Twój HTML
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// result.submissionId
|
||||
// result.emails.notification?.sent / result.emails.confirmation?.sent
|
||||
} else {
|
||||
// result.error — np. Turnstile / zapis
|
||||
}
|
||||
```
|
||||
|
||||
## Flow i gwarancje
|
||||
|
||||
1. **Turnstile** (jeśli token) → nieudany → odrzuć **przed** zapisem (brak
|
||||
spamu w bazie).
|
||||
2. **Zapis** submission (form-submissions).
|
||||
3. **Maile** — oba opcjonalne, HTML z frontu.
|
||||
4. Zwrot: `{ success, submissionId, emails: { notification?, confirmation? } }`.
|
||||
|
||||
**Zapisane zgłoszenie = sukces, nawet gdy mail padnie.** Status wysyłki maili
|
||||
jest osobno w `result.emails`, żeby dane zgłoszenia nie ginęły przez chwilową
|
||||
awarię SMTP. Front może zareagować (ostrzec, ponowić).
|
||||
|
||||
Oba maile opcjonalne — możesz wysłać jeden, drugi, oba lub żaden.
|
||||
`turnstileToken` opcjonalny — brak = pominięcie weryfikacji (decydujesz per
|
||||
formularz).
|
||||
|
||||
## Maile
|
||||
|
||||
Plugin nie składa maili formularzy. Po zapisie submission form-builder wysyła
|
||||
wiadomości skonfigurowane przez edytora (Forms → formularz → Emails), przez
|
||||
`payload.sendEmail`. Żeby wyszły, config musi mieć adapter:
|
||||
|
||||
```ts
|
||||
email: panelSmtpAdapter(), // z 'ipal-kit'
|
||||
```
|
||||
|
||||
Wtedy idą przez SMTP z Site Integrations. Edytor ustawia odbiorców, temat i
|
||||
treść (placeholdery: `{{pole}}`, `{{*}}`, `{{*:table}}`) bez dotykania kodu.
|
||||
|
||||
`submitForm` odpowiada tylko za weryfikację Turnstile i zapis.
|
||||
|
||||
## Własne pola w kolekcji formularzy
|
||||
|
||||
`formOverrides` przechodzi prosto do form-buildera — plugin nie ma opinii, czego
|
||||
formularz potrzebuje poza swoimi polami:
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
forms: {
|
||||
redirectRelationships: ['pages'],
|
||||
formOverrides: {
|
||||
fields: ({ defaultFields }) => [
|
||||
...defaultFields,
|
||||
{ name: 'internalNote', type: 'textarea' },
|
||||
],
|
||||
admin: { group: 'Content' },
|
||||
},
|
||||
// formSubmissionOverrides: { ... } // to samo dla zgłoszeń
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`fields` dostaje domyślne pola kolekcji i zwraca finalną listę — możesz dodawać,
|
||||
usuwać, zmieniać kolejność. Poza `fields` przyjmuje dowolne ustawienia kolekcji
|
||||
(admin, access, hooks).
|
||||
|
||||
## Bezpieczeństwo i wynik zgłoszenia
|
||||
|
||||
`submitForm` przechodzi trzy bramki w kolejności rosnącej po koszcie: rate limit
|
||||
(in-memory, per IP), Turnstile, walidacja względem schematu formularza. Dopiero
|
||||
potem zapis. Flood ginie, zanim dotknie sieci czy bazy.
|
||||
|
||||
Walidacja jest istotna, bo server action to publiczny endpoint — da się go wołać
|
||||
z pominięciem formularza. Plugin ładuje definicję formularza i: odrzuca nieznane
|
||||
klucze, wymusza pola `required`, tnie długość (5000 znaków). Do bazy trafia tylko
|
||||
to, co formularz definiuje.
|
||||
|
||||
Rate limit: 5/min/IP domyślnie, `maxPerMinute` zmienia, `0` wyłącza (gdy limiter
|
||||
jest z przodu). In-memory — przy wielu instancjach licznik jest per-proces, więc
|
||||
efektywny limit to per-instancja. Do formularza kontaktowego wystarcza.
|
||||
|
||||
### Wynik to KOD, nie tekst
|
||||
|
||||
`submitForm` zwraca ustrukturyzowany błąd — plugin mówi CO się stało, projekt
|
||||
decyduje JAK to pokazać (język, brzmienie, obsługa per pole):
|
||||
|
||||
```ts
|
||||
type SubmitFormResult =
|
||||
| { success: true; submissionId: string | number }
|
||||
| { success: false; reason: 'rate_limited' }
|
||||
| { success: false; reason: 'turnstile' }
|
||||
| { success: false; reason: 'validation'; field?: string; kind?: 'required' | 'too_long' | 'unknown_fields' }
|
||||
| { success: false; reason: 'not_found' }
|
||||
| { success: false; reason: 'error' }
|
||||
```
|
||||
|
||||
Front mapuje kody na własne komunikaty:
|
||||
|
||||
```tsx
|
||||
function errorMessage(r) {
|
||||
switch (r.reason) {
|
||||
case 'rate_limited': return 'Zbyt wiele zgłoszeń...'
|
||||
case 'validation':
|
||||
if (r.kind === 'required') return 'Uzupełnij wymagane pola.'
|
||||
// r.field → podświetl konkretne pole
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Frontend — wymagane implementacje
|
||||
|
||||
Co projekt klienta musi zrobić na froncie, żeby plugin działał. Zebrane z
|
||||
realnego wdrożenia (ipal-test). W przyszłości → pełny poradnik "first setup".
|
||||
|
||||
## Wymagania środowiska
|
||||
|
||||
### Tailwind CSS (WYMÓG)
|
||||
|
||||
Komponenty pluginu (CookieBanner, Turnstile widget, i inne) są w czystym
|
||||
Tailwind. Klient MUSI mieć Tailwind + skanować pakiet pluginu:
|
||||
|
||||
```bash
|
||||
pnpm add tailwindcss @tailwindcss/postcss # v4
|
||||
```
|
||||
|
||||
`postcss.config.mjs` (root):
|
||||
```js
|
||||
export default { plugins: { '@tailwindcss/postcss': {} } }
|
||||
```
|
||||
|
||||
W globalnym CSS (np. app/(frontend)/styles.css):
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@source "../../../node_modules/ipal-kit/dist/**/*.js";
|
||||
```
|
||||
|
||||
**@source jest kluczowy** — Tailwind domyślnie NIE skanuje node_modules, więc
|
||||
bez tego klasy komponentów pluginu się nie wygenerują (komponenty renderują
|
||||
się bez stylów). Ścieżka relatywna do pliku CSS.
|
||||
|
||||
### Przestylowanie pod klienta
|
||||
|
||||
Komponenty pluginu (CookieBanner, CookieButton) mają domyślny wygląd i działają
|
||||
bez konfiguracji. Kolory/zaokrąglenia przez CSS custom properties z fallbackami
|
||||
— nadpisz w swoim CSS:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
Pełna lista tokenów + opcja classNames (gdy tokeny nie starczą): docs/consent.md.
|
||||
Bloki są Twoje — plugin ich nie stylizuje, RenderBlocks nie dodaje markupu.
|
||||
|
||||
### Wymagane zależności (transitive)
|
||||
|
||||
Instalacja z npm zaciąga automatycznie. Przy lokalnym tarballu doinstaluj:
|
||||
```
|
||||
@payloadcms/plugin-seo @payloadcms/plugin-form-builder nodemailer
|
||||
lucide-react slugify server-only
|
||||
```
|
||||
|
||||
### Spójność wersji @payloadcms/*
|
||||
|
||||
pnpm.overrides wymuszające jedną wersję (patrz README).
|
||||
|
||||
### generate:importmap
|
||||
|
||||
Po wpięciu pluginu: `pnpm payload generate:importmap` (dla pól SEO w adminie).
|
||||
|
||||
## Struktura tras (lokalizacja)
|
||||
|
||||
```
|
||||
src/app/(frontend)/
|
||||
layout.tsx # root (<html><body>) — istniejący
|
||||
[locale]/
|
||||
layout.tsx # walidacja locale + ConsentProvider
|
||||
[[...slug]]/
|
||||
page.tsx # render strony (bloki)
|
||||
```
|
||||
|
||||
**[[...slug]] MUSI być podwójny nawias** (opcjonalny catch-all):
|
||||
- `[slug]` → string (błąd `slug.join is not a function`)
|
||||
- `[[...slug]]` → tablica (poprawne), łapie /pl (home) i /pl/o-nas jednym plikiem
|
||||
|
||||
## i18n — jedno źródło prawdy
|
||||
|
||||
Wydziel config locale do osobnego pliku, importuj wszędzie:
|
||||
|
||||
```ts
|
||||
// src/i18n.config.ts
|
||||
export const i18nConfig = {
|
||||
defaultLocale: 'pl',
|
||||
locales: [
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
],
|
||||
} as const // as const — inaczej TS: locales nie pasuje do niepustej tuple
|
||||
```
|
||||
|
||||
Importuj w: payload.config (ipalKit({ i18n: i18nConfig })), middleware.ts.
|
||||
Layout może czytać locale z payload config (config.localization.locales) —
|
||||
też jedno źródło.
|
||||
|
||||
## Middleware
|
||||
|
||||
```ts
|
||||
// src/middleware.ts
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLocaleMiddleware } from 'ipal-kit/next/middleware'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
|
||||
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const result = localeMiddleware(request)
|
||||
if (result.type === 'next') return NextResponse.next()
|
||||
const response = NextResponse.redirect(result.location)
|
||||
response.cookies.set(result.cookie.name, result.cookie.value)
|
||||
return response
|
||||
}
|
||||
|
||||
// matcher MUSI być inline (Next analizuje statycznie, nie wykonuje importów —
|
||||
// import DEFAULT_MIDDLEWARE_MATCHER byłby zignorowany → middleware łapie
|
||||
// /admin /_next /api → 500)
|
||||
export const config = {
|
||||
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
|
||||
}
|
||||
```
|
||||
|
||||
## Rozwiązywanie strony (page.tsx)
|
||||
|
||||
- brak slug (/pl) → home przez System Pages (settings.homepage), NIE hardkod slug
|
||||
- slug (/pl/o-nas) → payload.find by slug w danym locale
|
||||
- depth: 2 → żeby relacje w blokach (form) się populowały
|
||||
|
||||
## Consent (layout [locale])
|
||||
|
||||
Layout (server) czyta getConsentTexts, przekazuje jako prop do ConsentProvider
|
||||
(client). Banner + button renderują się same:
|
||||
|
||||
```ts
|
||||
import { getConsentTexts } from 'ipal-kit'
|
||||
import { ConsentProvider, CookieBanner, CookieButton } from 'ipal-kit/client'
|
||||
|
||||
const texts = await getConsentTexts({ config, locale, payload, privacyPolicy })
|
||||
// <ConsentProvider texts={texts}>{children}<CookieBanner/><CookieButton/></ConsentProvider>
|
||||
```
|
||||
|
||||
## Bloki (RenderBlocks)
|
||||
|
||||
Klient definiuje bloki (config + komponent) — plugin jest block-agnostic.
|
||||
|
||||
- `blocks/<Nazwa>/config.ts` — schemat Payload (Block)
|
||||
- `blocks/<Nazwa>/Component.tsx` — komponent (dane bloku jako propsy)
|
||||
- `blocks/registry.ts` — mapa blockType → komponent
|
||||
- Pages: pole `layout` typu blocks z listą bloków
|
||||
- page.tsx: `<RenderBlocks blocks={doc.layout} components={registry} />`
|
||||
|
||||
**Puste blocks: [] crashuje** (traverseFields) — zawsze z co najmniej jednym
|
||||
blokiem.
|
||||
|
||||
enhanceProps — wstrzykiwanie server-side wartości do bloku bez wiedzy pluginu
|
||||
(np. turnstileSiteKey, email do FormBlock):
|
||||
```ts
|
||||
const enhanceProps = ({ block }) => {
|
||||
if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo }
|
||||
return {}
|
||||
}
|
||||
```
|
||||
|
||||
## Formularz (FormBlock)
|
||||
|
||||
- FormRenderer (client) — renderuje pola form-buildera (text/email/select/
|
||||
country/checkbox/textarea/number/state/message)
|
||||
- Turnstile widget (ipal-kit/client) — siteKey jako prop, wstrzykiwany przez
|
||||
enhanceProps (z SiteIntegrations, publiczny — bezpieczny na kliencie)
|
||||
- server action → submitForm (ipal-kit/server) — weryfikuje Turnstile i zapisuje
|
||||
|
||||
Maili NIE składa się w kodzie. Po zapisie submission form-builder sam wysyła
|
||||
wiadomości skonfigurowane przez edytora (Forms → dany formularz → Emails:
|
||||
Email To / CC / BCC / Subject / Message z placeholderami {{pole}}, {{*}},
|
||||
{{*:table}}). Idą przez payload.sendEmail → panelSmtpAdapter → SMTP z panelu.
|
||||
|
||||
Wymaga w payload.config:
|
||||
|
||||
```ts
|
||||
import { ipalKit, panelSmtpAdapter } from 'ipal-kit'
|
||||
|
||||
export default buildConfig({
|
||||
email: panelSmtpAdapter(),
|
||||
plugins: [ipalKit({ ... })],
|
||||
})
|
||||
```
|
||||
|
||||
Wymaga w SiteIntegrations: SMTP (host, port, user, password, from) + Turnstile.
|
||||
Turnstile testowe klucze Cloudflare (zawsze pass):
|
||||
site 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA.
|
||||
|
||||
Walidacja server-side (limit długości pól) w actions.ts — browserowy `required`
|
||||
da się obejść wołając akcję bezpośrednio.
|
||||
|
||||
## Metadata (SEO na froncie)
|
||||
|
||||
createMetadataGenerator zwraca funkcję `({ payload, params, locale })` — Next
|
||||
woła `generateMetadata({ params })` bez payload/locale, a plugin nigdy nie
|
||||
wywołuje getPayload sam, więc klient opakowuje:
|
||||
|
||||
```ts
|
||||
const generate = createMetadataGenerator({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
homeSlug: 'homepage', // home zwija się do /pl, nie /pl/homepage
|
||||
resolveDocument: async ({ params, locale }) => { ... }, // locale: 'all'!
|
||||
resolveSiteName: async ({ locale }) => { ... },
|
||||
resolveImageUrl: async ({ doc }) => { ... },
|
||||
})
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { locale, slug } = await params
|
||||
const payload = await getPayload({ config: await config })
|
||||
return generate({ payload, params: { slug: slug ?? [] }, locale })
|
||||
}
|
||||
```
|
||||
|
||||
resolveDocument MUSI pobrać dokument z `locale: 'all'` — wtedy `slug` jest mapą
|
||||
locale→wartość, z której budowane są hreflang alternates. Zwykły fetch (jeden
|
||||
locale) da tylko string i hreflang nie powstanie.
|
||||
|
||||
Next uruchamia generateMetadata i komponent strony niezależnie — bez React
|
||||
cache() każde żądanie odpytuje bazę dwa razy o ten sam dokument.
|
||||
|
||||
## Analytics
|
||||
|
||||
W layoucie [locale], WEWNĄTRZ ConsentProvider (Analytics ustawia Consent Mode
|
||||
ze stored choice, zanim załaduje tag):
|
||||
|
||||
```ts
|
||||
const analytics = await getAnalyticsConfig(payload) // tylko publiczne GA4/GTM ID
|
||||
// <ConsentProvider texts={texts}> ... <Analytics {...analytics} /> </ConsentProvider>
|
||||
```
|
||||
|
||||
ID z SiteIntegrations. GTM ma priorytet nad GA4, gdy oba ustawione.
|
||||
@@ -0,0 +1,494 @@
|
||||
# Nowy projekt — krok po kroku
|
||||
|
||||
Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i
|
||||
formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat
|
||||
bazy, importMap, kolejność wpięcia).
|
||||
|
||||
Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
|
||||
|
||||
---
|
||||
|
||||
## 1. Szkielet Payloada
|
||||
|
||||
```bash
|
||||
npx create-payload-app@latest moj-projekt
|
||||
# → Blank, SQLite
|
||||
cd moj-projekt
|
||||
```
|
||||
|
||||
## 2. Instalacja IPAL
|
||||
|
||||
```bash
|
||||
pnpm add ipal-kit
|
||||
pnpm add @payloadcms/[email protected] @payloadcms/[email protected] \
|
||||
nodemailer lucide-react slugify server-only
|
||||
```
|
||||
|
||||
Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej
|
||||
zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo
|
||||
projekt się wywala. Wymuś w `package.json`:
|
||||
|
||||
```json
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"payload": "3.84.1",
|
||||
"@payloadcms/ui": "3.84.1",
|
||||
"@payloadcms/next": "3.84.1",
|
||||
"@payloadcms/db-sqlite": "3.84.1",
|
||||
"@payloadcms/richtext-lexical": "3.84.1",
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"@payloadcms/plugin-form-builder": "3.84.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
rm -rf node_modules pnpm-lock.yaml && pnpm install
|
||||
```
|
||||
|
||||
## 3. Konfiguracja locale — jedno źródło
|
||||
|
||||
Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc
|
||||
nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu
|
||||
miejscach:
|
||||
|
||||
```ts
|
||||
// src/i18n.config.ts
|
||||
export const i18nConfig = {
|
||||
defaultLocale: 'pl',
|
||||
locales: [
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
],
|
||||
} as const
|
||||
```
|
||||
|
||||
`as const` jest konieczne — bez niego TS nie uzna `locales` za niepustą listę.
|
||||
|
||||
## 4. payload.config.ts
|
||||
|
||||
```ts
|
||||
import { ipalKit, panelSmtpAdapter } from 'ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { Pages } from '@/collections/Pages'
|
||||
|
||||
export default buildConfig({
|
||||
// …reszta z template'u
|
||||
collections: [Users, Media, Pages],
|
||||
|
||||
// SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu.
|
||||
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
|
||||
email: panelSmtpAdapter(),
|
||||
|
||||
plugins: [
|
||||
ipalKit({
|
||||
i18n: i18nConfig,
|
||||
access: { authCollection: 'users' },
|
||||
pages: { slug: 'pages' },
|
||||
seo: { collections: ['pages'] },
|
||||
forms: { redirectRelationships: ['pages'] },
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## 5. Kolekcja Pages
|
||||
|
||||
```ts
|
||||
// src/collections/Pages.ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { buildSlugField } from 'ipal-kit'
|
||||
import { ContentBlock } from '@/blocks/Content/config'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
admin: { useAsTitle: 'title' },
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true, localized: true },
|
||||
buildSlugField({ from: 'title' }),
|
||||
{
|
||||
name: 'layout',
|
||||
type: 'blocks',
|
||||
blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Pierwszy blok
|
||||
|
||||
Bloki należą do projektu — plugin ich nie zna i nie stylizuje.
|
||||
|
||||
```ts
|
||||
// src/blocks/Content/config.ts
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const ContentBlock: Block = {
|
||||
slug: 'content',
|
||||
fields: [
|
||||
{ name: 'heading', type: 'text', localized: true },
|
||||
{ name: 'body', type: 'textarea', localized: true, required: true },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/blocks/Content/Component.tsx
|
||||
export function ContentBlockComponent({ heading, body }: { heading?: string; body?: string }) {
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-4 py-12">
|
||||
{heading && <h2 className="mb-4 text-2xl font-bold">{heading}</h2>}
|
||||
{body && <p className="whitespace-pre-line leading-relaxed">{body}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/blocks/registry.ts
|
||||
import type { BlockComponentMap } from 'ipal-kit/rsc'
|
||||
import { ContentBlockComponent } from '@/blocks/Content/Component'
|
||||
|
||||
export const blockRegistry: BlockComponentMap = {
|
||||
content: ContentBlockComponent,
|
||||
}
|
||||
```
|
||||
|
||||
Klucz w rejestrze = `slug` bloku.
|
||||
|
||||
## 7. Tailwind
|
||||
|
||||
Blank template go nie ma, a komponenty pluginu (banner cookies) są w Tailwindzie.
|
||||
|
||||
```bash
|
||||
pnpm add tailwindcss @tailwindcss/postcss
|
||||
```
|
||||
|
||||
```js
|
||||
// postcss.config.mjs (root)
|
||||
export default { plugins: { '@tailwindcss/postcss': {} } }
|
||||
```
|
||||
|
||||
```css
|
||||
/* src/app/(frontend)/styles.css — na górze */
|
||||
@import "tailwindcss";
|
||||
@source "../../../node_modules/ipal-kit/dist/**/*.js";
|
||||
```
|
||||
|
||||
`@source` jest **konieczny** — Tailwind nie skanuje `node_modules`, więc bez
|
||||
niego klasy komponentów pluginu nie powstaną i banner wyrenderuje się goły.
|
||||
Ścieżka jest relatywna do pliku CSS.
|
||||
|
||||
## 8. Middleware
|
||||
|
||||
```ts
|
||||
// src/middleware.ts
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLocaleMiddleware } from 'ipal-kit/next/middleware'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
|
||||
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const result = localeMiddleware(request)
|
||||
if (result.type === 'next') return NextResponse.next()
|
||||
|
||||
const response = NextResponse.redirect(result.location)
|
||||
response.cookies.set(result.cookie.name, result.cookie.value)
|
||||
return response
|
||||
}
|
||||
|
||||
// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje
|
||||
// importów. Importowana stała zostanie zignorowana, middleware złapie /admin
|
||||
// i /_next, i wszystko zwróci 500.
|
||||
export const config = {
|
||||
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Warstwa dostępu do danych
|
||||
|
||||
Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()`
|
||||
sprawia, że nie pytają bazy dwa razy o to samo.
|
||||
|
||||
```ts
|
||||
// src/lib/payload.ts
|
||||
import { cache } from 'react'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@/payload.config'
|
||||
|
||||
export const getCachedPayload = cache(async () => getPayload({ config: await config }))
|
||||
|
||||
export const getSettings = cache(async (locale: string) =>
|
||||
(await getCachedPayload()).findGlobal({
|
||||
slug: 'site-settings',
|
||||
locale: locale as 'pl' | 'en',
|
||||
depth: 2,
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/lib/locales.ts
|
||||
import { cache } from 'react'
|
||||
import config from '@/payload.config'
|
||||
|
||||
export const getConfiguredLocales = cache(async (): Promise<string[]> => {
|
||||
const payloadConfig = await config
|
||||
return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : []
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/lib/pages.ts
|
||||
import { cache } from 'react'
|
||||
import type { Page, SiteSetting } from '@/payload-types'
|
||||
import { getCachedPayload, getSettings } from './payload'
|
||||
|
||||
export const resolvePage = cache(
|
||||
async (locale: string, slugPath: string | null): Promise<Page | null> => {
|
||||
if (!slugPath) {
|
||||
// Strona główna z System Pages — edytor może ją zmienić bez zmiany kodu.
|
||||
const settings = (await getSettings(locale)) as SiteSetting
|
||||
const homepage = settings.homepage
|
||||
return homepage && typeof homepage === 'object' ? homepage : null
|
||||
}
|
||||
|
||||
const payload = await getCachedPayload()
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: slugPath } },
|
||||
locale: locale as 'pl' | 'en',
|
||||
depth: 2,
|
||||
limit: 1,
|
||||
})
|
||||
return result.docs[0] ?? null
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## 10. Trasy
|
||||
|
||||
Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje
|
||||
layout locale, bo `<html lang>` musi znać język, a `(frontend)` jest ponad
|
||||
segmentem `[locale]`. Każdy trafia na ścieżkę z locale — middleware przekierowuje.
|
||||
|
||||
```
|
||||
src/app/(frontend)/
|
||||
styles.css
|
||||
[locale]/
|
||||
layout.tsx
|
||||
[[...slug]]/
|
||||
page.tsx
|
||||
```
|
||||
|
||||
`[[...slug]]` — **podwójne** nawiasy. Pojedyncze `[slug]` dają string zamiast
|
||||
tablicy (`slug.join is not a function`) i nie łapią samego `/pl`.
|
||||
|
||||
```tsx
|
||||
// src/app/(frontend)/[locale]/layout.tsx
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getConsentTexts, getAnalyticsConfig } from 'ipal-kit'
|
||||
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from 'ipal-kit/client'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { getCachedPayload, getSettings } from '@/lib/payload'
|
||||
import { getConfiguredLocales } from '@/lib/locales'
|
||||
import '../styles.css'
|
||||
|
||||
export default async function LocaleLayout({ children, params }) {
|
||||
const { locale } = await params
|
||||
const locales = await getConfiguredLocales()
|
||||
if (!locales.includes(locale)) notFound()
|
||||
|
||||
const payload = await getCachedPayload()
|
||||
const settings = await getSettings(locale)
|
||||
const privacyPage = (settings as { privacyPolicy?: unknown }).privacyPolicy
|
||||
|
||||
const [texts, analytics] = await Promise.all([
|
||||
getConsentTexts({
|
||||
config: i18nConfig,
|
||||
locale,
|
||||
payload,
|
||||
privacyPolicy:
|
||||
privacyPage && typeof privacyPage === 'object'
|
||||
? { page: privacyPage, label: 'Polityka prywatności' }
|
||||
: undefined,
|
||||
}),
|
||||
getAnalyticsConfig(payload),
|
||||
])
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<ConsentProvider texts={texts}>
|
||||
<main>{children}</main>
|
||||
<CookieBanner />
|
||||
<CookieButton />
|
||||
<Analytics {...analytics} />
|
||||
</ConsentProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const locales = await getConfiguredLocales()
|
||||
return locales.map((locale) => ({ locale }))
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/app/(frontend)/[locale]/[[...slug]]/page.tsx
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { RenderBlocks } from 'ipal-kit/rsc'
|
||||
import { createPageMetadata } from 'ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { blockRegistry } from '@/blocks/registry'
|
||||
import { getCachedPayload } from '@/lib/payload'
|
||||
import { resolvePage } from '@/lib/pages'
|
||||
|
||||
const pageMetadata = createPageMetadata({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
})
|
||||
|
||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
const { locale, slug } = await params
|
||||
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
||||
}
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const { locale, slug } = await params
|
||||
const page = await resolvePage(locale, slug?.length ? slug.join('/') : null)
|
||||
if (!page) notFound()
|
||||
|
||||
return <RenderBlocks blocks={page.layout as never} components={blockRegistry} />
|
||||
}
|
||||
```
|
||||
|
||||
## 11. Środowisko
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DATABASE_URL=file:./moj-projekt.db
|
||||
PAYLOAD_SECRET=<losowy-ciąg>
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne.
|
||||
|
||||
## 12. Generowanie i start
|
||||
|
||||
```bash
|
||||
pnpm generate:types
|
||||
pnpm payload generate:importmap # pola SEO to komponenty admina
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`generate:importmap` powtarzaj po każdej zmianie, która dokłada komponenty
|
||||
admina.
|
||||
|
||||
## 13. Konfiguracja w panelu
|
||||
|
||||
`http://localhost:3000/admin`
|
||||
|
||||
1. **Utwórz pierwszego użytkownika** (dostanie rolę admin).
|
||||
2. **Site Settings → General** — nazwa witryny, kolejność i separator tytułu.
|
||||
3. **Pages** — utwórz stronę główną. Wypełnij tytuł **w każdym locale**
|
||||
(przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza
|
||||
404 na `/en/…`.
|
||||
4. **Site Settings → System Pages** — wskaż Homepage. Bez tego `/pl` da 404.
|
||||
5. **Cookie Settings** — treść bannera (bez tego lecą angielskie domyślne).
|
||||
|
||||
Wejdź na `/` — powinno przekierować na `/pl` i pokazać stronę.
|
||||
|
||||
---
|
||||
|
||||
## Rzeczy opcjonalne
|
||||
|
||||
### Formularz z Turnstile
|
||||
|
||||
Wymaga bloku formularza w projekcie (patrz forms.md) oraz:
|
||||
|
||||
- **Site Integrations → Turnstile** — site key i secret. Klucze testowe
|
||||
Cloudflare (zawsze przechodzą): site `1x00000000000000000000AA`, secret
|
||||
`1x0000000000000000000000000000000AA`.
|
||||
- **Site Integrations → SMTP** — host, port, user, hasło, adres nadawcy.
|
||||
- **Forms → dany formularz → Emails** — odbiorca, temat, treść (`{{*:table}}`
|
||||
wypisze wszystkie pola tabelką). Maile wysyła form-builder przez
|
||||
`panelSmtpAdapter` — nie pisze się ich w kodzie.
|
||||
|
||||
|
||||
### Blog / archiwum (kolekcja pod stroną-archiwum)
|
||||
|
||||
Pełny opis: content.md. W skrócie:
|
||||
|
||||
1. **Kolekcja** `src/collections/Posts.ts` — tytuł (localized), `buildSlugField`,
|
||||
pola, bloki. Dodaj ją do `collections` w payload.config.
|
||||
|
||||
2. **content.config.ts** obok i18n.config.ts:
|
||||
```ts
|
||||
import type { ContentOption } from 'ipal-kit'
|
||||
export const contentConfig: ContentOption = {
|
||||
collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }],
|
||||
}
|
||||
```
|
||||
|
||||
3. **payload.config** — `content: contentConfig`, plus `posts` w `seo.collections`.
|
||||
|
||||
4. **Front** — `createContentHelpers` w `src/lib/content.ts`, `resolveRoute`
|
||||
w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList).
|
||||
|
||||
5. **Baza + typy** — nowa kolekcja to nowy schemat:
|
||||
```bash
|
||||
rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev
|
||||
```
|
||||
|
||||
6. **W panelu** — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok
|
||||
listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy.
|
||||
|
||||
Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny
|
||||
typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config.
|
||||
|
||||
### Analytics
|
||||
|
||||
**Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują
|
||||
się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie
|
||||
zaakceptuje kategorii Analytics.
|
||||
|
||||
### Przestylowanie pod klienta
|
||||
|
||||
```css
|
||||
/* styles.css */
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
Pełna lista tokenów: consent.md.
|
||||
|
||||
---
|
||||
|
||||
## Kiedy coś nie działa
|
||||
|
||||
| Objaw | Przyczyna |
|
||||
|---|---|
|
||||
| Pusty tab SEO / brak kolekcji Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
|
||||
| `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
|
||||
| Banner bez stylów | brak `@source` na `node_modules/ipal-kit` albo brak Tailwinda |
|
||||
| `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline |
|
||||
| `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` |
|
||||
| `/pl` → 404 | Homepage nieustawiony w System Pages |
|
||||
| `/en/cokolwiek` → 404, `/pl/cokolwiek` działa | pusty tytuł (a więc i slug) w locale EN |
|
||||
| `Missing <html> and <body>` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma |
|
||||
| `SQLITE_ERROR: index … already exists` | zmiana schematu — usuń `*.db *.db-shm *.db-wal` |
|
||||
| Zmiany w pluginie nie widać | Turbopack cache — `rm -rf .next` |
|
||||
| Maile nie wychodzą | brak `email: panelSmtpAdapter()` w configu albo pusty SMTP w panelu |
|
||||
| GTM ładuje się, brak `_ga` | pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 |
|
||||
| `/pl/artykuly` → 404 | strona nieprzypisana jako archiwum w System Pages |
|
||||
| brak pola „archive page" w panelu | brak `content` w configu albo `generate:importmap` po dodaniu |
|
||||
| wpis 404 mimo że istnieje | slug pusty w tym locale — wypełnij tytuł w danym języku |
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# i18n
|
||||
|
||||
Lokalizacja: konfiguracja locali dla Payload, negocjacja języka
|
||||
(cookie / Accept-Language / default), budowanie ścieżek locale-aware,
|
||||
przełączanie języka bez 404.
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
i18n: {
|
||||
defaultLocale: 'pl',
|
||||
locales: [
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
// { code: 'ar', label: 'العربية', rtl: true },
|
||||
],
|
||||
// fallback: true, // domyślnie true
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Plugin ustawia `config.localization` z tego. Walidacja jest eager (fail-fast
|
||||
przy starcie): pusta lista, duplikaty kodów, `defaultLocale` spoza listy,
|
||||
zły format kodu → błąd `[ipal] i18n: ...`.
|
||||
|
||||
## Front — helpery
|
||||
|
||||
Wszystkie helpery są czyste (przyjmują config jako argument). Trzymaj swój
|
||||
`i18nConfig` w jednym miejscu i importuj gdzie trzeba.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getLocaleCodes, getDefaultLocale, isValidLocale, getLocaleDefinition,
|
||||
negotiateLocale, buildLocalizedPath, switchLocalePath,
|
||||
getLocalizedSlugs, LOCALE_COOKIE_NAME,
|
||||
} from 'ipal-kit'
|
||||
|
||||
const config = { defaultLocale: 'pl', locales: [{code:'pl',label:'Polski'},{code:'en',label:'English'}] }
|
||||
|
||||
getLocaleCodes(config) // ['pl', 'en']
|
||||
isValidLocale('de', config) // false
|
||||
```
|
||||
|
||||
### Budowanie ścieżek
|
||||
|
||||
```ts
|
||||
// dokument pobrany z locale:'all' → slug to mapa { pl, en }
|
||||
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
|
||||
const slugs = getLocalizedSlugs({ slugField: doc.slug, config })
|
||||
// { pl: 'o-nas', en: 'about' }
|
||||
|
||||
buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about'
|
||||
// home slug ('home') zwija się do roota:
|
||||
buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en'
|
||||
```
|
||||
|
||||
### Przełącznik języka (bez 404)
|
||||
|
||||
```ts
|
||||
// /pl/strona-glowna → klik EN → /en/home (albo /en jeśli brak tłumaczenia)
|
||||
const href = switchLocalePath({ slugs, targetLocale: 'en', config })
|
||||
router.push(href)
|
||||
```
|
||||
|
||||
`switchLocalePath` nigdy nie zwraca undefined — brak slug w danym locale →
|
||||
fallback na `/{locale}` (root), zamiast dead-endu na 404.
|
||||
|
||||
## Middleware — patrz osobno
|
||||
|
||||
Negocjacja locale + redirect na wejściu (`domena.com` → `/pl`) jest w
|
||||
`ipal-kit/next/middleware`. Zobacz [middleware w tej sekcji](#middleware)
|
||||
niżej.
|
||||
|
||||
## Middleware
|
||||
|
||||
```ts
|
||||
// next-middleware.ts (projekt klienta) — jedyna logika to podłączenie
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from 'ipal-kit/next/middleware'
|
||||
|
||||
const i18nConfig = {
|
||||
defaultLocale: 'pl',
|
||||
locales: [{ code: 'pl', label: 'Polski' }, { code: 'en', label: 'English' }],
|
||||
}
|
||||
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
|
||||
|
||||
export function middleware(req) {
|
||||
const r = localeMiddleware(req)
|
||||
if (r.type === 'next') return NextResponse.next()
|
||||
const res = NextResponse.redirect(r.location)
|
||||
res.cookies.set(r.cookie.name, r.cookie.value)
|
||||
return res
|
||||
}
|
||||
|
||||
export const config = { matcher: DEFAULT_MIDDLEWARE_MATCHER }
|
||||
```
|
||||
|
||||
Zachowanie:
|
||||
- ścieżka z locale (`/pl/...`) → przepuść
|
||||
- root albo ścieżka bez locale (`/`, `/o-nas`) → redirect na `/{locale}...`,
|
||||
locale z: cookie → Accept-Language → default
|
||||
- wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`)
|
||||
|
||||
`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne.
|
||||
@@ -0,0 +1,55 @@
|
||||
# pages
|
||||
|
||||
System pages: centralne przypisanie ról (homepage / privacyPolicy /
|
||||
cookiePolicy) do dokumentów z klienckiej kolekcji Pages, w SiteSettings.
|
||||
Front rozwiązuje rolę na ścieżkę locale-aware.
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
pages: {
|
||||
slug: 'pages', // slug Twojej kolekcji Pages
|
||||
// roles: ['homepage', 'privacyPolicy', 'cookiePolicy'], // domyślnie wszystkie
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Dodaje tab **System Pages** w SiteSettings z polami relationship do Twojej
|
||||
kolekcji Pages. Edytor wybiera, który dokument pełni którą rolę. Plugin nie
|
||||
zna Twojej kolekcji — slug podajesz w opcji.
|
||||
|
||||
## Front — rozwiązanie ścieżki roli
|
||||
|
||||
```ts
|
||||
import { getSystemPagePath } from 'ipal-kit'
|
||||
|
||||
// SiteSettings z locale:'all' + depth:1 (żeby relationship był obiektem, nie ID)
|
||||
const settings = await payload.findGlobal({
|
||||
slug: 'site-settings', locale: 'all', depth: 1,
|
||||
})
|
||||
|
||||
// homepage w locale 'pl'
|
||||
getSystemPagePath({ page: settings.homepage, locale: 'pl', config })
|
||||
// → '/pl' (home slug zwija się do roota) lub '/pl/strona-glowna'
|
||||
|
||||
// polityka prywatności w 'en'
|
||||
getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })
|
||||
// → '/en/privacy-policy'
|
||||
```
|
||||
|
||||
Zwraca `undefined`, gdy rola nieprzypisana lub dokument nie ma slug w danym
|
||||
locale — caller decyduje o fallbacku (404, redirect).
|
||||
|
||||
## Typowy przypadek: `/{locale}` → strona główna
|
||||
|
||||
W trasie `[locale]/page.tsx` czytasz `settings.homepage`, bierzesz jego slug
|
||||
w bieżącym locale i renderujesz ten dokument. Link do polityki prywatności w
|
||||
stopce / bannerze cookies bierzesz z `getSystemPagePath({ role: privacyPolicy })`.
|
||||
|
||||
## Role
|
||||
|
||||
```ts
|
||||
import { ALL_SYSTEM_PAGE_ROLES } from 'ipal-kit'
|
||||
// ['homepage', 'privacyPolicy', 'cookiePolicy']
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# payload-helpers
|
||||
|
||||
Typowany dostęp do globali pluginu (SiteSettings, SiteIntegrations) przez
|
||||
Local API. Zgodnie z zasadą: helpery przyjmują `payload` jako argument —
|
||||
plugin nigdy nie woła `getPayload` sam.
|
||||
|
||||
## Config
|
||||
|
||||
Brak — te globale są zawsze budowane przez plugin. Nie ma osobnej opcji.
|
||||
|
||||
## Front — odczyt globali
|
||||
|
||||
```ts
|
||||
import { getSiteSettings, getSiteIntegrations } from 'ipal-kit'
|
||||
import type { SiteSetting, SiteIntegration } from '@/payload-types'
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// SiteSettings (publiczne — siteName, logo, favicon, theme, system pages)
|
||||
const settings = await getSiteSettings<SiteSetting>(payload, { locale: 'pl' })
|
||||
|
||||
// SiteIntegrations (admin-only; Local API omija access control)
|
||||
const integrations = await getSiteIntegrations<SiteIntegration>(payload)
|
||||
```
|
||||
|
||||
Generyk `<T>` pozwala wstrzyknąć wygenerowany typ klienta. Bez niego zwraca
|
||||
`Record<string, unknown>`.
|
||||
|
||||
## ⚠️ SiteIntegrations zawiera sekrety
|
||||
|
||||
Local API domyślnie omija access control (`overrideAccess: true`), więc
|
||||
`getSiteIntegrations` **zwróci sekrety** (SMTP password, Turnstile secret,
|
||||
R2 keys) mimo bariery admin-only na globalu. To zamierzone — logika serwerowa
|
||||
tego potrzebuje.
|
||||
|
||||
**Nigdy nie przekazuj surowego wyniku do przeglądarki.** Czytaj konkretne
|
||||
wartości server-side, do klienta wysyłaj tylko bezpieczne (np. `turnstileSiteKey`,
|
||||
nie `turnstileSecretKey`):
|
||||
|
||||
```ts
|
||||
// ŹLE — wyciek sekretów do klienta
|
||||
return <Form data={await getSiteIntegrations(payload)} />
|
||||
|
||||
// DOBRZE — tylko publiczna wartość
|
||||
const { turnstileSiteKey } = await getSiteIntegrations(payload)
|
||||
return <Form siteKey={turnstileSiteKey} />
|
||||
```
|
||||
|
||||
## Niższy poziom: getGlobal
|
||||
|
||||
```ts
|
||||
import { getGlobal } from 'ipal-kit'
|
||||
const data = await getGlobal<MyType>(payload, 'moj-global', { locale: 'pl', depth: 1 })
|
||||
```
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# seo
|
||||
|
||||
Wpina `@payloadcms/plugin-seo` (pola meta w kolekcjach) i dodaje warstwę
|
||||
logiki: składanie tytułów, budowanie `Metadata` dla Next.js z canonical i
|
||||
hreflang, auto-fill pustych meta z treści dokumentu.
|
||||
|
||||
## Zależność
|
||||
|
||||
Dodaj do `dependencies` (pin do wersji payload):
|
||||
|
||||
```json
|
||||
"dependencies": { "@payloadcms/plugin-seo": "3.84.1" }
|
||||
```
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
seo: {
|
||||
collections: ['pages', 'posts'], // które kolekcje dostają meta
|
||||
// generateTitle: ({ doc }) => `${doc.title}`, // opcjonalne
|
||||
// generateDescription: ({ doc }) => doc.excerpt ?? '',
|
||||
// fields: [...], // extra pola w grupie SEO
|
||||
// autoFill: { title: 'title', description: 'excerpt' }, // mapowanie auto-fill
|
||||
// autoFill: false, // wyłącz auto-fill
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Dodaje tab **SEO** (title, description, image) do wskazanych kolekcji.
|
||||
Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola dokumentu
|
||||
(domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał ręcznie.
|
||||
|
||||
## Front — generateMetadata (factory)
|
||||
|
||||
Najprościej: factory redukuje boilerplate. Klient podaje resolvery (bo zna
|
||||
swoje kolekcje/routing), plugin składa metadata.
|
||||
|
||||
```ts
|
||||
// app/(frontend)/[locale]/[[...segments]]/page.tsx
|
||||
import { createMetadataGenerator } from 'ipal-kit'
|
||||
import { getSiteSettings } from 'ipal-kit'
|
||||
|
||||
const gen = createMetadataGenerator({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
resolveDocument: async ({ payload, params, locale }) => {
|
||||
const slug = /* z params */ ''
|
||||
const res = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: slug } },
|
||||
locale: 'all', depth: 1, limit: 1,
|
||||
})
|
||||
return res.docs[0] ?? null // musi mieć .meta i .slug (locale:'all')
|
||||
},
|
||||
resolveSiteName: async ({ payload, locale }) =>
|
||||
(await getSiteSettings(payload, { locale })).siteName ?? null,
|
||||
})
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const payload = await getPayload({ config })
|
||||
const { locale } = await params
|
||||
return gen({ payload, params: await params, locale })
|
||||
}
|
||||
```
|
||||
|
||||
## Front — niżej: buildMetadata bezpośrednio
|
||||
|
||||
Jeśli chcesz pełną kontrolę zamiast factory:
|
||||
|
||||
```ts
|
||||
import { buildMetadata, getLocalizedSlugs } from 'ipal-kit'
|
||||
|
||||
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all', depth: 1 })
|
||||
|
||||
return buildMetadata({
|
||||
meta: doc.meta, // z plugin-seo
|
||||
siteName: settings.siteName,
|
||||
imageUrl: /* url OG image */,
|
||||
locale: 'pl',
|
||||
slugs: getLocalizedSlugs({ slugField: doc.slug, config }),
|
||||
config,
|
||||
baseUrl: 'https://example.com',
|
||||
})
|
||||
// → { title, description, openGraph, alternates: { canonical, languages } }
|
||||
```
|
||||
|
||||
## Pomocnicze
|
||||
|
||||
```ts
|
||||
import { composeTitle, buildHreflangAlternates } from 'ipal-kit'
|
||||
|
||||
composeTitle({ pageTitle: 'O nas', siteName: 'Acme' }) // 'O nas | Acme'
|
||||
buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' }
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# turnstile
|
||||
|
||||
Cloudflare Turnstile: widget (client) + weryfikacja (server). Klucze z
|
||||
SiteIntegrations (panel, nie env). Widget dostaje `siteKey` jako prop; verify
|
||||
czyta secret server-side.
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji — pola `turnstileSiteKey` / `turnstileSecretKey` są w
|
||||
SiteIntegrations (tab Turnstile). Edytor wpisuje klucze w panelu.
|
||||
|
||||
## Front — widget (client)
|
||||
|
||||
Import z `ipal-kit/client`. `siteKey` pobierz server-side i przekaż jako prop:
|
||||
|
||||
```tsx
|
||||
// server component — pobiera publiczny siteKey z panelu
|
||||
import { getSiteIntegrations } from 'ipal-kit'
|
||||
|
||||
const { turnstileSiteKey } = await getSiteIntegrations(payload)
|
||||
// przekaż do swojego client-formularza → widget
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { Turnstile } from 'ipal-kit/client'
|
||||
import { useState } from 'react'
|
||||
|
||||
function ContactForm({ siteKey }) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
return (
|
||||
<form>
|
||||
{/* pola */}
|
||||
<Turnstile siteKey={siteKey} onToken={setToken} theme="auto" />
|
||||
<button disabled={!token}>Wyślij</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Widget ładuje skrypt Turnstile sam (bez `next/script`), zwraca token przez
|
||||
`onToken` (null przy wygaśnięciu/błędzie).
|
||||
|
||||
## Front — verify (server)
|
||||
|
||||
```ts
|
||||
import { verifyTurnstile } from 'ipal-kit/server'
|
||||
|
||||
const ok = await verifyTurnstile({ token, payload, ip })
|
||||
if (!ok) {
|
||||
// odrzuć zgłoszenie
|
||||
}
|
||||
```
|
||||
|
||||
`verifyTurnstile` czyta secret z SiteIntegrations (Local API), woła Cloudflare.
|
||||
Zwraca `false` na każdy problem (brak klucza, sieć, odrzucenie) — traktuj
|
||||
`false` jako „nie ufaj temu zgłoszeniu". `server-only` gwarantuje, że nie
|
||||
trafi do bundla przeglądarki.
|
||||
|
||||
> W formularzach zwykle nie wołasz `verifyTurnstile` wprost — robi to
|
||||
> `submitForm` (patrz [forms.md](./forms.md)).
|
||||
+32
-4
@@ -19,6 +19,16 @@
|
||||
"import": "./src/exports/rsc.ts",
|
||||
"types": "./src/exports/rsc.ts",
|
||||
"default": "./src/exports/rsc.ts"
|
||||
},
|
||||
"./server": {
|
||||
"import": "./src/exports/server.ts",
|
||||
"types": "./src/exports/server.ts",
|
||||
"default": "./src/exports/server.ts"
|
||||
},
|
||||
"./next/middleware": {
|
||||
"import": "./src/exports/next-middleware.ts",
|
||||
"types": "./src/exports/next-middleware.ts",
|
||||
"default": "./src/exports/next-middleware.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
@@ -44,9 +54,16 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:int": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/plugin-form-builder": "3.84.1",
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"lucide-react": "^0.400.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"slugify": "^1.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@payloadcms/db-mongodb": "3.84.1",
|
||||
"@payloadcms/db-postgres": "3.84.1",
|
||||
"@payloadcms/db-sqlite": "3.84.1",
|
||||
"@payloadcms/eslint-config": "3.28.0",
|
||||
@@ -57,6 +74,7 @@
|
||||
"@swc-node/register": "1.10.9",
|
||||
"@swc/cli": "0.6.0",
|
||||
"@types/node": "22.19.9",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"copyfiles": "2.4.1",
|
||||
@@ -80,7 +98,8 @@
|
||||
"vitest": "4.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.84.1"
|
||||
"payload": "^3.84.1",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0",
|
||||
@@ -102,6 +121,16 @@
|
||||
"import": "./dist/exports/rsc.js",
|
||||
"types": "./dist/exports/rsc.d.ts",
|
||||
"default": "./dist/exports/rsc.js"
|
||||
},
|
||||
"./server": {
|
||||
"import": "./dist/exports/server.js",
|
||||
"types": "./dist/exports/server.d.ts",
|
||||
"default": "./dist/exports/server.js"
|
||||
},
|
||||
"./next/middleware": {
|
||||
"import": "./dist/exports/next-middleware.js",
|
||||
"types": "./dist/exports/next-middleware.d.ts",
|
||||
"default": "./dist/exports/next-middleware.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
@@ -114,6 +143,5 @@
|
||||
"unrs-resolver"
|
||||
]
|
||||
},
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"dependencies": {}
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
|
||||
Generated
+100
-138
@@ -7,13 +7,29 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@payloadcms/plugin-form-builder':
|
||||
specifier: 3.84.1
|
||||
version: 3.84.1(@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
'@payloadcms/plugin-seo':
|
||||
specifier: 3.84.1
|
||||
version: 3.84.1(@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
lucide-react:
|
||||
specifier: ^0.400.0
|
||||
version: 0.400.0([email protected])
|
||||
nodemailer:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.11
|
||||
server-only:
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1
|
||||
slugify:
|
||||
specifier: ^1.6.6
|
||||
version: 1.6.9
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.2.0
|
||||
version: 3.3.5
|
||||
'@payloadcms/db-mongodb':
|
||||
specifier: 3.84.1
|
||||
version: 3.84.1([email protected]([email protected])([email protected]))
|
||||
'@payloadcms/db-postgres':
|
||||
specifier: 3.84.1
|
||||
version: 3.84.1(@libsql/[email protected])([email protected]([email protected])([email protected]))
|
||||
@@ -44,6 +60,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 22.19.9
|
||||
version: 22.19.9
|
||||
'@types/nodemailer':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
'@types/react':
|
||||
specifier: 19.2.14
|
||||
version: 19.2.14
|
||||
@@ -1747,11 +1766,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-HTP/Z6iQHFyHhuMImAC/GH0xGhjuvuHZHdB7crJUkXAyD677tp6C3mS8YB04s28bCteDqcXBYjHODZr5xDHBcw==}
|
||||
peerDependencies:
|
||||
payload: 3.84.1
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-/r1+7k58529ziTwqzySXsZb4x3FcfQIQH8R6gXLM3bTU9JA8wfLBraIgSyVKl6B3p+/EQYcaQ1DUlfrzVRxo1A==}
|
||||
peerDependencies:
|
||||
@@ -1788,6 +1802,20 @@ packages:
|
||||
next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.2 <17.0.0'
|
||||
payload: 3.84.1
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-pEr4QFceswL+3dpZWtzlLHyED8nxk7iukm/eNUxNn9vaUfb2xcqzQf6xRmoJra9R1yDx6mdC/DU+Whg5Op/yfQ==}
|
||||
peerDependencies:
|
||||
payload: 3.84.1
|
||||
react: ^19.0.1 || ^19.1.2 || ^19.2.1
|
||||
react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-9FYs5ML/eWR/A/rQfHt2NhPzkJWbUx5SN/+lEQ90r3c3Z8CQUVpt4vETXSI9Gxi764lTutIGemuZAJK9WRy3Lw==}
|
||||
peerDependencies:
|
||||
payload: 3.84.1
|
||||
react: ^19.0.1 || ^19.1.2 || ^19.2.1
|
||||
react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-KaNSz0RJFLnLc/hBRGg8Lgwk5FjCZSskA6KuufNWG5QdgUsgQe4Dx4Vr7G+VSR9zSZj+R4TVVSC0aVv4z7vL3g==}
|
||||
engines: {node: ^18.20.2 || >=20.9.0}
|
||||
@@ -2162,6 +2190,9 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
||||
|
||||
@@ -4110,10 +4141,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -4180,6 +4207,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -4352,33 +4384,6 @@ packages:
|
||||
resolution: {integrity: sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
peerDependencies:
|
||||
'@aws-sdk/credential-providers': ^3.188.0
|
||||
'@mongodb-js/zstd': ^1.1.0 || ^2.0.0
|
||||
gcp-metadata: ^5.2.0
|
||||
kerberos: ^2.0.1
|
||||
mongodb-client-encryption: '>=6.0.0 <7'
|
||||
snappy: ^7.2.2
|
||||
socks: ^2.7.1
|
||||
peerDependenciesMeta:
|
||||
'@aws-sdk/credential-providers':
|
||||
optional: true
|
||||
'@mongodb-js/zstd':
|
||||
optional: true
|
||||
gcp-metadata:
|
||||
optional: true
|
||||
kerberos:
|
||||
optional: true
|
||||
mongodb-client-encryption:
|
||||
optional: true
|
||||
snappy:
|
||||
optional: true
|
||||
socks:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
@@ -4406,32 +4411,6 @@ packages:
|
||||
socks:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8chOqpVE3bcoWT2pIgcJeIZlXaOfQCavZgQZF4qytUtjRBqsNMyzUoR16qdw9XL2kC478N8iA8z0AA+NSS0d1A==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
peerDependencies:
|
||||
mongoose: '>=5.11.10'
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0LOsVEQmjrbJKVDi/IvFEhIezmuRjUE4loGgslv57j9nK/NMC+mbKT0QnaPSPpib4lByKVBcy3VbDa1TvlHZjA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RhQ4DzmBi5BNGcS0w4u1vdMRIKcteXTCNzDt1j7XRcdWYBz1MjMjulBhPaeC5jBCHOD1yinuOFTTSOWLLGexWw==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -4493,6 +4472,10 @@ packages:
|
||||
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==}
|
||||
|
||||
@@ -5005,6 +4988,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5049,9 +5035,6 @@ packages:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@@ -5071,6 +5054,10 @@ packages:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||
|
||||
@@ -7044,23 +7031,6 @@ snapshots:
|
||||
'@oxc-resolver/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@payloadcms/[email protected]([email protected]([email protected])([email protected]))':
|
||||
dependencies:
|
||||
mongoose: 8.15.1
|
||||
mongoose-paginate-v2: 1.9.4([email protected])
|
||||
payload: 3.84.1([email protected])([email protected])
|
||||
prompts: 2.4.2
|
||||
uuid: 11.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
|
||||
'@payloadcms/[email protected](@libsql/[email protected])([email protected]([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@payloadcms/drizzle': 3.84.1(@libsql/[email protected])(@types/[email protected])([email protected]([email protected])([email protected]))([email protected])
|
||||
@@ -7289,6 +7259,34 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@payloadcms/[email protected](@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@payloadcms/ui': 3.84.1(@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
escape-html: 1.0.3
|
||||
payload: 3.84.1([email protected])([email protected])
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6([email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- monaco-editor
|
||||
- next
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@payloadcms/[email protected](@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@payloadcms/translations': 3.84.1
|
||||
'@payloadcms/ui': 3.84.1(@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
payload: 3.84.1([email protected])([email protected])
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6([email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- monaco-editor
|
||||
- next
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@payloadcms/[email protected](@faceless-ui/[email protected]([email protected]([email protected]))([email protected]))(@faceless-ui/[email protected]([email protected]([email protected]))([email protected]))(@payloadcms/[email protected](@types/[email protected])([email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected]))(@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@faceless-ui/modal': 3.0.0([email protected]([email protected]))([email protected])
|
||||
@@ -7649,6 +7647,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/node': 22.19.9
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
@@ -8881,7 +8883,7 @@ snapshots:
|
||||
eslint: 9.39.4
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-import-resolver-typescript: 3.10.1([email protected]([email protected])([email protected]))([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected])
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]([email protected]([email protected])([email protected]))([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected]))([email protected])
|
||||
eslint-plugin-jsx-a11y: 6.10.2([email protected])
|
||||
eslint-plugin-react: 7.37.5([email protected])
|
||||
eslint-plugin-react-hooks: 7.1.1([email protected])
|
||||
@@ -8918,7 +8920,7 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
unrs-resolver: 1.12.2
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]([email protected]([email protected])([email protected]))([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected]))([email protected])
|
||||
eslint-plugin-import-x: 4.6.1([email protected])([email protected])
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -8975,7 +8977,7 @@ snapshots:
|
||||
- typescript
|
||||
optional: true
|
||||
|
||||
[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected]):
|
||||
[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]([email protected]([email protected])([email protected]))([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -9971,8 +9973,6 @@ snapshots:
|
||||
object.assign: 4.1.7
|
||||
object.values: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -10037,6 +10037,10 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -10370,58 +10374,12 @@ snapshots:
|
||||
- socks
|
||||
- supports-color
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@mongodb-js/saslprep': 1.4.12
|
||||
bson: 6.10.4
|
||||
mongodb-connection-string-url: 3.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@mongodb-js/saslprep': 1.4.12
|
||||
bson: 6.10.4
|
||||
mongodb-connection-string-url: 3.0.2
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
mongoose: 8.15.1
|
||||
mpath: 0.8.4
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
mongoose-lean-virtuals: 1.1.1([email protected])
|
||||
transitivePeerDependencies:
|
||||
- mongoose
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
bson: 6.10.4
|
||||
kareem: 2.6.3
|
||||
mongodb: 6.16.0
|
||||
mpath: 0.9.0
|
||||
mquery: 5.0.0
|
||||
ms: 2.1.3
|
||||
sift: 17.1.3
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -10481,6 +10439,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
@@ -11088,6 +11048,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -11204,8 +11166,6 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -11220,6 +11180,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
'use client'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
import { formatAdminURL } from 'payload/shared'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const BeforeDashboardClient = () => {
|
||||
const { config } = useConfig()
|
||||
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessage = async () => {
|
||||
const response = await fetch(
|
||||
formatAdminURL({
|
||||
apiRoute: config.routes.api,
|
||||
path: '/my-plugin-endpoint',
|
||||
}),
|
||||
)
|
||||
const result = await response.json()
|
||||
setMessage(result.message)
|
||||
}
|
||||
|
||||
void fetchMessage()
|
||||
}, [config.serverURL, config.routes.api])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Added by the plugin: Before Dashboard Client</h1>
|
||||
<div>
|
||||
Message from the endpoint:
|
||||
<div>{message || 'Loading...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ServerComponentProps } from 'payload'
|
||||
|
||||
import styles from './BeforeDashboardServer.module.css'
|
||||
|
||||
export const BeforeDashboardServer = async (props: ServerComponentProps) => {
|
||||
const { payload } = props
|
||||
|
||||
const { docs } = await payload.find({ collection: 'plugin-collection' })
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<h1>Added by the plugin: Before Dashboard Server</h1>
|
||||
Docs from Local API:
|
||||
{docs.map((doc) => (
|
||||
<div key={doc.id}>{doc.id}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PayloadHandler } from 'payload'
|
||||
|
||||
export const customEndpointHandler: PayloadHandler = () => {
|
||||
return Response.json({ message: 'Hello from custom endpoint' })
|
||||
}
|
||||
+19
-1
@@ -1 +1,19 @@
|
||||
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
|
||||
'use client'
|
||||
export { Analytics } from '../modules/analytics/client.js'
|
||||
/**
|
||||
* Entry point: ipal-kit/client
|
||||
*
|
||||
* Client-side ('use client') exports — React hooks, providers, and UI
|
||||
* components. Kept separate from the main entry so server bundles don't pull in
|
||||
* client-only code.
|
||||
*/
|
||||
export {
|
||||
ConsentProvider,
|
||||
CookieBanner,
|
||||
CookieButton,
|
||||
useConsent,
|
||||
useConsentContext,
|
||||
} from '../modules/consent/client.js'
|
||||
export type { CookieBannerClassNames } from '../modules/consent/client.js'
|
||||
export { Turnstile } from '../modules/turnstile/client.js'
|
||||
export type { TurnstileProps } from '../modules/turnstile/client.js'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Entry point: ipal-kit/next/middleware
|
||||
*
|
||||
* Re-exports the locale middleware factory and default matcher for use in a
|
||||
* client project's next-middleware.ts. Kept as a thin re-export so the Next-facing
|
||||
* surface is separate from the main package export.
|
||||
*/
|
||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '../modules/i18n/index.js'
|
||||
export type { LocaleMiddlewareResult } from '../modules/i18n/index.js'
|
||||
+14
-1
@@ -1 +1,14 @@
|
||||
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
|
||||
/**
|
||||
* Entry point: ipal-kit/rsc
|
||||
*
|
||||
* Server-component exports. RenderBlocks is a React Server Component, so it
|
||||
* lives here rather than in the main package entry to keep React out of the
|
||||
* server-config bundle.
|
||||
*/
|
||||
export { RenderBlocks } from '../modules/blocks/index.js'
|
||||
export type {
|
||||
BlockComponentMap,
|
||||
BlockData,
|
||||
EnhanceProps,
|
||||
RenderBlocksProps,
|
||||
} from '../modules/blocks/index.js'
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Entry point: ipal-kit/server
|
||||
*
|
||||
* Server-only runtime functions — these import 'server-only' (Turnstile secret,
|
||||
* SMTP password, nodemailer). Kept OUT of the main entry so that loading the
|
||||
* Payload config or running `payload generate:importmap` (both Node scripts,
|
||||
* no bundler) never pulls in 'server-only', which throws outside a bundled
|
||||
* server context.
|
||||
*
|
||||
* Import these only from Server Actions, route handlers, or server components —
|
||||
* never from the Payload config or client code.
|
||||
*/
|
||||
export { verifyTurnstile } from '../modules/turnstile/index.js'
|
||||
export { sendEmail } from '../modules/email/index.js'
|
||||
export type { SendEmailArgs, SendEmailResult } from '../modules/email/index.js'
|
||||
export { submitForm } from '../modules/forms/index.js'
|
||||
export type { SubmitFormArgs, SubmitFormResult } from '../modules/forms/index.js'
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
import { CONSENT_CATEGORIES } from '../../modules/consent/index.js'
|
||||
|
||||
/**
|
||||
* Fields for the CookieSettings global — GDPR consent banner copy, editable
|
||||
* per locale. The privacy-policy link is NOT stored here: it comes from the
|
||||
* pages module (system page role 'privacyPolicy'), keeping one source of truth.
|
||||
*/
|
||||
export const cookieSettingsFields: Field[] = [
|
||||
{
|
||||
name: 'message',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Main consent message shown in the banner.',
|
||||
},
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'settingsTitle',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Heading for the detailed settings panel.',
|
||||
},
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'buttons',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Button labels.',
|
||||
},
|
||||
fields: [
|
||||
{ name: 'acceptAll', type: 'text', localized: true },
|
||||
{ name: 'reject', type: 'text', localized: true },
|
||||
{ name: 'settings', type: 'text', localized: true },
|
||||
{ name: 'save', type: 'text', localized: true },
|
||||
{ name: 'back', type: 'text', localized: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Per-category titles and descriptions.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
type: 'select',
|
||||
options: CONSENT_CATEGORIES.map((c) => ({ label: c, value: c })),
|
||||
required: true,
|
||||
},
|
||||
{ name: 'title', type: 'text', localized: true },
|
||||
{ name: 'description', type: 'textarea', localized: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { cookieSettingsFields } from './fields.js'
|
||||
|
||||
/**
|
||||
* Builds the CookieSettings global — consent banner copy managed by editors.
|
||||
* Public read (the banner needs it on the frontend for anonymous visitors).
|
||||
*/
|
||||
export function buildCookieSettings(): GlobalConfig {
|
||||
return {
|
||||
slug: 'cookie-settings',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
admin: {
|
||||
group: 'Settings',
|
||||
},
|
||||
fields: cookieSettingsFields,
|
||||
label: 'Cookie Settings',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
/**
|
||||
* Analytics configuration — GA4 measurement ID and GTM container ID.
|
||||
* IDs are public (exposed client-side), so no read restriction needed.
|
||||
*/
|
||||
export const analyticsFields: Field[] = [
|
||||
{
|
||||
name: 'ga4MeasurementId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Google Analytics 4 Measurement ID.',
|
||||
placeholder: 'G-XXXXXXXXXX',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gtmContainerId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Google Tag Manager container ID.',
|
||||
placeholder: 'GTM-XXXXXXX',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
/**
|
||||
* SMTP transport settings for outbound email.
|
||||
*
|
||||
* Protected at the global level (SiteIntegrations requires an authenticated
|
||||
* user), so all fields — including the password — stay editable in the admin
|
||||
* panel while remaining inaccessible to anonymous API requests.
|
||||
*/
|
||||
export const smtpFields: Field[] = [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'smtpHost',
|
||||
type: 'text',
|
||||
admin: { placeholder: 'smtp.example.com', width: '70%' },
|
||||
},
|
||||
{
|
||||
name: 'smtpPort',
|
||||
type: 'number',
|
||||
admin: { width: '30%' },
|
||||
defaultValue: 587,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'smtpUser',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'SMTP account username.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtpPassword',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'SMTP account password.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtpFromAddress',
|
||||
type: 'email',
|
||||
admin: {
|
||||
description: 'Default "from" address for outgoing mail.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtpFromName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Default "from" display name.',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
/**
|
||||
* Cloudflare R2 storage credentials.
|
||||
* Reserved for future use — media offloading to R2.
|
||||
*
|
||||
* Protected at the global level (SiteIntegrations requires an authenticated
|
||||
* user), so the access keys stay editable in the admin panel while remaining
|
||||
* inaccessible to anonymous API requests.
|
||||
*/
|
||||
export const storageFields: Field[] = [
|
||||
{
|
||||
name: 'r2Bucket',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 bucket name.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'r2Endpoint',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 S3-compatible endpoint URL.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'r2AccessKeyId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 access key ID.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'r2SecretAccessKey',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 secret access key.',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile credentials.
|
||||
*
|
||||
* siteKey is public (rendered in the widget); secretKey is used for
|
||||
* server-side verification. Both are protected at the global level
|
||||
* (SiteIntegrations requires an authenticated user) rather than per-field,
|
||||
* so they remain editable in the admin panel.
|
||||
*/
|
||||
export const turnstileFields: Field[] = [
|
||||
{
|
||||
name: 'turnstileSiteKey',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Public site key rendered in the Turnstile widget.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'turnstileSecretKey',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Secret key used for server-side verification.',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Field, GlobalConfig } from 'payload'
|
||||
|
||||
import { isAdmin } from '../../modules/access/index.js'
|
||||
import { analyticsFields } from './fields/analytics.js'
|
||||
import { smtpFields } from './fields/smtp.js'
|
||||
import { storageFields } from './fields/storage.js'
|
||||
import { turnstileFields } from './fields/turnstile.js'
|
||||
|
||||
type BuildSiteIntegrationsArgs = {
|
||||
/** Extra fields injected by the client project */
|
||||
additionalFields?: Field[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the SiteIntegrations global.
|
||||
*
|
||||
* Holds third-party service credentials. Access is enforced at the global
|
||||
* level — the whole global requires an authenticated user — so secrets stay
|
||||
* out of anonymous API responses while remaining editable in the admin panel
|
||||
* and readable via the server-side Local API. (Field-level read:false was
|
||||
* avoided because it also hides fields from the admin UI, making them
|
||||
* impossible to enter.)
|
||||
*
|
||||
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
|
||||
*/
|
||||
export function buildSiteIntegrations({
|
||||
additionalFields,
|
||||
}: BuildSiteIntegrationsArgs = {}): GlobalConfig {
|
||||
return {
|
||||
slug: 'site-integrations',
|
||||
access: {
|
||||
// Admin-only — secrets live here. Anonymous and non-admin users get
|
||||
// nothing through the API; admins read/edit in the panel and via Local API.
|
||||
read: ({ req: { user } }) => isAdmin(user),
|
||||
update: ({ req: { user } }) => isAdmin(user),
|
||||
},
|
||||
admin: {
|
||||
group: 'Settings',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{ fields: analyticsFields, label: 'Analytics' },
|
||||
{ fields: turnstileFields, label: 'Turnstile' },
|
||||
{ fields: smtpFields, label: 'SMTP' },
|
||||
{ fields: storageFields, label: 'Storage' },
|
||||
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
label: 'Site Integrations',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
/**
|
||||
* General site identity fields.
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||
*/
|
||||
export const generalFields: Field[] = [
|
||||
{
|
||||
name: 'siteName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Used in page titles and Open Graph metadata.',
|
||||
},
|
||||
localized: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'titleOrder',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'Which comes first in browser tabs.',
|
||||
},
|
||||
defaultValue: 'page-first',
|
||||
options: [
|
||||
{ label: 'Page first — About Us | Acme', value: 'page-first' },
|
||||
{ label: 'Site first — Acme | About Us', value: 'site-first' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'titleSeparator',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'Separates the page title from the site name in browser tabs.',
|
||||
},
|
||||
defaultValue: '|',
|
||||
options: [
|
||||
{ label: 'Pipe — Page | Site', value: '|' },
|
||||
{ label: 'Dash — Page – Site', value: '–' },
|
||||
{ label: 'Hyphen — Page - Site', value: '-' },
|
||||
{ label: 'Bullet — Page · Site', value: '·' },
|
||||
{ label: 'Slash — Page / Site', value: '/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Primary site logo.',
|
||||
},
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'defaultShareImage',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Fallback Open Graph image when a page has none.',
|
||||
},
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'favicon',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.',
|
||||
},
|
||||
relationTo: 'media',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
/**
|
||||
* Theme behavior configuration.
|
||||
*
|
||||
* These are decisions, not visuals — the plugin stores them, the client
|
||||
* template reads them and decides whether to render a toggle and which
|
||||
* mode to start in. Appearance itself stays in the template.
|
||||
*/
|
||||
export const themeFields: Field[] = [
|
||||
{
|
||||
name: 'defaultTheme',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'Theme applied on a visitor’s first visit.',
|
||||
},
|
||||
defaultValue: 'light',
|
||||
options: [
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'allowThemeToggle',
|
||||
type: 'checkbox',
|
||||
admin: {
|
||||
description: 'Show a light/dark switch on the site.',
|
||||
},
|
||||
defaultValue: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Field, GlobalConfig } from 'payload'
|
||||
|
||||
import type { ContentOption } from '../../modules/content/index.js'
|
||||
import type { PagesOption } from '../../modules/pages/index.js'
|
||||
|
||||
import { buildArchiveFields } from '../../modules/content/index.js'
|
||||
import { buildSystemPagesFields } from '../../modules/pages/index.js'
|
||||
import { generalFields } from './fields/general.js'
|
||||
import { themeFields } from './fields/theme.js'
|
||||
|
||||
type BuildSiteSettingsArgs = {
|
||||
/** Extra fields injected by the client project */
|
||||
additionalFields?: Field[]
|
||||
/** Archive-page assignments for content collections — joins the same tab */
|
||||
content?: ContentOption
|
||||
/** System-page assignments — adds a "System Pages" tab when provided */
|
||||
pages?: PagesOption
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the SiteSettings global.
|
||||
*
|
||||
* Uses unnamed tabs — data stays flat (siteSettings.siteName, not
|
||||
* siteSettings.general.siteName). Client-provided fields land in their
|
||||
* own "Custom" tab so core data paths never shift.
|
||||
*/
|
||||
export function buildSiteSettings({
|
||||
additionalFields,
|
||||
content,
|
||||
pages,
|
||||
}: BuildSiteSettingsArgs = {}): GlobalConfig {
|
||||
// Archive assignments sit with the system pages: both answer "which page
|
||||
// plays this role", and both turn into URLs through the page's own slug.
|
||||
const systemPageFields = [
|
||||
...(pages ? buildSystemPagesFields(pages) : []),
|
||||
...(content && pages ? buildArchiveFields(content, pages.slug) : []),
|
||||
]
|
||||
return {
|
||||
slug: 'site-settings',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
admin: {
|
||||
group: 'Settings',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
fields: generalFields,
|
||||
label: 'General',
|
||||
},
|
||||
{
|
||||
fields: themeFields,
|
||||
label: 'Theme',
|
||||
},
|
||||
...(systemPageFields.length ? [{ fields: systemPageFields, label: 'System Pages' }] : []),
|
||||
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
label: 'Site Settings',
|
||||
}
|
||||
}
|
||||
+95
-113
@@ -1,113 +1,95 @@
|
||||
import type { CollectionSlug, Config } from 'payload'
|
||||
|
||||
import { customEndpointHandler } from './endpoints/customEndpointHandler.js'
|
||||
|
||||
export type IpalKitConfig = {
|
||||
/**
|
||||
* List of collections to add a custom field
|
||||
*/
|
||||
collections?: Partial<Record<CollectionSlug, true>>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const ipalKit =
|
||||
(pluginOptions: IpalKitConfig) =>
|
||||
(config: Config): Config => {
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
}
|
||||
|
||||
config.collections.push({
|
||||
slug: 'plugin-collection',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (pluginOptions.collections) {
|
||||
for (const collectionSlug in pluginOptions.collections) {
|
||||
const collection = config.collections.find(
|
||||
(collection) => collection.slug === collectionSlug,
|
||||
)
|
||||
|
||||
if (collection) {
|
||||
collection.fields.push({
|
||||
name: 'addedByPlugin',
|
||||
type: 'text',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations.
|
||||
* If your plugin heavily modifies the database schema, you may want to remove this property.
|
||||
*/
|
||||
if (pluginOptions.disabled) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (!config.endpoints) {
|
||||
config.endpoints = []
|
||||
}
|
||||
|
||||
if (!config.admin) {
|
||||
config.admin = {}
|
||||
}
|
||||
|
||||
if (!config.admin.components) {
|
||||
config.admin.components = {}
|
||||
}
|
||||
|
||||
if (!config.admin.components.beforeDashboard) {
|
||||
config.admin.components.beforeDashboard = []
|
||||
}
|
||||
|
||||
config.admin.components.beforeDashboard.push(
|
||||
`ipal-kit/client#BeforeDashboardClient`,
|
||||
)
|
||||
config.admin.components.beforeDashboard.push(
|
||||
`ipal-kit/rsc#BeforeDashboardServer`,
|
||||
)
|
||||
|
||||
config.endpoints.push({
|
||||
handler: customEndpointHandler,
|
||||
method: 'get',
|
||||
path: '/my-plugin-endpoint',
|
||||
})
|
||||
|
||||
const incomingOnInit = config.onInit
|
||||
|
||||
config.onInit = async (payload) => {
|
||||
// Ensure we are executing any existing onInit functions before running our own.
|
||||
if (incomingOnInit) {
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: 'plugin-collection',
|
||||
where: {
|
||||
id: {
|
||||
equals: 'seeded-by-plugin',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (totalDocs === 0) {
|
||||
await payload.create({
|
||||
collection: 'plugin-collection',
|
||||
data: {
|
||||
id: 'seeded-by-plugin',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
export type { AccessOption, Role } from './modules/access/index.js'
|
||||
export {
|
||||
adminOnly,
|
||||
adminOnlyField,
|
||||
adminOrEditor,
|
||||
adminOrEditorField,
|
||||
adminOrSelf,
|
||||
authenticated,
|
||||
hasMinimumRole,
|
||||
isAdmin,
|
||||
isEditor,
|
||||
requireRole,
|
||||
requireRoleField,
|
||||
ROLE_HIERARCHY,
|
||||
} from './modules/access/index.js'
|
||||
export type { AnalyticsConfig } from './modules/analytics/index.js'
|
||||
export { getAnalyticsConfig } from './modules/analytics/index.js'
|
||||
export {
|
||||
ACCEPT_ALL_CONSENT,
|
||||
CONSENT_CATEGORIES,
|
||||
CONSENT_COOKIE,
|
||||
CONSENT_MAX_AGE,
|
||||
CONSENT_VERSION,
|
||||
DEFAULT_CONSENT,
|
||||
getConsentTexts,
|
||||
parseConsent,
|
||||
REJECT_ALL_CONSENT,
|
||||
serializeConsent,
|
||||
setDefaultConsent,
|
||||
updateConsent,
|
||||
} from './modules/consent/index.js'
|
||||
export type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'
|
||||
export type {
|
||||
ContentCollectionOption,
|
||||
ContentOption,
|
||||
ResolvedRoute,
|
||||
} from './modules/content/index.js'
|
||||
export {
|
||||
archiveFieldName,
|
||||
buildArchivePath,
|
||||
buildEntryPath,
|
||||
getArchiveEntries,
|
||||
parsePageParam,
|
||||
resolveRoute,
|
||||
} from './modules/content/index.js'
|
||||
export type { ArchiveEntries } from './modules/content/index.js'
|
||||
// Imported straight from the file, NOT from ./modules/email/index.js — that
|
||||
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
|
||||
// Payload loads the config (or runs generate:importmap) as a plain Node script.
|
||||
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'
|
||||
export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'
|
||||
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'
|
||||
export type {
|
||||
FormsCollectionOverrides,
|
||||
FormsFieldsOverride,
|
||||
FormsOption,
|
||||
} from './modules/forms/types.js'
|
||||
export { createContentHelpers } from './modules/frontend/index.js'
|
||||
export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'
|
||||
export {
|
||||
buildLocalizedPath,
|
||||
getDefaultLocale,
|
||||
getLocaleCodes,
|
||||
getLocaleDefinition,
|
||||
getLocalizedSlugs,
|
||||
isValidLocale,
|
||||
LOCALE_COOKIE_NAME,
|
||||
matchAcceptLanguage,
|
||||
negotiateLocale,
|
||||
switchLocalePath,
|
||||
} from './modules/i18n/index.js'
|
||||
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
|
||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
|
||||
export type { PagesOption, SystemPageRole } from './modules/pages/index.js'
|
||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'
|
||||
export type { GlobalQueryOptions } from './modules/payload/index.js'
|
||||
export {
|
||||
getGlobal,
|
||||
getSiteIntegrations,
|
||||
getSiteSettings,
|
||||
SITE_INTEGRATIONS_SLUG,
|
||||
SITE_SETTINGS_SLUG,
|
||||
} from './modules/payload/index.js'
|
||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
||||
export type { AutoFillMapping } from './modules/seo/index.js'
|
||||
export {
|
||||
buildAutoFillMetaHook,
|
||||
createMetadataGenerator,
|
||||
createPageMetadata,
|
||||
injectAutoFillMeta,
|
||||
} from './modules/seo/index.js'
|
||||
export { buildSlugField, toSlug } from './modules/slug/index.js'
|
||||
export { default as ipalKit } from './plugin.js'
|
||||
export type { IpalOptions } from './types.js'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Access, FieldAccess } from 'payload'
|
||||
|
||||
import type { Role } from './types.js'
|
||||
|
||||
import { hasMinimumRole, isAdmin } from './predicates.js'
|
||||
|
||||
/**
|
||||
* Collection-level access (returns boolean | Where).
|
||||
* Use in collection `access.read/create/update/delete`.
|
||||
*/
|
||||
export const adminOnly: Access = ({ req: { user } }) => isAdmin(user)
|
||||
|
||||
export const adminOrEditor: Access = ({ req: { user } }) => hasMinimumRole(user, 'editor')
|
||||
|
||||
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
|
||||
|
||||
/** Requires at least the given role. */
|
||||
export const requireRole =
|
||||
(minimum: Role): Access =>
|
||||
({ req: { user } }) =>
|
||||
hasMinimumRole(user, minimum)
|
||||
|
||||
/** Admins see all; others are constrained to their own document. */
|
||||
export const adminOrSelf: Access = ({ req: { user } }) => {
|
||||
if (isAdmin(user)) {return true}
|
||||
if (!user) {return false}
|
||||
return { id: { equals: user.id } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-level access (returns boolean only — no Where support).
|
||||
* Use in field `access.read/update`.
|
||||
*/
|
||||
export const adminOnlyField: FieldAccess = ({ req: { user } }) => isAdmin(user)
|
||||
|
||||
export const adminOrEditorField: FieldAccess = ({ req: { user } }) => hasMinimumRole(user, 'editor')
|
||||
|
||||
/** Requires at least the given role, for field-level access. */
|
||||
export const requireRoleField =
|
||||
(minimum: Role): FieldAccess =>
|
||||
({ req: { user } }) =>
|
||||
hasMinimumRole(user, minimum)
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
adminOnly,
|
||||
adminOnlyField,
|
||||
adminOrEditor,
|
||||
adminOrEditorField,
|
||||
adminOrSelf,
|
||||
authenticated,
|
||||
requireRole,
|
||||
requireRoleField,
|
||||
} from './access.js'
|
||||
export { injectRoles } from './injectRoles.js'
|
||||
export { hasMinimumRole, isAdmin, isEditor } from './predicates.js'
|
||||
export { buildRolesField } from './rolesField.js'
|
||||
export type { AccessOption, Role } from './types.js'
|
||||
export { ROLE_HIERARCHY } from './types.js'
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Config } from 'payload'
|
||||
|
||||
import type { AccessOption } from './types.js'
|
||||
|
||||
import { buildRolesField } from './rolesField.js'
|
||||
|
||||
/**
|
||||
* Injects the fixed `roles` field into the client's auth collection.
|
||||
*
|
||||
* The plugin owns the role definition; the client owns the collection. This
|
||||
* finds the collection by slug and appends the field. We control the whole
|
||||
* stack, so no conflict handling is needed — the field is simply added.
|
||||
*/
|
||||
export function injectRoles(config: Config, access: AccessOption): Config {
|
||||
const rolesField = buildRolesField(access.defaultRole)
|
||||
|
||||
return {
|
||||
...config,
|
||||
collections: (config.collections ?? []).map((collection) => {
|
||||
if (collection.slug !== access.authCollection) {
|
||||
return collection
|
||||
}
|
||||
return {
|
||||
...collection,
|
||||
fields: [...collection.fields, rolesField],
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Role } from './types.js'
|
||||
|
||||
import { ROLE_HIERARCHY } from './types.js'
|
||||
|
||||
/**
|
||||
* The plugin can't know the client's generated User type, and Payload types
|
||||
* `req.user` loosely (UntypedUser | null). Predicates therefore accept an
|
||||
* unknown-ish user and read `roles` defensively — no assumptions about shape
|
||||
* beyond an optional roles array.
|
||||
*/
|
||||
type MaybeUser = { roles?: null | Role[] } | null | Record<string, unknown> | undefined
|
||||
|
||||
/** Safely extracts the roles array from a loosely-typed user. */
|
||||
function getRoles(user: MaybeUser): Role[] {
|
||||
if (!user || typeof user !== 'object') {return []}
|
||||
const roles = (user as { roles?: unknown }).roles
|
||||
if (!Array.isArray(roles)) {return []}
|
||||
return roles.filter((role): role is Role => ROLE_HIERARCHY.includes(role as Role))
|
||||
}
|
||||
|
||||
/** Highest-privilege role index the user holds, or -1 if none. */
|
||||
function highestRoleIndex(user: MaybeUser): number {
|
||||
const roles = getRoles(user)
|
||||
if (!roles.length) {return -1}
|
||||
return Math.max(...roles.map((role) => ROLE_HIERARCHY.indexOf(role)))
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the user holds at least the given role in the hierarchy.
|
||||
* admin satisfies 'editor' and 'user'; editor satisfies 'user'.
|
||||
*/
|
||||
export function hasMinimumRole(user: MaybeUser, minimum: Role): boolean {
|
||||
return highestRoleIndex(user) >= ROLE_HIERARCHY.indexOf(minimum)
|
||||
}
|
||||
|
||||
/** True if the user is an admin. */
|
||||
export function isAdmin(user: MaybeUser): boolean {
|
||||
return hasMinimumRole(user, 'admin')
|
||||
}
|
||||
|
||||
/** True if the user is an editor or higher (editor, admin). */
|
||||
export function isEditor(user: MaybeUser): boolean {
|
||||
return hasMinimumRole(user, 'editor')
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
import type { Role } from './types.js'
|
||||
|
||||
import { isAdmin } from './predicates.js'
|
||||
import { ROLE_HIERARCHY } from './types.js'
|
||||
|
||||
/**
|
||||
* Builds the fixed `roles` field the plugin injects into the auth collection.
|
||||
*
|
||||
* Saved to the JWT so role checks avoid a database lookup. Only admins can
|
||||
* change roles, preventing privilege escalation by lower-privilege users.
|
||||
*/
|
||||
export function buildRolesField(defaultRole: Role = 'user'): Field {
|
||||
return {
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
access: {
|
||||
// Only admins may assign or change roles
|
||||
update: ({ req: { user } }) => isAdmin(user),
|
||||
},
|
||||
admin: {
|
||||
description: 'Role hierarchy: admin > editor > user.',
|
||||
},
|
||||
defaultValue: [defaultRole],
|
||||
hasMany: true,
|
||||
options: ROLE_HIERARCHY.map((role) => ({
|
||||
label: role.charAt(0).toUpperCase() + role.slice(1),
|
||||
value: role,
|
||||
})),
|
||||
required: true,
|
||||
saveToJWT: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Role hierarchy, lowest to highest privilege.
|
||||
* A higher role satisfies any requirement met by a lower one.
|
||||
*/
|
||||
export const ROLE_HIERARCHY = ['user', 'editor', 'admin'] as const
|
||||
|
||||
export type Role = (typeof ROLE_HIERARCHY)[number]
|
||||
|
||||
/**
|
||||
* Access-control options.
|
||||
*
|
||||
* The plugin injects a fixed `roles` field into the client's auth collection
|
||||
* — the collection itself belongs to the client (create-payload-app), the
|
||||
* role definition belongs to the plugin.
|
||||
*/
|
||||
export type AccessOption = {
|
||||
/** Slug of the client's auth collection, e.g. 'users'. */
|
||||
authCollection: string
|
||||
/** Role assigned to new users. Defaults to 'user'. */
|
||||
defaultRole?: Role
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { AnalyticsConfig } from './types.js'
|
||||
|
||||
import { gtag } from '../consent/googleConsent.js'
|
||||
import { CONSENT_COOKIE, DEFAULT_CONSENT, parseConsent, setDefaultConsent } from '../consent/index.js'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: unknown[]
|
||||
}
|
||||
}
|
||||
|
||||
const GTM_SCRIPT = (id: string) => `https://www.googletagmanager.com/gtm.js?id=${id}`
|
||||
const GA4_SCRIPT = (id: string) => `https://www.googletagmanager.com/gtag/js?id=${id}`
|
||||
|
||||
/** Reads the current consent state from cookie, or defaults if none. */
|
||||
function readConsent() {
|
||||
if (typeof document === 'undefined') {return DEFAULT_CONSENT}
|
||||
const raw = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`))
|
||||
?.split('=')[1]
|
||||
return parseConsent(raw) ?? DEFAULT_CONSENT
|
||||
}
|
||||
|
||||
function injectScript(src: string) {
|
||||
if (document.querySelector(`script[src="${src}"]`)) {return}
|
||||
const s = document.createElement('script')
|
||||
s.src = src
|
||||
s.async = true
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Google Analytics — GTM if a container ID is set, otherwise GA4 gtag.
|
||||
*
|
||||
* Consent Mode is initialised to the visitor's stored consent BEFORE the tag
|
||||
* loads (via setDefaultConsent from the consent module), so tags respect the
|
||||
* choice from the first hit; useConsent sends an update the moment the visitor
|
||||
* decides. IDs are public and come from SiteIntegrations, passed in as props
|
||||
* (the client project reads them server-side).
|
||||
*
|
||||
* Renders nothing — it only injects the scripts. Mount once, high in the tree
|
||||
* (e.g. root layout), inside ConsentProvider.
|
||||
*/
|
||||
export function Analytics({ ga4MeasurementId, gtmContainerId }: AnalyticsConfig) {
|
||||
useEffect(() => {
|
||||
if (!ga4MeasurementId && !gtmContainerId) {return}
|
||||
|
||||
// 1. Consent Mode default = the visitor's stored choice, before tags load.
|
||||
setDefaultConsent(readConsent())
|
||||
|
||||
// 2. Load the tag.
|
||||
if (gtmContainerId) {
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.dataLayer.push({ event: 'gtm.js', 'gtm.start': Date.now() })
|
||||
injectScript(GTM_SCRIPT(gtmContainerId))
|
||||
} else if (ga4MeasurementId) {
|
||||
injectScript(GA4_SCRIPT(ga4MeasurementId))
|
||||
// Uses the shared gtag (pushes `arguments`, as Google's tags expect);
|
||||
// real gtag.js wires itself up once the script loads.
|
||||
gtag('js', new Date())
|
||||
gtag('config', ga4MeasurementId)
|
||||
}
|
||||
}, [ga4MeasurementId, gtmContainerId])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
'use client'
|
||||
export { Analytics } from './Analytics.js'
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import type { AnalyticsConfig } from './types.js'
|
||||
|
||||
import { getSiteIntegrations } from '../payload/index.js'
|
||||
|
||||
/**
|
||||
* Reads the public analytics IDs from SiteIntegrations, server-side.
|
||||
*
|
||||
* Returns only the two public IDs (GA4 / GTM) — safe to pass to the client
|
||||
* <Analytics> component. Does NOT expose any secret from SiteIntegrations.
|
||||
*/
|
||||
export async function getAnalyticsConfig(payload: BasePayload): Promise<AnalyticsConfig> {
|
||||
const integrations = await getSiteIntegrations<AnalyticsConfig>(payload)
|
||||
return {
|
||||
ga4MeasurementId: integrations.ga4MeasurementId ?? null,
|
||||
gtmContainerId: integrations.gtmContainerId ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Server-safe: config type + the helper that reads IDs from SiteIntegrations.
|
||||
// The <Analytics> component is client-side — exported via ./client.
|
||||
export type { AnalyticsConfig } from './types.js'
|
||||
export { getAnalyticsConfig } from './getAnalyticsConfig.js'
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Analytics IDs, read from SiteIntegrations (public — safe on the client).
|
||||
*/
|
||||
export type AnalyticsConfig = {
|
||||
/** GA4 Measurement ID, e.g. 'G-XXXXXXXXXX'. */
|
||||
ga4MeasurementId?: null | string
|
||||
/** GTM Container ID, e.g. 'GTM-XXXXXXX'. */
|
||||
gtmContainerId?: null | string
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'
|
||||
|
||||
export type RenderBlocksProps = {
|
||||
/** Block data array from a Payload document (e.g. page.layout). */
|
||||
blocks: BlockData[] | null | undefined
|
||||
/** Client-provided map of blockType → component. */
|
||||
components: BlockComponentMap
|
||||
/**
|
||||
* Optional client hook to inject block-specific props (nav anchors, etc.).
|
||||
* Keeps the engine generic — the plugin knows no concrete block types.
|
||||
*/
|
||||
enhanceProps?: EnhanceProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic, server-side block renderer.
|
||||
*
|
||||
* Iterates a document's blocks and renders each via the client's component
|
||||
* map. Unknown blocks are skipped (null), never thrown. Block-specific logic is
|
||||
* delegated to the client's optional `enhanceProps` — the plugin itself is
|
||||
* block-agnostic.
|
||||
*
|
||||
* The client owns components and block schemas (they pass `components` and
|
||||
* define blocks in their config); the plugin owns only the iteration and
|
||||
* wiring. No wrapper markup is added — spacing/layout belong to the client's
|
||||
* components.
|
||||
*
|
||||
* Nested blocks: a block that needs to render child blocks should render its
|
||||
* own <RenderBlocks> and import the registry itself, rather than receiving the
|
||||
* component map as a prop. Passing the map as a prop breaks React Server
|
||||
* Components — functions (client components) can't cross the server→client
|
||||
* boundary via props.
|
||||
*/
|
||||
export function RenderBlocks({ blocks, components, enhanceProps }: RenderBlocksProps) {
|
||||
if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{blocks.map((block, index) => {
|
||||
const Component = components[block.blockType]
|
||||
if (!Component) {return null}
|
||||
|
||||
const extra = enhanceProps ? enhanceProps({ allBlocks: blocks, block, index }) : {}
|
||||
|
||||
return <Component key={index} {...block} {...extra} />
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { RenderBlocks } from './RenderBlocks.js'
|
||||
export type { RenderBlocksProps } from './RenderBlocks.js'
|
||||
export type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* A single block's data as stored by Payload — always has a blockType, plus
|
||||
* arbitrary block-specific fields. The plugin stays generic over the shape.
|
||||
*/
|
||||
export type BlockData = {
|
||||
[key: string]: unknown
|
||||
blockType: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a blockType to the client's component for it.
|
||||
* The client owns the components; the plugin only receives this map.
|
||||
*/
|
||||
export type BlockComponentMap = Record<string, ComponentType<any>>
|
||||
|
||||
/**
|
||||
* Optional per-block prop enhancer supplied by the client.
|
||||
*
|
||||
* Lets the client inject block-specific logic (e.g. collect anchors for a nav
|
||||
* block) without the plugin knowing any concrete block types. Returns extra
|
||||
* props merged into the rendered block.
|
||||
*/
|
||||
export type EnhanceProps = (args: {
|
||||
allBlocks: BlockData[]
|
||||
block: BlockData
|
||||
index: number
|
||||
}) => Record<string, unknown>
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
import { createContext, type ReactNode, use } from 'react'
|
||||
|
||||
import type { ConsentTexts } from './texts.js'
|
||||
|
||||
import { useConsent } from './useConsent.js'
|
||||
|
||||
type ConsentContextValue = { texts: ConsentTexts } & ReturnType<typeof useConsent>
|
||||
|
||||
const ConsentContext = createContext<ConsentContextValue | null>(null)
|
||||
|
||||
export function ConsentProvider({ children, texts }: { children: ReactNode; texts: ConsentTexts }) {
|
||||
const consent = useConsent()
|
||||
return <ConsentContext value={{ ...consent, texts }}>{children}</ConsentContext>
|
||||
}
|
||||
|
||||
export function useConsentContext(): ConsentContextValue {
|
||||
const ctx = use(ConsentContext)
|
||||
if (!ctx) {throw new Error('useConsentContext must be used within ConsentProvider')}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
import { Cookie } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { CONSENT_CATEGORIES, type ConsentCategory } from './categories.js'
|
||||
import { useConsentContext } from './ConsentContext.js'
|
||||
|
||||
/**
|
||||
* Optional class overrides — for when tokens aren't enough, e.g. turning the
|
||||
* bottom bar into a centred modal. Replaces the slot's classes outright.
|
||||
*/
|
||||
export type CookieBannerClassNames = {
|
||||
primaryButton?: string
|
||||
root?: string
|
||||
secondaryButton?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours and radius come from CSS custom properties with built-in fallbacks,
|
||||
* so the banner looks right with no setup, and re-skinning per client means
|
||||
* declaring a few variables — no imports, no props, no specificity fights:
|
||||
*
|
||||
* ```css
|
||||
* :root {
|
||||
* --ipal-primary: #16a34a;
|
||||
* --ipal-radius: 1rem;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Available: --ipal-surface, --ipal-border, --ipal-text, --ipal-text-strong,
|
||||
* --ipal-text-muted, --ipal-primary, --ipal-primary-hover, --ipal-primary-text,
|
||||
* --ipal-hover, --ipal-radius. Each has a `-dark` counterpart used under
|
||||
* Tailwind's `dark:` variant (e.g. --ipal-surface-dark).
|
||||
*
|
||||
* Layout stays in Tailwind: spacing and flow aren't things a brand changes, and
|
||||
* exposing them as tokens would mean re-inventing CSS one variable at a time.
|
||||
*/
|
||||
const surface = 'bg-[var(--ipal-surface,#fff)] dark:bg-[var(--ipal-surface-dark,#171717)]'
|
||||
const border = 'border-[var(--ipal-border,#e5e5e5)] dark:border-[var(--ipal-border-dark,#262626)]'
|
||||
const radius = 'rounded-[var(--ipal-radius,0.375rem)]'
|
||||
const accent = 'text-[var(--ipal-primary,#2563eb)]'
|
||||
|
||||
const defaults = {
|
||||
primaryButton: `${radius} px-3 py-1.5 text-sm font-medium bg-[var(--ipal-primary,#2563eb)] text-[var(--ipal-primary-text,#fff)] hover:bg-[var(--ipal-primary-hover,#1d4ed8)]`,
|
||||
root: `fixed inset-x-0 bottom-0 z-50 border-t p-4 shadow-lg ${surface} ${border}`,
|
||||
secondaryButton: `${radius} px-3 py-1.5 text-sm font-medium text-[var(--ipal-text,#404040)] hover:bg-[var(--ipal-hover,#f5f5f5)] dark:text-[var(--ipal-text-dark,#e5e5e5)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]`,
|
||||
}
|
||||
|
||||
const bodyText = 'text-[var(--ipal-text,#404040)] dark:text-[var(--ipal-text-dark,#d4d4d4)]'
|
||||
const strongText =
|
||||
'text-[var(--ipal-text-strong,#171717)] dark:text-[var(--ipal-text-strong-dark,#f5f5f5)]'
|
||||
const mutedText =
|
||||
'text-[var(--ipal-text-muted,#737373)] dark:text-[var(--ipal-text-muted-dark,#a3a3a3)]'
|
||||
|
||||
export function CookieBanner({ classNames }: { classNames?: CookieBannerClassNames } = {}) {
|
||||
const { acceptAll, decided, rejectAll, savePreferences, state, texts } = useConsentContext()
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [choices, setChoices] = useState<Record<ConsentCategory, boolean>>(state)
|
||||
|
||||
useEffect(() => {
|
||||
setChoices(state)
|
||||
}, [state])
|
||||
|
||||
if (decided) {return null}
|
||||
|
||||
const toggle = (cat: ConsentCategory) => {
|
||||
if (cat === 'necessary') {return}
|
||||
setChoices((prev) => ({ ...prev, [cat]: !prev[cat] }))
|
||||
}
|
||||
|
||||
const rootClass = classNames?.root ?? defaults.root
|
||||
const primaryClass = classNames?.primaryButton ?? defaults.primaryButton
|
||||
const secondaryClass = classNames?.secondaryButton ?? defaults.secondaryButton
|
||||
|
||||
return (
|
||||
<div aria-label="Cookie consent" className={rootClass} data-ipal="banner" role="dialog">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{!showSettings ? (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Cookie aria-hidden="true" className={`h-6 w-6 shrink-0 ${accent}`} />
|
||||
<p className={`text-justify text-sm ${bodyText}`}>
|
||||
{texts.message}
|
||||
{texts.privacyLink && (
|
||||
<>
|
||||
{' '}
|
||||
<a
|
||||
className={`${accent} underline hover:no-underline`}
|
||||
href={texts.privacyLink.href}
|
||||
>
|
||||
{texts.privacyLink.label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button className={secondaryClass} onClick={() => setShowSettings(true)}>
|
||||
{texts.buttons.settings}
|
||||
</button>
|
||||
<button className={secondaryClass} onClick={rejectAll}>
|
||||
{texts.buttons.reject}
|
||||
</button>
|
||||
<button className={primaryClass} onClick={acceptAll}>
|
||||
{texts.buttons.acceptAll}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className={`flex items-center gap-2 text-base font-semibold ${strongText}`}>
|
||||
<Cookie aria-hidden="true" className={`h-5 w-5 ${accent}`} />
|
||||
{texts.settingsTitle}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{CONSENT_CATEGORIES.map((cat) => (
|
||||
<li className="flex items-start justify-between gap-4" key={cat}>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${strongText}`}>
|
||||
{texts.categories[cat].title}
|
||||
</p>
|
||||
<p className={`text-xs ${mutedText}`}>{texts.categories[cat].description}</p>
|
||||
</div>
|
||||
<input
|
||||
checked={cat === 'necessary' ? true : choices[cat]}
|
||||
className="mt-1 h-4 w-4 shrink-0 accent-[var(--ipal-primary,#2563eb)]"
|
||||
disabled={cat === 'necessary'}
|
||||
onChange={() => toggle(cat)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className={secondaryClass} onClick={() => setShowSettings(false)}>
|
||||
{texts.buttons.back}
|
||||
</button>
|
||||
<button className={primaryClass} onClick={() => savePreferences(choices)}>
|
||||
{texts.buttons.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
import { Cookie } from 'lucide-react'
|
||||
|
||||
import { useConsentContext } from './ConsentContext.js'
|
||||
|
||||
/**
|
||||
* Floating "cookie settings" button — lets visitors reopen the consent panel
|
||||
* after deciding (GDPR: withdrawing consent must be as easy as giving it).
|
||||
* Hidden while the banner is showing.
|
||||
*
|
||||
* Colours follow the same CSS custom properties as CookieBanner
|
||||
* (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's
|
||||
* palette applies to both without touching either component. Pass className to
|
||||
* replace the styling outright.
|
||||
*/
|
||||
export function CookieButton({ className }: { className?: string } = {}) {
|
||||
const { decided, reopen } = useConsentContext()
|
||||
|
||||
if (!decided) {return null}
|
||||
|
||||
const cls =
|
||||
className ??
|
||||
'fixed bottom-4 left-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border shadow-md transition-colors ' +
|
||||
'bg-[var(--ipal-surface,#fff)] border-[var(--ipal-border,#e5e5e5)] hover:bg-[var(--ipal-hover,#f5f5f5)] ' +
|
||||
'dark:bg-[var(--ipal-surface-dark,#171717)] dark:border-[var(--ipal-border-dark,#262626)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]'
|
||||
|
||||
return (
|
||||
<button aria-label="Cookie settings" className={cls} data-ipal="cookie-button" onClick={reopen}>
|
||||
<Cookie
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-[var(--ipal-text,#525252)] dark:text-[var(--ipal-text-dark,#d4d4d4)]"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Cookie consent categories (GDPR). 'necessary' is always granted and cannot be
|
||||
* disabled. The rest default to false until the user opts in.
|
||||
*/
|
||||
export const CONSENT_CATEGORIES = ['necessary', 'functional', 'analytics', 'marketing'] as const
|
||||
|
||||
export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]
|
||||
|
||||
export type ConsentState = Record<ConsentCategory, boolean>
|
||||
|
||||
export const DEFAULT_CONSENT: ConsentState = {
|
||||
necessary: true,
|
||||
functional: false,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
}
|
||||
|
||||
export const ACCEPT_ALL_CONSENT: ConsentState = {
|
||||
necessary: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
}
|
||||
|
||||
export const REJECT_ALL_CONSENT: ConsentState = { ...DEFAULT_CONSENT }
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
export { ConsentProvider, useConsentContext } from './ConsentContext.js'
|
||||
export { CookieBanner } from './CookieBanner.js'
|
||||
export type { CookieBannerClassNames } from './CookieBanner.js'
|
||||
export { CookieButton } from './CookieButton.js'
|
||||
// Client-only consent exports — hook, provider, and UI components.
|
||||
export { useConsent } from './useConsent.js'
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import type { I18nConfig } from '../i18n/index.js'
|
||||
import type { ConsentTexts } from './texts.js'
|
||||
|
||||
import { getSystemPagePath } from '../pages/index.js'
|
||||
import { getGlobal } from '../payload/index.js'
|
||||
import { CONSENT_CATEGORIES } from './categories.js'
|
||||
|
||||
/** English fallback used when the global has no value for a field. */
|
||||
const FALLBACK: ConsentTexts = {
|
||||
buttons: {
|
||||
acceptAll: 'Accept all',
|
||||
back: 'Back',
|
||||
reject: 'Reject',
|
||||
save: 'Save preferences',
|
||||
settings: 'Settings',
|
||||
},
|
||||
categories: {
|
||||
analytics: { description: 'Help us understand usage.', title: 'Analytics' },
|
||||
functional: { description: 'Remember preferences.', title: 'Functional' },
|
||||
marketing: { description: 'Used to show relevant ads.', title: 'Marketing' },
|
||||
necessary: {
|
||||
description:
|
||||
'Required for the site to work, including your language and theme preferences. Always on.',
|
||||
title: 'Necessary',
|
||||
},
|
||||
},
|
||||
message:
|
||||
'We use cookies to run the site, analyze traffic, and improve your experience. You can accept all, reject non-essential, or choose which to allow.',
|
||||
privacyLink: null,
|
||||
settingsTitle: 'Cookie settings',
|
||||
}
|
||||
|
||||
type CookieSettingsData = {
|
||||
buttons?: null | Partial<ConsentTexts['buttons']>
|
||||
categories?: Array<{ description?: null | string; key: string; title?: null | string }> | null
|
||||
message?: null | string
|
||||
settingsTitle?: null | string
|
||||
}
|
||||
|
||||
type GetConsentTextsArgs = {
|
||||
config: I18nConfig
|
||||
locale?: string
|
||||
payload: BasePayload
|
||||
/**
|
||||
* Privacy-policy label + the system page (from SiteSettings.privacyPolicy,
|
||||
* read with locale:'all') to link to. Optional — omit for no link.
|
||||
*/
|
||||
privacyPolicy?: {
|
||||
label: string
|
||||
page: { slug?: null | Record<string, unknown> } | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves consent banner texts from the CookieSettings global, falling back to
|
||||
* English defaults per field. The privacy-policy link is built from the pages
|
||||
* module (system page role), not stored in CookieSettings — one source of truth.
|
||||
*/
|
||||
export async function getConsentTexts({
|
||||
config,
|
||||
locale,
|
||||
payload,
|
||||
privacyPolicy,
|
||||
}: GetConsentTextsArgs): Promise<ConsentTexts> {
|
||||
const g = await getGlobal<CookieSettingsData>(payload, 'cookie-settings', { locale })
|
||||
|
||||
const categories = {} as ConsentTexts['categories']
|
||||
for (const cat of CONSENT_CATEGORIES) {
|
||||
const entry = g.categories?.find((c) => c.key === cat)
|
||||
categories[cat] = {
|
||||
description: entry?.description || FALLBACK.categories[cat].description,
|
||||
title: entry?.title || FALLBACK.categories[cat].title,
|
||||
}
|
||||
}
|
||||
|
||||
let privacyLink: ConsentTexts['privacyLink'] = null
|
||||
if (privacyPolicy?.page && locale) {
|
||||
const href = getSystemPagePath({ config, locale, page: privacyPolicy.page })
|
||||
if (href) {
|
||||
privacyLink = { href, label: privacyPolicy.label }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buttons: {
|
||||
acceptAll: g.buttons?.acceptAll || FALLBACK.buttons.acceptAll,
|
||||
back: g.buttons?.back || FALLBACK.buttons.back,
|
||||
reject: g.buttons?.reject || FALLBACK.buttons.reject,
|
||||
save: g.buttons?.save || FALLBACK.buttons.save,
|
||||
settings: g.buttons?.settings || FALLBACK.buttons.settings,
|
||||
},
|
||||
categories,
|
||||
message: g.message || FALLBACK.message,
|
||||
privacyLink,
|
||||
settingsTitle: g.settingsTitle || FALLBACK.settingsTitle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ConsentState } from './categories.js'
|
||||
|
||||
type ConsentValue = 'denied' | 'granted'
|
||||
type GoogleConsentSignals = Record<string, ConsentValue>
|
||||
|
||||
function toSignals(state: ConsentState): GoogleConsentSignals {
|
||||
const g = (b: boolean): ConsentValue => (b ? 'granted' : 'denied')
|
||||
return {
|
||||
ad_personalization: g(state.marketing),
|
||||
ad_storage: g(state.marketing),
|
||||
ad_user_data: g(state.marketing),
|
||||
analytics_storage: g(state.analytics),
|
||||
functionality_storage: g(state.functional),
|
||||
personalization_storage: g(state.functional),
|
||||
security_storage: 'granted', // necessary — always on
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: unknown[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`.
|
||||
*
|
||||
* Pushing `arguments` rather than a plain array is not a stylistic detail.
|
||||
* Google's tags recognise a consent command by the Arguments object it arrives
|
||||
* in; a real array lands in the dataLayer as ordinary data and the command is
|
||||
* silently ignored — the tag loads, but consent never updates and analytics
|
||||
* stays denied. The rest parameter exists only to type the call sites.
|
||||
*
|
||||
* Exported for the analytics module (which needs `js`/`config` commands) —
|
||||
* intentionally not re-exported from the package's public API.
|
||||
*/
|
||||
export function gtag(..._args: unknown[]) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
window.dataLayer = window.dataLayer || []
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
window.dataLayer.push(arguments)
|
||||
}
|
||||
|
||||
/** Sets the default consent state (call before Google tags load). */
|
||||
export function setDefaultConsent(state: ConsentState) {
|
||||
gtag('consent', 'default', {
|
||||
...toSignals(state),
|
||||
wait_for_update: 500, // ms to wait for an update before firing
|
||||
})
|
||||
}
|
||||
|
||||
/** Updates consent after the user decides. */
|
||||
export function updateConsent(state: ConsentState) {
|
||||
gtag('consent', 'update', toSignals(state))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Server-safe exports (logic, types, helper). Client components (banner,
|
||||
// provider, hook) are exported separately via ./client to keep the RSC/client
|
||||
// boundary clean.
|
||||
export {
|
||||
ACCEPT_ALL_CONSENT,
|
||||
CONSENT_CATEGORIES,
|
||||
DEFAULT_CONSENT,
|
||||
REJECT_ALL_CONSENT,
|
||||
} from './categories.js'
|
||||
export type { ConsentCategory, ConsentState } from './categories.js'
|
||||
export { getConsentTexts } from './getConsentTexts.js'
|
||||
export { setDefaultConsent, updateConsent } from './googleConsent.js'
|
||||
export {
|
||||
CONSENT_COOKIE,
|
||||
CONSENT_MAX_AGE,
|
||||
CONSENT_VERSION,
|
||||
parseConsent,
|
||||
serializeConsent,
|
||||
} from './storage.js'
|
||||
export type { ConsentTexts } from './texts.js'
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ConsentState } from './categories.js'
|
||||
|
||||
import { CONSENT_CATEGORIES, DEFAULT_CONSENT } from './categories.js'
|
||||
|
||||
/**
|
||||
* Consent persistence in a cookie. Stored with a version so we can invalidate
|
||||
* old consents when the policy changes, and a timestamp (GDPR: consent must be
|
||||
* dated). Readable both client-side (banner) and server-side (script gating).
|
||||
*/
|
||||
export const CONSENT_COOKIE = 'cookie-consent'
|
||||
export const CONSENT_VERSION = 1
|
||||
export const CONSENT_MAX_AGE = 60 * 60 * 24 * 180 // 180 days
|
||||
|
||||
type StoredConsent = {
|
||||
state: ConsentState
|
||||
timestamp: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export function serializeConsent(state: ConsentState): string {
|
||||
const payload: StoredConsent = {
|
||||
state,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: CONSENT_VERSION,
|
||||
}
|
||||
return encodeURIComponent(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
export function parseConsent(raw: string | undefined): ConsentState | null {
|
||||
if (!raw) {return null}
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(raw)) as StoredConsent
|
||||
if (parsed.version !== CONSENT_VERSION) {return null}
|
||||
const state = { ...DEFAULT_CONSENT }
|
||||
for (const cat of CONSENT_CATEGORIES) {
|
||||
if (typeof parsed.state?.[cat] === 'boolean') {state[cat] = parsed.state[cat]}
|
||||
}
|
||||
state.necessary = true
|
||||
return state
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ConsentCategory } from './categories.js'
|
||||
|
||||
export type ConsentTexts = {
|
||||
buttons: {
|
||||
acceptAll: string
|
||||
back: string
|
||||
reject: string
|
||||
save: string
|
||||
settings: string
|
||||
}
|
||||
categories: Record<ConsentCategory, { description: string; title: string }>
|
||||
message: string
|
||||
/** null = no privacy-policy link shown */
|
||||
privacyLink: { href: string; label: string } | null
|
||||
settingsTitle: string
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import type { ConsentCategory, ConsentState } from './categories.js'
|
||||
|
||||
import { ACCEPT_ALL_CONSENT, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'
|
||||
import { updateConsent } from './googleConsent.js'
|
||||
import { CONSENT_COOKIE, CONSENT_MAX_AGE, parseConsent, serializeConsent } from './storage.js'
|
||||
|
||||
export function useConsent() {
|
||||
const [state, setState] = useState<ConsentState>(DEFAULT_CONSENT)
|
||||
const [decided, setDecided] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const raw = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`))
|
||||
?.split('=')[1]
|
||||
const parsed = parseConsent(raw)
|
||||
if (parsed) {
|
||||
setState(parsed)
|
||||
setDecided(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const persist = useCallback((next: ConsentState) => {
|
||||
document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`
|
||||
setState(next)
|
||||
setDecided(true)
|
||||
// Tell Google right away. Tags are already on the page holding whatever
|
||||
// defaults Analytics set at load; without this update they keep them for
|
||||
// the rest of the session and never write their cookies — the banner would
|
||||
// look like it worked while nothing changed.
|
||||
updateConsent(next)
|
||||
}, [])
|
||||
|
||||
const acceptAll = useCallback(() => persist(ACCEPT_ALL_CONSENT), [persist])
|
||||
const rejectAll = useCallback(() => persist(REJECT_ALL_CONSENT), [persist])
|
||||
const savePreferences = useCallback(
|
||||
(choices: Partial<Record<ConsentCategory, boolean>>) =>
|
||||
persist({ ...DEFAULT_CONSENT, ...choices, necessary: true }),
|
||||
[persist],
|
||||
)
|
||||
const reopen = useCallback(() => setDecided(false), [])
|
||||
|
||||
return { acceptAll, decided, rejectAll, reopen, savePreferences, state }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Field, RelationshipField } from 'payload'
|
||||
|
||||
import type { ContentOption } from './types.js'
|
||||
|
||||
import { archiveFieldName } from './types.js'
|
||||
|
||||
/**
|
||||
* Builds one relationship field per content collection, pointing at the
|
||||
* client's Pages collection.
|
||||
*
|
||||
* Sits alongside the system-page roles (homepage, privacy policy) because it's
|
||||
* the same idea: a page referenced by function rather than by slug. The
|
||||
* assignment is locale-agnostic — one page document — while the resulting path
|
||||
* is locale-aware, since the page's slug is localized. Assign the "Artykuły"
|
||||
* page once and you get /pl/artykuly and /en/articles from its own slugs.
|
||||
*/
|
||||
export function buildArchiveFields(content: ContentOption, pagesSlug: string): Field[] {
|
||||
return content.collections.map((collection): RelationshipField => {
|
||||
const label = collection.label ?? collection.slug
|
||||
|
||||
return {
|
||||
name: archiveFieldName(collection.slug),
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
description: `Page listing ${label}. Its slug becomes the URL segment for entries (e.g. /pl/artykuly/moj-wpis).`,
|
||||
},
|
||||
label: `${label} — archive page`,
|
||||
maxDepth: 1,
|
||||
relationTo: pagesSlug,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
export type ArchiveEntries<T = Record<string, unknown>> = {
|
||||
docs: T[]
|
||||
hasNextPage: boolean
|
||||
hasPrevPage: boolean
|
||||
/** 1-based. */
|
||||
page: number
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
type GetArchiveEntriesArgs = {
|
||||
/** Collection to list, e.g. 'posts'. */
|
||||
collection: string
|
||||
/** Relationship depth. Defaults to 1 — enough for a cover image. */
|
||||
depth?: number
|
||||
locale: string
|
||||
/** 1-based. Values below 1 are clamped. */
|
||||
page?: number
|
||||
payload: BasePayload
|
||||
/** Defaults to 10. */
|
||||
perPage?: number
|
||||
/** Payload sort string. Defaults to newest first. */
|
||||
sort?: string
|
||||
/** Extra constraints merged into the query, e.g. { category: { equals: id } }. */
|
||||
where?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one page of a collection's entries for an archive listing.
|
||||
*
|
||||
* Only published documents are returned when the collection has drafts enabled;
|
||||
* Payload's `where` on `_status` is left to the caller, since a collection
|
||||
* without drafts has no such field.
|
||||
*
|
||||
* Returns pagination facts rather than markup — the listing itself belongs to
|
||||
* the client (as a block on the archive page, most likely), because how a list
|
||||
* of posts should look isn't something a plugin can decide.
|
||||
*/
|
||||
export async function getArchiveEntries<T = Record<string, unknown>>({
|
||||
collection,
|
||||
depth = 1,
|
||||
locale,
|
||||
page = 1,
|
||||
payload,
|
||||
perPage = 10,
|
||||
sort = '-createdAt',
|
||||
where,
|
||||
}: GetArchiveEntriesArgs): Promise<ArchiveEntries<T>> {
|
||||
const result = await payload.find({
|
||||
collection: collection as never,
|
||||
depth,
|
||||
limit: perPage,
|
||||
locale: locale as never,
|
||||
page: Math.max(1, page),
|
||||
sort,
|
||||
...(where ? { where: where as never } : {}),
|
||||
})
|
||||
|
||||
return {
|
||||
docs: result.docs as T[],
|
||||
hasNextPage: result.hasNextPage,
|
||||
hasPrevPage: result.hasPrevPage,
|
||||
page: result.page ?? 1,
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { buildArchiveFields } from './archiveFields.js'
|
||||
export { getArchiveEntries } from './getArchiveEntries.js'
|
||||
export type { ArchiveEntries } from './getArchiveEntries.js'
|
||||
export { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js'
|
||||
export { resolveRoute } from './resolveRoute.js'
|
||||
export type { ResolvedRoute } from './resolveRoute.js'
|
||||
export type { ContentCollectionOption, ContentOption } from './types.js'
|
||||
export { archiveFieldName } from './types.js'
|
||||
@@ -0,0 +1,47 @@
|
||||
type ArchivePathArgs = {
|
||||
/** Archive page's slug in this locale, e.g. 'artykuly'. */
|
||||
archiveSlug: string
|
||||
locale: string
|
||||
/** 1-based. Page 1 is omitted from the URL. */
|
||||
page?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to an archive listing: /pl/artykuly, /pl/artykuly?page=2.
|
||||
*
|
||||
* Pagination goes in the query string rather than the path. A path segment
|
||||
* (/pl/artykuly/2) would be ambiguous with an entry slugged "2", and the
|
||||
* alternative (/pl/artykuly/strona/2) drags in yet another localized segment to
|
||||
* configure and translate. Google has understood ?page= for years, and
|
||||
* rel=next/prev — the reason people used to prefer path segments — was retired.
|
||||
*/
|
||||
export function buildArchivePath({ archiveSlug, locale, page = 1 }: ArchivePathArgs): string {
|
||||
const base = `/${locale}/${archiveSlug}`
|
||||
return page > 1 ? `${base}?page=${page}` : base
|
||||
}
|
||||
|
||||
type EntryPathArgs = {
|
||||
/** Archive page's slug in this locale, e.g. 'artykuly'. */
|
||||
archiveSlug: string
|
||||
/** Entry's slug in this locale, e.g. 'moj-post'. */
|
||||
entrySlug: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
/** Path to a single entry: /pl/artykuly/moj-post. */
|
||||
export function buildEntryPath({ archiveSlug, entrySlug, locale }: EntryPathArgs): string {
|
||||
return `/${locale}/${archiveSlug}/${entrySlug}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a page number out of a query parameter.
|
||||
*
|
||||
* Anything that isn't a positive integer is page 1 — `?page=abc`, `?page=-5`,
|
||||
* `?page=1.5` and a repeated `?page=1&page=2` all have to resolve to something,
|
||||
* and silently showing the first page beats a 500 on a URL a crawler invented.
|
||||
*/
|
||||
export function parsePageParam(value: string | string[] | undefined): number {
|
||||
const raw = Array.isArray(value) ? value[0] : value
|
||||
const n = Number(raw)
|
||||
return Number.isInteger(n) && n > 0 ? n : 1
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import type { ArchiveEntries } from './getArchiveEntries.js'
|
||||
import type { ContentOption } from './types.js'
|
||||
|
||||
import { getArchiveEntries } from './getArchiveEntries.js'
|
||||
import { archiveFieldName } from './types.js'
|
||||
|
||||
type ArchivePage = { id: number | string; slug?: unknown }
|
||||
|
||||
export type ResolvedRoute =
|
||||
/** Locale root — the page assigned as Homepage in System Pages. */
|
||||
| {
|
||||
/** The archive page this entry lives under — its slug is the URL prefix. */
|
||||
archive: Record<string, unknown>
|
||||
collection: string
|
||||
doc: Record<string, unknown>
|
||||
type: 'entry'
|
||||
}
|
||||
/** An ordinary page. */
|
||||
| {
|
||||
collection: string
|
||||
doc: Record<string, unknown>
|
||||
/** Present only when resolved with `withEntries` — metadata doesn't need them. */
|
||||
entries?: ArchiveEntries
|
||||
/** 1-based, from ?page=. */
|
||||
page: number
|
||||
perPage: number
|
||||
type: 'archive'
|
||||
}
|
||||
/** A collection's archive page, e.g. /pl/artykuly. */
|
||||
| { doc: Record<string, unknown>; type: 'home' }
|
||||
/** A single entry, e.g. /pl/artykuly/moj-post. */
|
||||
| { doc: Record<string, unknown>; type: 'page' }
|
||||
|
||||
type ResolveRouteArgs = {
|
||||
content?: ContentOption
|
||||
locale: string
|
||||
/** Page number from the query string (?page=2). Defaults to 1. */
|
||||
page?: number
|
||||
/** Client's Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
payload: BasePayload
|
||||
/** Route segments after the locale, e.g. ['artykuly', 'moj-post']. */
|
||||
segments?: string[]
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
/**
|
||||
* Fetch the archive's entries too. The page component wants them; metadata
|
||||
* generation doesn't, and would pay for a query it throws away.
|
||||
*/
|
||||
withEntries?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out what a URL points at: the home page, an ordinary page, a
|
||||
* collection's archive, or a single entry.
|
||||
*
|
||||
* The trick is that archive prefixes aren't configured anywhere — they're the
|
||||
* slug of whichever page an editor assigned as that collection's archive. So
|
||||
* /pl/artykuly and /en/articles come from one assignment, and renaming the page
|
||||
* moves the whole section.
|
||||
*
|
||||
* Resolution order matters: a first segment matching an archive's slug wins
|
||||
* over an ordinary page of the same name, because an archive *is* a page and
|
||||
* would otherwise shadow its own entries.
|
||||
*
|
||||
* Depth beyond {archive}/{entry} isn't supported — categories in the path would
|
||||
* make canonical and hreflang ambiguous (the same entry reachable under several
|
||||
* URLs).
|
||||
*/
|
||||
export async function resolveRoute({
|
||||
content,
|
||||
locale,
|
||||
page = 1,
|
||||
pagesSlug = 'pages',
|
||||
payload,
|
||||
segments,
|
||||
settingsSlug = 'site-settings',
|
||||
withEntries = false,
|
||||
}: ResolveRouteArgs): Promise<null | ResolvedRoute> {
|
||||
const settings = (await payload.findGlobal({
|
||||
slug: settingsSlug,
|
||||
depth: 1,
|
||||
locale: locale as never,
|
||||
})) as Record<string, unknown>
|
||||
|
||||
// Locale root → Homepage from System Pages.
|
||||
if (!segments?.length) {
|
||||
const homepage = settings.homepage
|
||||
if (homepage && typeof homepage === 'object') {
|
||||
return { type: 'home', doc: homepage as Record<string, unknown> }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Which archive (if any) does the first segment name?
|
||||
const [first, ...rest] = segments
|
||||
|
||||
for (const collection of content?.collections ?? []) {
|
||||
const archive = settings[archiveFieldName(collection.slug)]
|
||||
if (!archive || typeof archive !== 'object') {continue}
|
||||
|
||||
const archiveDoc = archive as ArchivePage
|
||||
if (archiveDoc.slug !== first) {continue}
|
||||
|
||||
// /pl/artykuly → the archive page itself.
|
||||
if (rest.length === 0) {
|
||||
const perPage = collection.perPage ?? 10
|
||||
return {
|
||||
type: 'archive',
|
||||
collection: collection.slug,
|
||||
doc: archiveDoc as Record<string, unknown>,
|
||||
page,
|
||||
perPage,
|
||||
...(withEntries
|
||||
? {
|
||||
entries: await getArchiveEntries({
|
||||
collection: collection.slug,
|
||||
locale,
|
||||
page,
|
||||
payload,
|
||||
perPage,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
// /pl/artykuly/moj-post → an entry.
|
||||
if (rest.length === 1) {
|
||||
const found = await payload.find({
|
||||
collection: collection.slug as never,
|
||||
depth: 2,
|
||||
limit: 1,
|
||||
locale: locale as never,
|
||||
where: { slug: { equals: rest[0] } },
|
||||
})
|
||||
const entry = found.docs[0]
|
||||
if (!entry) {return null}
|
||||
|
||||
return {
|
||||
type: 'entry',
|
||||
archive: archiveDoc as Record<string, unknown>,
|
||||
collection: collection.slug,
|
||||
doc: entry as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
// Deeper than {archive}/{entry}.
|
||||
return null
|
||||
}
|
||||
|
||||
// An ordinary page. Joined, so nested slugs ('a/b') keep working.
|
||||
const found = await payload.find({
|
||||
collection: pagesSlug as never,
|
||||
depth: 2,
|
||||
limit: 1,
|
||||
locale: locale as never,
|
||||
where: { slug: { equals: segments.join('/') } },
|
||||
})
|
||||
const doc = found.docs[0]
|
||||
if (!doc) {return null}
|
||||
|
||||
return { type: 'page', doc: doc as Record<string, unknown> }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* A collection whose entries live under an archive page, e.g. blog posts at
|
||||
* /pl/artykuly/moj-post.
|
||||
*
|
||||
* The collection itself belongs to the client — Payload keeps its schema in
|
||||
* code, so adding one is always a code change. What this option adds is the
|
||||
* routing: the plugin exposes an "archive page" assignment in SiteSettings, and
|
||||
* whichever page an editor picks becomes the URL segment. Rename that page from
|
||||
* "Artykuły" to "Wpisy" and the path follows, per locale, with no deploy.
|
||||
*/
|
||||
export type ContentCollectionOption = {
|
||||
/** Slug of the client's collection, e.g. 'posts'. */
|
||||
slug: string
|
||||
/** Admin label for the archive assignment. Defaults to the slug. */
|
||||
label?: string
|
||||
/** Entries per page in listings. Defaults to 10. */
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin option wiring archive-backed collections into routing.
|
||||
*/
|
||||
export type ContentOption = {
|
||||
collections: ContentCollectionOption[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Field name holding a collection's archive page in SiteSettings.
|
||||
* `posts` → `postsArchive`.
|
||||
*/
|
||||
export function archiveFieldName(collectionSlug: string): string {
|
||||
return `${collectionSlug}Archive`
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
|
||||
// so this must never be imported from a client component.
|
||||
export { sendEmail } from './sendEmail.js'
|
||||
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js'
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'
|
||||
|
||||
import { getSiteIntegrations } from '../payload/index.js'
|
||||
|
||||
/** SMTP fields the adapter reads from SiteIntegrations. */
|
||||
type SmtpIntegrations = {
|
||||
smtpFromAddress?: null | string
|
||||
smtpFromName?: null | string
|
||||
smtpHost?: null | string
|
||||
smtpPassword?: null | string
|
||||
smtpPort?: null | number
|
||||
smtpUser?: null | string
|
||||
}
|
||||
|
||||
export type PanelSmtpAdapterArgs = {
|
||||
/**
|
||||
* Used only until the panel is filled in — Payload requires a from-address
|
||||
* synchronously at boot, before any global can be read. Once SiteIntegrations
|
||||
* has an address, it wins.
|
||||
*/
|
||||
fallbackFromAddress?: string
|
||||
fallbackFromName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload email adapter backed by the SMTP settings in the SiteIntegrations
|
||||
* global.
|
||||
*
|
||||
* Why this exists: Payload builds its email adapter once, at boot, from the
|
||||
* config — which would normally mean SMTP credentials living in env vars and a
|
||||
* redeploy to change them. This adapter instead resolves SMTP on every send, so
|
||||
* an editor can change the mailbox in the admin panel and the next email uses
|
||||
* it.
|
||||
*
|
||||
* Wiring it into the config means `payload.sendEmail` works everywhere — which
|
||||
* includes the form-builder's own submission emails (the ones an editor
|
||||
* configures per form under "Emails"). Those go out over the panel's SMTP with
|
||||
* no extra code.
|
||||
*
|
||||
* Boot-time constraints shape two details:
|
||||
* - `defaultFromAddress` / `defaultFromName` must be returned synchronously, so
|
||||
* they're placeholders; the real from-address is applied per message below.
|
||||
* - nodemailer is imported dynamically inside sendEmail, so merely loading the
|
||||
* Payload config (or running `generate:importmap`) doesn't pull it in.
|
||||
*/
|
||||
export const panelSmtpAdapter =
|
||||
(args: PanelSmtpAdapterArgs = {}): PayloadEmailAdapter =>
|
||||
({ payload }) => ({
|
||||
name: 'ipal-panel-smtp',
|
||||
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
|
||||
defaultFromName: args.fallbackFromName ?? 'Website',
|
||||
|
||||
sendEmail: async (message: SendEmailOptions) => {
|
||||
const smtp = await getSiteIntegrations<SmtpIntegrations>(payload)
|
||||
|
||||
const host = smtp.smtpHost
|
||||
const port = smtp.smtpPort ?? 587
|
||||
const user = smtp.smtpUser
|
||||
const pass = smtp.smtpPassword
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
payload.logger.error('[ipal] Email not sent: SMTP is not configured in Site Integrations.')
|
||||
return { error: 'SMTP is not configured in Site Integrations.', sent: false }
|
||||
}
|
||||
|
||||
// The panel is the source of truth for the sender; fall back to whatever
|
||||
// the caller set (Payload fills in defaultFromAddress when unset).
|
||||
const fromAddress = smtp.smtpFromAddress
|
||||
const fromName = smtp.smtpFromName
|
||||
const from = fromAddress
|
||||
? fromName
|
||||
? `${fromName} <${fromAddress}>`
|
||||
: fromAddress
|
||||
: message.from
|
||||
|
||||
// Defence in depth against header injection. Modern nodemailer already
|
||||
// normalises CRLF in standard headers, but the form-builder interpolates
|
||||
// user data into the subject via {{field}} placeholders, so strip any
|
||||
// newlines from header-bound values before they reach the transport.
|
||||
const stripCRLF = (v: unknown): string =>
|
||||
String(v ?? '')
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const { default: nodemailer } = await import('nodemailer')
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
auth: { pass, user },
|
||||
host,
|
||||
port,
|
||||
secure: port === 465, // implicit TLS on 465, STARTTLS otherwise
|
||||
})
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
...message,
|
||||
from,
|
||||
...(message.subject ? { subject: stripCRLF(message.subject) } : {}),
|
||||
})
|
||||
return { messageId: info.messageId, sent: true }
|
||||
} catch (err) {
|
||||
// Never surface SMTP internals to the caller — a form submission
|
||||
// shouldn't fail loudly because the mailbox is misconfigured.
|
||||
payload.logger.error(`[ipal] Email send failed: ${(err as Error).message}`)
|
||||
return { error: 'Failed to send email.', sent: false }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'server-only'
|
||||
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
import { getSiteIntegrations } from '../payload/index.js'
|
||||
|
||||
/** SMTP fields the sender reads from SiteIntegrations. */
|
||||
type SmtpIntegrations = {
|
||||
smtpFromAddress?: null | string
|
||||
smtpFromName?: null | string
|
||||
smtpHost?: null | string
|
||||
smtpPassword?: null | string
|
||||
smtpPort?: null | number
|
||||
smtpUser?: null | string
|
||||
}
|
||||
|
||||
export type SendEmailArgs = {
|
||||
/** Override the configured from-address for this message. */
|
||||
from?: string
|
||||
/** HTML body. */
|
||||
html: string
|
||||
/** Payload instance — used to read SMTP config from SiteIntegrations. */
|
||||
payload: BasePayload
|
||||
replyTo?: string
|
||||
subject: string
|
||||
/** Optional plain-text fallback. */
|
||||
text?: string
|
||||
to: string | string[]
|
||||
}
|
||||
|
||||
export type SendEmailResult = { error: string; sent: false } | { messageId: string; sent: true }
|
||||
|
||||
/**
|
||||
* Sends an email using SMTP settings from the SiteIntegrations global.
|
||||
*
|
||||
* Reads config at call time (not at boot) so editors can change SMTP in the
|
||||
* admin panel without a restart — consistent with the plugin's panel-managed
|
||||
* model. `server-only` keeps the SMTP password out of any client bundle.
|
||||
*
|
||||
* Returns a result object rather than throwing, so callers (e.g. form
|
||||
* submission) can handle failure without a try/catch and never leak SMTP
|
||||
* details to the client.
|
||||
*/
|
||||
export async function sendEmail({
|
||||
from,
|
||||
html,
|
||||
payload,
|
||||
replyTo,
|
||||
subject,
|
||||
text,
|
||||
to,
|
||||
}: SendEmailArgs): Promise<SendEmailResult> {
|
||||
const smtp = await getSiteIntegrations<SmtpIntegrations>(payload)
|
||||
|
||||
const host = smtp.smtpHost
|
||||
const port = smtp.smtpPort ?? 587
|
||||
const user = smtp.smtpUser
|
||||
const pass = smtp.smtpPassword
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
return { error: 'SMTP is not configured in Site Integrations.', sent: false }
|
||||
}
|
||||
|
||||
const fromAddress = from ?? smtp.smtpFromAddress
|
||||
if (!fromAddress) {
|
||||
return { error: 'No from-address configured.', sent: false }
|
||||
}
|
||||
|
||||
const fromName = smtp.smtpFromName
|
||||
const fromHeader = fromName ? `${fromName} <${fromAddress}>` : fromAddress
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
auth: { pass, user },
|
||||
host,
|
||||
port,
|
||||
secure: port === 465, // implicit TLS on 465, STARTTLS otherwise
|
||||
})
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: fromHeader,
|
||||
html,
|
||||
subject,
|
||||
to,
|
||||
...(text ? { text } : {}),
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
})
|
||||
return { messageId: info.messageId, sent: true }
|
||||
} catch (err) {
|
||||
payload.logger.error(`[ipal] Email send failed: ${(err as Error).message}`)
|
||||
return { error: 'Failed to send email.', sent: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Plugin } from 'payload'
|
||||
|
||||
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
|
||||
|
||||
import type { FormsOption } from './types.js'
|
||||
|
||||
/**
|
||||
* Configures @payloadcms/plugin-form-builder from IPAL's FormsOption.
|
||||
*
|
||||
* Provides the form/form-submissions collections and field types. Email
|
||||
* delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel
|
||||
* sender (submitForm) does that, so the form-builder's built-in email (which
|
||||
* needs a Payload email adapter) is left unused.
|
||||
*/
|
||||
export function buildFormsPlugin(forms: FormsOption): Plugin {
|
||||
return formBuilderPlugin({
|
||||
fields: {
|
||||
checkbox: true,
|
||||
email: true,
|
||||
message: true,
|
||||
number: true,
|
||||
payment: false,
|
||||
select: true,
|
||||
text: true,
|
||||
textarea: true,
|
||||
...forms.fields,
|
||||
},
|
||||
...(forms.redirectRelationships ? { redirectRelationships: forms.redirectRelationships } : {}),
|
||||
// Client-supplied collection overrides (e.g. a per-form notification
|
||||
// address). The plugin provides the hook, not the opinion about which
|
||||
// extra fields a form should carry.
|
||||
...(forms.formOverrides ? { formOverrides: forms.formOverrides } : {}),
|
||||
...(forms.formSubmissionOverrides
|
||||
? { formSubmissionOverrides: forms.formSubmissionOverrides }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// buildFormsPlugin is config-time (safe anywhere). submitForm is server-only
|
||||
// (Turnstile secret) — never import it from a client component.
|
||||
export { buildFormsPlugin } from './formsPluginConfig.js'
|
||||
export { checkRateLimit } from './rateLimit.js'
|
||||
export type { RateLimitArgs } from './rateLimit.js'
|
||||
export { submitForm } from './submitForm.js'
|
||||
export type { SubmitFormArgs, SubmitFormResult } from './submitForm.js'
|
||||
export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption } from './types.js'
|
||||
export { validateSubmission } from './validateSubmission.js'
|
||||
export type { FormValidationResult } from './validateSubmission.js'
|
||||
@@ -0,0 +1,62 @@
|
||||
type Bucket = { count: number; resetAt: number }
|
||||
|
||||
/**
|
||||
* Per-IP sliding window, in memory.
|
||||
*
|
||||
* Deliberately simple: no Redis, no dependency. The trade-off is that the
|
||||
* counter lives in one process — with several instances behind a load balancer
|
||||
* each keeps its own, so the effective limit is per-instance, not global. For a
|
||||
* contact form that's fine (it raises the cost of flooding without pretending
|
||||
* to be airtight); a high-security form should put a real limiter in front.
|
||||
*
|
||||
* State is module-level, so it survives between requests but resets on redeploy
|
||||
* — acceptable for abuse throttling.
|
||||
*/
|
||||
const buckets = new Map<string, Bucket>()
|
||||
|
||||
/** Sweep expired buckets occasionally so the map doesn't grow unbounded. */
|
||||
let lastSweep = Date.now()
|
||||
const SWEEP_INTERVAL = 60_000
|
||||
|
||||
function sweep(now: number) {
|
||||
if (now - lastSweep < SWEEP_INTERVAL) {return}
|
||||
lastSweep = now
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.resetAt <= now) {buckets.delete(key)}
|
||||
}
|
||||
}
|
||||
|
||||
export type RateLimitArgs = {
|
||||
/** Identifier to limit on — typically the client IP. */
|
||||
key: string
|
||||
/** Max submissions allowed per window. Defaults to 5. */
|
||||
max?: number
|
||||
/** Window length in ms. Defaults to 60_000 (one minute). */
|
||||
windowMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the request is within the limit, false when it should be
|
||||
* rejected. A missing key (no IP) is allowed through — better to accept a
|
||||
* submission than to block everyone behind a proxy that strips the header.
|
||||
*/
|
||||
export function checkRateLimit({ key, max = 5, windowMs = 60_000 }: RateLimitArgs): boolean {
|
||||
if (!key) {return true}
|
||||
|
||||
const now = Date.now()
|
||||
sweep(now)
|
||||
|
||||
const bucket = buckets.get(key)
|
||||
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs })
|
||||
return true
|
||||
}
|
||||
|
||||
if (bucket.count >= max) {
|
||||
return false
|
||||
}
|
||||
|
||||
bucket.count += 1
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'server-only'
|
||||
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import { verifyTurnstile } from '../turnstile/index.js'
|
||||
import { checkRateLimit } from './rateLimit.js'
|
||||
import { validateSubmission } from './validateSubmission.js'
|
||||
|
||||
export type SubmitFormArgs = {
|
||||
/** Submitted field data — shape matches the form's fields. */
|
||||
data: Record<string, unknown>
|
||||
/** Form-builder form ID this submission belongs to. */
|
||||
formId: string
|
||||
/** Client IP — used for Turnstile and rate limiting. */
|
||||
ip?: string
|
||||
/**
|
||||
* Rate limit: max submissions per IP per minute. Defaults to 5.
|
||||
* Set to 0 to disable (e.g. when a real limiter sits in front).
|
||||
*/
|
||||
maxPerMinute?: number
|
||||
payload: BasePayload
|
||||
/** Turnstile token; when present it is verified, when absent it is skipped. */
|
||||
turnstileToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a submission failed — a code, never a user-facing string.
|
||||
*
|
||||
* The plugin knows what went wrong; it deliberately doesn't decide how to say
|
||||
* it. The frontend maps these to its own copy, in its own language, and renders
|
||||
* whatever component fits — a field error, a toast, a full message. The plugin
|
||||
* has no business choosing the wording or the locale.
|
||||
*
|
||||
* - `rate_limited` — too many submissions from this IP
|
||||
* - `turnstile` — bot check failed
|
||||
* - `validation` — a field is missing/too long, or unknown keys were sent;
|
||||
* `field` and `kind` narrow it down when a specific field is
|
||||
* at fault (absent for whole-payload problems like unknown keys)
|
||||
* - `not_found` — no form with this id
|
||||
* - `error` — persistence failed unexpectedly
|
||||
*/
|
||||
export type SubmitFailure =
|
||||
| {
|
||||
/** The offending field's name, when one field is at fault. */
|
||||
field?: string
|
||||
/** What was wrong with it. */
|
||||
kind?: 'required' | 'too_long' | 'unknown_fields'
|
||||
reason: 'validation'
|
||||
success: false
|
||||
}
|
||||
| { reason: 'error'; success: false }
|
||||
| { reason: 'not_found'; success: false }
|
||||
| { reason: 'rate_limited'; success: false }
|
||||
| { reason: 'turnstile'; success: false }
|
||||
|
||||
export type SubmitFormResult = { submissionId: number | string; success: true } | SubmitFailure
|
||||
|
||||
/**
|
||||
* Handles a form submission end to end: rate limit, verify Turnstile, validate
|
||||
* against the form's own schema, then store.
|
||||
*
|
||||
* The order is cost-ascending on purpose — the cheapest checks reject first, so
|
||||
* a flood never reaches Turnstile's network call or the database.
|
||||
*
|
||||
* Emails aren't sent here. The form-builder sends whatever an editor configured
|
||||
* under the form's "Emails" tab (form-submissions hook → payload.sendEmail),
|
||||
* which goes out over panelSmtpAdapter. Storing the submission is enough.
|
||||
*
|
||||
* server-only: touches the Turnstile secret.
|
||||
*/
|
||||
export async function submitForm({
|
||||
data,
|
||||
formId,
|
||||
ip,
|
||||
maxPerMinute = 5,
|
||||
payload,
|
||||
turnstileToken,
|
||||
}: SubmitFormArgs): Promise<SubmitFormResult> {
|
||||
// 1. Rate limit — cheapest gate, drops a flood before any real work.
|
||||
if (maxPerMinute > 0 && ip) {
|
||||
if (!checkRateLimit({ key: ip, max: maxPerMinute })) {
|
||||
return { reason: 'rate_limited', success: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Turnstile — verify when a token is supplied; reject on failure.
|
||||
if (turnstileToken !== undefined) {
|
||||
const ok = await verifyTurnstile({ ip, payload, token: turnstileToken })
|
||||
if (!ok) {
|
||||
return { reason: 'turnstile', success: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate against the form's schema. A public endpoint can't trust the
|
||||
// shape of `data` — drop unknown keys, enforce required, cap length.
|
||||
const validation = await validateSubmission(payload, formId, data)
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === 'not_found') {
|
||||
return { reason: 'not_found', success: false }
|
||||
}
|
||||
return {
|
||||
reason: 'validation',
|
||||
success: false,
|
||||
...(validation.field ? { field: validation.field } : {}),
|
||||
...(validation.kind ? { kind: validation.kind } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Persist (form-builder shape: submissionData array). Triggers the email
|
||||
// hook. Only validated, known fields are stored.
|
||||
try {
|
||||
const submission = await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
form: formId,
|
||||
submissionData: Object.entries(validation.cleaned).map(([field, value]) => ({
|
||||
field,
|
||||
value: value == null ? '' : String(value),
|
||||
})),
|
||||
} as never,
|
||||
})
|
||||
return { submissionId: submission.id, success: true }
|
||||
} catch (err) {
|
||||
payload.logger.error(`[ipal] Form submission failed: ${(err as Error).message}`)
|
||||
return { reason: 'error', success: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CollectionConfig, Field } from 'payload'
|
||||
|
||||
/**
|
||||
* Receives the collection's default fields and returns the final list — add,
|
||||
* remove, or reorder. Same shape the form-builder uses.
|
||||
*/
|
||||
export type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]
|
||||
|
||||
/**
|
||||
* Overrides for a forms-related collection: replace the fields and/or any
|
||||
* other collection setting (admin, access, hooks…).
|
||||
*/
|
||||
export type FormsCollectionOverrides = {
|
||||
fields?: FormsFieldsOverride
|
||||
} & Partial<Omit<CollectionConfig, 'fields'>>
|
||||
|
||||
/**
|
||||
* Forms configuration — mirrors the fields a client enables in the
|
||||
* form-builder plugin. Kept minimal; the plugin passes these through.
|
||||
*/
|
||||
export type FormsOption = {
|
||||
/** Field types available in the form builder. Sensible defaults applied. */
|
||||
fields?: {
|
||||
checkbox?: boolean
|
||||
email?: boolean
|
||||
message?: boolean
|
||||
number?: boolean
|
||||
payment?: boolean
|
||||
select?: boolean
|
||||
text?: boolean
|
||||
textarea?: boolean
|
||||
}
|
||||
/**
|
||||
* Override the forms collection. The plugin stays opinion-free about what a
|
||||
* form needs beyond its fields — a client that wants, say, a per-form
|
||||
* notification address adds it here:
|
||||
*
|
||||
* formOverrides: {
|
||||
* fields: ({ defaultFields }) => [
|
||||
* ...defaultFields,
|
||||
* { name: 'notificationEmail', type: 'email' },
|
||||
* ],
|
||||
* }
|
||||
*/
|
||||
formOverrides?: FormsCollectionOverrides
|
||||
/** Override the form-submissions collection (same shape). */
|
||||
formSubmissionOverrides?: FormsCollectionOverrides
|
||||
/** Collections a form can redirect to (e.g. ['pages']). */
|
||||
redirectRelationships?: string[]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
/** A form-builder field, trimmed to what validation needs. */
|
||||
type FormField = {
|
||||
blockType?: string
|
||||
label?: string
|
||||
name?: string
|
||||
required?: boolean | null
|
||||
}
|
||||
|
||||
type FormDoc = {
|
||||
fields?: FormField[]
|
||||
id: number | string
|
||||
/** Per-form notification address, when the client added the field. */
|
||||
notificationEmail?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation outcome — codes, not user-facing strings. The frontend turns these
|
||||
* into its own copy (see SubmitFailure in submitForm).
|
||||
*/
|
||||
export type FormValidationResult =
|
||||
| {
|
||||
/** Offending field, when a single field is at fault. */
|
||||
field?: string
|
||||
kind: 'required' | 'too_long' | 'unknown_fields'
|
||||
ok: false
|
||||
reason: 'invalid'
|
||||
}
|
||||
| { cleaned: Record<string, unknown>; form: FormDoc; ok: true }
|
||||
| { ok: false; reason: 'not_found' }
|
||||
|
||||
/** Field block types that don't carry a submittable value. */
|
||||
const NON_DATA_BLOCKS = new Set(['message'])
|
||||
|
||||
/** Hard ceiling on a single field's length, independent of the form config. */
|
||||
const MAX_FIELD_LENGTH = 5000
|
||||
|
||||
/**
|
||||
* Checks submitted data against the form's own definition, rather than trusting
|
||||
* whatever arrived.
|
||||
*
|
||||
* The server action is a public endpoint: a caller can skip the rendered form
|
||||
* and post arbitrary keys. Without this, unknown fields would be stored,
|
||||
* required fields could be missing, and an oversized value could sail through.
|
||||
* So we load the form, keep only keys that are real fields, reject when a
|
||||
* required one is blank, and cap length.
|
||||
*
|
||||
* Returns the loaded form on success so the caller doesn't fetch it twice, and
|
||||
* a code + offending field on failure so the frontend can point at it.
|
||||
*/
|
||||
export async function validateSubmission(
|
||||
payload: BasePayload,
|
||||
formId: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<FormValidationResult> {
|
||||
let form: FormDoc
|
||||
try {
|
||||
form = (await payload.findByID({
|
||||
id: formId,
|
||||
collection: 'forms',
|
||||
depth: 0,
|
||||
})) as FormDoc
|
||||
} catch {
|
||||
return { ok: false, reason: 'not_found' }
|
||||
}
|
||||
|
||||
const fields = (form.fields ?? []).filter(
|
||||
(f): f is { name: string } & FormField =>
|
||||
typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''),
|
||||
)
|
||||
const known = new Map(fields.map((f) => [f.name, f]))
|
||||
|
||||
const cleaned: Record<string, unknown> = {}
|
||||
|
||||
for (const field of fields) {
|
||||
const value = data[field.name]
|
||||
const isBlank =
|
||||
value == null || (typeof value === 'string' && value.trim() === '') || value === false
|
||||
|
||||
if (field.required && isBlank) {
|
||||
return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {
|
||||
return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' }
|
||||
}
|
||||
|
||||
// Only carry through keys that belong to the form — unknown keys from a
|
||||
// hand-crafted request are dropped, not stored.
|
||||
if (value !== undefined) {
|
||||
cleaned[field.name] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Reject outright if the payload carried keys the form doesn't define — a
|
||||
// sign the request wasn't produced by the rendered form.
|
||||
const unknownKeys = Object.keys(data).filter((k) => !known.has(k))
|
||||
if (unknownKeys.length > 0) {
|
||||
return { kind: 'unknown_fields', ok: false, reason: 'invalid' }
|
||||
}
|
||||
|
||||
return { cleaned, form, ok: true }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { BasePayload, SanitizedConfig } from 'payload'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import { cache } from 'react'
|
||||
|
||||
import type { ContentOption, ResolvedRoute } from '../content/index.js'
|
||||
|
||||
import { resolveRoute as resolveRouteRaw } from '../content/index.js'
|
||||
|
||||
type CreateContentHelpersArgs = {
|
||||
/**
|
||||
* The client's payload config promise (the default export of payload.config).
|
||||
* Passed in because the plugin never imports the client's config directly.
|
||||
*/
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||
* wrappers every project was writing by hand (getPayload, settings, locale
|
||||
* list, route resolution).
|
||||
*
|
||||
* Everything is wrapped in React `cache()`, so within one request a value is
|
||||
* fetched once no matter how many times it's asked for — which matters because
|
||||
* Next runs generateMetadata and the page component separately, and both hit
|
||||
* these. Crucially the Payload instance is cached *here*, once, so every helper
|
||||
* shares it; that's why this is a factory and not loose functions importing a
|
||||
* shared module.
|
||||
*
|
||||
* ```ts
|
||||
* // src/lib/content.ts
|
||||
* import { createContentHelpers } from 'ipal-kit'
|
||||
* import config from '@/payload.config'
|
||||
* import { contentConfig } from '@/content.config'
|
||||
*
|
||||
* export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute } =
|
||||
* createContentHelpers({ config, content: contentConfig })
|
||||
* ```
|
||||
*/
|
||||
export function createContentHelpers({
|
||||
config,
|
||||
content,
|
||||
pagesSlug = 'pages',
|
||||
settingsSlug = 'site-settings',
|
||||
}: CreateContentHelpersArgs) {
|
||||
const getCachedPayload = cache(async (): Promise<BasePayload> =>
|
||||
getPayload({ config: await config }),
|
||||
)
|
||||
|
||||
const getConfiguredLocales = cache(async (): Promise<string[]> => {
|
||||
const c = await config
|
||||
return c.localization ? c.localization.locales.map((l) => l.code) : []
|
||||
})
|
||||
|
||||
const getSettings = cache(async (locale: string) => {
|
||||
const payload = await getCachedPayload()
|
||||
return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolves a URL to a page / archive / entry. Pass `withEntries` on the page
|
||||
* component (it needs the listing); omit it for metadata (it doesn't, and the
|
||||
* query would be wasted).
|
||||
*/
|
||||
const resolveRoute = cache(
|
||||
async (
|
||||
locale: string,
|
||||
segments: string[] | undefined,
|
||||
page: number,
|
||||
withEntries = false,
|
||||
): Promise<null | ResolvedRoute> => {
|
||||
const payload = await getCachedPayload()
|
||||
return resolveRouteRaw({
|
||||
content,
|
||||
locale,
|
||||
page,
|
||||
pagesSlug,
|
||||
payload,
|
||||
segments,
|
||||
settingsSlug,
|
||||
withEntries,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
return { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createContentHelpers } from './createContentHelpers.js'
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { LocalizedSlugs } from './localizedPath.js'
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
import { isValidLocale } from './helpers.js'
|
||||
|
||||
/**
|
||||
* A localized field as Payload returns it when queried with `locale: 'all'`:
|
||||
* a map of locale code → value.
|
||||
*/
|
||||
type LocalizedField = Record<string, unknown>
|
||||
|
||||
type GetLocalizedSlugsArgs = {
|
||||
config: I18nConfig
|
||||
/**
|
||||
* The document's localized slug field, as returned by Payload with
|
||||
* `locale: 'all'` — e.g. { pl: 'strona-glowna', en: 'home' }.
|
||||
*/
|
||||
slugField: LocalizedField | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a document's localized slug field into the LocalizedSlugs
|
||||
* contract consumed by buildLocalizedPath / switchLocalePath.
|
||||
*
|
||||
* Pure function — the template fetches the document (payload.findByID with
|
||||
* `locale: 'all'`, where the slug field must be `localized: true`) and passes
|
||||
* the raw field in. The plugin never touches the database.
|
||||
*
|
||||
* Keeps only entries whose locale is configured and whose slug is a
|
||||
* non-empty string, so callers get a clean, trustworthy map.
|
||||
*
|
||||
* @example
|
||||
* const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
|
||||
* const slugs = getLocalizedSlugs({ slugField: doc.slug, config })
|
||||
* // → { pl: 'strona-glowna', en: 'home' }
|
||||
* switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en'
|
||||
*/
|
||||
export function getLocalizedSlugs({ config, slugField }: GetLocalizedSlugsArgs): LocalizedSlugs {
|
||||
const result: LocalizedSlugs = {}
|
||||
|
||||
if (!slugField || typeof slugField !== 'object') {
|
||||
return result
|
||||
}
|
||||
|
||||
for (const [locale, value] of Object.entries(slugField)) {
|
||||
if (!isValidLocale(locale, config)) {continue}
|
||||
if (typeof value !== 'string') {continue}
|
||||
|
||||
const slug = value.trim()
|
||||
if (!slug) {continue}
|
||||
|
||||
result[locale] = slug
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { I18nConfig, LocaleDefinition } from './types.js'
|
||||
|
||||
/**
|
||||
* Returns all configured locale codes.
|
||||
*/
|
||||
export function getLocaleCodes(config: I18nConfig): string[] {
|
||||
return config.locales.map((locale) => locale.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default locale code.
|
||||
*/
|
||||
export function getDefaultLocale(config: I18nConfig): string {
|
||||
return config.defaultLocale
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid configured locale code.
|
||||
*/
|
||||
export function isValidLocale(code: string, config: I18nConfig): boolean {
|
||||
return config.locales.some((locale) => locale.code === code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full locale definition for a given code, or undefined if not found.
|
||||
*/
|
||||
export function getLocaleDefinition(
|
||||
code: string,
|
||||
config: I18nConfig,
|
||||
): LocaleDefinition | undefined {
|
||||
return config.locales.find((locale) => locale.code === code)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { getLocalizedSlugs } from './getLocalizedSlugs.js'
|
||||
export { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js'
|
||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js'
|
||||
export type { LocaleMiddlewareResult } from './localeMiddleware.js'
|
||||
export { buildLocalizationConfig } from './localizationConfig.js'
|
||||
export { buildLocalizedPath, switchLocalePath } from './localizedPath.js'
|
||||
export type { LocalizedSlugs } from './localizedPath.js'
|
||||
export { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js'
|
||||
export type { I18nConfig, LocaleDefinition } from './types.js'
|
||||
export { validateI18nConfig } from './validation.js'
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'
|
||||
|
||||
/**
|
||||
* Minimal request shape the middleware reads. Kept structural so the plugin
|
||||
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
||||
*/
|
||||
type MiddlewareRequest = {
|
||||
cookies: { get: (name: string) => { value: string } | undefined }
|
||||
headers: { get: (name: string) => null | string }
|
||||
nextUrl: { clone: () => URL; pathname: string; search: string }
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What the factory returns — the caller (in Next next-middleware.ts) decides how to
|
||||
* act: `redirect` means send a 307 to `location` and set the locale cookie;
|
||||
* `next` means let the request pass through untouched.
|
||||
*/
|
||||
export type LocaleMiddlewareResult =
|
||||
{ cookie: { name: string; value: string }; location: string; type: 'redirect' } | { type: 'next' }
|
||||
|
||||
type CreateLocaleMiddlewareArgs = {
|
||||
config: I18nConfig
|
||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||
cookieName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* First path segment of a URL pathname, or '' for root.
|
||||
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
|
||||
*/
|
||||
function firstSegment(pathname: string): string {
|
||||
return pathname.split('/').filter(Boolean)[0] ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds locale-routing logic for Next.js middleware.
|
||||
*
|
||||
* Behavior:
|
||||
* - path already starts with a valid locale (/pl/...) → pass through
|
||||
* - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale
|
||||
* comes from negotiateLocale (cookie → Accept-Language → default)
|
||||
* - the chosen locale is written to a cookie so the next visit is stable
|
||||
*
|
||||
* The plugin returns a decision; the thin next-middleware.ts in the client project
|
||||
* turns it into a NextResponse. This keeps all logic in the plugin while
|
||||
* respecting that next-middleware.ts must physically live in the client app.
|
||||
*
|
||||
* @example
|
||||
* // next-middleware.ts (client project) — one wiring file, no logic:
|
||||
* import { NextResponse } from 'next/server'
|
||||
* import { localeMiddleware } from './ipal.middleware' // created from this factory
|
||||
* export function middleware(req) {
|
||||
* const r = localeMiddleware(req)
|
||||
* if (r.type === 'next') return NextResponse.next()
|
||||
* const res = NextResponse.redirect(r.location)
|
||||
* res.cookies.set(r.cookie.name, r.cookie.value)
|
||||
* return res
|
||||
* }
|
||||
*/
|
||||
export function createLocaleMiddleware({
|
||||
config,
|
||||
cookieName = LOCALE_COOKIE_NAME,
|
||||
}: CreateLocaleMiddlewareArgs) {
|
||||
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Already locale-prefixed → nothing to do
|
||||
if (isValidLocale(firstSegment(pathname), config)) {
|
||||
return { type: 'next' }
|
||||
}
|
||||
|
||||
// Resolve the locale to use
|
||||
const locale = negotiateLocale({
|
||||
acceptLanguage: request.headers.get('accept-language'),
|
||||
config,
|
||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||
})
|
||||
|
||||
// Redirect to the locale-prefixed path, preserving the rest
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`
|
||||
|
||||
return {
|
||||
type: 'redirect',
|
||||
cookie: { name: cookieName, value: locale },
|
||||
location: url.toString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Next.js middleware matcher: run on everything except API routes, the
|
||||
* admin panel, Next internals, and files with an extension (static assets).
|
||||
*/
|
||||
export const DEFAULT_MIDDLEWARE_MATCHER = ['/((?!api|admin|_next|.*\\..*).*)']
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Config } from 'payload'
|
||||
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
type PayloadLocalizationConfig = NonNullable<Config['localization']>
|
||||
|
||||
/**
|
||||
* Transforms validated i18n config into Payload's localization object.
|
||||
* Pure function — no side effects, no validation (caller validates first).
|
||||
*/
|
||||
export function buildLocalizationConfig(config: I18nConfig): PayloadLocalizationConfig {
|
||||
const { defaultLocale, fallback = true, locales } = config
|
||||
|
||||
return {
|
||||
defaultLocale,
|
||||
fallback,
|
||||
locales: locales.map((locale) => ({
|
||||
code: locale.code,
|
||||
label: locale.label,
|
||||
...(locale.rtl && { rtl: true }),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
import { isValidLocale } from './helpers.js'
|
||||
|
||||
/**
|
||||
* Minimal shape the path builder needs from a document.
|
||||
*
|
||||
* The plugin doesn't know the client's Pages type, so it depends only on
|
||||
* this contract: a map of locale code → slug for that locale. The template
|
||||
* supplies it (e.g. by reading the localized slug field across locales).
|
||||
*/
|
||||
export type LocalizedSlugs = Record<string, string>
|
||||
|
||||
type BuildPathArgs = {
|
||||
config: I18nConfig
|
||||
/**
|
||||
* Slug that represents the site root (served at /{locale} with no trailing
|
||||
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
||||
*/
|
||||
homeSlug?: string
|
||||
/** Target locale to build the path for */
|
||||
locale: string
|
||||
/**
|
||||
* Localized segment the document lives under, e.g.
|
||||
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
||||
*
|
||||
* These are the slugs of the collection's archive page, so the prefix is
|
||||
* whatever an editor named that page — and it differs per locale for free.
|
||||
* A document under a prefix is never the home page, so homeSlug is ignored.
|
||||
*/
|
||||
prefix?: LocalizedSlugs
|
||||
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
||||
slugs: LocalizedSlugs
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a locale-prefixed path for a document in a target locale.
|
||||
*
|
||||
* Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug
|
||||
* collapses to the locale root. Returns undefined if the document has no slug
|
||||
* in the target locale (caller decides fallback behavior).
|
||||
*
|
||||
* @example
|
||||
* buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config })
|
||||
* // → '/en' (home slug collapses to root)
|
||||
*
|
||||
* buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config })
|
||||
* // → '/en/about'
|
||||
*
|
||||
* buildLocalizedPath({
|
||||
* slugs: { pl: 'moj-post', en: 'my-post' },
|
||||
* prefix: { pl: 'artykuly', en: 'articles' },
|
||||
* locale: 'en',
|
||||
* config,
|
||||
* })
|
||||
* // → '/en/articles/my-post'
|
||||
*/
|
||||
export function buildLocalizedPath({
|
||||
config,
|
||||
homeSlug = 'home',
|
||||
locale,
|
||||
prefix,
|
||||
slugs,
|
||||
}: BuildPathArgs): string | undefined {
|
||||
if (!isValidLocale(locale, config)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const slug = slugs[locale]
|
||||
if (!slug) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (prefix) {
|
||||
const segment = prefix[locale]
|
||||
// No archive slug in this locale means the entry is unreachable there —
|
||||
// there's no path to point at, so hreflang should omit it rather than
|
||||
// invent /en/moj-post.
|
||||
if (!segment) {
|
||||
return undefined
|
||||
}
|
||||
return `/${locale}/${segment}/${slug}`
|
||||
}
|
||||
|
||||
if (slug === homeSlug) {
|
||||
return `/${locale}`
|
||||
}
|
||||
|
||||
return `/${locale}/${slug}`
|
||||
}
|
||||
|
||||
type SwitchLocaleArgs = {
|
||||
config: I18nConfig
|
||||
homeSlug?: string
|
||||
prefix?: LocalizedSlugs
|
||||
slugs: LocalizedSlugs
|
||||
targetLocale: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the equivalent path for the same document in a different locale —
|
||||
* the language-switcher use case (/pl/strona-glowna → /en/home).
|
||||
*
|
||||
* Never dead-ends on a 404. When the document has no slug in the target locale,
|
||||
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
||||
* to the locale root otherwise — the closest place the visitor would want.
|
||||
*/
|
||||
export function switchLocalePath({
|
||||
config,
|
||||
homeSlug = 'home',
|
||||
prefix,
|
||||
slugs,
|
||||
targetLocale,
|
||||
}: SwitchLocaleArgs): string {
|
||||
const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })
|
||||
if (path) {return path}
|
||||
|
||||
const archiveSegment = prefix?.[targetLocale]
|
||||
if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}
|
||||
|
||||
return `/${targetLocale}`
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
import { getLocaleCodes, isValidLocale } from './helpers.js'
|
||||
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */
|
||||
export const LOCALE_COOKIE_NAME = 'ipal-locale'
|
||||
|
||||
type NegotiateLocaleArgs = {
|
||||
/** Raw Accept-Language header value */
|
||||
acceptLanguage?: null | string
|
||||
config: I18nConfig
|
||||
/** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */
|
||||
cookieLocale?: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which locale to serve, in priority order:
|
||||
* 1. Cookie (explicit prior choice)
|
||||
* 2. Accept-Language header (best match against configured locales)
|
||||
* 3. Configured default locale
|
||||
*
|
||||
* Pure function — the template feeds it request data and acts on the result
|
||||
* (redirect, cookie set). No Next.js or request objects here.
|
||||
*/
|
||||
export function negotiateLocale({
|
||||
acceptLanguage,
|
||||
config,
|
||||
cookieLocale,
|
||||
}: NegotiateLocaleArgs): string {
|
||||
// 1. Explicit prior choice wins
|
||||
if (cookieLocale && isValidLocale(cookieLocale, config)) {
|
||||
return cookieLocale
|
||||
}
|
||||
|
||||
// 2. Best match from Accept-Language
|
||||
const fromHeader = matchAcceptLanguage(acceptLanguage, config)
|
||||
if (fromHeader) {
|
||||
return fromHeader
|
||||
}
|
||||
|
||||
// 3. Fall back to configured default
|
||||
return config.defaultLocale
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an Accept-Language header and returns the best-matching configured
|
||||
* locale, or undefined if none match.
|
||||
*
|
||||
* Matches on the primary subtag (e.g. "en-US" matches configured "en"),
|
||||
* respecting the header's quality-value ordering.
|
||||
*/
|
||||
export function matchAcceptLanguage(
|
||||
acceptLanguage: null | string | undefined,
|
||||
config: I18nConfig,
|
||||
): string | undefined {
|
||||
if (!acceptLanguage) {return undefined}
|
||||
|
||||
const available = getLocaleCodes(config)
|
||||
const ranked = parseAcceptLanguage(acceptLanguage)
|
||||
|
||||
for (const tag of ranked) {
|
||||
// Exact match (e.g. "pt-BR" === "pt-BR")
|
||||
const exact = available.find((code) => code.toLowerCase() === tag)
|
||||
if (exact) {return exact}
|
||||
|
||||
// Primary-subtag match (e.g. "en-us" → "en")
|
||||
const primary = tag.split('-')[0]
|
||||
const partial = available.find((code) => code.toLowerCase().split('-')[0] === primary)
|
||||
if (partial) {return partial}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an Accept-Language header into locale tags ordered by descending
|
||||
* quality value. Tags are lowercased for comparison.
|
||||
*
|
||||
* "en-US,en;q=0.9,pl;q=0.8" → ["en-us", "en", "pl"]
|
||||
*/
|
||||
function parseAcceptLanguage(header: string): string[] {
|
||||
return header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [tag, ...params] = part.trim().split(';')
|
||||
const qParam = params.find((p) => p.trim().startsWith('q='))
|
||||
const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1
|
||||
return { quality: Number.isNaN(quality) ? 0 : quality, tag: tag.trim().toLowerCase() }
|
||||
})
|
||||
.filter((entry) => entry.tag && entry.tag !== '*')
|
||||
.sort((a, b) => b.quality - a.quality)
|
||||
.map((entry) => entry.tag)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Single locale definition provided by the client project.
|
||||
*/
|
||||
export type LocaleDefinition = {
|
||||
/** BCP-47 language code, e.g. 'pl', 'en', 'de' */
|
||||
code: string
|
||||
/** Human-readable label, e.g. 'Polski', 'English' */
|
||||
label: string
|
||||
/** Right-to-left script (defaults to false) */
|
||||
rtl?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Full i18n configuration passed through plugin options.
|
||||
*/
|
||||
export type I18nConfig = {
|
||||
defaultLocale: string
|
||||
/** Enable locale fallback when content is missing (defaults to true) */
|
||||
fallback?: boolean
|
||||
locales: [LocaleDefinition, ...LocaleDefinition[]]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
const LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Z]{2})?$/
|
||||
|
||||
/**
|
||||
* Validates i18n config at plugin initialization.
|
||||
* Throws descriptive errors — fail fast, no silent defaults.
|
||||
*/
|
||||
export function validateI18nConfig(config: I18nConfig): void {
|
||||
const { defaultLocale, locales } = config
|
||||
|
||||
if (!locales?.length) {
|
||||
throw new Error('[ipal] i18n: "locales" must contain at least one locale.')
|
||||
}
|
||||
|
||||
const codes = new Set<string>()
|
||||
|
||||
for (const locale of locales) {
|
||||
if (!locale.code || !locale.label) {
|
||||
throw new Error(
|
||||
`[ipal] i18n: Every locale must have "code" and "label". Received: ${JSON.stringify(locale)}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!LOCALE_CODE_PATTERN.test(locale.code)) {
|
||||
throw new Error(
|
||||
`[ipal] i18n: Invalid locale code "${locale.code}". Expected format: "pl", "en", "pt-BR".`,
|
||||
)
|
||||
}
|
||||
|
||||
if (codes.has(locale.code)) {
|
||||
throw new Error(`[ipal] i18n: Duplicate locale code "${locale.code}".`)
|
||||
}
|
||||
|
||||
codes.add(locale.code)
|
||||
}
|
||||
|
||||
if (!codes.has(defaultLocale)) {
|
||||
throw new Error(
|
||||
`[ipal] i18n: defaultLocale "${defaultLocale}" not found in locales [${[...codes].join(', ')}].`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { I18nConfig } from '../i18n/index.js'
|
||||
import type { SystemPageRole } from './types.js'
|
||||
|
||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'
|
||||
|
||||
/**
|
||||
* The shape we need from an assigned system-page document: its localized slug
|
||||
* field, as returned by Payload when the parent is read with `locale: 'all'`.
|
||||
*
|
||||
* The plugin doesn't know the client's Pages type, so it depends only on this
|
||||
* minimal contract.
|
||||
*/
|
||||
type AssignedPage = {
|
||||
slug?: null | Record<string, unknown>
|
||||
}
|
||||
|
||||
type GetSystemPagePathArgs = {
|
||||
config: I18nConfig
|
||||
/**
|
||||
* Slug that represents the site root. Defaults to 'home'. When the assigned
|
||||
* page's slug in the target locale equals this, the path collapses to the
|
||||
* locale root (/pl, /en).
|
||||
*/
|
||||
homeSlug?: string
|
||||
/** Target locale to build the path for. */
|
||||
locale: string
|
||||
/**
|
||||
* The resolved system-page assignment from SiteSettings — the related
|
||||
* document object (not just an ID), read with `locale: 'all'` so its slug
|
||||
* is a locale→value map. Pass null/undefined if the role is unassigned.
|
||||
*/
|
||||
page: AssignedPage | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a system-page assignment to a locale-aware path.
|
||||
*
|
||||
* Bridges the Pages module (which document plays a role) and the i18n module
|
||||
* (how that document's localized slug becomes a URL). This is what powers
|
||||
* "visit /pl → serve the homepage": read SiteSettings.homepage, pass the
|
||||
* related document here, get /pl/strona-glowna (or /pl if it's the home slug).
|
||||
*
|
||||
* Returns undefined when the role is unassigned or the assigned page has no
|
||||
* slug in the target locale — the caller decides the fallback (e.g. 404,
|
||||
* redirect to default locale).
|
||||
*
|
||||
* @example
|
||||
* const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })
|
||||
* getSystemPagePath({ page: settings.homepage, locale: 'pl', config })
|
||||
* // → '/pl' (home slug collapses to root)
|
||||
* getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })
|
||||
* // → '/en/privacy-policy'
|
||||
*/
|
||||
export function getSystemPagePath({
|
||||
config,
|
||||
homeSlug = 'home',
|
||||
locale,
|
||||
page,
|
||||
}: GetSystemPagePathArgs): string | undefined {
|
||||
if (!page?.slug) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const slugs = getLocalizedSlugs({ config, slugField: page.slug })
|
||||
return buildLocalizedPath({ config, homeSlug, locale, slugs })
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { getSystemPagePath } from './getSystemPagePath.js'
|
||||
export { buildSystemPagesFields } from './systemPagesFields.js'
|
||||
export type { PagesOption, SystemPageRole } from './types.js'
|
||||
export { ALL_SYSTEM_PAGE_ROLES } from './types.js'
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Field, RelationshipField } from 'payload'
|
||||
|
||||
import type { PagesOption, SystemPageRole } from './types.js'
|
||||
|
||||
import { ALL_SYSTEM_PAGE_ROLES } from './types.js'
|
||||
|
||||
/** Admin-facing labels per role. */
|
||||
const ROLE_LABELS: Record<SystemPageRole, string> = {
|
||||
cookiePolicy: 'Cookie Policy',
|
||||
homepage: 'Homepage',
|
||||
privacyPolicy: 'Privacy Policy',
|
||||
}
|
||||
|
||||
/** Admin descriptions per role. */
|
||||
const ROLE_DESCRIPTIONS: Record<SystemPageRole, string> = {
|
||||
cookiePolicy: 'Page linked from the cookie consent banner.',
|
||||
homepage: 'Page served at the locale root (e.g. /pl, /en).',
|
||||
privacyPolicy: 'Page linked as the privacy policy.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one relationship field per configured system-page role.
|
||||
*
|
||||
* Each field points at the client's Pages collection (via the provided slug)
|
||||
* and holds a single document reference. The referenced document's slug is
|
||||
* localized, so the same assignment resolves to /pl/strona-glowna and
|
||||
* /en/home at runtime — role assignment is locale-agnostic, path resolution
|
||||
* is locale-aware (see getSystemPagePath).
|
||||
*/
|
||||
export function buildSystemPagesFields(pages: PagesOption): Field[] {
|
||||
const roles = pages.roles?.length ? pages.roles : [...ALL_SYSTEM_PAGE_ROLES]
|
||||
|
||||
return roles.map((role): RelationshipField => ({
|
||||
name: role,
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
description: ROLE_DESCRIPTIONS[role],
|
||||
},
|
||||
label: ROLE_LABELS[role],
|
||||
maxDepth: 1,
|
||||
relationTo: pages.slug,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Built-in "system page" roles — documents from the client's Pages
|
||||
* collection that the site references by function rather than by slug.
|
||||
*/
|
||||
export type SystemPageRole = 'cookiePolicy' | 'homepage' | 'privacyPolicy'
|
||||
|
||||
/**
|
||||
* All system roles in a stable order (used when no subset is configured).
|
||||
*/
|
||||
export const ALL_SYSTEM_PAGE_ROLES: readonly SystemPageRole[] = [
|
||||
'homepage',
|
||||
'privacyPolicy',
|
||||
'cookiePolicy',
|
||||
]
|
||||
|
||||
/**
|
||||
* Plugin option enabling system-page assignments in SiteSettings.
|
||||
*
|
||||
* The plugin doesn't own the Pages collection — the client creates it in
|
||||
* their own project. This option hands the plugin the collection's slug so
|
||||
* it can build relationship fields pointing at it. No hardcoding: the slug
|
||||
* always comes from the client.
|
||||
*/
|
||||
export type PagesOption = {
|
||||
/**
|
||||
* Which system roles to expose as assignable fields.
|
||||
* Defaults to all roles when omitted.
|
||||
*/
|
||||
roles?: SystemPageRole[]
|
||||
/** Slug of the client's Pages collection, e.g. 'pages'. */
|
||||
slug: string
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
/** Locale argument accepted by the global helpers. */
|
||||
export type GlobalQueryOptions = {
|
||||
/** Relationship population depth. Defaults to Payload's config default. */
|
||||
depth?: number
|
||||
/** Locale to fetch, or 'all' for every locale's values. Defaults to Payload's default. */
|
||||
locale?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper over payload.findGlobal that keeps locale/depth handling in one
|
||||
* place. The plugin never calls getPayload itself — the caller passes the
|
||||
* instance (from getPayload in their app, or req.payload in a hook), matching
|
||||
* the plugin's data-access rule: logic here, instance from outside.
|
||||
*/
|
||||
export async function getGlobal<T = Record<string, unknown>>(
|
||||
payload: BasePayload,
|
||||
slug: string,
|
||||
options: GlobalQueryOptions = {},
|
||||
): Promise<T> {
|
||||
const result = await payload.findGlobal({
|
||||
slug,
|
||||
...(options.locale ? { locale: options.locale as never } : {}),
|
||||
...(typeof options.depth === 'number' ? { depth: options.depth } : {}),
|
||||
})
|
||||
|
||||
return result as T
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import type { GlobalQueryOptions } from './getGlobal.js'
|
||||
|
||||
import { getGlobal } from './getGlobal.js'
|
||||
|
||||
/** Slug of the SiteIntegrations global defined by the plugin. */
|
||||
export const SITE_INTEGRATIONS_SLUG = 'site-integrations'
|
||||
|
||||
/**
|
||||
* Fetches the SiteIntegrations global.
|
||||
*
|
||||
* This global is admin-only through access control, but the Local API bypasses
|
||||
* access control by default (overrideAccess: true), so server-side callers get
|
||||
* the secrets they need (SMTP password, Turnstile secret, R2 keys). Never
|
||||
* expose the raw result to the client — read the specific values you need
|
||||
* server-side and pass only what's safe to the browser.
|
||||
*
|
||||
* Generic over the return type so the client can pass their generated
|
||||
* `SiteIntegration` type.
|
||||
*
|
||||
* @example
|
||||
* import type { SiteIntegration } from '@/payload-types'
|
||||
* const integrations = await getSiteIntegrations<SiteIntegration>(payload)
|
||||
*/
|
||||
export function getSiteIntegrations<T = Record<string, unknown>>(
|
||||
payload: BasePayload,
|
||||
options?: GlobalQueryOptions,
|
||||
): Promise<T> {
|
||||
return getGlobal<T>(payload, SITE_INTEGRATIONS_SLUG, options)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import type { GlobalQueryOptions } from './getGlobal.js'
|
||||
|
||||
import { getGlobal } from './getGlobal.js'
|
||||
|
||||
/** Slug of the SiteSettings global defined by the plugin. */
|
||||
export const SITE_SETTINGS_SLUG = 'site-settings'
|
||||
|
||||
/**
|
||||
* Fetches the SiteSettings global.
|
||||
*
|
||||
* Generic over the return type so the client can pass their generated
|
||||
* `SiteSetting` type for full type safety, while still working without it.
|
||||
*
|
||||
* @example
|
||||
* import type { SiteSetting } from '@/payload-types'
|
||||
* const settings = await getSiteSettings<SiteSetting>(payload, { locale: 'pl' })
|
||||
*/
|
||||
export function getSiteSettings<T = Record<string, unknown>>(
|
||||
payload: BasePayload,
|
||||
options?: GlobalQueryOptions,
|
||||
): Promise<T> {
|
||||
return getGlobal<T>(payload, SITE_SETTINGS_SLUG, options)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { GlobalQueryOptions } from './getGlobal.js'
|
||||
export { getGlobal } from './getGlobal.js'
|
||||
export { getSiteIntegrations, SITE_INTEGRATIONS_SLUG } from './getSiteIntegrations.js'
|
||||
export { getSiteSettings, SITE_SETTINGS_SLUG } from './getSiteSettings.js'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user