diff --git a/dev/app/(payload)/admin/importMap.js b/dev/app/(payload)/admin/importMap.js index bae511d..af86423 100644 --- a/dev/app/(payload)/admin/importMap.js +++ b/dev/app/(payload)/admin/importMap.js @@ -1,9 +1,6 @@ -import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'ipal-kit/client' -import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'ipal-kit/rsc' +import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' +/** @type import('payload').ImportMap */ export const importMap = { - 'ipal-kit/client#BeforeDashboardClient': - BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343, - 'ipal-kit/rsc#BeforeDashboardServer': - BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f, + "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/dev/helpers/credentials.ts b/dev/helpers/credentials.ts index 7ccbcae..b3a6c73 100644 --- a/dev/helpers/credentials.ts +++ b/dev/helpers/credentials.ts @@ -1,4 +1,4 @@ export const devUser = { - email: 'dev@payloadcms.com', - password: 'test', + email: 'it@intecion.com', + password: 'it@intecion.com', } diff --git a/dev/payload-types.ts b/dev/payload-types.ts index 620ba8e..4c99049 100644 --- a/dev/payload-types.ts +++ b/dev/payload-types.ts @@ -6,38 +6,104 @@ * and re-run `payload generate:types` to regenerate this file. */ +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + export interface Config { auth: { users: UserAuthOperations; }; + blocks: {}; collections: { + users: User; posts: Post; media: Media; - 'plugin-collection': PluginCollection; - users: User; + 'payload-kv': PayloadKv; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; }; collectionsJoins: {}; collectionsSelect: { + users: UsersSelect | UsersSelect; posts: PostsSelect | PostsSelect; media: MediaSelect | MediaSelect; - 'plugin-collection': PluginCollectionSelect | PluginCollectionSelect; - users: UsersSelect | UsersSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; - globals: {}; - globalsSelect: {}; - locale: null; - user: User & { - collection: 'users'; + fallbackLocale: ('false' | 'none' | 'null') | false | null | ('pl' | 'en') | ('pl' | 'en')[]; + globals: { + 'site-settings': SiteSetting; + 'site-integrations': SiteIntegration; + 'cookie-settings': CookieSetting; }; + globalsSelect: { + 'site-settings': SiteSettingsSelect | SiteSettingsSelect; + 'site-integrations': SiteIntegrationsSelect | SiteIntegrationsSelect; + 'cookie-settings': CookieSettingsSelect | CookieSettingsSelect; + }; + locale: 'pl' | 'en'; + widgets: { + collections: CollectionsWidget; + }; + user: User; jobs: { tasks: unknown; workflows: unknown; @@ -61,13 +127,49 @@ export interface UserAuthOperations { password: string; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: number; + /** + * Role hierarchy: admin > editor > user. + */ + roles: ('user' | 'editor' | 'admin')[]; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; + password?: string | null; + collection: 'users'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts". */ export interface Post { - id: string; - addedByPlugin?: string | null; + id: number; + meta?: { + title?: string | null; + description?: string | null; + /** + * Maximum upload file size: 12MB. Recommended file size for images is <500KB. + */ + image?: (number | null) | Media; + }; updatedAt: string; createdAt: string; } @@ -76,7 +178,7 @@ export interface Post { * via the `definition` "media". */ export interface Media { - id: string; + id: number; updatedAt: string; createdAt: string; url?: string | null; @@ -91,57 +193,44 @@ export interface Media { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "plugin-collection". + * via the `definition` "payload-kv". */ -export interface PluginCollection { - id: string; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users". - */ -export interface User { - id: string; - updatedAt: string; - createdAt: string; - email: string; - resetPasswordToken?: string | null; - resetPasswordExpiration?: string | null; - salt?: string | null; - hash?: string | null; - loginAttempts?: number | null; - lockUntil?: string | null; - password?: string | null; +export interface PayloadKv { + id: number; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; } /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: + | ({ + relationTo: 'users'; + value: number | User; + } | null) | ({ relationTo: 'posts'; - value: string | Post; + value: number | Post; } | null) | ({ relationTo: 'media'; - value: string | Media; - } | null) - | ({ - relationTo: 'plugin-collection'; - value: string | PluginCollection; - } | null) - | ({ - relationTo: 'users'; - value: string | User; + value: number | Media; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; updatedAt: string; createdAt: string; @@ -151,10 +240,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; key?: string | null; value?: @@ -174,18 +263,47 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + roles?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts_select". */ export interface PostsSelect { - addedByPlugin?: T; + meta?: + | T + | { + title?: T; + description?: T; + image?: T; + }; updatedAt?: T; createdAt?: T; } @@ -208,27 +326,11 @@ export interface MediaSelect { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "plugin-collection_select". + * via the `definition` "payload-kv_select". */ -export interface PluginCollectionSelect { - id?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users_select". - */ -export interface UsersSelect { - updatedAt?: T; - createdAt?: T; - email?: T; - resetPasswordToken?: T; - resetPasswordExpiration?: T; - salt?: T; - hash?: T; - loginAttempts?: T; - lockUntil?: T; +export interface PayloadKvSelect { + key?: T; + data?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -262,6 +364,212 @@ export interface PayloadMigrationsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "site-settings". + */ +export interface SiteSetting { + id: number; + /** + * Used in page titles and Open Graph metadata. + */ + siteName: string; + /** + * Primary site logo. + */ + logo?: (number | null) | Media; + /** + * Fallback Open Graph image when a page has none. + */ + defaultShareImage?: (number | null) | Media; + /** + * Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend. + */ + favicon?: (number | null) | Media; + /** + * Theme applied on a visitor’s first visit. + */ + defaultTheme: 'light' | 'dark'; + /** + * Show a light/dark switch on the site. + */ + allowThemeToggle?: boolean | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "site-integrations". + */ +export interface SiteIntegration { + id: number; + /** + * Google Analytics 4 Measurement ID. + */ + ga4MeasurementId?: string | null; + /** + * Google Tag Manager container ID. + */ + gtmContainerId?: string | null; + /** + * Public site key rendered in the Turnstile widget. + */ + turnstileSiteKey?: string | null; + /** + * Secret key used for server-side verification. + */ + turnstileSecretKey?: string | null; + smtpHost?: string | null; + smtpPort?: number | null; + /** + * SMTP account username. + */ + smtpUser?: string | null; + /** + * SMTP account password. + */ + smtpPassword?: string | null; + /** + * Default "from" address for outgoing mail. + */ + smtpFromAddress?: string | null; + /** + * Default "from" display name. + */ + smtpFromName?: string | null; + /** + * R2 bucket name. + */ + r2Bucket?: string | null; + /** + * R2 S3-compatible endpoint URL. + */ + r2Endpoint?: string | null; + /** + * R2 access key ID. + */ + r2AccessKeyId?: string | null; + /** + * R2 secret access key. + */ + r2SecretAccessKey?: string | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "cookie-settings". + */ +export interface CookieSetting { + id: number; + /** + * Main consent message shown in the banner. + */ + message?: string | null; + /** + * Heading for the detailed settings panel. + */ + settingsTitle?: string | null; + /** + * Button labels. + */ + buttons?: { + acceptAll?: string | null; + reject?: string | null; + settings?: string | null; + save?: string | null; + back?: string | null; + }; + /** + * Per-category titles and descriptions. + */ + categories?: + | { + key: 'necessary' | 'functional' | 'analytics' | 'marketing'; + title?: string | null; + description?: string | null; + id?: string | null; + }[] + | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "site-settings_select". + */ +export interface SiteSettingsSelect { + 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 { + 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 { + message?: T; + settingsTitle?: T; + buttons?: + | T + | { + acceptAll?: T; + reject?: T; + settings?: T; + save?: T; + back?: T; + }; + categories?: + | T + | { + key?: T; + title?: T; + description?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collections_widget". + */ +export interface CollectionsWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/dev/payload.config.ts b/dev/payload.config.ts index 0976180..67f2a91 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -1,9 +1,9 @@ -import { mongooseAdapter } from '@payloadcms/db-mongodb' +import { sqliteAdapter } from '@payloadcms/db-sqlite' import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { ipalKit } from 'ipal-kit' import { MongoMemoryReplSet } from 'mongodb-memory-server' import path from 'path' import { buildConfig } from 'payload' -import { ipalKit } from 'ipal-kit' import sharp from 'sharp' import { fileURLToPath } from 'url' @@ -36,6 +36,11 @@ const buildConfigWithMemoryDB = async () => { }, }, collections: [ + { + slug: 'users', + auth: true, + fields: [], + }, { slug: 'posts', fields: [], @@ -43,14 +48,14 @@ const buildConfigWithMemoryDB = async () => { { slug: 'media', fields: [], - upload: { - staticDir: path.resolve(dirname, 'media'), - }, + upload: { staticDir: path.resolve(dirname, 'media') }, }, ], - db: mongooseAdapter({ - ensureIndexes: true, - url: process.env.DATABASE_URL || '', + + db: sqliteAdapter({ + client: { + url: process.env.DATABASE_URI || 'file:./payload.db', + }, }), editor: lexicalEditor(), email: testEmailAdapter, @@ -59,9 +64,15 @@ const buildConfigWithMemoryDB = async () => { }, plugins: [ ipalKit({ - collections: { - posts: true, + access: { authCollection: 'users' }, + i18n: { + defaultLocale: 'pl', + locales: [ + { code: 'pl', label: 'Polski' }, + { code: 'en', label: 'English' }, + ], }, + seo: { collections: ['posts', 'pages'] }, }), ], secret: process.env.PAYLOAD_SECRET || 'test-secret_key', diff --git a/dev/seed.ts b/dev/seed.ts index 8e731f1..c6ebdd9 100644 --- a/dev/seed.ts +++ b/dev/seed.ts @@ -15,7 +15,11 @@ export const seed = async (payload: Payload) => { if (!totalDocs) { await payload.create({ collection: 'users', - data: devUser, + data: { + email: devUser.email, + password: devUser.password, + roles: ['admin'], + }, }) } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7803514 --- /dev/null +++ b/docs/README.md @@ -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ą `` (np. wygenerowany + `SiteSetting`), bo plugin nie zna typów projektu. \ No newline at end of file diff --git a/docs/access.md b/docs/access.md new file mode 100644 index 0000000..7651ff6 --- /dev/null +++ b/docs/access.md @@ -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 +``` \ No newline at end of file diff --git a/docs/blocks.md b/docs/blocks.md new file mode 100644 index 0000000..00d1a68 --- /dev/null +++ b/docs/blocks.md @@ -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 +} +``` + +- 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 } +} + + +``` + +## 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
{/* ... */}
+} +``` \ No newline at end of file diff --git a/docs/consent.md b/docs/consent.md new file mode 100644 index 0000000..5546750 --- /dev/null +++ b/docs/consent.md @@ -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 ( + + {children} + + + + ) +} +``` + +## Nadpisywanie wyglądu (Poziom 2) + +Domyślne klasy Tailwind można nadpisać przez `classNames`: + +```tsx + +``` + +## 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 + + +``` + +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. \ No newline at end of file diff --git a/docs/content.md b/docs/content.md new file mode 100644 index 0000000..a321d94 --- /dev/null +++ b/docs/content.md @@ -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' +``` \ No newline at end of file diff --git a/docs/email.md b/docs/email.md new file mode 100644 index 0000000..86711d2 --- /dev/null +++ b/docs/email.md @@ -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: 'kontakt@klient.pl', + subject: 'Nowa wiadomość', + html: '

