init commit for iPAL-kit plugin

This commit is contained in:
2026-07-18 20:30:23 +02:00
parent 10638127a9
commit 5733a9c8bf
119 changed files with 6892 additions and 411 deletions
+3 -6
View File
@@ -1,9 +1,6 @@
import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'ipal-kit/client' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'ipal-kit/rsc'
/** @type import('payload').ImportMap */
export const importMap = { export const importMap = {
'ipal-kit/client#BeforeDashboardClient': "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343,
'ipal-kit/rsc#BeforeDashboardServer':
BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f,
} }
+2 -2
View File
@@ -1,4 +1,4 @@
export const devUser = { export const devUser = {
email: 'dev@payloadcms.com', email: 'it@intecion.com',
password: 'test', password: '[email protected]',
} }
+378 -70
View File
@@ -6,38 +6,104 @@
* and re-run `payload generate:types` to regenerate this file. * 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 { export interface Config {
auth: { auth: {
users: UserAuthOperations; users: UserAuthOperations;
}; };
blocks: {};
collections: { collections: {
users: User;
posts: Post; posts: Post;
media: Media; media: Media;
'plugin-collection': PluginCollection; 'payload-kv': PayloadKv;
users: User;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
}; };
collectionsJoins: {}; collectionsJoins: {};
collectionsSelect: { collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>; posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>; media: MediaSelect<false> | MediaSelect<true>;
'plugin-collection': PluginCollectionSelect<false> | PluginCollectionSelect<true>; 'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
}; };
db: { db: {
defaultIDType: string; defaultIDType: number;
}; };
globals: {}; fallbackLocale: ('false' | 'none' | 'null') | false | null | ('pl' | 'en') | ('pl' | 'en')[];
globalsSelect: {}; globals: {
locale: null; 'site-settings': SiteSetting;
user: User & { 'site-integrations': SiteIntegration;
collection: 'users'; '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: { jobs: {
tasks: unknown; tasks: unknown;
workflows: unknown; workflows: unknown;
@@ -61,13 +127,49 @@ export interface UserAuthOperations {
password: string; 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts". * via the `definition` "posts".
*/ */
export interface Post { export interface Post {
id: string; id: number;
addedByPlugin?: string | null; 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; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -76,7 +178,7 @@ export interface Post {
* via the `definition` "media". * via the `definition` "media".
*/ */
export interface Media { export interface Media {
id: string; id: number;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -91,57 +193,44 @@ export interface Media {
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-collection". * via the `definition` "payload-kv".
*/ */
export interface PluginCollection { export interface PayloadKv {
id: string; id: number;
updatedAt: string; key: string;
createdAt: string; data:
| {
[k: string]: unknown;
} }
/** | unknown[]
* This interface was referenced by `Config`'s JSON-Schema | string
* via the `definition` "users". | number
*/ | boolean
export interface User { | null;
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;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
*/ */
export interface PayloadLockedDocument { export interface PayloadLockedDocument {
id: string; id: number;
document?: document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({ | ({
relationTo: 'posts'; relationTo: 'posts';
value: string | Post; value: number | Post;
} | null) } | null)
| ({ | ({
relationTo: 'media'; relationTo: 'media';
value: string | Media; value: number | Media;
} | null)
| ({
relationTo: 'plugin-collection';
value: string | PluginCollection;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}; };
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -151,10 +240,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences". * via the `definition` "payload-preferences".
*/ */
export interface PayloadPreference { export interface PayloadPreference {
id: string; id: number;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}; };
key?: string | null; key?: string | null;
value?: value?:
@@ -174,18 +263,47 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
*/ */
export interface PayloadMigration { export interface PayloadMigration {
id: string; id: number;
name?: string | null; name?: string | null;
batch?: number | null; batch?: number | null;
updatedAt: string; updatedAt: string;
createdAt: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select". * via the `definition` "posts_select".
*/ */
export interface PostsSelect<T extends boolean = true> { export interface PostsSelect<T extends boolean = true> {
addedByPlugin?: T; meta?:
| T
| {
title?: T;
description?: T;
image?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
@@ -208,27 +326,11 @@ export interface MediaSelect<T extends boolean = true> {
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * 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> { export interface PayloadKvSelect<T extends boolean = true> {
id?: T; key?: T;
updatedAt?: T; data?: 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;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -262,6 +364,212 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth". * via the `definition` "auth".
+21 -10
View File
@@ -1,9 +1,9 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb' import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { lexicalEditor } from '@payloadcms/richtext-lexical' import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { ipalKit } from 'ipal-kit'
import { MongoMemoryReplSet } from 'mongodb-memory-server' import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path' import path from 'path'
import { buildConfig } from 'payload' import { buildConfig } from 'payload'
import { ipalKit } from 'ipal-kit'
import sharp from 'sharp' import sharp from 'sharp'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -36,6 +36,11 @@ const buildConfigWithMemoryDB = async () => {
}, },
}, },
collections: [ collections: [
{
slug: 'users',
auth: true,
fields: [],
},
{ {
slug: 'posts', slug: 'posts',
fields: [], fields: [],
@@ -43,14 +48,14 @@ const buildConfigWithMemoryDB = async () => {
{ {
slug: 'media', slug: 'media',
fields: [], fields: [],
upload: { upload: { staticDir: path.resolve(dirname, 'media') },
staticDir: path.resolve(dirname, 'media'),
},
}, },
], ],
db: mongooseAdapter({
ensureIndexes: true, db: sqliteAdapter({
url: process.env.DATABASE_URL || '', client: {
url: process.env.DATABASE_URI || 'file:./payload.db',
},
}), }),
editor: lexicalEditor(), editor: lexicalEditor(),
email: testEmailAdapter, email: testEmailAdapter,
@@ -59,9 +64,15 @@ const buildConfigWithMemoryDB = async () => {
}, },
plugins: [ plugins: [
ipalKit({ ipalKit({
collections: { access: { authCollection: 'users' },
posts: true, i18n: {
defaultLocale: 'pl',
locales: [
{ code: 'pl', label: 'Polski' },
{ code: 'en', label: 'English' },
],
}, },
seo: { collections: ['posts', 'pages'] },
}), }),
], ],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key', secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
+5 -1
View File
@@ -15,7 +15,11 @@ export const seed = async (payload: Payload) => {
if (!totalDocs) { if (!totalDocs) {
await payload.create({ await payload.create({
collection: 'users', collection: 'users',
data: devUser, data: {
email: devUser.email,
password: devUser.password,
roles: ['admin'],
},
}) })
} }
} }
+125
View File
@@ -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.
+79
View File
@@ -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
```
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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'
```
+51
View File
@@ -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
View File
@@ -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.
+237
View File
@@ -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.
+494
View File
@@ -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
View File
@@ -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.
+55
View File
@@ -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']
```
+54
View File
@@ -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
View File
@@ -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: '...' }
```
+61
View File
@@ -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
View File
@@ -19,6 +19,16 @@
"import": "./src/exports/rsc.ts", "import": "./src/exports/rsc.ts",
"types": "./src/exports/rsc.ts", "types": "./src/exports/rsc.ts",
"default": "./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", "main": "./src/index.ts",
@@ -44,9 +54,16 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:int": "vitest" "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": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "3.84.1",
"@payloadcms/db-postgres": "3.84.1", "@payloadcms/db-postgres": "3.84.1",
"@payloadcms/db-sqlite": "3.84.1", "@payloadcms/db-sqlite": "3.84.1",
"@payloadcms/eslint-config": "3.28.0", "@payloadcms/eslint-config": "3.28.0",
@@ -57,6 +74,7 @@
"@swc-node/register": "1.10.9", "@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0", "@swc/cli": "0.6.0",
"@types/node": "22.19.9", "@types/node": "22.19.9",
"@types/nodemailer": "^8.0.1",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
@@ -80,7 +98,8 @@
"vitest": "4.0.18" "vitest": "4.0.18"
}, },
"peerDependencies": { "peerDependencies": {
"payload": "^3.84.1" "payload": "^3.84.1",
"react": "^19.0.0"
}, },
"engines": { "engines": {
"node": "^18.20.2 || >=20.9.0", "node": "^18.20.2 || >=20.9.0",
@@ -102,6 +121,16 @@
"import": "./dist/exports/rsc.js", "import": "./dist/exports/rsc.js",
"types": "./dist/exports/rsc.d.ts", "types": "./dist/exports/rsc.d.ts",
"default": "./dist/exports/rsc.js" "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", "main": "./dist/index.js",
@@ -114,6 +143,5 @@
"unrs-resolver" "unrs-resolver"
] ]
}, },
"registry": "https://registry.npmjs.org/", "registry": "https://registry.npmjs.org/"
"dependencies": {}
} }
+100 -138
View File
@@ -7,13 +7,29 @@ settings:
importers: 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: devDependencies:
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.3.5 version: 3.3.5
'@payloadcms/db-mongodb':
specifier: 3.84.1
version: 3.84.1([email protected]([email protected])([email protected]))
'@payloadcms/db-postgres': '@payloadcms/db-postgres':
specifier: 3.84.1 specifier: 3.84.1
version: 3.84.1(@libsql/[email protected])([email protected]([email protected])([email protected])) version: 3.84.1(@libsql/[email protected])([email protected]([email protected])([email protected]))
@@ -44,6 +60,9 @@ importers:
'@types/node': '@types/node':
specifier: 22.19.9 specifier: 22.19.9
version: 22.19.9 version: 22.19.9
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/react': '@types/react':
specifier: 19.2.14 specifier: 19.2.14
version: 19.2.14 version: 19.2.14
@@ -1747,11 +1766,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@payloadcms/[email protected]':
resolution: {integrity: sha512-HTP/Z6iQHFyHhuMImAC/GH0xGhjuvuHZHdB7crJUkXAyD677tp6C3mS8YB04s28bCteDqcXBYjHODZr5xDHBcw==}
peerDependencies:
payload: 3.84.1
'@payloadcms/[email protected]': '@payloadcms/[email protected]':
resolution: {integrity: sha512-/r1+7k58529ziTwqzySXsZb4x3FcfQIQH8R6gXLM3bTU9JA8wfLBraIgSyVKl6B3p+/EQYcaQ1DUlfrzVRxo1A==} resolution: {integrity: sha512-/r1+7k58529ziTwqzySXsZb4x3FcfQIQH8R6gXLM3bTU9JA8wfLBraIgSyVKl6B3p+/EQYcaQ1DUlfrzVRxo1A==}
peerDependencies: 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' 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 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]': '@payloadcms/[email protected]':
resolution: {integrity: sha512-KaNSz0RJFLnLc/hBRGg8Lgwk5FjCZSskA6KuufNWG5QdgUsgQe4Dx4Vr7G+VSR9zSZj+R4TVVSC0aVv4z7vL3g==} resolution: {integrity: sha512-KaNSz0RJFLnLc/hBRGg8Lgwk5FjCZSskA6KuufNWG5QdgUsgQe4Dx4Vr7G+VSR9zSZj+R4TVVSC0aVv4z7vL3g==}
engines: {node: ^18.20.2 || >=20.9.0} engines: {node: ^18.20.2 || >=20.9.0}
@@ -2162,6 +2190,9 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==}
'@types/[email protected]':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@@ -4110,10 +4141,6 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
[email protected]:
resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==}
engines: {node: '>=12.0.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -4180,6 +4207,11 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} 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]: [email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4352,33 +4384,6 @@ packages:
resolution: {integrity: sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg==} resolution: {integrity: sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg==}
engines: {node: '>=16.20.1'} 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]: [email protected]:
resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==} resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==}
engines: {node: '>=16.20.1'} engines: {node: '>=16.20.1'}
@@ -4406,32 +4411,6 @@ packages:
socks: socks:
optional: true 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]: [email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4493,6 +4472,10 @@ packages:
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==}
engines: {node: '>=6.0.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==}
@@ -5005,6 +4988,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5049,9 +5035,6 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -5071,6 +5054,10 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
engines: {node: '>=8.0.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
@@ -7044,23 +7031,6 @@ snapshots:
'@oxc-resolver/[email protected]': '@oxc-resolver/[email protected]':
optional: true 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]))': '@payloadcms/[email protected](@libsql/[email protected])([email protected]([email protected])([email protected]))':
dependencies: dependencies:
'@payloadcms/drizzle': 3.84.1(@libsql/[email protected])(@types/[email protected])([email protected]([email protected])([email protected]))([email protected]) '@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 - supports-color
- typescript - 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])': '@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: dependencies:
'@faceless-ui/modal': 3.0.0([email protected]([email protected]))([email protected]) '@faceless-ui/modal': 3.0.0([email protected]([email protected]))([email protected])
@@ -7649,6 +7647,10 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/[email protected]':
dependencies:
'@types/node': 22.19.9
'@types/[email protected]': {} '@types/[email protected]': {}
'@types/[email protected]': '@types/[email protected]':
@@ -8881,7 +8883,7 @@ snapshots:
eslint: 9.39.4 eslint: 9.39.4
eslint-import-resolver-node: 0.3.10 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-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-jsx-a11y: 6.10.2([email protected])
eslint-plugin-react: 7.37.5([email protected]) eslint-plugin-react: 7.37.5([email protected])
eslint-plugin-react-hooks: 7.1.1([email protected]) eslint-plugin-react-hooks: 7.1.1([email protected])
@@ -8918,7 +8920,7 @@ snapshots:
tinyglobby: 0.2.17 tinyglobby: 0.2.17
unrs-resolver: 1.12.2 unrs-resolver: 1.12.2
optionalDependencies: 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]) eslint-plugin-import-x: 4.6.1([email protected])([email protected])
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -8975,7 +8977,7 @@ snapshots:
- typescript - typescript
optional: true 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: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -9971,8 +9973,6 @@ snapshots:
object.assign: 4.1.7 object.assign: 4.1.7
object.values: 1.2.1 object.values: 1.2.1
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@@ -10037,6 +10037,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
[email protected]([email protected]):
dependencies:
react: 19.2.6
[email protected]: [email protected]:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -10370,58 +10374,12 @@ snapshots:
- socks - socks
- supports-color - supports-color
[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: dependencies:
'@mongodb-js/saslprep': 1.4.12 '@mongodb-js/saslprep': 1.4.12
bson: 6.10.4 bson: 6.10.4
mongodb-connection-string-url: 3.0.2 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]: {}
[email protected]: {} [email protected]: {}
@@ -10481,6 +10439,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -11088,6 +11048,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -11204,8 +11166,6 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -11220,6 +11180,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
atomic-sleep: 1.0.0 atomic-sleep: 1.0.0
-35
View File
@@ -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;
}
-19
View File
@@ -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>
)
}
-5
View File
@@ -1,5 +0,0 @@
import type { PayloadHandler } from 'payload'
export const customEndpointHandler: PayloadHandler = () => {
return Response.json({ message: 'Hello from custom endpoint' })
}
+19 -1
View File
@@ -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'
+9
View File
@@ -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
View File
@@ -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'
+17
View File
@@ -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'
+58
View File
@@ -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 },
],
},
]
+21
View File
@@ -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.',
},
},
]
+54
View File
@@ -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',
},
]
+32
View File
@@ -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,
},
]
+65
View File
@@ -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
View File
@@ -1,113 +1,95 @@
import type { CollectionSlug, Config } from 'payload' export type { AccessOption, Role } from './modules/access/index.js'
export {
import { customEndpointHandler } from './endpoints/customEndpointHandler.js' adminOnly,
adminOnlyField,
export type IpalKitConfig = { adminOrEditor,
/** adminOrEditorField,
* List of collections to add a custom field adminOrSelf,
*/ authenticated,
collections?: Partial<Record<CollectionSlug, true>> hasMinimumRole,
disabled?: boolean isAdmin,
} isEditor,
requireRole,
export const ipalKit = requireRoleField,
(pluginOptions: IpalKitConfig) => ROLE_HIERARCHY,
(config: Config): Config => { } from './modules/access/index.js'
if (!config.collections) { export type { AnalyticsConfig } from './modules/analytics/index.js'
config.collections = [] export { getAnalyticsConfig } from './modules/analytics/index.js'
} export {
ACCEPT_ALL_CONSENT,
config.collections.push({ CONSENT_CATEGORIES,
slug: 'plugin-collection', CONSENT_COOKIE,
fields: [ CONSENT_MAX_AGE,
{ CONSENT_VERSION,
name: 'id', DEFAULT_CONSENT,
type: 'text', getConsentTexts,
}, parseConsent,
], REJECT_ALL_CONSENT,
}) serializeConsent,
setDefaultConsent,
if (pluginOptions.collections) { updateConsent,
for (const collectionSlug in pluginOptions.collections) { } from './modules/consent/index.js'
const collection = config.collections.find( export type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'
(collection) => collection.slug === collectionSlug, export type {
) ContentCollectionOption,
ContentOption,
if (collection) { ResolvedRoute,
collection.fields.push({ } from './modules/content/index.js'
name: 'addedByPlugin', export {
type: 'text', archiveFieldName,
admin: { buildArchivePath,
position: 'sidebar', 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
* If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations. // Payload loads the config (or runs generate:importmap) as a plain Node script.
* If your plugin heavily modifies the database schema, you may want to remove this property. export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'
*/ export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'
if (pluginOptions.disabled) { export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'
return config export type {
} FormsCollectionOverrides,
FormsFieldsOverride,
if (!config.endpoints) { FormsOption,
config.endpoints = [] } from './modules/forms/types.js'
} export { createContentHelpers } from './modules/frontend/index.js'
export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'
if (!config.admin) { export {
config.admin = {} buildLocalizedPath,
} getDefaultLocale,
getLocaleCodes,
if (!config.admin.components) { getLocaleDefinition,
config.admin.components = {} getLocalizedSlugs,
} isValidLocale,
LOCALE_COOKIE_NAME,
if (!config.admin.components.beforeDashboard) { matchAcceptLanguage,
config.admin.components.beforeDashboard = [] negotiateLocale,
} switchLocalePath,
} from './modules/i18n/index.js'
config.admin.components.beforeDashboard.push( export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
`ipal-kit/client#BeforeDashboardClient`, export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
) export type { PagesOption, SystemPageRole } from './modules/pages/index.js'
config.admin.components.beforeDashboard.push( export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'
`ipal-kit/rsc#BeforeDashboardServer`, export type { GlobalQueryOptions } from './modules/payload/index.js'
) export {
getGlobal,
config.endpoints.push({ getSiteIntegrations,
handler: customEndpointHandler, getSiteSettings,
method: 'get', SITE_INTEGRATIONS_SLUG,
path: '/my-plugin-endpoint', SITE_SETTINGS_SLUG,
}) } from './modules/payload/index.js'
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
const incomingOnInit = config.onInit export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
export type { AutoFillMapping } from './modules/seo/index.js'
config.onInit = async (payload) => { export {
// Ensure we are executing any existing onInit functions before running our own. buildAutoFillMetaHook,
if (incomingOnInit) { createMetadataGenerator,
await incomingOnInit(payload) createPageMetadata,
} injectAutoFillMeta,
} from './modules/seo/index.js'
const { totalDocs } = await payload.count({ export { buildSlugField, toSlug } from './modules/slug/index.js'
collection: 'plugin-collection', export { default as ipalKit } from './plugin.js'
where: { export type { IpalOptions } from './types.js'
id: {
equals: 'seeded-by-plugin',
},
},
})
if (totalDocs === 0) {
await payload.create({
collection: 'plugin-collection',
data: {
id: 'seeded-by-plugin',
},
})
}
}
return config
}
+42
View File
@@ -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)
+15
View File
@@ -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'
+29
View File
@@ -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],
}
}),
}
}
+44
View File
@@ -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')
}
+34
View File
@@ -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,
}
}
+21
View File
@@ -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
}
+70
View File
@@ -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
}
+2
View File
@@ -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,
}
}
+4
View File
@@ -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'
+9
View File
@@ -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
}
+53
View File
@@ -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>
)
}
+3
View File
@@ -0,0 +1,3 @@
export { RenderBlocks } from './RenderBlocks.js'
export type { RenderBlocksProps } from './RenderBlocks.js'
export type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'
+29
View File
@@ -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>
+21
View File
@@ -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
}
+147
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+25
View File
@@ -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 }
+7
View File
@@ -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'
+99
View File
@@ -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,
}
}
+55
View File
@@ -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))
}
+20
View File
@@ -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'
+43
View File
@@ -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
}
}
+16
View File
@@ -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
}
+47
View File
@@ -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 }
}
+32
View File
@@ -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,
}
})
}
+69
View File
@@ -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,
}
}
+8
View File
@@ -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'
+47
View File
@@ -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
}
+166
View File
@@ -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> }
}
+33
View File
@@ -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`
}
+4
View File
@@ -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'
+108
View File
@@ -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 }
}
},
})
+95
View File
@@ -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 }
}
}
+37
View File
@@ -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 }
: {}),
})
}
+10
View File
@@ -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'
+62
View File
@@ -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
}
+127
View File
@@ -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 }
}
}
+50
View File
@@ -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[]
}
+105
View File
@@ -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 }
}
+1
View File
@@ -0,0 +1 @@
export { createContentHelpers } from './createContentHelpers.js'
+56
View File
@@ -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
}
+32
View File
@@ -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)
}
+10
View File
@@ -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'
+98
View File
@@ -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|.*\\..*).*)']
+23
View File
@@ -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 }),
})),
}
}
+122
View File
@@ -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}`
}
+93
View File
@@ -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)
}
+21
View File
@@ -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[]]
}
+43
View File
@@ -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(', ')}].`,
)
}
}
+66
View File
@@ -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 })
}
+4
View File
@@ -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'
+43
View File
@@ -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,
}))
}
+32
View File
@@ -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
}
+29
View File
@@ -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)
}
+25
View File
@@ -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)
}
+4
View File
@@ -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