Cześć

Treść…

', // 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`. \ No newline at end of file diff --git a/docs/forms.md b/docs/forms.md new file mode 100644 index 0000000..cf7fcad --- /dev/null +++ b/docs/forms.md @@ -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: 'kontakt@klient.pl', + 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. \ No newline at end of file diff --git a/docs/frontend-setup.md b/docs/frontend-setup.md new file mode 100644 index 0000000..5abd840 --- /dev/null +++ b/docs/frontend-setup.md @@ -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 () — 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 }) +// {children} +``` + +## Bloki (RenderBlocks) + +Klient definiuje bloki (config + komponent) — plugin jest block-agnostic. + +- `blocks//config.ts` — schemat Payload (Block) +- `blocks//Component.tsx` — komponent (dane bloku jako propsy) +- `blocks/registry.ts` — mapa blockType → komponent +- Pages: pole `layout` typu blocks z listą bloków +- page.tsx: `` + +**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 +// ... +``` + +ID z SiteIntegrations. GTM ma priorytet nad GA4, gdy oba ustawione. \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..7aa1538 --- /dev/null +++ b/docs/getting-started.md @@ -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/plugin-seo@3.84.1 @payloadcms/plugin-form-builder@3.84.1 \ + 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 ( +
+ {heading &&

{heading}

} + {body &&

{body}

} +
+ ) +} +``` + +```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 => { + 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 => { + 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 `` 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 ( + + + +
{children}
+ + + +
+ + + ) +} + +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 { + 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 +} +``` + +## 11. Środowisko + +```bash +# .env +DATABASE_URL=file:./moj-projekt.db +PAYLOAD_SECRET= +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 and ` | 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 | \ No newline at end of file diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..c50465e --- /dev/null +++ b/docs/i18n.md @@ -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. \ No newline at end of file diff --git a/docs/pages.md b/docs/pages.md new file mode 100644 index 0000000..019b876 --- /dev/null +++ b/docs/pages.md @@ -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'] +``` \ No newline at end of file diff --git a/docs/payload-helpers.md b/docs/payload-helpers.md new file mode 100644 index 0000000..598a2ce --- /dev/null +++ b/docs/payload-helpers.md @@ -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(payload, { locale: 'pl' }) + +// SiteIntegrations (admin-only; Local API omija access control) +const integrations = await getSiteIntegrations(payload) +``` + +Generyk `` pozwala wstrzyknąć wygenerowany typ klienta. Bez niego zwraca +`Record`. + +## ⚠️ 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
+ +// DOBRZE — tylko publiczna wartość +const { turnstileSiteKey } = await getSiteIntegrations(payload) +return +``` + +## Niższy poziom: getGlobal + +```ts +import { getGlobal } from 'ipal-kit' +const data = await getGlobal(payload, 'moj-global', { locale: 'pl', depth: 1 }) +``` \ No newline at end of file diff --git a/docs/seo.md b/docs/seo.md new file mode 100644 index 0000000..0ab6d28 --- /dev/null +++ b/docs/seo.md @@ -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: '...' } +``` \ No newline at end of file diff --git a/docs/turnstile.md b/docs/turnstile.md new file mode 100644 index 0000000..65842ab --- /dev/null +++ b/docs/turnstile.md @@ -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(null) + return ( + + {/* pola */} + + + + ) +} +``` + +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)). \ No newline at end of file diff --git a/package.json b/package.json index 7bb2e70..9ae69a6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,16 @@ "import": "./src/exports/rsc.ts", "types": "./src/exports/rsc.ts", "default": "./src/exports/rsc.ts" + }, + "./server": { + "import": "./src/exports/server.ts", + "types": "./src/exports/server.ts", + "default": "./src/exports/server.ts" + }, + "./next/middleware": { + "import": "./src/exports/next-middleware.ts", + "types": "./src/exports/next-middleware.ts", + "default": "./src/exports/next-middleware.ts" } }, "main": "./src/index.ts", @@ -44,9 +54,16 @@ "test:e2e": "playwright test", "test:int": "vitest" }, + "dependencies": { + "@payloadcms/plugin-form-builder": "3.84.1", + "@payloadcms/plugin-seo": "3.84.1", + "lucide-react": "^0.400.0", + "nodemailer": "^8.0.1", + "server-only": "^0.0.1", + "slugify": "^1.6.6" + }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", - "@payloadcms/db-mongodb": "3.84.1", "@payloadcms/db-postgres": "3.84.1", "@payloadcms/db-sqlite": "3.84.1", "@payloadcms/eslint-config": "3.28.0", @@ -57,6 +74,7 @@ "@swc-node/register": "1.10.9", "@swc/cli": "0.6.0", "@types/node": "22.19.9", + "@types/nodemailer": "^8.0.1", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "copyfiles": "2.4.1", @@ -80,7 +98,8 @@ "vitest": "4.0.18" }, "peerDependencies": { - "payload": "^3.84.1" + "payload": "^3.84.1", + "react": "^19.0.0" }, "engines": { "node": "^18.20.2 || >=20.9.0", @@ -102,6 +121,16 @@ "import": "./dist/exports/rsc.js", "types": "./dist/exports/rsc.d.ts", "default": "./dist/exports/rsc.js" + }, + "./server": { + "import": "./dist/exports/server.js", + "types": "./dist/exports/server.d.ts", + "default": "./dist/exports/server.js" + }, + "./next/middleware": { + "import": "./dist/exports/next-middleware.js", + "types": "./dist/exports/next-middleware.d.ts", + "default": "./dist/exports/next-middleware.js" } }, "main": "./dist/index.js", @@ -114,6 +143,5 @@ "unrs-resolver" ] }, - "registry": "https://registry.npmjs.org/", - "dependencies": {} + "registry": "https://registry.npmjs.org/" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a257f13..4335a1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,13 +7,29 @@ settings: importers: .: + dependencies: + '@payloadcms/plugin-form-builder': + specifier: 3.84.1 + version: 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + '@payloadcms/plugin-seo': + specifier: 3.84.1 + version: 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + lucide-react: + specifier: ^0.400.0 + version: 0.400.0(react@19.2.6) + nodemailer: + specifier: ^8.0.1 + version: 8.0.11 + server-only: + specifier: ^0.0.1 + version: 0.0.1 + slugify: + specifier: ^1.6.6 + version: 1.6.9 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 version: 3.3.5 - '@payloadcms/db-mongodb': - specifier: 3.84.1 - version: 3.84.1(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3)) '@payloadcms/db-postgres': specifier: 3.84.1 version: 3.84.1(@libsql/client@0.14.0)(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3)) @@ -44,6 +60,9 @@ importers: '@types/node': specifier: 22.19.9 version: 22.19.9 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/react': specifier: 19.2.14 version: 19.2.14 @@ -1747,11 +1766,6 @@ packages: cpu: [x64] os: [win32] - '@payloadcms/db-mongodb@3.84.1': - resolution: {integrity: sha512-HTP/Z6iQHFyHhuMImAC/GH0xGhjuvuHZHdB7crJUkXAyD677tp6C3mS8YB04s28bCteDqcXBYjHODZr5xDHBcw==} - peerDependencies: - payload: 3.84.1 - '@payloadcms/db-postgres@3.84.1': resolution: {integrity: sha512-/r1+7k58529ziTwqzySXsZb4x3FcfQIQH8R6gXLM3bTU9JA8wfLBraIgSyVKl6B3p+/EQYcaQ1DUlfrzVRxo1A==} peerDependencies: @@ -1788,6 +1802,20 @@ packages: next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.2 <17.0.0' payload: 3.84.1 + '@payloadcms/plugin-form-builder@3.84.1': + 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/plugin-seo@3.84.1': + 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/richtext-lexical@3.84.1': resolution: {integrity: sha512-KaNSz0RJFLnLc/hBRGg8Lgwk5FjCZSskA6KuufNWG5QdgUsgQe4Dx4Vr7G+VSR9zSZj+R4TVVSC0aVv4z7vL3g==} engines: {node: ^18.20.2 || >=20.9.0} @@ -2162,6 +2190,9 @@ packages: '@types/node@22.19.9': resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -4110,10 +4141,6 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - kareem@2.6.3: - resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} - engines: {node: '>=12.0.0'} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4180,6 +4207,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.400.0: + resolution: {integrity: sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4352,33 +4384,6 @@ packages: resolution: {integrity: sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg==} engines: {node: '>=16.20.1'} - mongodb@6.16.0: - 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 - mongodb@6.21.0: resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==} engines: {node: '>=16.20.1'} @@ -4406,32 +4411,6 @@ packages: socks: optional: true - mongoose-lean-virtuals@1.1.1: - resolution: {integrity: sha512-8chOqpVE3bcoWT2pIgcJeIZlXaOfQCavZgQZF4qytUtjRBqsNMyzUoR16qdw9XL2kC478N8iA8z0AA+NSS0d1A==} - engines: {node: '>=16.20.1'} - peerDependencies: - mongoose: '>=5.11.10' - - mongoose-paginate-v2@1.9.4: - resolution: {integrity: sha512-0LOsVEQmjrbJKVDi/IvFEhIezmuRjUE4loGgslv57j9nK/NMC+mbKT0QnaPSPpib4lByKVBcy3VbDa1TvlHZjA==} - engines: {node: '>=4.0.0'} - - mongoose@8.15.1: - resolution: {integrity: sha512-RhQ4DzmBi5BNGcS0w4u1vdMRIKcteXTCNzDt1j7XRcdWYBz1MjMjulBhPaeC5jBCHOD1yinuOFTTSOWLLGexWw==} - engines: {node: '>=16.20.1'} - - mpath@0.8.4: - resolution: {integrity: sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g==} - engines: {node: '>=4.0.0'} - - mpath@0.9.0: - resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} - engines: {node: '>=4.0.0'} - - mquery@5.0.0: - resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==} - engines: {node: '>=14.0.0'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4493,6 +4472,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nodemailer@8.0.11: + resolution: {integrity: sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==} + engines: {node: '>=6.0.0'} + noms@0.0.0: resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} @@ -5005,6 +4988,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5049,9 +5035,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - sift@17.1.3: - resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -5071,6 +5054,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -7044,23 +7031,6 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@1.12.0': optional: true - '@payloadcms/db-mongodb@3.84.1(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))': - dependencies: - mongoose: 8.15.1 - mongoose-paginate-v2: 1.9.4(mongoose@8.15.1) - payload: 3.84.1(graphql@16.14.2)(typescript@5.7.3) - 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/db-postgres@3.84.1(@libsql/client@0.14.0)(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))': dependencies: '@payloadcms/drizzle': 3.84.1(@libsql/client@0.14.0)(@types/pg@8.20.0)(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(pg@8.20.0) @@ -7289,6 +7259,34 @@ snapshots: - supports-color - typescript + '@payloadcms/plugin-form-builder@3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3)': + dependencies: + '@payloadcms/ui': 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + escape-html: 1.0.3 + payload: 3.84.1(graphql@16.14.2)(typescript@5.7.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - monaco-editor + - next + - supports-color + - typescript + + '@payloadcms/plugin-seo@3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3)': + dependencies: + '@payloadcms/translations': 3.84.1 + '@payloadcms/ui': 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + payload: 3.84.1(graphql@16.14.2)(typescript@5.7.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - monaco-editor + - next + - supports-color + - typescript + '@payloadcms/richtext-lexical@3.84.1(@faceless-ui/modal@3.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@payloadcms/next@3.84.1(@types/react@19.2.14)(graphql@16.14.2)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3)(yjs@13.6.31)': dependencies: '@faceless-ui/modal': 3.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -7649,6 +7647,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 22.19.9 + '@types/parse-json@4.0.2': {} '@types/pg@8.20.0': @@ -8881,7 +8883,7 @@ snapshots: eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.39.4)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint@9.39.4))(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.39.4)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4) eslint-plugin-react: 7.37.5(eslint@9.39.4) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4) @@ -8918,7 +8920,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.39.4)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4) eslint-plugin-import-x: 4.6.1(eslint@9.39.4)(typescript@5.7.3) transitivePeerDependencies: - supports-color @@ -8975,7 +8977,7 @@ snapshots: - typescript optional: true - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.39.4)(typescript@5.7.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4)(typescript@5.7.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9971,8 +9973,6 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - kareem@2.6.3: {} - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -10037,6 +10037,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.400.0(react@19.2.6): + dependencies: + react: 19.2.6 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10370,58 +10374,12 @@ snapshots: - socks - supports-color - mongodb@6.16.0: - dependencies: - '@mongodb-js/saslprep': 1.4.12 - bson: 6.10.4 - mongodb-connection-string-url: 3.0.2 - mongodb@6.21.0: dependencies: '@mongodb-js/saslprep': 1.4.12 bson: 6.10.4 mongodb-connection-string-url: 3.0.2 - mongoose-lean-virtuals@1.1.1(mongoose@8.15.1): - dependencies: - mongoose: 8.15.1 - mpath: 0.8.4 - - mongoose-paginate-v2@1.9.4(mongoose@8.15.1): - dependencies: - mongoose-lean-virtuals: 1.1.1(mongoose@8.15.1) - transitivePeerDependencies: - - mongoose - - mongoose@8.15.1: - 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 - - mpath@0.8.4: {} - - mpath@0.9.0: {} - - mquery@5.0.0: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - ms@2.1.3: {} nanoid@3.3.15: {} @@ -10481,6 +10439,8 @@ snapshots: node-releases@2.0.50: {} + nodemailer@8.0.11: {} + noms@0.0.0: dependencies: inherits: 2.0.4 @@ -11088,6 +11048,8 @@ snapshots: semver@7.8.5: {} + server-only@0.0.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -11204,8 +11166,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - sift@17.1.3: {} - siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -11220,6 +11180,8 @@ snapshots: slash@3.0.0: {} + slugify@1.6.9: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 diff --git a/src/components/BeforeDashboardClient.tsx b/src/components/BeforeDashboardClient.tsx deleted file mode 100644 index 801d6d7..0000000 --- a/src/components/BeforeDashboardClient.tsx +++ /dev/null @@ -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 ( -
-

Added by the plugin: Before Dashboard Client

-
- Message from the endpoint: -
{message || 'Loading...'}
-
-
- ) -} diff --git a/src/components/BeforeDashboardServer.module.css b/src/components/BeforeDashboardServer.module.css deleted file mode 100644 index 162c927..0000000 --- a/src/components/BeforeDashboardServer.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.wrapper { - display: flex; - gap: 5px; - flex-direction: column; -} diff --git a/src/components/BeforeDashboardServer.tsx b/src/components/BeforeDashboardServer.tsx deleted file mode 100644 index cc590d9..0000000 --- a/src/components/BeforeDashboardServer.tsx +++ /dev/null @@ -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 ( -
-

Added by the plugin: Before Dashboard Server

- Docs from Local API: - {docs.map((doc) => ( -
{doc.id}
- ))} -
- ) -} diff --git a/src/endpoints/customEndpointHandler.ts b/src/endpoints/customEndpointHandler.ts deleted file mode 100644 index 2501117..0000000 --- a/src/endpoints/customEndpointHandler.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PayloadHandler } from 'payload' - -export const customEndpointHandler: PayloadHandler = () => { - return Response.json({ message: 'Hello from custom endpoint' }) -} diff --git a/src/exports/client.ts b/src/exports/client.ts index 4db01b7..a2b0523 100644 --- a/src/exports/client.ts +++ b/src/exports/client.ts @@ -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' diff --git a/src/exports/next-middleware.ts b/src/exports/next-middleware.ts new file mode 100644 index 0000000..e506a84 --- /dev/null +++ b/src/exports/next-middleware.ts @@ -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' diff --git a/src/exports/rsc.ts b/src/exports/rsc.ts index 4a9b5f4..cdd4fb9 100644 --- a/src/exports/rsc.ts +++ b/src/exports/rsc.ts @@ -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' diff --git a/src/exports/server.ts b/src/exports/server.ts new file mode 100644 index 0000000..261bf93 --- /dev/null +++ b/src/exports/server.ts @@ -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' diff --git a/src/globals/CookieSettings/fields.ts b/src/globals/CookieSettings/fields.ts new file mode 100644 index 0000000..d3403f7 --- /dev/null +++ b/src/globals/CookieSettings/fields.ts @@ -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 }, + ], + }, +] diff --git a/src/globals/CookieSettings/index.ts b/src/globals/CookieSettings/index.ts new file mode 100644 index 0000000..587650d --- /dev/null +++ b/src/globals/CookieSettings/index.ts @@ -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', + } +} diff --git a/src/globals/SiteIntegrations/fields/analytics.ts b/src/globals/SiteIntegrations/fields/analytics.ts new file mode 100644 index 0000000..bebcf9d --- /dev/null +++ b/src/globals/SiteIntegrations/fields/analytics.ts @@ -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', + }, + }, +] diff --git a/src/globals/SiteIntegrations/fields/smtp.ts b/src/globals/SiteIntegrations/fields/smtp.ts new file mode 100644 index 0000000..4bc9d5e --- /dev/null +++ b/src/globals/SiteIntegrations/fields/smtp.ts @@ -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.', + }, + }, +] diff --git a/src/globals/SiteIntegrations/fields/storage.ts b/src/globals/SiteIntegrations/fields/storage.ts new file mode 100644 index 0000000..20d7b78 --- /dev/null +++ b/src/globals/SiteIntegrations/fields/storage.ts @@ -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.', + }, + }, +] diff --git a/src/globals/SiteIntegrations/fields/turnstile.ts b/src/globals/SiteIntegrations/fields/turnstile.ts new file mode 100644 index 0000000..54a1a75 --- /dev/null +++ b/src/globals/SiteIntegrations/fields/turnstile.ts @@ -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.', + }, + }, +] diff --git a/src/globals/SiteIntegrations/index.ts b/src/globals/SiteIntegrations/index.ts new file mode 100644 index 0000000..2cc75de --- /dev/null +++ b/src/globals/SiteIntegrations/index.ts @@ -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', + } +} diff --git a/src/globals/SiteSettings/fields/general.ts b/src/globals/SiteSettings/fields/general.ts new file mode 100644 index 0000000..8cc4268 --- /dev/null +++ b/src/globals/SiteSettings/fields/general.ts @@ -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', + }, +] diff --git a/src/globals/SiteSettings/fields/navigation.ts b/src/globals/SiteSettings/fields/navigation.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/globals/SiteSettings/fields/theme.ts b/src/globals/SiteSettings/fields/theme.ts new file mode 100644 index 0000000..d153717 --- /dev/null +++ b/src/globals/SiteSettings/fields/theme.ts @@ -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, + }, +] diff --git a/src/globals/SiteSettings/index.ts b/src/globals/SiteSettings/index.ts new file mode 100644 index 0000000..f6c098a --- /dev/null +++ b/src/globals/SiteSettings/index.ts @@ -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', + } +} diff --git a/src/index.ts b/src/index.ts index 730835f..004bd29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,113 +1,95 @@ -import type { CollectionSlug, Config } from 'payload' - -import { customEndpointHandler } from './endpoints/customEndpointHandler.js' - -export type IpalKitConfig = { - /** - * List of collections to add a custom field - */ - collections?: Partial> - disabled?: boolean -} - -export const ipalKit = - (pluginOptions: IpalKitConfig) => - (config: Config): Config => { - if (!config.collections) { - config.collections = [] - } - - config.collections.push({ - slug: 'plugin-collection', - fields: [ - { - name: 'id', - type: 'text', - }, - ], - }) - - if (pluginOptions.collections) { - for (const collectionSlug in pluginOptions.collections) { - const collection = config.collections.find( - (collection) => collection.slug === collectionSlug, - ) - - if (collection) { - collection.fields.push({ - name: 'addedByPlugin', - type: 'text', - admin: { - position: 'sidebar', - }, - }) - } - } - } - - /** - * If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations. - * If your plugin heavily modifies the database schema, you may want to remove this property. - */ - if (pluginOptions.disabled) { - return config - } - - if (!config.endpoints) { - config.endpoints = [] - } - - if (!config.admin) { - config.admin = {} - } - - if (!config.admin.components) { - config.admin.components = {} - } - - if (!config.admin.components.beforeDashboard) { - config.admin.components.beforeDashboard = [] - } - - config.admin.components.beforeDashboard.push( - `ipal-kit/client#BeforeDashboardClient`, - ) - config.admin.components.beforeDashboard.push( - `ipal-kit/rsc#BeforeDashboardServer`, - ) - - config.endpoints.push({ - handler: customEndpointHandler, - method: 'get', - path: '/my-plugin-endpoint', - }) - - const incomingOnInit = config.onInit - - config.onInit = async (payload) => { - // Ensure we are executing any existing onInit functions before running our own. - if (incomingOnInit) { - await incomingOnInit(payload) - } - - const { totalDocs } = await payload.count({ - collection: 'plugin-collection', - where: { - id: { - equals: 'seeded-by-plugin', - }, - }, - }) - - if (totalDocs === 0) { - await payload.create({ - collection: 'plugin-collection', - data: { - id: 'seeded-by-plugin', - }, - }) - } - } - - return config - } +export type { AccessOption, Role } from './modules/access/index.js' +export { + adminOnly, + adminOnlyField, + adminOrEditor, + adminOrEditorField, + adminOrSelf, + authenticated, + hasMinimumRole, + isAdmin, + isEditor, + requireRole, + requireRoleField, + ROLE_HIERARCHY, +} from './modules/access/index.js' +export type { AnalyticsConfig } from './modules/analytics/index.js' +export { getAnalyticsConfig } from './modules/analytics/index.js' +export { + ACCEPT_ALL_CONSENT, + CONSENT_CATEGORIES, + CONSENT_COOKIE, + CONSENT_MAX_AGE, + CONSENT_VERSION, + DEFAULT_CONSENT, + getConsentTexts, + parseConsent, + REJECT_ALL_CONSENT, + serializeConsent, + setDefaultConsent, + updateConsent, +} from './modules/consent/index.js' +export type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js' +export type { + ContentCollectionOption, + ContentOption, + ResolvedRoute, +} from './modules/content/index.js' +export { + archiveFieldName, + buildArchivePath, + buildEntryPath, + getArchiveEntries, + parsePageParam, + resolveRoute, +} from './modules/content/index.js' +export type { ArchiveEntries } from './modules/content/index.js' +// Imported straight from the file, NOT from ./modules/email/index.js — that +// barrel re-exports sendEmail, which imports 'server-only' and would crash when +// Payload loads the config (or runs generate:importmap) as a plain Node script. +export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js' +export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js' +export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js' +export type { + FormsCollectionOverrides, + FormsFieldsOverride, + FormsOption, +} from './modules/forms/types.js' +export { createContentHelpers } from './modules/frontend/index.js' +export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js' +export { + buildLocalizedPath, + getDefaultLocale, + getLocaleCodes, + getLocaleDefinition, + getLocalizedSlugs, + isValidLocale, + LOCALE_COOKIE_NAME, + matchAcceptLanguage, + negotiateLocale, + switchLocalePath, +} from './modules/i18n/index.js' +export type { LocaleMiddlewareResult } from './modules/i18n/index.js' +export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js' +export type { PagesOption, SystemPageRole } from './modules/pages/index.js' +export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js' +export type { GlobalQueryOptions } from './modules/payload/index.js' +export { + getGlobal, + getSiteIntegrations, + getSiteSettings, + SITE_INTEGRATIONS_SLUG, + SITE_SETTINGS_SLUG, +} from './modules/payload/index.js' +export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js' +export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js' +export type { AutoFillMapping } from './modules/seo/index.js' +export { + buildAutoFillMetaHook, + createMetadataGenerator, + createPageMetadata, + injectAutoFillMeta, +} from './modules/seo/index.js' +export { buildSlugField, toSlug } from './modules/slug/index.js' +export { default as ipalKit } from './plugin.js' +export type { IpalOptions } from './types.js' diff --git a/src/modules/access/access.ts b/src/modules/access/access.ts new file mode 100644 index 0000000..9d50143 --- /dev/null +++ b/src/modules/access/access.ts @@ -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) diff --git a/src/modules/access/index.ts b/src/modules/access/index.ts new file mode 100644 index 0000000..7047886 --- /dev/null +++ b/src/modules/access/index.ts @@ -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' diff --git a/src/modules/access/injectRoles.ts b/src/modules/access/injectRoles.ts new file mode 100644 index 0000000..040588c --- /dev/null +++ b/src/modules/access/injectRoles.ts @@ -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], + } + }), + } +} diff --git a/src/modules/access/predicates.ts b/src/modules/access/predicates.ts new file mode 100644 index 0000000..3322099 --- /dev/null +++ b/src/modules/access/predicates.ts @@ -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 | 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') +} diff --git a/src/modules/access/rolesField.ts b/src/modules/access/rolesField.ts new file mode 100644 index 0000000..165bc6a --- /dev/null +++ b/src/modules/access/rolesField.ts @@ -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, + } +} diff --git a/src/modules/access/types.ts b/src/modules/access/types.ts new file mode 100644 index 0000000..b78da05 --- /dev/null +++ b/src/modules/access/types.ts @@ -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 +} diff --git a/src/modules/analytics/Analytics.tsx b/src/modules/analytics/Analytics.tsx new file mode 100644 index 0000000..0664a53 --- /dev/null +++ b/src/modules/analytics/Analytics.tsx @@ -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 +} diff --git a/src/modules/analytics/client.ts b/src/modules/analytics/client.ts new file mode 100644 index 0000000..791497f --- /dev/null +++ b/src/modules/analytics/client.ts @@ -0,0 +1,2 @@ +'use client' +export { Analytics } from './Analytics.js' diff --git a/src/modules/analytics/getAnalyticsConfig.ts b/src/modules/analytics/getAnalyticsConfig.ts new file mode 100644 index 0000000..d7256fc --- /dev/null +++ b/src/modules/analytics/getAnalyticsConfig.ts @@ -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 + * component. Does NOT expose any secret from SiteIntegrations. + */ +export async function getAnalyticsConfig(payload: BasePayload): Promise { + const integrations = await getSiteIntegrations(payload) + return { + ga4MeasurementId: integrations.ga4MeasurementId ?? null, + gtmContainerId: integrations.gtmContainerId ?? null, + } +} diff --git a/src/modules/analytics/index.ts b/src/modules/analytics/index.ts new file mode 100644 index 0000000..abbf5f7 --- /dev/null +++ b/src/modules/analytics/index.ts @@ -0,0 +1,4 @@ +// Server-safe: config type + the helper that reads IDs from SiteIntegrations. +// The component is client-side — exported via ./client. +export type { AnalyticsConfig } from './types.js' +export { getAnalyticsConfig } from './getAnalyticsConfig.js' diff --git a/src/modules/analytics/types.ts b/src/modules/analytics/types.ts new file mode 100644 index 0000000..0c08fbe --- /dev/null +++ b/src/modules/analytics/types.ts @@ -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 +} diff --git a/src/modules/blocks/RenderBlocks.tsx b/src/modules/blocks/RenderBlocks.tsx new file mode 100644 index 0000000..70cf26d --- /dev/null +++ b/src/modules/blocks/RenderBlocks.tsx @@ -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 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 ( + + {blocks.map((block, index) => { + const Component = components[block.blockType] + if (!Component) {return null} + + const extra = enhanceProps ? enhanceProps({ allBlocks: blocks, block, index }) : {} + + return + })} + + ) +} diff --git a/src/modules/blocks/index.ts b/src/modules/blocks/index.ts new file mode 100644 index 0000000..c75fbc8 --- /dev/null +++ b/src/modules/blocks/index.ts @@ -0,0 +1,3 @@ +export { RenderBlocks } from './RenderBlocks.js' +export type { RenderBlocksProps } from './RenderBlocks.js' +export type { BlockComponentMap, BlockData, EnhanceProps } from './types.js' diff --git a/src/modules/blocks/types.ts b/src/modules/blocks/types.ts new file mode 100644 index 0000000..4926375 --- /dev/null +++ b/src/modules/blocks/types.ts @@ -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> + +/** + * 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 diff --git a/src/modules/consent/ConsentContext.tsx b/src/modules/consent/ConsentContext.tsx new file mode 100644 index 0000000..1cbb62d --- /dev/null +++ b/src/modules/consent/ConsentContext.tsx @@ -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 + +const ConsentContext = createContext(null) + +export function ConsentProvider({ children, texts }: { children: ReactNode; texts: ConsentTexts }) { + const consent = useConsent() + return {children} +} + +export function useConsentContext(): ConsentContextValue { + const ctx = use(ConsentContext) + if (!ctx) {throw new Error('useConsentContext must be used within ConsentProvider')} + return ctx +} diff --git a/src/modules/consent/CookieBanner.tsx b/src/modules/consent/CookieBanner.tsx new file mode 100644 index 0000000..b6242b6 --- /dev/null +++ b/src/modules/consent/CookieBanner.tsx @@ -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>(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 ( +
+
+ {!showSettings ? ( +
+
+
+
+ + + +
+
+ ) : ( +
+

+

+
    + {CONSENT_CATEGORIES.map((cat) => ( +
  • +
    +

    + {texts.categories[cat].title} +

    +

    {texts.categories[cat].description}

    +
    + toggle(cat)} + type="checkbox" + /> +
  • + ))} +
+
+ + +
+
+ )} +
+
+ ) +} diff --git a/src/modules/consent/CookieButton.tsx b/src/modules/consent/CookieButton.tsx new file mode 100644 index 0000000..c65c507 --- /dev/null +++ b/src/modules/consent/CookieButton.tsx @@ -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 ( + + ) +} diff --git a/src/modules/consent/categories.ts b/src/modules/consent/categories.ts new file mode 100644 index 0000000..0164510 --- /dev/null +++ b/src/modules/consent/categories.ts @@ -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 + +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 } diff --git a/src/modules/consent/client.ts b/src/modules/consent/client.ts new file mode 100644 index 0000000..c8f97c3 --- /dev/null +++ b/src/modules/consent/client.ts @@ -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' diff --git a/src/modules/consent/getConsentTexts.ts b/src/modules/consent/getConsentTexts.ts new file mode 100644 index 0000000..3b563c0 --- /dev/null +++ b/src/modules/consent/getConsentTexts.ts @@ -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 + 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 } | 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 { + const g = await getGlobal(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, + } +} diff --git a/src/modules/consent/googleConsent.ts b/src/modules/consent/googleConsent.ts new file mode 100644 index 0000000..d6c04d7 --- /dev/null +++ b/src/modules/consent/googleConsent.ts @@ -0,0 +1,55 @@ +import type { ConsentState } from './categories.js' + +type ConsentValue = 'denied' | 'granted' +type GoogleConsentSignals = Record + +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)) +} diff --git a/src/modules/consent/index.ts b/src/modules/consent/index.ts new file mode 100644 index 0000000..043be1e --- /dev/null +++ b/src/modules/consent/index.ts @@ -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' diff --git a/src/modules/consent/storage.ts b/src/modules/consent/storage.ts new file mode 100644 index 0000000..93f3957 --- /dev/null +++ b/src/modules/consent/storage.ts @@ -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 + } +} diff --git a/src/modules/consent/texts.ts b/src/modules/consent/texts.ts new file mode 100644 index 0000000..0eadd02 --- /dev/null +++ b/src/modules/consent/texts.ts @@ -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 + message: string + /** null = no privacy-policy link shown */ + privacyLink: { href: string; label: string } | null + settingsTitle: string +} diff --git a/src/modules/consent/useConsent.ts b/src/modules/consent/useConsent.ts new file mode 100644 index 0000000..e71fe3a --- /dev/null +++ b/src/modules/consent/useConsent.ts @@ -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(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>) => + persist({ ...DEFAULT_CONSENT, ...choices, necessary: true }), + [persist], + ) + const reopen = useCallback(() => setDecided(false), []) + + return { acceptAll, decided, rejectAll, reopen, savePreferences, state } +} diff --git a/src/modules/content/archiveFields.ts b/src/modules/content/archiveFields.ts new file mode 100644 index 0000000..3e282e5 --- /dev/null +++ b/src/modules/content/archiveFields.ts @@ -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, + } + }) +} diff --git a/src/modules/content/getArchiveEntries.ts b/src/modules/content/getArchiveEntries.ts new file mode 100644 index 0000000..a205e65 --- /dev/null +++ b/src/modules/content/getArchiveEntries.ts @@ -0,0 +1,69 @@ +import type { BasePayload } from 'payload' + +export type ArchiveEntries> = { + 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 +} + +/** + * 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>({ + collection, + depth = 1, + locale, + page = 1, + payload, + perPage = 10, + sort = '-createdAt', + where, +}: GetArchiveEntriesArgs): Promise> { + 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, + } +} diff --git a/src/modules/content/index.ts b/src/modules/content/index.ts new file mode 100644 index 0000000..c3177e2 --- /dev/null +++ b/src/modules/content/index.ts @@ -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' diff --git a/src/modules/content/paths.ts b/src/modules/content/paths.ts new file mode 100644 index 0000000..3a5a029 --- /dev/null +++ b/src/modules/content/paths.ts @@ -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 +} diff --git a/src/modules/content/resolveRoute.ts b/src/modules/content/resolveRoute.ts new file mode 100644 index 0000000..d78d1fa --- /dev/null +++ b/src/modules/content/resolveRoute.ts @@ -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 + collection: string + doc: Record + type: 'entry' + } + /** An ordinary page. */ + | { + collection: string + doc: Record + /** 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; type: 'home' } + /** A single entry, e.g. /pl/artykuly/moj-post. */ + | { doc: Record; 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 { + const settings = (await payload.findGlobal({ + slug: settingsSlug, + depth: 1, + locale: locale as never, + })) as Record + + // 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 } + } + 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, + 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, + collection: collection.slug, + doc: entry as Record, + } + } + + // 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 } +} diff --git a/src/modules/content/types.ts b/src/modules/content/types.ts new file mode 100644 index 0000000..99ee850 --- /dev/null +++ b/src/modules/content/types.ts @@ -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` +} diff --git a/src/modules/email/index.ts b/src/modules/email/index.ts new file mode 100644 index 0000000..03d4236 --- /dev/null +++ b/src/modules/email/index.ts @@ -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' diff --git a/src/modules/email/panelSmtpAdapter.ts b/src/modules/email/panelSmtpAdapter.ts new file mode 100644 index 0000000..3cb32b5 --- /dev/null +++ b/src/modules/email/panelSmtpAdapter.ts @@ -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(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 } + } + }, + }) diff --git a/src/modules/email/sendEmail.ts b/src/modules/email/sendEmail.ts new file mode 100644 index 0000000..3e06dd5 --- /dev/null +++ b/src/modules/email/sendEmail.ts @@ -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 { + const smtp = await getSiteIntegrations(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 } + } +} diff --git a/src/modules/forms/formsPluginConfig.ts b/src/modules/forms/formsPluginConfig.ts new file mode 100644 index 0000000..c6d3d15 --- /dev/null +++ b/src/modules/forms/formsPluginConfig.ts @@ -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 } + : {}), + }) +} diff --git a/src/modules/forms/index.ts b/src/modules/forms/index.ts new file mode 100644 index 0000000..984e590 --- /dev/null +++ b/src/modules/forms/index.ts @@ -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' diff --git a/src/modules/forms/rateLimit.ts b/src/modules/forms/rateLimit.ts new file mode 100644 index 0000000..f41d9d0 --- /dev/null +++ b/src/modules/forms/rateLimit.ts @@ -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() + +/** 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 +} diff --git a/src/modules/forms/submitForm.ts b/src/modules/forms/submitForm.ts new file mode 100644 index 0000000..216da1b --- /dev/null +++ b/src/modules/forms/submitForm.ts @@ -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 + /** 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 { + // 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 } + } +} diff --git a/src/modules/forms/types.ts b/src/modules/forms/types.ts new file mode 100644 index 0000000..39b0d5d --- /dev/null +++ b/src/modules/forms/types.ts @@ -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> + +/** + * 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[] +} diff --git a/src/modules/forms/validateSubmission.ts b/src/modules/forms/validateSubmission.ts new file mode 100644 index 0000000..d35feba --- /dev/null +++ b/src/modules/forms/validateSubmission.ts @@ -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; 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, +): Promise { + 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 = {} + + 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 } +} diff --git a/src/modules/frontend/createContentHelpers.ts b/src/modules/frontend/createContentHelpers.ts new file mode 100644 index 0000000..14a4bc2 --- /dev/null +++ b/src/modules/frontend/createContentHelpers.ts @@ -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 + /** 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 => + getPayload({ config: await config }), + ) + + const getConfiguredLocales = cache(async (): Promise => { + 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 => { + const payload = await getCachedPayload() + return resolveRouteRaw({ + content, + locale, + page, + pagesSlug, + payload, + segments, + settingsSlug, + withEntries, + }) + }, + ) + + return { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute } +} diff --git a/src/modules/frontend/index.ts b/src/modules/frontend/index.ts new file mode 100644 index 0000000..42513c9 --- /dev/null +++ b/src/modules/frontend/index.ts @@ -0,0 +1 @@ +export { createContentHelpers } from './createContentHelpers.js' diff --git a/src/modules/i18n/getLocalizedSlugs.ts b/src/modules/i18n/getLocalizedSlugs.ts new file mode 100644 index 0000000..e40ff9c --- /dev/null +++ b/src/modules/i18n/getLocalizedSlugs.ts @@ -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 + +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 +} diff --git a/src/modules/i18n/helpers.ts b/src/modules/i18n/helpers.ts new file mode 100644 index 0000000..ac49e71 --- /dev/null +++ b/src/modules/i18n/helpers.ts @@ -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) +} diff --git a/src/modules/i18n/index.ts b/src/modules/i18n/index.ts new file mode 100644 index 0000000..9380ad5 --- /dev/null +++ b/src/modules/i18n/index.ts @@ -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' diff --git a/src/modules/i18n/localeMiddleware.ts b/src/modules/i18n/localeMiddleware.ts new file mode 100644 index 0000000..e32ca35 --- /dev/null +++ b/src/modules/i18n/localeMiddleware.ts @@ -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|.*\\..*).*)'] diff --git a/src/modules/i18n/localizationConfig.ts b/src/modules/i18n/localizationConfig.ts new file mode 100644 index 0000000..cf83ce2 --- /dev/null +++ b/src/modules/i18n/localizationConfig.ts @@ -0,0 +1,23 @@ +import type { Config } from 'payload' + +import type { I18nConfig } from './types.js' + +type PayloadLocalizationConfig = NonNullable + +/** + * 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 }), + })), + } +} diff --git a/src/modules/i18n/localizedPath.ts b/src/modules/i18n/localizedPath.ts new file mode 100644 index 0000000..39bad99 --- /dev/null +++ b/src/modules/i18n/localizedPath.ts @@ -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 + +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}` +} diff --git a/src/modules/i18n/negotiateLocale.ts b/src/modules/i18n/negotiateLocale.ts new file mode 100644 index 0000000..3a88cb2 --- /dev/null +++ b/src/modules/i18n/negotiateLocale.ts @@ -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) +} diff --git a/src/modules/i18n/types.ts b/src/modules/i18n/types.ts new file mode 100644 index 0000000..cf96b2e --- /dev/null +++ b/src/modules/i18n/types.ts @@ -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[]] +} diff --git a/src/modules/i18n/validation.ts b/src/modules/i18n/validation.ts new file mode 100644 index 0000000..f2a0786 --- /dev/null +++ b/src/modules/i18n/validation.ts @@ -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() + + 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(', ')}].`, + ) + } +} diff --git a/src/modules/pages/getSystemPagePath.ts b/src/modules/pages/getSystemPagePath.ts new file mode 100644 index 0000000..1afc1a2 --- /dev/null +++ b/src/modules/pages/getSystemPagePath.ts @@ -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 +} + +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 }) +} diff --git a/src/modules/pages/index.ts b/src/modules/pages/index.ts new file mode 100644 index 0000000..71b9634 --- /dev/null +++ b/src/modules/pages/index.ts @@ -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' diff --git a/src/modules/pages/systemPagesFields.ts b/src/modules/pages/systemPagesFields.ts new file mode 100644 index 0000000..2438093 --- /dev/null +++ b/src/modules/pages/systemPagesFields.ts @@ -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 = { + cookiePolicy: 'Cookie Policy', + homepage: 'Homepage', + privacyPolicy: 'Privacy Policy', +} + +/** Admin descriptions per role. */ +const ROLE_DESCRIPTIONS: Record = { + 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, + })) +} diff --git a/src/modules/pages/types.ts b/src/modules/pages/types.ts new file mode 100644 index 0000000..3d00796 --- /dev/null +++ b/src/modules/pages/types.ts @@ -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 +} diff --git a/src/modules/payload/getGlobal.ts b/src/modules/payload/getGlobal.ts new file mode 100644 index 0000000..fd8b557 --- /dev/null +++ b/src/modules/payload/getGlobal.ts @@ -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>( + payload: BasePayload, + slug: string, + options: GlobalQueryOptions = {}, +): Promise { + const result = await payload.findGlobal({ + slug, + ...(options.locale ? { locale: options.locale as never } : {}), + ...(typeof options.depth === 'number' ? { depth: options.depth } : {}), + }) + + return result as T +} diff --git a/src/modules/payload/getSiteIntegrations.ts b/src/modules/payload/getSiteIntegrations.ts new file mode 100644 index 0000000..c78ceb3 --- /dev/null +++ b/src/modules/payload/getSiteIntegrations.ts @@ -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(payload) + */ +export function getSiteIntegrations>( + payload: BasePayload, + options?: GlobalQueryOptions, +): Promise { + return getGlobal(payload, SITE_INTEGRATIONS_SLUG, options) +} diff --git a/src/modules/payload/getSiteSettings.ts b/src/modules/payload/getSiteSettings.ts new file mode 100644 index 0000000..bfe2915 --- /dev/null +++ b/src/modules/payload/getSiteSettings.ts @@ -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(payload, { locale: 'pl' }) + */ +export function getSiteSettings>( + payload: BasePayload, + options?: GlobalQueryOptions, +): Promise { + return getGlobal(payload, SITE_SETTINGS_SLUG, options) +} diff --git a/src/modules/payload/index.ts b/src/modules/payload/index.ts new file mode 100644 index 0000000..39eacfc --- /dev/null +++ b/src/modules/payload/index.ts @@ -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' diff --git a/src/modules/seo/autoFillMeta.ts b/src/modules/seo/autoFillMeta.ts new file mode 100644 index 0000000..996c6f9 --- /dev/null +++ b/src/modules/seo/autoFillMeta.ts @@ -0,0 +1,58 @@ +import type { CollectionBeforeChangeHook } from 'payload' + +/** + * Maps document fields to SEO meta fields for auto-fill. + * Defaults match the Payload website template (title → meta.title). + */ +export type AutoFillMapping = { + /** Document field used to fill meta.description when empty. */ + description?: string + /** Document field used to fill meta.title when empty. Default: 'title'. */ + title?: string +} + +/** + * Reads a possibly-localized field value into a plain string. + * With localized fields at this hook stage the value is the active-locale + * string, so we just coerce defensively. + */ +function readString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) {return value.trim()} + return undefined +} + +/** + * Builds a beforeChange hook that fills empty SEO meta fields from document + * content. Only fills when the meta field is blank — never overwrites what an + * editor typed. This runs server-side on every create/update, so editors get + * sensible meta without clicking "auto-generate". + * + * The plugin doesn't know the client's collection shape, so the field mapping + * is configurable; defaults follow the website template. + */ +export function buildAutoFillMetaHook(mapping: AutoFillMapping = {}): CollectionBeforeChangeHook { + const titleField = mapping.title ?? 'title' + const descriptionField = mapping.description + + return ({ data }) => { + if (!data) {return data} + + // plugin-seo stores meta as a group under `meta` + const meta = (data.meta as Record | undefined) ?? {} + + // Fill meta.title from the document title when empty + if (!readString(meta.title)) { + const sourceTitle = readString(data[titleField]) + if (sourceTitle) {meta.title = sourceTitle} + } + + // Fill meta.description from a configured field when empty + if (descriptionField && !readString(meta.description)) { + const sourceDescription = readString(data[descriptionField]) + if (sourceDescription) {meta.description = sourceDescription} + } + + data.meta = meta + return data + } +} diff --git a/src/modules/seo/buildMetadata.ts b/src/modules/seo/buildMetadata.ts new file mode 100644 index 0000000..ff7f02a --- /dev/null +++ b/src/modules/seo/buildMetadata.ts @@ -0,0 +1,122 @@ +import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js' +import type { TitleOrder } from './composeTitle.js' +import type { SeoMeta } from './types.js' + +import { buildLocalizedPath } from '../i18n/index.js' +import { composeTitle } from './composeTitle.js' +import { buildHreflangAlternates } from './hreflang.js' + +/** + * Subset of Next.js `Metadata` this helper produces. Kept local so the plugin + * doesn't depend on `next` types; the shape is assignable to Next's Metadata. + */ +export type PageMetadata = { + alternates?: { + canonical?: string + languages?: Record + } + description?: string + openGraph?: { + description?: string + images?: { url: string }[] + locale?: string + title: string + } + title: string +} + +type BuildMetadataArgs = { + /** Absolute site origin, e.g. 'https://example.com'. */ + baseUrl?: string + config: I18nConfig + /** Home slug that collapses to the locale root. Defaults to 'home'. */ + homeSlug?: string + /** Resolved OG image URL (page image or site defaultShareImage). */ + imageUrl?: null | string + /** Current locale being rendered. */ + locale: string + /** SEO meta from the document (plugin-seo group). */ + meta?: null | SeoMeta + /** Page title or site name first. Defaults to 'page-first'. */ + order?: TitleOrder + /** + * Localized segment the document lives under (an archive page's slugs). + * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and + * /en/articles/my-post point at each other correctly. + */ + prefix?: LocalizedSlugs + /** + * Query string appended to canonical and every hreflang, e.g. '?page=2'. + * + * A paginated listing must be canonical to itself — pointing page 2 at page 1 + * tells Google the entries on it don't exist. Alternates carry the same page, + * since /pl/artykuly?page=2 corresponds to /en/articles?page=2. + */ + query?: string + /** Separator between page title and site name. Defaults to ' | '. */ + separator?: string + /** Site name for title composition and OG. */ + siteName?: null | string + /** slug per locale for this document — drives canonical + hreflang. */ + slugs: LocalizedSlugs +} + +/** + * Assembles a Next.js-compatible Metadata object from document SEO fields and + * site-level data. Locale-aware: canonical points at the current locale's + * path, and hreflang alternates cover every locale the document exists in. + * + * Designed for use inside Next.js `generateMetadata`. The caller resolves the + * pieces (meta group, site name, image URL, localized slugs) and passes them + * in — the plugin composes, it doesn't fetch. + */ +export function buildMetadata({ + baseUrl, + config, + homeSlug = 'home', + imageUrl, + locale, + meta, + order, + prefix, + query, + separator, + siteName, + slugs, +}: BuildMetadataArgs): PageMetadata { + // titleOverride wins outright: an editor who filled it in wants that exact + // string in the tab, not a composition. + const override = meta?.titleOverride?.trim() + const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName }) + const description = meta?.description?.trim() || undefined + const origin = baseUrl?.replace(/\/$/, '') ?? '' + + const suffix = query ?? '' + + const currentPath = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs }) + const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined + + const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs }) + if (suffix) { + for (const code of Object.keys(languages)) { + languages[code] = `${languages[code]}${suffix}` + } + } + + const images = imageUrl ? [{ url: imageUrl }] : undefined + + return { + title, + ...(description && { description }), + alternates: { + ...(canonical && { canonical }), + ...(Object.keys(languages).length > 0 && { languages }), + }, + openGraph: { + title, + ...(description && { description }), + ...(images && { images }), + locale, + }, + } +} diff --git a/src/modules/seo/composeTitle.ts b/src/modules/seo/composeTitle.ts new file mode 100644 index 0000000..2f26ec2 --- /dev/null +++ b/src/modules/seo/composeTitle.ts @@ -0,0 +1,39 @@ +/** Which part comes first in a composed title. */ +export type TitleOrder = 'page-first' | 'site-first' + +type ComposeTitleArgs = { + /** Defaults to 'page-first' — the page title is what a visitor scans for. */ + order?: TitleOrder + /** Page-specific title, e.g. 'About Us'. */ + pageTitle?: null | string + /** Separator between page title and site name. Defaults to ' | '. */ + separator?: string + /** Site name, e.g. 'Acme Inc'. */ + siteName?: null | string +} + +/** + * Composes a full document title from a page title and the site name. + * + * - both, page-first: "About Us | Acme Inc" + * - both, site-first: "Acme Inc | About Us" + * - page only: "About Us" + * - site only: "Acme Inc" + * - neither: "" + * + * Pure function — no dependency on Payload or request state. + */ +export function composeTitle({ + order = 'page-first', + pageTitle, + separator = ' | ', + siteName, +}: ComposeTitleArgs): string { + const page = pageTitle?.trim() + const site = siteName?.trim() + + if (page && site) { + return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}` + } + return page || site || '' +} diff --git a/src/modules/seo/createMetadataGenerator.ts b/src/modules/seo/createMetadataGenerator.ts new file mode 100644 index 0000000..fcb977f --- /dev/null +++ b/src/modules/seo/createMetadataGenerator.ts @@ -0,0 +1,105 @@ +import type { BasePayload } from 'payload' + +import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js' +import type { PageMetadata } from './buildMetadata.js' +import type { SeoMeta } from './types.js' + +import { getLocalizedSlugs } from '../i18n/index.js' +import { buildMetadata } from './buildMetadata.js' + +/** + * A document as needed for metadata: its SEO meta group and localized slug. + */ +type MetadataDocument = { + meta?: null | SeoMeta + slug?: null | Record +} + +type CreateMetadataGeneratorArgs = { + /** Absolute site origin, e.g. 'https://example.com'. */ + baseUrl?: string + config: I18nConfig + /** Home slug that collapses to the locale root. Defaults to 'home'. */ + homeSlug?: string + /** + * Resolves the document to build metadata for, given route params. + * The client supplies this (they own the collections and routing); it should + * fetch with `locale: 'all'` so the slug field is a locale→value map. + */ + resolveDocument: (args: { + locale: string + params: Record + payload: BasePayload + }) => Promise + /** Resolves the OG image URL for the document, if any. */ + resolveImageUrl?: (args: { + doc: MetadataDocument + locale: string + payload: BasePayload + }) => Promise + /** Resolves the site name (e.g. from SiteSettings). */ + resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise +} + +/** + * Builds a metadata generator, collapsing the usual generateMetadata + * boilerplate into a single wired-up function. + * + * The client provides resolvers (they own collections/routing); the plugin + * owns the assembly (title composition, canonical, hreflang, OG). + * + * The returned function takes `{ payload, params, locale }` — Next calls + * `generateMetadata({ params })` without those, and the plugin never calls + * getPayload itself, so the client wraps it in their route file: + * + * ```ts + * // app/(frontend)/[locale]/[[...slug]]/page.tsx + * const generate = createMetadataGenerator({ + * config: i18nConfig, + * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, + * resolveDocument: async ({ payload, params, locale }) => { ... }, + * }) + * + * 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` should fetch with `locale: 'all'` so the slug field comes + * back as a locale→value map — that's what hreflang alternates are built from. + */ +export function createMetadataGenerator(args: CreateMetadataGeneratorArgs) { + const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args + + return async function generateMetadata(context: { + locale: string + params: Record + payload: BasePayload + }): Promise { + const { locale, params, payload } = context + + const doc = await resolveDocument({ locale, params, payload }) + + const slugs: LocalizedSlugs = doc?.slug + ? getLocalizedSlugs({ config, slugField: doc.slug }) + : {} + + const [siteName, imageUrl] = await Promise.all([ + resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null), + doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null), + ]) + + return buildMetadata({ + baseUrl, + config, + homeSlug, + imageUrl, + locale, + meta: doc?.meta, + siteName, + slugs, + }) + } +} diff --git a/src/modules/seo/createPageMetadata.ts b/src/modules/seo/createPageMetadata.ts new file mode 100644 index 0000000..87b6dcb --- /dev/null +++ b/src/modules/seo/createPageMetadata.ts @@ -0,0 +1,198 @@ +import type { BasePayload } from 'payload' + +import type { ContentOption } from '../content/index.js' +import type { I18nConfig } from '../i18n/index.js' +import type { PageMetadata } from './buildMetadata.js' +import type { TitleOrder } from './composeTitle.js' +import type { SeoMeta } from './types.js' + +import { resolveRoute } from '../content/index.js' +import { getLocalizedSlugs } from '../i18n/index.js' +import { buildMetadata } from './buildMetadata.js' + +type CreatePageMetadataArgs = { + /** Absolute site origin, e.g. 'https://example.com'. */ + baseUrl?: string + /** Collection holding pages. Defaults to 'pages'. */ + collection?: string + config: I18nConfig + /** + * Archive-backed collections, same value as the plugin option. Pass it and + * entry URLs (/pl/artykuly/moj-post) get correct canonical and hreflang; + * omit it and only pages are handled. + */ + content?: ContentOption + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string + /** Field on SiteSettings holding the site name. Defaults to 'siteName'. */ + siteNameField?: string +} + +type PageMetadataContext = { + locale: string + /** + * Page number from ?page= on an archive listing. Pass it and page 2 gets a + * canonical to itself; leave it out and every page claims to be page 1. + */ + page?: number + payload: BasePayload + /** Route slug segments; empty/undefined means the locale root. */ + slug?: string[] +} + +type SettingsShape = { + [key: string]: unknown + homepage?: { id: number | string; slug?: unknown } | null | number | string + titleOrder?: null | TitleOrder + titleSeparator?: null | string +} + +type DocShape = { + id: number | string + meta?: null | SeoMeta + /** A string when read in one locale, a locale→value map when read with 'all'. */ + slug?: unknown +} + +/** Reads a document again across locales — the slug map hreflang needs. */ +async function slugsAcrossLocales( + payload: BasePayload, + collection: string, + id: number | string, + config: I18nConfig, +) { + const doc = (await payload.findByID({ + id, + collection: collection as never, + depth: 0, + locale: 'all', + })) as DocShape + + return doc.slug && typeof doc.slug === 'object' + ? getLocalizedSlugs({ config, slugField: doc.slug as Record }) + : {} +} + +/** + * Metadata for the page route, with every resolver already wired. + * + * `createMetadataGenerator` asks the client for resolvers because it can't know + * their collections. But for the plugin's own conventions it does know: pages + * live in one collection, the site name sits on SiteSettings, the OG image sits + * on the plugin-seo `meta` group, the home page is whatever System Pages points + * at, and — with `content` — entries live under their collection's archive page. + * Re-declaring all that in every project is copy-paste, so this resolves it. + * + * Reach for `createMetadataGenerator` instead when a route doesn't follow those + * conventions — custom image logic, a different global, hand-rolled paths. + * + * The plugin never calls getPayload, and Next calls generateMetadata without a + * payload, so the client keeps a small wrapper: + * + * ```ts + * const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl, content }) + * + * export async function generateMetadata({ params }) { + * const { locale, slug } = await params + * return pageMetadata({ payload: await getPayload({ config }), locale, slug }) + * } + * ``` + */ +export function createPageMetadata(args: CreatePageMetadataArgs) { + const { + baseUrl, + collection = 'pages', + config, + content, + settingsSlug = 'site-settings', + siteNameField = 'siteName', + } = args + + return async function pageMetadata({ + slug, + locale, + page, + payload, + }: PageMetadataContext): Promise { + const settings = (await payload.findGlobal({ + slug: settingsSlug, + depth: 1, + locale: locale as never, + })) as SettingsShape + + const siteName = (settings[siteNameField] as string | undefined) ?? null + + // The panel stores the bare character ('|'); titles need it padded. + const separator = settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined + const order = settings.titleOrder ?? undefined + + const homepage = + settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null + // The home page's slug collapses to the locale root (/pl, not /pl/homepage). + const homeSlug = typeof homepage?.slug === 'string' ? homepage.slug : undefined + + const empty = () => + buildMetadata({ + baseUrl, + config, + homeSlug, + locale, + meta: null, + order, + separator, + siteName, + slugs: {}, + }) + + const route = await resolveRoute({ + content, + locale, + page, + pagesSlug: collection, + payload, + segments: slug, + settingsSlug, + }) + + // Unknown route (the page component will 404) — still return something + // coherent rather than throwing during metadata generation. + if (!route) {return empty()} + + const doc = route.doc as DocShape + + // An entry sits under its archive, so its URLs need that segment — and the + // segment differs per locale, since it's the archive page's own slug. + const prefix = + route.type === 'entry' + ? await slugsAcrossLocales(payload, collection, (route.archive as DocShape).id, config) + : undefined + + const docCollection = route.type === 'entry' ? route.collection : collection + const slugs = await slugsAcrossLocales(payload, docCollection, doc.id, config) + + // Page 2 of a listing is its own URL, not a variant of page 1. + const query = route.type === 'archive' && route.page > 1 ? `?page=${route.page}` : undefined + + // plugin-seo stores the OG image as an upload relationship. + const image = (doc.meta as { image?: unknown } | null | undefined)?.image + const imageUrl = + image && typeof image === 'object' && 'url' in image + ? ((image as { url: string }).url ?? null) + : null + + return buildMetadata({ + baseUrl, + config, + homeSlug, + imageUrl, + locale, + meta: doc.meta, + order, + prefix, + query, + separator, + siteName, + slugs, + }) + } +} diff --git a/src/modules/seo/hreflang.ts b/src/modules/seo/hreflang.ts new file mode 100644 index 0000000..d4b349c --- /dev/null +++ b/src/modules/seo/hreflang.ts @@ -0,0 +1,55 @@ +import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js' + +import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js' + +type BuildHreflangArgs = { + /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */ + baseUrl?: string + config: I18nConfig + /** Home slug that collapses to the locale root. Defaults to 'home'. */ + homeSlug?: string + /** + * Localized segment the document lives under (an archive page's slugs), + * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix + * are omitted — an entry with no archive in that language has no URL there. + */ + prefix?: LocalizedSlugs + /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */ + slugs: LocalizedSlugs +} + +/** + * Builds a map of locale → URL for hreflang alternate links, suitable for + * Next.js Metadata `alternates.languages`. + * + * Bridges SEO and i18n: for each configured locale that the document has a + * slug in, it produces the locale-aware path (via buildLocalizedPath), + * optionally prefixed with an absolute origin. + * + * @example + * buildHreflangAlternates({ + * slugs: { pl: 'o-nas', en: 'about' }, + * config, + * baseUrl: 'https://example.com', + * }) + * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' } + */ +export function buildHreflangAlternates({ + baseUrl, + config, + homeSlug = 'home', + prefix, + slugs, +}: BuildHreflangArgs): Record { + const origin = baseUrl?.replace(/\/$/, '') ?? '' + const alternates: Record = {} + + for (const locale of getLocaleCodes(config)) { + const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs }) + if (path) { + alternates[locale] = `${origin}${path}` + } + } + + return alternates +} diff --git a/src/modules/seo/index.ts b/src/modules/seo/index.ts new file mode 100644 index 0000000..8920bac --- /dev/null +++ b/src/modules/seo/index.ts @@ -0,0 +1,13 @@ +export { buildAutoFillMetaHook } from './autoFillMeta.js' +export type { AutoFillMapping } from './autoFillMeta.js' +export { buildMetadata } from './buildMetadata.js' +export type { PageMetadata } from './buildMetadata.js' +export { composeTitle } from './composeTitle.js' +export type { TitleOrder } from './composeTitle.js' +export { createMetadataGenerator } from './createMetadataGenerator.js' +export { createPageMetadata } from './createPageMetadata.js' +export { buildHreflangAlternates } from './hreflang.js' +export { injectAutoFillMeta } from './injectAutoFillMeta.js' +export { injectSeoTabs } from './injectSeoTabs.js' +export { buildSeoPlugin } from './seoPluginConfig.js' +export type { SeoMeta, SeoOption } from './types.js' diff --git a/src/modules/seo/injectAutoFillMeta.ts b/src/modules/seo/injectAutoFillMeta.ts new file mode 100644 index 0000000..773db92 --- /dev/null +++ b/src/modules/seo/injectAutoFillMeta.ts @@ -0,0 +1,38 @@ +import type { Config } from 'payload' + +import type { SeoOption } from './types.js' + +import { buildAutoFillMetaHook } from './autoFillMeta.js' + +/** + * Injects the auto-fill meta hook into every SEO-enabled collection. + * + * Runs after @payloadcms/plugin-seo has added the meta group, appending a + * beforeChange hook that fills empty meta from document content. Existing + * hooks are preserved (plugin hook runs last, so editor input and other hooks + * win first). + */ +export function injectAutoFillMeta(config: Config, seo: SeoOption): Config { + // autoFill disabled explicitly — leave collections untouched + if (seo.autoFill === false) { + return config + } + + const hook = buildAutoFillMetaHook(seo.autoFill) + + return { + ...config, + collections: (config.collections ?? []).map((collection) => { + if (!seo.collections.includes(collection.slug)) { + return collection + } + return { + ...collection, + hooks: { + ...collection.hooks, + beforeChange: [...(collection.hooks?.beforeChange ?? []), hook], + }, + } + }), + } +} diff --git a/src/modules/seo/injectSeoTabs.ts b/src/modules/seo/injectSeoTabs.ts new file mode 100644 index 0000000..15eac36 --- /dev/null +++ b/src/modules/seo/injectSeoTabs.ts @@ -0,0 +1,69 @@ +import type { Config, Field, TabsField } from 'payload' + +import type { SeoOption } from './types.js' + +/** + * Wraps each SEO-enabled collection's fields into a tabbed UI: a "Content" tab + * holding the collection's own fields, and an "SEO" tab holding the `meta` + * group that @payloadcms/plugin-seo added. + * + * This replaces plugin-seo's own `tabbedUI`, which merges tabs by inspecting + * the first field and breaks when other plugins/fields (roles, slug) already + * sit in the collection — producing an empty SEO tab. Running this AFTER + * plugin-seo (so `meta` already exists) and after other field injections lets + * us build the tabs deterministically. + * + * If a collection's first field is already a tabs field, the SEO tab is + * appended to it instead of creating a new wrapper. + */ +export function injectSeoTabs(config: Config, seo: SeoOption): Config { + return { + ...config, + collections: (config.collections ?? []).map((collection) => { + if (!seo.collections.includes(collection.slug)) { + return collection + } + + const fields = collection.fields ?? [] + + // Separate the meta group (added by plugin-seo) from the rest. + const metaField = fields.find( + (f): f is { name: string } & Field => 'name' in f && f.name === 'meta', + ) + const otherFields = fields.filter( + (f) => !('name' in f && f.name === 'meta'), + ) + + // Nothing to do if plugin-seo hasn't added meta (shouldn't happen). + if (!metaField) {return collection} + + // If the collection already leads with a tabs field, append an SEO tab. + const firstField = otherFields[0] + if (firstField && firstField.type === 'tabs') { + const existingTabs = firstField + const withSeoTab: TabsField = { + ...existingTabs, + tabs: [...existingTabs.tabs, { fields: [metaField], label: 'SEO' }], + } + return { + ...collection, + fields: [withSeoTab, ...otherFields.slice(1)], + } + } + + // Otherwise wrap everything: Content tab + SEO tab. + const tabs: TabsField = { + type: 'tabs', + tabs: [ + { fields: otherFields, label: collection.labels?.singular?.toString() || 'Content' }, + { fields: [metaField], label: 'SEO' }, + ], + } + + return { + ...collection, + fields: [tabs], + } + }), + } +} diff --git a/src/modules/seo/seoPluginConfig.ts b/src/modules/seo/seoPluginConfig.ts new file mode 100644 index 0000000..78c8c99 --- /dev/null +++ b/src/modules/seo/seoPluginConfig.ts @@ -0,0 +1,45 @@ +import type { Field, Plugin } from 'payload' + +import { seoPlugin } from '@payloadcms/plugin-seo' + +import type { SeoOption } from './types.js' + +type BuildSeoPluginArgs = { + seo: SeoOption + /** Upload collection slug for the meta image (typically 'media'). */ + uploadsCollection?: string +} + +/** + * Escape hatch from automatic title composition: whatever is typed here becomes + * the entire title — no site name, no separator. Useful on a home page, where + * composing would produce "Intecion Software | Intecion Software". + */ +const titleOverrideField: Field = { + name: 'titleOverride', + type: 'text', + admin: { + description: + 'Use this exact text as the browser-tab title — no site name, no separator. Leave empty to compose the title automatically.', + }, + localized: true, +} + +/** + * Configures @payloadcms/plugin-seo from IPAL's SeoOption. + * + * Adds the `meta` field group (title, description, image) to the client's + * chosen collections. tabbedUI is intentionally NOT used — it merges tabs by + * inspecting the first field, which breaks when other plugins/fields (roles, + * slug) already sit in the collection, leaving an empty SEO tab. Instead the + * SEO UI fields are injected into a controlled tab by injectSeoTabs. + */ +export function buildSeoPlugin({ seo, uploadsCollection = 'media' }: BuildSeoPluginArgs): Plugin { + return seoPlugin({ + collections: seo.collections, + fields: ({ defaultFields }) => [...defaultFields, titleOverrideField, ...(seo.fields ?? [])], + uploadsCollection, + ...(seo.generateTitle && { generateTitle: seo.generateTitle }), + ...(seo.generateDescription && { generateDescription: seo.generateDescription }), + }) +} diff --git a/src/modules/seo/types.ts b/src/modules/seo/types.ts new file mode 100644 index 0000000..12addc9 --- /dev/null +++ b/src/modules/seo/types.ts @@ -0,0 +1,39 @@ +import type { Field } from 'payload' + +import type { AutoFillMapping } from './autoFillMeta.js' + +/** + * SEO configuration. + * + * The plugin wires @payloadcms/plugin-seo into the client's config and adds + * locale-aware metadata helpers on top. Collections come from options because + * the plugin doesn't own the client's content collections (e.g. Pages). + */ +export type SeoOption = { + /** + * Auto-fill empty meta from document fields on save. Defaults to the website + * template mapping (title → meta.title). Set to false to disable. + */ + autoFill?: AutoFillMapping | false + /** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */ + collections: string[] + /** Extra fields appended to the SEO group in the admin. */ + fields?: Field[] + /** Optional: customize how meta descriptions are generated. */ + generateDescription?: (args: { doc: Record }) => string + /** Optional: customize how meta titles are generated in the admin preview. */ + generateTitle?: (args: { doc: Record }) => string +} + +/** + * Minimal shape of the SEO meta group as stored on a document by + * @payloadcms/plugin-seo. The client's generated types are richer; helpers + * depend only on this. + */ +export type SeoMeta = { + description?: null | string + image?: unknown + title?: null | string + /** When set, used as the whole title — no site name, no separator. */ + titleOverride?: null | string +} diff --git a/src/modules/slug/buildSlugField.ts b/src/modules/slug/buildSlugField.ts new file mode 100644 index 0000000..125bb33 --- /dev/null +++ b/src/modules/slug/buildSlugField.ts @@ -0,0 +1,82 @@ +import type { Field, FieldHook, TextField } from 'payload' + +import slugify from 'slugify' + +/** + * Normalizes a string into a URL slug. slugify handles diacritics out of the + * box (Polish included: "Strona główna" → "strona-glowna"). + */ +export function toSlug(input: string): string { + return slugify(input, { + lower: true, + strict: true, // drop characters that aren't url-safe + trim: true, + }) +} + +type BuildSlugFieldOptions = { + /** Source field to derive the slug from. Default: 'title'. */ + from?: string + /** Whether the slug is localized (per-locale). Default: true. */ + localized?: boolean + /** Field name for the slug. Default: 'slug'. */ + name?: string + /** Override or extend the generated field config. */ + overrides?: Partial + /** Whether the slug is required. Default: true. */ + required?: boolean +} + +/** + * Builds a slug field that auto-generates from a source field (default 'title') + * only when left empty — an editor's manual slug is never overwritten (mode 4a). + * + * Localized-safe: on a localized field Payload runs the hook per locale, so + * `value` is the slug for the active locale and `siblingData[from]` is the + * source in that same locale. Editing the doc in 'pl' fills slug.pl from + * title.pl; editing in 'en' fills slug.en from title.en — giving genuinely + * per-locale slugs (strona-glowna / homepage) that the language switcher needs. + */ +export function buildSlugField(options: BuildSlugFieldOptions = {}): Field { + const { + name = 'slug', + from = 'title', + localized = true, + overrides = {}, + required = true, + } = options + + const autoGenerate: FieldHook = ({ originalDoc, siblingData, value }) => { + // Respect a manually entered slug — only generate when empty (mode 4a). + if (typeof value === 'string' && value.trim()) { + return toSlug(value) // still normalize what the editor typed + } + + // Derive from the source field in the current locale. + const source = + (siblingData as Record)?.[from] ?? + (originalDoc as Record)?.[from] + + if (typeof source === 'string' && source.trim()) { + return toSlug(source) + } + + return value + } + + return { + name, + type: 'text', + admin: { + description: 'Auto-generated from the title when left empty. You can override it.', + ...overrides.admin, + }, + hooks: { + beforeValidate: [autoGenerate], + }, + index: true, + localized, + required, + ...overrides, + } as TextField +} diff --git a/src/modules/slug/index.ts b/src/modules/slug/index.ts new file mode 100644 index 0000000..d1a5481 --- /dev/null +++ b/src/modules/slug/index.ts @@ -0,0 +1 @@ +export { buildSlugField, toSlug } from './buildSlugField.js' diff --git a/src/modules/turnstile/Turnstile.tsx b/src/modules/turnstile/Turnstile.tsx new file mode 100644 index 0000000..c5af08d --- /dev/null +++ b/src/modules/turnstile/Turnstile.tsx @@ -0,0 +1,77 @@ +'use client' +import { useEffect, useRef } from 'react' + +declare global { + interface Window { + turnstile?: { + render: (el: HTMLElement, opts: Record) => string + reset: (id?: string) => void + } + } +} + +const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' + +/** Loads the Turnstile script once, shared across all widget instances. */ +function ensureScript(): void { + if (typeof document === 'undefined') {return} + if (document.querySelector(`script[src="${SCRIPT_SRC}"]`)) {return} + const script = document.createElement('script') + script.src = SCRIPT_SRC + script.async = true + script.defer = true + document.head.appendChild(script) +} + +export type TurnstileProps = { + /** Called with the token once solved, or null on expiry/error. */ + onToken: (token: null | string) => void + /** + * Public Turnstile site key. The client project reads it server-side from + * SiteIntegrations (turnstileSiteKey) and passes it in — the widget is a pure + * client component and can't read Payload itself. + */ + siteKey: string + theme?: 'auto' | 'dark' | 'light' +} + +/** + * Cloudflare Turnstile widget — reusable across any form. Renders the challenge + * and reports the token via onToken. The token must then be verified + * server-side (see verifyTurnstile) before a submission is trusted. + * + * siteKey is a prop rather than an env var so all Turnstile config lives in one + * place (SiteIntegrations), consistent with the plugin's panel-managed model. + * The script is injected directly (no next/script dependency) so the widget + * stays framework-agnostic. + */ +export function Turnstile({ onToken, siteKey, theme = 'auto' }: TurnstileProps) { + const ref = useRef(null) + const widgetId = useRef(null) + + useEffect(() => { + if (!siteKey) {return} + + ensureScript() + + function render() { + if (!ref.current || !window.turnstile || widgetId.current) {return} + widgetId.current = window.turnstile.render(ref.current, { + callback: (token: string) => onToken(token), + 'error-callback': () => onToken(null), + 'expired-callback': () => onToken(null), + sitekey: siteKey, + theme, + }) + } + + render() + // Script may load after mount — retry briefly until ready. + const interval = setInterval(render, 300) + return () => clearInterval(interval) + }, [siteKey, theme, onToken]) + + if (!siteKey) {return null} + + return
+} diff --git a/src/modules/turnstile/client.ts b/src/modules/turnstile/client.ts new file mode 100644 index 0000000..b586876 --- /dev/null +++ b/src/modules/turnstile/client.ts @@ -0,0 +1,5 @@ +'use client' +// Client-only exports — the Turnstile widget. Kept separate from index.ts so +// the server-only verify never leaks into a browser bundle. +export { Turnstile } from './Turnstile.js' +export type { TurnstileProps } from './Turnstile.js' diff --git a/src/modules/turnstile/index.ts b/src/modules/turnstile/index.ts new file mode 100644 index 0000000..14ae88e --- /dev/null +++ b/src/modules/turnstile/index.ts @@ -0,0 +1,3 @@ +// Server-only exports. verify.ts imports 'server-only', so this must never be +// imported from a client component — use ./client for the widget instead. +export { verifyTurnstile } from './verify.js' diff --git a/src/modules/turnstile/verify.ts b/src/modules/turnstile/verify.ts new file mode 100644 index 0000000..209b6a5 --- /dev/null +++ b/src/modules/turnstile/verify.ts @@ -0,0 +1,62 @@ +import 'server-only' + +import type { BasePayload } from 'payload' + +import { getSiteIntegrations } from '../payload/index.js' + +type SiteverifyResponse = { + challenge_ts?: string + 'error-codes'?: string[] + hostname?: string + success: boolean +} + +type IntegrationsWithTurnstile = { + turnstileSecretKey?: null | string +} + +type VerifyTurnstileArgs = { + /** Optional client IP for stricter verification. */ + ip?: string + /** Payload instance — used to read the secret from SiteIntegrations. */ + payload: BasePayload + /** Token produced by the client-side widget. */ + token: string +} + +/** + * Verifies a Turnstile token with Cloudflare, server-side only. + * + * The secret comes from the SiteIntegrations global (editor-managed, per the + * plugin's "secrets in the panel" model), read via the Local API which bypasses + * access control. `server-only` guarantees this never reaches the browser + * bundle, keeping the secret off the client. + * + * Returns false on any failure (missing secret/token, network error, rejected + * challenge) — callers treat false as "do not trust this submission". + */ +export async function verifyTurnstile({ + ip, + payload, + token, +}: VerifyTurnstileArgs): Promise { + if (!token) {return false} + + const integrations = await getSiteIntegrations(payload) + const secret = integrations.turnstileSecretKey + if (!secret) {return false} + + const body = new URLSearchParams({ response: token, secret }) + if (ip) {body.append('remoteip', ip)} + + try { + const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + body, + method: 'POST', + }) + const data = (await res.json()) as SiteverifyResponse + return data.success + } catch { + return false + } +} diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..84e05cd --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,98 @@ +import type { Config, Plugin } from 'payload' + +import type { IpalOptions } from './types.js' + +import { buildCookieSettings } from './globals/CookieSettings/index.js' +import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js' +import { buildSiteSettings } from './globals/SiteSettings/index.js' +import { injectRoles } from './modules/access/index.js' +import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js' +import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js' +import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js' + +/** + * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3. + * + * @example + * ```ts + * import { ipalKit } from 'ipal-kit' + * + * export default buildConfig({ + * plugins: [ + * ipalKit({ + * i18n: { + * locales: [ + * { code: 'pl', label: 'Polski' }, + * { code: 'en', label: 'English' }, + * ], + * defaultLocale: 'pl', + * }, + * access: { authCollection: 'users' }, + * }), + * ], + * }) + * ``` + */ +const ipalKit = (options: IpalOptions): Plugin => { + // Validate eagerly — fail fast before Payload boots + validateI18nConfig(options.i18n) + + return async (incomingConfig: Config): Promise => { + // Early return when disabled — schema stays, behavior off + if (options.enabled === false) { + return incomingConfig + } + + let config = { ...incomingConfig } + + // --- i18n --- + config.localization = buildLocalizationConfig(options.i18n) + + // --- access: inject roles into the client's auth collection --- + if (options.access) { + config = injectRoles(config, options.access) + } + + // --- seo: apply @payloadcms/plugin-seo directly --- + // NOTE: apply the plugin function to the config immediately rather than + // pushing it onto config.plugins. Payload has already iterated the plugins + // array by the time IPAL runs, so nested plugins added to that list are + // never executed. Calling the plugin as (config) => config applies its + // transform now. + if (options.seo) { + config = await buildSeoPlugin({ seo: options.seo })(config) + // Auto-fill empty meta from document content on save + config = injectAutoFillMeta(config, options.seo) + // Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI, + // which breaks when other fields already exist in the collection) + config = injectSeoTabs(config, options.seo) + } + + // --- forms: apply @payloadcms/plugin-form-builder directly --- + if (options.forms) { + config = await buildFormsPlugin(options.forms)(config) + } + + // --- globals --- + config.globals = [ + ...(config.globals ?? []), + buildSiteSettings({ + additionalFields: options.siteSettingsFields, + content: options.content, + pages: options.pages, + }), + buildSiteIntegrations({ additionalFields: options.integrationsFields }), + buildCookieSettings(), + ] + + // --- hooks: onInit --- + const incomingOnInit = config.onInit + config.onInit = async (payload) => { + if (incomingOnInit) {await incomingOnInit(payload)} + payload.logger.info('[ipal] Plugin initialized.') + } + + return config + } +} +export default ipalKit diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7fb34a7 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,58 @@ +import type { Field } from 'payload' + +import type { AccessOption } from './modules/access/types.js' +import type { ContentOption } from './modules/content/types.js' +import type { FormsOption } from './modules/forms/types.js' +import type { I18nConfig } from './modules/i18n/types.js' +import type { PagesOption } from './modules/pages/types.js' +import type { SeoOption } from './modules/seo/types.js' + +/** + * Configuration options for the IPAL plugin. + * Passed by the client project in payload.config.ts. + */ +export type IpalOptions = { + /** + * Role-based access control. Injects a fixed `roles` field + * (admin > editor > user) into the client's auth collection. + */ + access?: AccessOption + + /** + * Collections whose entries live under an archive page — blog posts, case + * studies, anything with a listing. Adds an "archive page" assignment per + * collection in SiteSettings; the assigned page's localized slug becomes the + * URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`. + */ + content?: ContentOption + + /** Disable the plugin without uninstalling (keeps DB schema intact) */ + enabled?: boolean + + /** + * Forms — form-builder collections (forms, form-submissions) plus the + * callable submitForm (Turnstile + persistence + SMTP-from-panel email). + */ + forms?: FormsOption + + /** Internationalization — locales, default locale, fallback behavior */ + i18n: I18nConfig + + /** Additional fields injected into SiteIntegrations global */ + integrationsFields?: Field[] + + /** + * System-page assignments (homepage, privacy, cookies) in SiteSettings. + * Provide the slug of the client's Pages collection to enable. + */ + pages?: PagesOption + + /** + * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo) + * and enables locale-aware metadata helpers. + */ + seo?: SeoOption + + /** Additional fields injected into SiteSettings global */ + siteSettingsFields?: Field[] +}