This commit is contained in:
2026-07-31 23:21:51 +02:00
parent f6b4b36df8
commit f14f2cfd98
294 changed files with 5880 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import type { Access, FieldAccess } from 'payload';
import type { Role } from './types.js';
/**
* Collection-level access (returns boolean | Where).
* Use in collection `access.read/create/update/delete`.
*/
export declare const adminOnly: Access;
export declare const adminOrEditor: Access;
export declare const authenticated: Access;
/** Requires at least the given role. */
export declare const requireRole: (minimum: Role) => Access;
/** Admins see all; others are constrained to their own document. */
export declare const adminOrSelf: Access;
/**
* Field-level access (returns boolean only — no Where support).
* Use in field `access.read/update`.
*/
export declare const adminOnlyField: FieldAccess;
export declare const adminOrEditorField: FieldAccess;
/** Requires at least the given role, for field-level access. */
export declare const requireRoleField: (minimum: Role) => FieldAccess;
+29
View File
@@ -0,0 +1,29 @@
import { hasMinimumRole, isAdmin } from './predicates.js';
/**
* Collection-level access (returns boolean | Where).
* Use in collection `access.read/create/update/delete`.
*/ export const adminOnly = ({ req: { user } })=>isAdmin(user);
export const adminOrEditor = ({ req: { user } })=>hasMinimumRole(user, 'editor');
export const authenticated = ({ req: { user } })=>Boolean(user);
/** Requires at least the given role. */ export const requireRole = (minimum)=>({ req: { user } })=>hasMinimumRole(user, minimum);
/** Admins see all; others are constrained to their own document. */ export const adminOrSelf = ({ 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 = ({ req: { user } })=>isAdmin(user);
export const adminOrEditorField = ({ req: { user } })=>hasMinimumRole(user, 'editor');
/** Requires at least the given role, for field-level access. */ export const requireRoleField = (minimum)=>({ req: { user } })=>hasMinimumRole(user, minimum);
//# sourceMappingURL=access.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/access/access.ts"],"sourcesContent":["import type { Access, FieldAccess } from 'payload'\n\nimport type { Role } from './types.js'\n\nimport { hasMinimumRole, isAdmin } from './predicates.js'\n\n/**\n * Collection-level access (returns boolean | Where).\n * Use in collection `access.read/create/update/delete`.\n */\nexport const adminOnly: Access = ({ req: { user } }) => isAdmin(user)\n\nexport const adminOrEditor: Access = ({ req: { user } }) => hasMinimumRole(user, 'editor')\n\nexport const authenticated: Access = ({ req: { user } }) => Boolean(user)\n\n/** Requires at least the given role. */\nexport const requireRole =\n (minimum: Role): Access =>\n ({ req: { user } }) =>\n hasMinimumRole(user, minimum)\n\n/** Admins see all; others are constrained to their own document. */\nexport const adminOrSelf: Access = ({ req: { user } }) => {\n if (isAdmin(user)) {return true}\n if (!user) {return false}\n return { id: { equals: user.id } }\n}\n\n/**\n * Field-level access (returns boolean only — no Where support).\n * Use in field `access.read/update`.\n */\nexport const adminOnlyField: FieldAccess = ({ req: { user } }) => isAdmin(user)\n\nexport const adminOrEditorField: FieldAccess = ({ req: { user } }) => hasMinimumRole(user, 'editor')\n\n/** Requires at least the given role, for field-level access. */\nexport const requireRoleField =\n (minimum: Role): FieldAccess =>\n ({ req: { user } }) =>\n hasMinimumRole(user, minimum)\n"],"names":["hasMinimumRole","isAdmin","adminOnly","req","user","adminOrEditor","authenticated","Boolean","requireRole","minimum","adminOrSelf","id","equals","adminOnlyField","adminOrEditorField","requireRoleField"],"mappings":"AAIA,SAASA,cAAc,EAAEC,OAAO,QAAQ,kBAAiB;AAEzD;;;CAGC,GACD,OAAO,MAAMC,YAAoB,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKH,QAAQG,MAAK;AAErE,OAAO,MAAMC,gBAAwB,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKJ,eAAeI,MAAM,UAAS;AAE1F,OAAO,MAAME,gBAAwB,CAAC,EAAEH,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKG,QAAQH,MAAK;AAEzE,sCAAsC,GACtC,OAAO,MAAMI,cACX,CAACC,UACD,CAAC,EAAEN,KAAK,EAAEC,IAAI,EAAE,EAAE,GAChBJ,eAAeI,MAAMK,SAAQ;AAEjC,kEAAkE,GAClE,OAAO,MAAMC,cAAsB,CAAC,EAAEP,KAAK,EAAEC,IAAI,EAAE,EAAE;IACnD,IAAIH,QAAQG,OAAO;QAAC,OAAO;IAAI;IAC/B,IAAI,CAACA,MAAM;QAAC,OAAO;IAAK;IACxB,OAAO;QAAEO,IAAI;YAAEC,QAAQR,KAAKO,EAAE;QAAC;IAAE;AACnC,EAAC;AAED;;;CAGC,GACD,OAAO,MAAME,iBAA8B,CAAC,EAAEV,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKH,QAAQG,MAAK;AAE/E,OAAO,MAAMU,qBAAkC,CAAC,EAAEX,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKJ,eAAeI,MAAM,UAAS;AAEpG,8DAA8D,GAC9D,OAAO,MAAMW,mBACX,CAACN,UACD,CAAC,EAAEN,KAAK,EAAEC,IAAI,EAAE,EAAE,GAChBJ,eAAeI,MAAMK,SAAQ"}
+6
View File
@@ -0,0 +1,6 @@
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';
+7
View File
@@ -0,0 +1,7 @@
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 { ROLE_HIERARCHY } from './types.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/access/index.ts"],"sourcesContent":["export {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n requireRole,\n requireRoleField,\n} from './access.js'\nexport { injectRoles } from './injectRoles.js'\nexport { hasMinimumRole, isAdmin, isEditor } from './predicates.js'\nexport { buildRolesField } from './rolesField.js'\nexport type { AccessOption, Role } from './types.js'\nexport { ROLE_HIERARCHY } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","requireRole","requireRoleField","injectRoles","hasMinimumRole","isAdmin","isEditor","buildRolesField","ROLE_HIERARCHY"],"mappings":"AAAA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,WAAW,EACXC,gBAAgB,QACX,cAAa;AACpB,SAASC,WAAW,QAAQ,mBAAkB;AAC9C,SAASC,cAAc,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,kBAAiB;AACnE,SAASC,eAAe,QAAQ,kBAAiB;AAEjD,SAASC,cAAc,QAAQ,aAAY"}
+10
View File
@@ -0,0 +1,10 @@
import type { Config } from 'payload';
import type { AccessOption } from './types.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 declare function injectRoles(config: Config, access: AccessOption): Config;
+27
View File
@@ -0,0 +1,27 @@
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, access) {
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
]
};
})
};
}
//# sourceMappingURL=injectRoles.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/access/injectRoles.ts"],"sourcesContent":["import type { Config } from 'payload'\n\nimport type { AccessOption } from './types.js'\n\nimport { buildRolesField } from './rolesField.js'\n\n/**\n * Injects the fixed `roles` field into the client's auth collection.\n *\n * The plugin owns the role definition; the client owns the collection. This\n * finds the collection by slug and appends the field. We control the whole\n * stack, so no conflict handling is needed — the field is simply added.\n */\nexport function injectRoles(config: Config, access: AccessOption): Config {\n const rolesField = buildRolesField(access.defaultRole)\n\n return {\n ...config,\n collections: (config.collections ?? []).map((collection) => {\n if (collection.slug !== access.authCollection) {\n return collection\n }\n return {\n ...collection,\n fields: [...collection.fields, rolesField],\n }\n }),\n }\n}\n"],"names":["buildRolesField","injectRoles","config","access","rolesField","defaultRole","collections","map","collection","slug","authCollection","fields"],"mappings":"AAIA,SAASA,eAAe,QAAQ,kBAAiB;AAEjD;;;;;;CAMC,GACD,OAAO,SAASC,YAAYC,MAAc,EAAEC,MAAoB;IAC9D,MAAMC,aAAaJ,gBAAgBG,OAAOE,WAAW;IAErD,OAAO;QACL,GAAGH,MAAM;QACTI,aAAa,AAACJ,CAAAA,OAAOI,WAAW,IAAI,EAAE,AAAD,EAAGC,GAAG,CAAC,CAACC;YAC3C,IAAIA,WAAWC,IAAI,KAAKN,OAAOO,cAAc,EAAE;gBAC7C,OAAOF;YACT;YACA,OAAO;gBACL,GAAGA,UAAU;gBACbG,QAAQ;uBAAIH,WAAWG,MAAM;oBAAEP;iBAAW;YAC5C;QACF;IACF;AACF"}
+20
View File
@@ -0,0 +1,20 @@
import type { Role } from './types.js';
/**
* The plugin can't know the client's generated User type, and Payload types
* `req.user` loosely (UntypedUser | null). Predicates therefore accept an
* unknown-ish user and read `roles` defensively — no assumptions about shape
* beyond an optional roles array.
*/
type MaybeUser = {
roles?: null | Role[];
} | null | Record<string, unknown> | undefined;
/**
* True if the user holds at least the given role in the hierarchy.
* admin satisfies 'editor' and 'user'; editor satisfies 'user'.
*/
export declare function hasMinimumRole(user: MaybeUser, minimum: Role): boolean;
/** True if the user is an admin. */
export declare function isAdmin(user: MaybeUser): boolean;
/** True if the user is an editor or higher (editor, admin). */
export declare function isEditor(user: MaybeUser): boolean;
export {};
+32
View File
@@ -0,0 +1,32 @@
import { ROLE_HIERARCHY } from './types.js';
/** Safely extracts the roles array from a loosely-typed user. */ function getRoles(user) {
if (!user || typeof user !== 'object') {
return [];
}
const roles = user.roles;
if (!Array.isArray(roles)) {
return [];
}
return roles.filter((role)=>ROLE_HIERARCHY.includes(role));
}
/** Highest-privilege role index the user holds, or -1 if none. */ function highestRoleIndex(user) {
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, minimum) {
return highestRoleIndex(user) >= ROLE_HIERARCHY.indexOf(minimum);
}
/** True if the user is an admin. */ export function isAdmin(user) {
return hasMinimumRole(user, 'admin');
}
/** True if the user is an editor or higher (editor, admin). */ export function isEditor(user) {
return hasMinimumRole(user, 'editor');
}
//# sourceMappingURL=predicates.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/access/predicates.ts"],"sourcesContent":["import type { Role } from './types.js'\n\nimport { ROLE_HIERARCHY } from './types.js'\n\n/**\n * The plugin can't know the client's generated User type, and Payload types\n * `req.user` loosely (UntypedUser | null). Predicates therefore accept an\n * unknown-ish user and read `roles` defensively — no assumptions about shape\n * beyond an optional roles array.\n */\ntype MaybeUser = { roles?: null | Role[] } | null | Record<string, unknown> | undefined\n\n/** Safely extracts the roles array from a loosely-typed user. */\nfunction getRoles(user: MaybeUser): Role[] {\n if (!user || typeof user !== 'object') {return []}\n const roles = (user as { roles?: unknown }).roles\n if (!Array.isArray(roles)) {return []}\n return roles.filter((role): role is Role => ROLE_HIERARCHY.includes(role as Role))\n}\n\n/** Highest-privilege role index the user holds, or -1 if none. */\nfunction highestRoleIndex(user: MaybeUser): number {\n const roles = getRoles(user)\n if (!roles.length) {return -1}\n return Math.max(...roles.map((role) => ROLE_HIERARCHY.indexOf(role)))\n}\n\n/**\n * True if the user holds at least the given role in the hierarchy.\n * admin satisfies 'editor' and 'user'; editor satisfies 'user'.\n */\nexport function hasMinimumRole(user: MaybeUser, minimum: Role): boolean {\n return highestRoleIndex(user) >= ROLE_HIERARCHY.indexOf(minimum)\n}\n\n/** True if the user is an admin. */\nexport function isAdmin(user: MaybeUser): boolean {\n return hasMinimumRole(user, 'admin')\n}\n\n/** True if the user is an editor or higher (editor, admin). */\nexport function isEditor(user: MaybeUser): boolean {\n return hasMinimumRole(user, 'editor')\n}\n"],"names":["ROLE_HIERARCHY","getRoles","user","roles","Array","isArray","filter","role","includes","highestRoleIndex","length","Math","max","map","indexOf","hasMinimumRole","minimum","isAdmin","isEditor"],"mappings":"AAEA,SAASA,cAAc,QAAQ,aAAY;AAU3C,+DAA+D,GAC/D,SAASC,SAASC,IAAe;IAC/B,IAAI,CAACA,QAAQ,OAAOA,SAAS,UAAU;QAAC,OAAO,EAAE;IAAA;IACjD,MAAMC,QAAQ,AAACD,KAA6BC,KAAK;IACjD,IAAI,CAACC,MAAMC,OAAO,CAACF,QAAQ;QAAC,OAAO,EAAE;IAAA;IACrC,OAAOA,MAAMG,MAAM,CAAC,CAACC,OAAuBP,eAAeQ,QAAQ,CAACD;AACtE;AAEA,gEAAgE,GAChE,SAASE,iBAAiBP,IAAe;IACvC,MAAMC,QAAQF,SAASC;IACvB,IAAI,CAACC,MAAMO,MAAM,EAAE;QAAC,OAAO,CAAC;IAAC;IAC7B,OAAOC,KAAKC,GAAG,IAAIT,MAAMU,GAAG,CAAC,CAACN,OAASP,eAAec,OAAO,CAACP;AAChE;AAEA;;;CAGC,GACD,OAAO,SAASQ,eAAeb,IAAe,EAAEc,OAAa;IAC3D,OAAOP,iBAAiBP,SAASF,eAAec,OAAO,CAACE;AAC1D;AAEA,kCAAkC,GAClC,OAAO,SAASC,QAAQf,IAAe;IACrC,OAAOa,eAAeb,MAAM;AAC9B;AAEA,6DAA6D,GAC7D,OAAO,SAASgB,SAAShB,IAAe;IACtC,OAAOa,eAAeb,MAAM;AAC9B"}
+9
View File
@@ -0,0 +1,9 @@
import type { Field } from 'payload';
import type { Role } 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 declare function buildRolesField(defaultRole?: Role): Field;
+32
View File
@@ -0,0 +1,32 @@
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 = 'user') {
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
};
}
//# sourceMappingURL=rolesField.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/access/rolesField.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { Role } from './types.js'\n\nimport { isAdmin } from './predicates.js'\nimport { ROLE_HIERARCHY } from './types.js'\n\n/**\n * Builds the fixed `roles` field the plugin injects into the auth collection.\n *\n * Saved to the JWT so role checks avoid a database lookup. Only admins can\n * change roles, preventing privilege escalation by lower-privilege users.\n */\nexport function buildRolesField(defaultRole: Role = 'user'): Field {\n return {\n name: 'roles',\n type: 'select',\n access: {\n // Only admins may assign or change roles\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n description: 'Role hierarchy: admin > editor > user.',\n },\n defaultValue: [defaultRole],\n hasMany: true,\n options: ROLE_HIERARCHY.map((role) => ({\n label: role.charAt(0).toUpperCase() + role.slice(1),\n value: role,\n })),\n required: true,\n saveToJWT: true,\n }\n}\n"],"names":["isAdmin","ROLE_HIERARCHY","buildRolesField","defaultRole","name","type","access","update","req","user","admin","description","defaultValue","hasMany","options","map","role","label","charAt","toUpperCase","slice","value","required","saveToJWT"],"mappings":"AAIA,SAASA,OAAO,QAAQ,kBAAiB;AACzC,SAASC,cAAc,QAAQ,aAAY;AAE3C;;;;;CAKC,GACD,OAAO,SAASC,gBAAgBC,cAAoB,MAAM;IACxD,OAAO;QACLC,MAAM;QACNC,MAAM;QACNC,QAAQ;YACN,yCAAyC;YACzCC,QAAQ,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKT,QAAQS;QACzC;QACAC,OAAO;YACLC,aAAa;QACf;QACAC,cAAc;YAACT;SAAY;QAC3BU,SAAS;QACTC,SAASb,eAAec,GAAG,CAAC,CAACC,OAAU,CAAA;gBACrCC,OAAOD,KAAKE,MAAM,CAAC,GAAGC,WAAW,KAAKH,KAAKI,KAAK,CAAC;gBACjDC,OAAOL;YACT,CAAA;QACAM,UAAU;QACVC,WAAW;IACb;AACF"}
+19
View File
@@ -0,0 +1,19 @@
/**
* Role hierarchy, lowest to highest privilege.
* A higher role satisfies any requirement met by a lower one.
*/
export declare const ROLE_HIERARCHY: readonly ["user", "editor", "admin"];
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;
};
+10
View File
@@ -0,0 +1,10 @@
/**
* Role hierarchy, lowest to highest privilege.
* A higher role satisfies any requirement met by a lower one.
*/ export const ROLE_HIERARCHY = [
'user',
'editor',
'admin'
];
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/access/types.ts"],"sourcesContent":["/**\n * Role hierarchy, lowest to highest privilege.\n * A higher role satisfies any requirement met by a lower one.\n */\nexport const ROLE_HIERARCHY = ['user', 'editor', 'admin'] as const\n\nexport type Role = (typeof ROLE_HIERARCHY)[number]\n\n/**\n * Access-control options.\n *\n * The plugin injects a fixed `roles` field into the client's auth collection\n * — the collection itself belongs to the client (create-payload-app), the\n * role definition belongs to the plugin.\n */\nexport type AccessOption = {\n /** Slug of the client's auth collection, e.g. 'users'. */\n authCollection: string\n /** Role assigned to new users. Defaults to 'user'. */\n defaultRole?: Role\n}\n"],"names":["ROLE_HIERARCHY"],"mappings":"AAAA;;;CAGC,GACD,OAAO,MAAMA,iBAAiB;IAAC;IAAQ;IAAU;CAAQ,CAAS"}
+19
View File
@@ -0,0 +1,19 @@
import type { AnalyticsConfig } from './types.js';
declare global {
interface Window {
dataLayer?: unknown[];
}
}
/**
* 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 declare function Analytics({ ga4MeasurementId, gtmContainerId }: AnalyticsConfig): null;
+63
View File
@@ -0,0 +1,63 @@
'use client';
import { useEffect } from 'react';
import { gtag } from '../consent/googleConsent.js';
import { CONSENT_COOKIE, DEFAULT_CONSENT, parseConsent, setDefaultConsent } from '../consent/index.js';
const GTM_SCRIPT = (id)=>`https://www.googletagmanager.com/gtm.js?id=${id}`;
const GA4_SCRIPT = (id)=>`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) {
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 }) {
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;
}
//# sourceMappingURL=Analytics.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/analytics/Analytics.tsx"],"sourcesContent":["'use client'\nimport { useEffect } from 'react'\n\nimport type { AnalyticsConfig } from './types.js'\n\nimport { gtag } from '../consent/googleConsent.js'\nimport { CONSENT_COOKIE, DEFAULT_CONSENT, parseConsent, setDefaultConsent } from '../consent/index.js'\n\ndeclare global {\n interface Window {\n dataLayer?: unknown[]\n }\n}\n\nconst GTM_SCRIPT = (id: string) => `https://www.googletagmanager.com/gtm.js?id=${id}`\nconst GA4_SCRIPT = (id: string) => `https://www.googletagmanager.com/gtag/js?id=${id}`\n\n/** Reads the current consent state from cookie, or defaults if none. */\nfunction readConsent() {\n if (typeof document === 'undefined') {return DEFAULT_CONSENT}\n const raw = document.cookie\n .split('; ')\n .find((c) => c.startsWith(`${CONSENT_COOKIE}=`))\n ?.split('=')[1]\n return parseConsent(raw) ?? DEFAULT_CONSENT\n}\n\nfunction injectScript(src: string) {\n if (document.querySelector(`script[src=\"${src}\"]`)) {return}\n const s = document.createElement('script')\n s.src = src\n s.async = true\n document.head.appendChild(s)\n}\n\n/**\n * Loads Google Analytics — GTM if a container ID is set, otherwise GA4 gtag.\n *\n * Consent Mode is initialised to the visitor's stored consent BEFORE the tag\n * loads (via setDefaultConsent from the consent module), so tags respect the\n * choice from the first hit; useConsent sends an update the moment the visitor\n * decides. IDs are public and come from SiteIntegrations, passed in as props\n * (the client project reads them server-side).\n *\n * Renders nothing — it only injects the scripts. Mount once, high in the tree\n * (e.g. root layout), inside ConsentProvider.\n */\nexport function Analytics({ ga4MeasurementId, gtmContainerId }: AnalyticsConfig) {\n useEffect(() => {\n if (!ga4MeasurementId && !gtmContainerId) {return}\n\n // 1. Consent Mode default = the visitor's stored choice, before tags load.\n setDefaultConsent(readConsent())\n\n // 2. Load the tag.\n if (gtmContainerId) {\n window.dataLayer = window.dataLayer || []\n window.dataLayer.push({ event: 'gtm.js', 'gtm.start': Date.now() })\n injectScript(GTM_SCRIPT(gtmContainerId))\n } else if (ga4MeasurementId) {\n injectScript(GA4_SCRIPT(ga4MeasurementId))\n // Uses the shared gtag (pushes `arguments`, as Google's tags expect);\n // real gtag.js wires itself up once the script loads.\n gtag('js', new Date())\n gtag('config', ga4MeasurementId)\n }\n }, [ga4MeasurementId, gtmContainerId])\n\n return null\n}\n"],"names":["useEffect","gtag","CONSENT_COOKIE","DEFAULT_CONSENT","parseConsent","setDefaultConsent","GTM_SCRIPT","id","GA4_SCRIPT","readConsent","document","raw","cookie","split","find","c","startsWith","injectScript","src","querySelector","s","createElement","async","head","appendChild","Analytics","ga4MeasurementId","gtmContainerId","window","dataLayer","push","event","Date","now"],"mappings":"AAAA;AACA,SAASA,SAAS,QAAQ,QAAO;AAIjC,SAASC,IAAI,QAAQ,8BAA6B;AAClD,SAASC,cAAc,EAAEC,eAAe,EAAEC,YAAY,EAAEC,iBAAiB,QAAS,sBAAqB;AAQvG,MAAMC,aAAa,CAACC,KAAe,CAAC,2CAA2C,EAAEA,IAAI;AACrF,MAAMC,aAAa,CAACD,KAAe,CAAC,4CAA4C,EAAEA,IAAI;AAEtF,sEAAsE,GACtE,SAASE;IACP,IAAI,OAAOC,aAAa,aAAa;QAAC,OAAOP;IAAe;IAC5D,MAAMQ,MAAMD,SAASE,MAAM,CACxBC,KAAK,CAAC,MACNC,IAAI,CAAC,CAACC,IAAMA,EAAEC,UAAU,CAAC,GAAGd,eAAe,CAAC,CAAC,IAC5CW,MAAM,IAAI,CAAC,EAAE;IACjB,OAAOT,aAAaO,QAAQR;AAC9B;AAEA,SAASc,aAAaC,GAAW;IAC/B,IAAIR,SAASS,aAAa,CAAC,CAAC,YAAY,EAAED,IAAI,EAAE,CAAC,GAAG;QAAC;IAAM;IAC3D,MAAME,IAAIV,SAASW,aAAa,CAAC;IACjCD,EAAEF,GAAG,GAAGA;IACRE,EAAEE,KAAK,GAAG;IACVZ,SAASa,IAAI,CAACC,WAAW,CAACJ;AAC5B;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASK,UAAU,EAAEC,gBAAgB,EAAEC,cAAc,EAAmB;IAC7E3B,UAAU;QACR,IAAI,CAAC0B,oBAAoB,CAACC,gBAAgB;YAAC;QAAM;QAEjD,2EAA2E;QAC3EtB,kBAAkBI;QAElB,mBAAmB;QACnB,IAAIkB,gBAAgB;YAClBC,OAAOC,SAAS,GAAGD,OAAOC,SAAS,IAAI,EAAE;YACzCD,OAAOC,SAAS,CAACC,IAAI,CAAC;gBAAEC,OAAO;gBAAU,aAAaC,KAAKC,GAAG;YAAG;YACjEhB,aAAaX,WAAWqB;QAC1B,OAAO,IAAID,kBAAkB;YAC3BT,aAAaT,WAAWkB;YACxB,sEAAsE;YACtE,sDAAsD;YACtDzB,KAAK,MAAM,IAAI+B;YACf/B,KAAK,UAAUyB;QACjB;IACF,GAAG;QAACA;QAAkBC;KAAe;IAErC,OAAO;AACT"}
+1
View File
@@ -0,0 +1 @@
export { Analytics } from './Analytics.js';
+4
View File
@@ -0,0 +1,4 @@
'use client';
export { Analytics } from './Analytics.js';
//# sourceMappingURL=client.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/analytics/client.ts"],"sourcesContent":["'use client'\nexport { Analytics } from './Analytics.js'\n"],"names":["Analytics"],"mappings":"AAAA;AACA,SAASA,SAAS,QAAQ,iBAAgB"}
+9
View File
@@ -0,0 +1,9 @@
import type { BasePayload } from 'payload';
import type { AnalyticsConfig } from './types.js';
/**
* Reads the public analytics IDs from SiteIntegrations, server-side.
*
* Returns only the two public IDs (GA4 / GTM) — safe to pass to the client
* <Analytics> component. Does NOT expose any secret from SiteIntegrations.
*/
export declare function getAnalyticsConfig(payload: BasePayload): Promise<AnalyticsConfig>;
+15
View File
@@ -0,0 +1,15 @@
import { getSiteIntegrations } from '../payload/index.js';
/**
* Reads the public analytics IDs from SiteIntegrations, server-side.
*
* Returns only the two public IDs (GA4 / GTM) — safe to pass to the client
* <Analytics> component. Does NOT expose any secret from SiteIntegrations.
*/ export async function getAnalyticsConfig(payload) {
const integrations = await getSiteIntegrations(payload);
return {
ga4MeasurementId: integrations.ga4MeasurementId ?? null,
gtmContainerId: integrations.gtmContainerId ?? null
};
}
//# sourceMappingURL=getAnalyticsConfig.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/analytics/getAnalyticsConfig.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { AnalyticsConfig } from './types.js'\n\nimport { getSiteIntegrations } from '../payload/index.js'\n\n/**\n * Reads the public analytics IDs from SiteIntegrations, server-side.\n *\n * Returns only the two public IDs (GA4 / GTM) — safe to pass to the client\n * <Analytics> component. Does NOT expose any secret from SiteIntegrations.\n */\nexport async function getAnalyticsConfig(payload: BasePayload): Promise<AnalyticsConfig> {\n const integrations = await getSiteIntegrations<AnalyticsConfig>(payload)\n return {\n ga4MeasurementId: integrations.ga4MeasurementId ?? null,\n gtmContainerId: integrations.gtmContainerId ?? null,\n }\n}\n"],"names":["getSiteIntegrations","getAnalyticsConfig","payload","integrations","ga4MeasurementId","gtmContainerId"],"mappings":"AAIA,SAASA,mBAAmB,QAAQ,sBAAqB;AAEzD;;;;;CAKC,GACD,OAAO,eAAeC,mBAAmBC,OAAoB;IAC3D,MAAMC,eAAe,MAAMH,oBAAqCE;IAChE,OAAO;QACLE,kBAAkBD,aAAaC,gBAAgB,IAAI;QACnDC,gBAAgBF,aAAaE,cAAc,IAAI;IACjD;AACF"}
+2
View File
@@ -0,0 +1,2 @@
export type { AnalyticsConfig } from './types.js';
export { getAnalyticsConfig } from './getAnalyticsConfig.js';
+5
View File
@@ -0,0 +1,5 @@
// Server-safe: config type + the helper that reads IDs from SiteIntegrations.
// The <Analytics> component is client-side — exported via ./client.
export { getAnalyticsConfig } from './getAnalyticsConfig.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/analytics/index.ts"],"sourcesContent":["// Server-safe: config type + the helper that reads IDs from SiteIntegrations.\n// The <Analytics> component is client-side — exported via ./client.\nexport type { AnalyticsConfig } from './types.js'\nexport { getAnalyticsConfig } from './getAnalyticsConfig.js'\n"],"names":["getAnalyticsConfig"],"mappings":"AAAA,8EAA8E;AAC9E,oEAAoE;AAEpE,SAASA,kBAAkB,QAAQ,0BAAyB"}
+9
View File
@@ -0,0 +1,9 @@
/**
* Analytics IDs, read from SiteIntegrations (public — safe on the client).
*/
export type AnalyticsConfig = {
/** GA4 Measurement ID, e.g. 'G-XXXXXXXXXX'. */
ga4MeasurementId?: null | string;
/** GTM Container ID, e.g. 'GTM-XXXXXXX'. */
gtmContainerId?: null | string;
};
+5
View File
@@ -0,0 +1,5 @@
/**
* Analytics IDs, read from SiteIntegrations (public — safe on the client).
*/ export { };
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/analytics/types.ts"],"sourcesContent":["/**\n * Analytics IDs, read from SiteIntegrations (public — safe on the client).\n */\nexport type AnalyticsConfig = {\n /** GA4 Measurement ID, e.g. 'G-XXXXXXXXXX'. */\n ga4MeasurementId?: null | string\n /** GTM Container ID, e.g. 'GTM-XXXXXXX'. */\n gtmContainerId?: null | string\n}\n"],"names":[],"mappings":"AAAA;;CAEC,GACD,WAKC"}
+32
View File
@@ -0,0 +1,32 @@
import type { BlockComponentMap, BlockData, EnhanceProps } from './types.js';
export type RenderBlocksProps = {
/** Block data array from a Payload document (e.g. page.layout). */
blocks: BlockData[] | null | undefined;
/** Client-provided map of blockType → component. */
components: BlockComponentMap;
/**
* Optional client hook to inject block-specific props (nav anchors, etc.).
* Keeps the engine generic — the plugin knows no concrete block types.
*/
enhanceProps?: EnhanceProps;
};
/**
* Generic, server-side block renderer.
*
* Iterates a document's blocks and renders each via the client's component
* map. Unknown blocks are skipped (null), never thrown. Block-specific logic is
* delegated to the client's optional `enhanceProps` — the plugin itself is
* block-agnostic.
*
* The client owns components and block schemas (they pass `components` and
* define blocks in their config); the plugin owns only the iteration and
* wiring. No wrapper markup is added — spacing/layout belong to the client's
* components.
*
* Nested blocks: a block that needs to render child blocks should render its
* own <RenderBlocks> and import the registry itself, rather than receiving the
* component map as a prop. Passing the map as a prop breaks React Server
* Components — functions (client components) can't cross the server→client
* boundary via props.
*/
export declare function RenderBlocks({ blocks, components, enhanceProps }: RenderBlocksProps): import("react/jsx-runtime").JSX.Element | null;
+44
View File
@@ -0,0 +1,44 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { Fragment } from 'react';
/**
* Generic, server-side block renderer.
*
* Iterates a document's blocks and renders each via the client's component
* map. Unknown blocks are skipped (null), never thrown. Block-specific logic is
* delegated to the client's optional `enhanceProps` — the plugin itself is
* block-agnostic.
*
* The client owns components and block schemas (they pass `components` and
* define blocks in their config); the plugin owns only the iteration and
* wiring. No wrapper markup is added — spacing/layout belong to the client's
* components.
*
* Nested blocks: a block that needs to render child blocks should render its
* own <RenderBlocks> and import the registry itself, rather than receiving the
* component map as a prop. Passing the map as a prop breaks React Server
* Components — functions (client components) can't cross the server→client
* boundary via props.
*/ export function RenderBlocks({ blocks, components, enhanceProps }) {
if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
return null;
}
return /*#__PURE__*/ _jsx(Fragment, {
children: blocks.map((block, index)=>{
const Component = components[block.blockType];
if (!Component) {
return null;
}
const extra = enhanceProps ? enhanceProps({
allBlocks: blocks,
block,
index
}) : {};
return /*#__PURE__*/ _jsx(Component, {
...block,
...extra
}, index);
})
});
}
//# sourceMappingURL=RenderBlocks.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/blocks/RenderBlocks.tsx"],"sourcesContent":["import { Fragment } from 'react'\n\nimport type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'\n\nexport type RenderBlocksProps = {\n /** Block data array from a Payload document (e.g. page.layout). */\n blocks: BlockData[] | null | undefined\n /** Client-provided map of blockType → component. */\n components: BlockComponentMap\n /**\n * Optional client hook to inject block-specific props (nav anchors, etc.).\n * Keeps the engine generic — the plugin knows no concrete block types.\n */\n enhanceProps?: EnhanceProps\n}\n\n/**\n * Generic, server-side block renderer.\n *\n * Iterates a document's blocks and renders each via the client's component\n * map. Unknown blocks are skipped (null), never thrown. Block-specific logic is\n * delegated to the client's optional `enhanceProps` — the plugin itself is\n * block-agnostic.\n *\n * The client owns components and block schemas (they pass `components` and\n * define blocks in their config); the plugin owns only the iteration and\n * wiring. No wrapper markup is added — spacing/layout belong to the client's\n * components.\n *\n * Nested blocks: a block that needs to render child blocks should render its\n * own <RenderBlocks> and import the registry itself, rather than receiving the\n * component map as a prop. Passing the map as a prop breaks React Server\n * Components — functions (client components) can't cross the server→client\n * boundary via props.\n */\nexport function RenderBlocks({ blocks, components, enhanceProps }: RenderBlocksProps) {\n if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {\n return null\n }\n\n return (\n <Fragment>\n {blocks.map((block, index) => {\n const Component = components[block.blockType]\n if (!Component) {return null}\n\n const extra = enhanceProps ? enhanceProps({ allBlocks: blocks, block, index }) : {}\n\n return <Component key={index} {...block} {...extra} />\n })}\n </Fragment>\n )\n}\n"],"names":["Fragment","RenderBlocks","blocks","components","enhanceProps","Array","isArray","length","map","block","index","Component","blockType","extra","allBlocks"],"mappings":";AAAA,SAASA,QAAQ,QAAQ,QAAO;AAgBhC;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,aAAa,EAAEC,MAAM,EAAEC,UAAU,EAAEC,YAAY,EAAqB;IAClF,IAAI,CAACF,UAAU,CAACG,MAAMC,OAAO,CAACJ,WAAWA,OAAOK,MAAM,KAAK,GAAG;QAC5D,OAAO;IACT;IAEA,qBACE,KAACP;kBACEE,OAAOM,GAAG,CAAC,CAACC,OAAOC;YAClB,MAAMC,YAAYR,UAAU,CAACM,MAAMG,SAAS,CAAC;YAC7C,IAAI,CAACD,WAAW;gBAAC,OAAO;YAAI;YAE5B,MAAME,QAAQT,eAAeA,aAAa;gBAAEU,WAAWZ;gBAAQO;gBAAOC;YAAM,KAAK,CAAC;YAElF,qBAAO,KAACC;gBAAuB,GAAGF,KAAK;gBAAG,GAAGI,KAAK;eAA3BH;QACzB;;AAGN"}
+3
View File
@@ -0,0 +1,3 @@
export { RenderBlocks } from './RenderBlocks.js';
export type { RenderBlocksProps } from './RenderBlocks.js';
export type { BlockComponentMap, BlockData, EnhanceProps } from './types.js';
+3
View File
@@ -0,0 +1,3 @@
export { RenderBlocks } from './RenderBlocks.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/blocks/index.ts"],"sourcesContent":["export { RenderBlocks } from './RenderBlocks.js'\nexport type { RenderBlocksProps } from './RenderBlocks.js'\nexport type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'\n"],"names":["RenderBlocks"],"mappings":"AAAA,SAASA,YAAY,QAAQ,oBAAmB"}
+26
View File
@@ -0,0 +1,26 @@
import type { ComponentType } from 'react';
/**
* A single block's data as stored by Payload — always has a blockType, plus
* arbitrary block-specific fields. The plugin stays generic over the shape.
*/
export type BlockData = {
[key: string]: unknown;
blockType: string;
};
/**
* Maps a blockType to the client's component for it.
* The client owns the components; the plugin only receives this map.
*/
export type BlockComponentMap = Record<string, ComponentType<any>>;
/**
* Optional per-block prop enhancer supplied by the client.
*
* Lets the client inject block-specific logic (e.g. collect anchors for a nav
* block) without the plugin knowing any concrete block types. Returns extra
* props merged into the rendered block.
*/
export type EnhanceProps = (args: {
allBlocks: BlockData[];
block: BlockData;
index: number;
}) => Record<string, unknown>;
+9
View File
@@ -0,0 +1,9 @@
/**
* 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 { };
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/blocks/types.ts"],"sourcesContent":["import type { ComponentType } from 'react'\n\n/**\n * A single block's data as stored by Payload — always has a blockType, plus\n * arbitrary block-specific fields. The plugin stays generic over the shape.\n */\nexport type BlockData = {\n [key: string]: unknown\n blockType: string\n}\n\n/**\n * Maps a blockType to the client's component for it.\n * The client owns the components; the plugin only receives this map.\n */\nexport type BlockComponentMap = Record<string, ComponentType<any>>\n\n/**\n * Optional per-block prop enhancer supplied by the client.\n *\n * Lets the client inject block-specific logic (e.g. collect anchors for a nav\n * block) without the plugin knowing any concrete block types. Returns extra\n * props merged into the rendered block.\n */\nexport type EnhanceProps = (args: {\n allBlocks: BlockData[]\n block: BlockData\n index: number\n}) => Record<string, unknown>\n"],"names":[],"mappings":"AAiBA;;;;;;CAMC,GACD,WAI6B"}
+12
View File
@@ -0,0 +1,12 @@
import { type ReactNode } from 'react';
import type { ConsentTexts } from './texts.js';
import { useConsent } from './useConsent.js';
type ConsentContextValue = {
texts: ConsentTexts;
} & ReturnType<typeof useConsent>;
export declare function ConsentProvider({ children, texts }: {
children: ReactNode;
texts: ConsentTexts;
}): import("react/jsx-runtime").JSX.Element;
export declare function useConsentContext(): ConsentContextValue;
export {};
+24
View File
@@ -0,0 +1,24 @@
'use client';
import { jsx as _jsx } from "react/jsx-runtime";
import { createContext, use } from 'react';
import { useConsent } from './useConsent.js';
const ConsentContext = /*#__PURE__*/ createContext(null);
export function ConsentProvider({ children, texts }) {
const consent = useConsent();
return /*#__PURE__*/ _jsx(ConsentContext, {
value: {
...consent,
texts
},
children: children
});
}
export function useConsentContext() {
const ctx = use(ConsentContext);
if (!ctx) {
throw new Error('useConsentContext must be used within ConsentProvider');
}
return ctx;
}
//# sourceMappingURL=ConsentContext.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/ConsentContext.tsx"],"sourcesContent":["'use client'\nimport { createContext, type ReactNode, use } from 'react'\n\nimport type { ConsentTexts } from './texts.js'\n\nimport { useConsent } from './useConsent.js'\n\ntype ConsentContextValue = { texts: ConsentTexts } & ReturnType<typeof useConsent>\n\nconst ConsentContext = createContext<ConsentContextValue | null>(null)\n\nexport function ConsentProvider({ children, texts }: { children: ReactNode; texts: ConsentTexts }) {\n const consent = useConsent()\n return <ConsentContext value={{ ...consent, texts }}>{children}</ConsentContext>\n}\n\nexport function useConsentContext(): ConsentContextValue {\n const ctx = use(ConsentContext)\n if (!ctx) {throw new Error('useConsentContext must be used within ConsentProvider')}\n return ctx\n}\n"],"names":["createContext","use","useConsent","ConsentContext","ConsentProvider","children","texts","consent","value","useConsentContext","ctx","Error"],"mappings":"AAAA;;AACA,SAASA,aAAa,EAAkBC,GAAG,QAAQ,QAAO;AAI1D,SAASC,UAAU,QAAQ,kBAAiB;AAI5C,MAAMC,+BAAiBH,cAA0C;AAEjE,OAAO,SAASI,gBAAgB,EAAEC,QAAQ,EAAEC,KAAK,EAAgD;IAC/F,MAAMC,UAAUL;IAChB,qBAAO,KAACC;QAAeK,OAAO;YAAE,GAAGD,OAAO;YAAED;QAAM;kBAAID;;AACxD;AAEA,OAAO,SAASI;IACd,MAAMC,MAAMT,IAAIE;IAChB,IAAI,CAACO,KAAK;QAAC,MAAM,IAAIC,MAAM;IAAwD;IACnF,OAAOD;AACT"}
+12
View File
@@ -0,0 +1,12 @@
/**
* 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;
};
export declare function CookieBanner({ classNames }?: {
classNames?: CookieBannerClassNames;
}): import("react/jsx-runtime").JSX.Element | null;
+179
View File
@@ -0,0 +1,179 @@
'use client';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { Cookie } from 'lucide-react';
import { useEffect, useState } from 'react';
import { CONSENT_CATEGORIES } from './categories.js';
import { useConsentContext } from './ConsentContext.js';
/**
* 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 } = {}) {
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)=>{
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 /*#__PURE__*/ _jsx("div", {
"aria-label": "Cookie consent",
className: rootClass,
"data-ipal": "banner",
role: "dialog",
children: /*#__PURE__*/ _jsx("div", {
className: "mx-auto max-w-4xl",
children: !showSettings ? /*#__PURE__*/ _jsxs("div", {
className: "flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between",
children: [
/*#__PURE__*/ _jsxs("div", {
className: "flex items-start gap-3",
children: [
/*#__PURE__*/ _jsx(Cookie, {
"aria-hidden": "true",
className: `h-6 w-6 shrink-0 ${accent}`
}),
/*#__PURE__*/ _jsxs("p", {
className: `text-justify text-sm ${bodyText}`,
children: [
texts.message,
texts.privacyLink && /*#__PURE__*/ _jsxs(_Fragment, {
children: [
' ',
/*#__PURE__*/ _jsx("a", {
className: `${accent} underline hover:no-underline`,
href: texts.privacyLink.href,
children: texts.privacyLink.label
})
]
})
]
})
]
}),
/*#__PURE__*/ _jsxs("div", {
className: "flex shrink-0 gap-2",
children: [
/*#__PURE__*/ _jsx("button", {
className: secondaryClass,
onClick: ()=>setShowSettings(true),
children: texts.buttons.settings
}),
/*#__PURE__*/ _jsx("button", {
className: secondaryClass,
onClick: rejectAll,
children: texts.buttons.reject
}),
/*#__PURE__*/ _jsx("button", {
className: primaryClass,
onClick: acceptAll,
children: texts.buttons.acceptAll
})
]
})
]
}) : /*#__PURE__*/ _jsxs("div", {
className: "flex flex-col gap-4",
children: [
/*#__PURE__*/ _jsxs("h2", {
className: `flex items-center gap-2 text-base font-semibold ${strongText}`,
children: [
/*#__PURE__*/ _jsx(Cookie, {
"aria-hidden": "true",
className: `h-5 w-5 ${accent}`
}),
texts.settingsTitle
]
}),
/*#__PURE__*/ _jsx("ul", {
className: "flex flex-col gap-3",
children: CONSENT_CATEGORIES.map((cat)=>/*#__PURE__*/ _jsxs("li", {
className: "flex items-start justify-between gap-4",
children: [
/*#__PURE__*/ _jsxs("div", {
children: [
/*#__PURE__*/ _jsx("p", {
className: `text-sm font-medium ${strongText}`,
children: texts.categories[cat].title
}),
/*#__PURE__*/ _jsx("p", {
className: `text-xs ${mutedText}`,
children: texts.categories[cat].description
})
]
}),
/*#__PURE__*/ _jsx("input", {
checked: cat === 'necessary' ? true : choices[cat],
className: "mt-1 h-4 w-4 shrink-0 accent-[var(--ipal-primary,#2563eb)]",
disabled: cat === 'necessary',
onChange: ()=>toggle(cat),
type: "checkbox"
})
]
}, cat))
}),
/*#__PURE__*/ _jsxs("div", {
className: "flex justify-end gap-2",
children: [
/*#__PURE__*/ _jsx("button", {
className: secondaryClass,
onClick: ()=>setShowSettings(false),
children: texts.buttons.back
}),
/*#__PURE__*/ _jsx("button", {
className: primaryClass,
onClick: ()=>savePreferences(choices),
children: texts.buttons.save
})
]
})
]
})
})
});
}
//# sourceMappingURL=CookieBanner.js.map
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
/**
* 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 declare function CookieButton({ className }?: {
className?: string;
}): import("react/jsx-runtime").JSX.Element | null;
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { jsx as _jsx } from "react/jsx-runtime";
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 } = {}) {
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 /*#__PURE__*/ _jsx("button", {
"aria-label": "Cookie settings",
className: cls,
"data-ipal": "cookie-button",
onClick: reopen,
children: /*#__PURE__*/ _jsx(Cookie, {
"aria-hidden": "true",
className: "h-5 w-5 text-[var(--ipal-text,#525252)] dark:text-[var(--ipal-text-dark,#d4d4d4)]"
})
});
}
//# sourceMappingURL=CookieButton.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/CookieButton.tsx"],"sourcesContent":["'use client'\nimport { Cookie } from 'lucide-react'\n\nimport { useConsentContext } from './ConsentContext.js'\n\n/**\n * Floating \"cookie settings\" button — lets visitors reopen the consent panel\n * after deciding (GDPR: withdrawing consent must be as easy as giving it).\n * Hidden while the banner is showing.\n *\n * Colours follow the same CSS custom properties as CookieBanner\n * (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's\n * palette applies to both without touching either component. Pass className to\n * replace the styling outright.\n */\nexport function CookieButton({ className }: { className?: string } = {}) {\n const { decided, reopen } = useConsentContext()\n\n if (!decided) {return null}\n\n const cls =\n className ??\n 'fixed bottom-4 left-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border shadow-md transition-colors ' +\n 'bg-[var(--ipal-surface,#fff)] border-[var(--ipal-border,#e5e5e5)] hover:bg-[var(--ipal-hover,#f5f5f5)] ' +\n 'dark:bg-[var(--ipal-surface-dark,#171717)] dark:border-[var(--ipal-border-dark,#262626)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]'\n\n return (\n <button aria-label=\"Cookie settings\" className={cls} data-ipal=\"cookie-button\" onClick={reopen}>\n <Cookie\n aria-hidden=\"true\"\n className=\"h-5 w-5 text-[var(--ipal-text,#525252)] dark:text-[var(--ipal-text-dark,#d4d4d4)]\"\n />\n </button>\n )\n}\n"],"names":["Cookie","useConsentContext","CookieButton","className","decided","reopen","cls","button","aria-label","data-ipal","onClick","aria-hidden"],"mappings":"AAAA;;AACA,SAASA,MAAM,QAAQ,eAAc;AAErC,SAASC,iBAAiB,QAAQ,sBAAqB;AAEvD;;;;;;;;;CASC,GACD,OAAO,SAASC,aAAa,EAAEC,SAAS,EAA0B,GAAG,CAAC,CAAC;IACrE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAE,GAAGJ;IAE5B,IAAI,CAACG,SAAS;QAAC,OAAO;IAAI;IAE1B,MAAME,MACJH,aACA,2HACE,4GACA;IAEJ,qBACE,KAACI;QAAOC,cAAW;QAAkBL,WAAWG;QAAKG,aAAU;QAAgBC,SAASL;kBACtF,cAAA,KAACL;YACCW,eAAY;YACZR,WAAU;;;AAIlB"}
+10
View File
@@ -0,0 +1,10 @@
/**
* Cookie consent categories (GDPR). 'necessary' is always granted and cannot be
* disabled. The rest default to false until the user opts in.
*/
export declare const CONSENT_CATEGORIES: readonly ["necessary", "functional", "analytics", "marketing"];
export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number];
export type ConsentState = Record<ConsentCategory, boolean>;
export declare const DEFAULT_CONSENT: ConsentState;
export declare const ACCEPT_ALL_CONSENT: ConsentState;
export declare const REJECT_ALL_CONSENT: ConsentState;
+26
View File
@@ -0,0 +1,26 @@
/**
* 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'
];
export const DEFAULT_CONSENT = {
necessary: true,
functional: false,
analytics: false,
marketing: false
};
export const ACCEPT_ALL_CONSENT = {
necessary: true,
functional: true,
analytics: true,
marketing: true
};
export const REJECT_ALL_CONSENT = {
...DEFAULT_CONSENT
};
//# sourceMappingURL=categories.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/categories.ts"],"sourcesContent":["/**\n * Cookie consent categories (GDPR). 'necessary' is always granted and cannot be\n * disabled. The rest default to false until the user opts in.\n */\nexport const CONSENT_CATEGORIES = ['necessary', 'functional', 'analytics', 'marketing'] as const\n\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\nexport type ConsentState = Record<ConsentCategory, boolean>\n\nexport const DEFAULT_CONSENT: ConsentState = {\n necessary: true,\n functional: false,\n analytics: false,\n marketing: false,\n}\n\nexport const ACCEPT_ALL_CONSENT: ConsentState = {\n necessary: true,\n functional: true,\n analytics: true,\n marketing: true,\n}\n\nexport const REJECT_ALL_CONSENT: ConsentState = { ...DEFAULT_CONSENT }\n"],"names":["CONSENT_CATEGORIES","DEFAULT_CONSENT","necessary","functional","analytics","marketing","ACCEPT_ALL_CONSENT","REJECT_ALL_CONSENT"],"mappings":"AAAA;;;CAGC,GACD,OAAO,MAAMA,qBAAqB;IAAC;IAAa;IAAc;IAAa;CAAY,CAAS;AAMhG,OAAO,MAAMC,kBAAgC;IAC3CC,WAAW;IACXC,YAAY;IACZC,WAAW;IACXC,WAAW;AACb,EAAC;AAED,OAAO,MAAMC,qBAAmC;IAC9CJ,WAAW;IACXC,YAAY;IACZC,WAAW;IACXC,WAAW;AACb,EAAC;AAED,OAAO,MAAME,qBAAmC;IAAE,GAAGN,eAAe;AAAC,EAAC"}
+5
View File
@@ -0,0 +1,5 @@
export { ConsentProvider, useConsentContext } from './ConsentContext.js';
export { CookieBanner } from './CookieBanner.js';
export type { CookieBannerClassNames } from './CookieBanner.js';
export { CookieButton } from './CookieButton.js';
export { useConsent } from './useConsent.js';
+8
View File
@@ -0,0 +1,8 @@
'use client';
export { ConsentProvider, useConsentContext } from './ConsentContext.js';
export { CookieBanner } from './CookieBanner.js';
export { CookieButton } from './CookieButton.js';
// Client-only consent exports — hook, provider, and UI components.
export { useConsent } from './useConsent.js';
//# sourceMappingURL=client.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/client.ts"],"sourcesContent":["'use client'\nexport { ConsentProvider, useConsentContext } from './ConsentContext.js'\nexport { CookieBanner } from './CookieBanner.js'\nexport type { CookieBannerClassNames } from './CookieBanner.js'\nexport { CookieButton } from './CookieButton.js'\n// Client-only consent exports — hook, provider, and UI components.\nexport { useConsent } from './useConsent.js'\n"],"names":["ConsentProvider","useConsentContext","CookieBanner","CookieButton","useConsent"],"mappings":"AAAA;AACA,SAASA,eAAe,EAAEC,iBAAiB,QAAQ,sBAAqB;AACxE,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,mEAAmE;AACnE,SAASC,UAAU,QAAQ,kBAAiB"}
+25
View File
@@ -0,0 +1,25 @@
import type { BasePayload } from 'payload';
import type { I18nConfig } from '../i18n/index.js';
import type { ConsentTexts } from './texts.js';
type GetConsentTextsArgs = {
config: I18nConfig;
locale?: string;
payload: BasePayload;
/**
* Privacy-policy label + the system page (from SiteSettings.privacyPolicy,
* read with locale:'all') to link to. Optional — omit for no link.
*/
privacyPolicy?: {
label: string;
page: {
slug?: null | Record<string, unknown>;
} | null;
};
};
/**
* Resolves consent banner texts from the CookieSettings global, falling back to
* English defaults per field. The privacy-policy link is built from the pages
* module (system page role), not stored in CookieSettings — one source of truth.
*/
export declare function getConsentTexts({ config, locale, payload, privacyPolicy, }: GetConsentTextsArgs): Promise<ConsentTexts>;
export {};
+79
View File
@@ -0,0 +1,79 @@
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 = {
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'
};
/**
* 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 }) {
const g = await getGlobal(payload, 'cookie-settings', {
locale
});
const 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 = 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
};
}
//# sourceMappingURL=getConsentTexts.js.map
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
import type { ConsentState } from './categories.js';
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 declare function gtag(..._args: unknown[]): void;
/** Sets the default consent state (call before Google tags load). */
export declare function setDefaultConsent(state: ConsentState): void;
/** Updates consent after the user decides. */
export declare function updateConsent(state: ConsentState): void;
+42
View File
@@ -0,0 +1,42 @@
function toSignals(state) {
const g = (b)=>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'
};
}
/**
* 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) {
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) {
gtag('consent', 'default', {
...toSignals(state),
wait_for_update: 500
});
}
/** Updates consent after the user decides. */ export function updateConsent(state) {
gtag('consent', 'update', toSignals(state));
}
//# sourceMappingURL=googleConsent.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/googleConsent.ts"],"sourcesContent":["import type { ConsentState } from './categories.js'\n\ntype ConsentValue = 'denied' | 'granted'\ntype GoogleConsentSignals = Record<string, ConsentValue>\n\nfunction toSignals(state: ConsentState): GoogleConsentSignals {\n const g = (b: boolean): ConsentValue => (b ? 'granted' : 'denied')\n return {\n ad_personalization: g(state.marketing),\n ad_storage: g(state.marketing),\n ad_user_data: g(state.marketing),\n analytics_storage: g(state.analytics),\n functionality_storage: g(state.functional),\n personalization_storage: g(state.functional),\n security_storage: 'granted', // necessary — always on\n }\n}\n\ndeclare global {\n interface Window {\n dataLayer?: unknown[]\n }\n}\n\n/**\n * Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`.\n *\n * Pushing `arguments` rather than a plain array is not a stylistic detail.\n * Google's tags recognise a consent command by the Arguments object it arrives\n * in; a real array lands in the dataLayer as ordinary data and the command is\n * silently ignored — the tag loads, but consent never updates and analytics\n * stays denied. The rest parameter exists only to type the call sites.\n *\n * Exported for the analytics module (which needs `js`/`config` commands) —\n * intentionally not re-exported from the package's public API.\n */\nexport function gtag(..._args: unknown[]) {\n if (typeof window === 'undefined') {return}\n window.dataLayer = window.dataLayer || []\n // eslint-disable-next-line prefer-rest-params\n window.dataLayer.push(arguments)\n}\n\n/** Sets the default consent state (call before Google tags load). */\nexport function setDefaultConsent(state: ConsentState) {\n gtag('consent', 'default', {\n ...toSignals(state),\n wait_for_update: 500, // ms to wait for an update before firing\n })\n}\n\n/** Updates consent after the user decides. */\nexport function updateConsent(state: ConsentState) {\n gtag('consent', 'update', toSignals(state))\n}\n"],"names":["toSignals","state","g","b","ad_personalization","marketing","ad_storage","ad_user_data","analytics_storage","analytics","functionality_storage","functional","personalization_storage","security_storage","gtag","_args","window","dataLayer","push","arguments","setDefaultConsent","wait_for_update","updateConsent"],"mappings":"AAKA,SAASA,UAAUC,KAAmB;IACpC,MAAMC,IAAI,CAACC,IAA8BA,IAAI,YAAY;IACzD,OAAO;QACLC,oBAAoBF,EAAED,MAAMI,SAAS;QACrCC,YAAYJ,EAAED,MAAMI,SAAS;QAC7BE,cAAcL,EAAED,MAAMI,SAAS;QAC/BG,mBAAmBN,EAAED,MAAMQ,SAAS;QACpCC,uBAAuBR,EAAED,MAAMU,UAAU;QACzCC,yBAAyBV,EAAED,MAAMU,UAAU;QAC3CE,kBAAkB;IACpB;AACF;AAQA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,KAAK,GAAGC,KAAgB;IACtC,IAAI,OAAOC,WAAW,aAAa;QAAC;IAAM;IAC1CA,OAAOC,SAAS,GAAGD,OAAOC,SAAS,IAAI,EAAE;IACzC,8CAA8C;IAC9CD,OAAOC,SAAS,CAACC,IAAI,CAACC;AACxB;AAEA,mEAAmE,GACnE,OAAO,SAASC,kBAAkBnB,KAAmB;IACnDa,KAAK,WAAW,WAAW;QACzB,GAAGd,UAAUC,MAAM;QACnBoB,iBAAiB;IACnB;AACF;AAEA,4CAA4C,GAC5C,OAAO,SAASC,cAAcrB,KAAmB;IAC/Ca,KAAK,WAAW,UAAUd,UAAUC;AACtC"}
+6
View File
@@ -0,0 +1,6 @@
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';
+9
View File
@@ -0,0 +1,9 @@
// 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 { getConsentTexts } from './getConsentTexts.js';
export { setDefaultConsent, updateConsent } from './googleConsent.js';
export { CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, parseConsent, serializeConsent } from './storage.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/index.ts"],"sourcesContent":["// Server-safe exports (logic, types, helper). Client components (banner,\n// provider, hook) are exported separately via ./client to keep the RSC/client\n// boundary clean.\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n DEFAULT_CONSENT,\n REJECT_ALL_CONSENT,\n} from './categories.js'\nexport type { ConsentCategory, ConsentState } from './categories.js'\nexport { getConsentTexts } from './getConsentTexts.js'\nexport { setDefaultConsent, updateConsent } from './googleConsent.js'\nexport {\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n parseConsent,\n serializeConsent,\n} from './storage.js'\nexport type { ConsentTexts } from './texts.js'\n"],"names":["ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","DEFAULT_CONSENT","REJECT_ALL_CONSENT","getConsentTexts","setDefaultConsent","updateConsent","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","parseConsent","serializeConsent"],"mappings":"AAAA,yEAAyE;AACzE,8EAA8E;AAC9E,kBAAkB;AAClB,SACEA,kBAAkB,EAClBC,kBAAkB,EAClBC,eAAe,EACfC,kBAAkB,QACb,kBAAiB;AAExB,SAASC,eAAe,QAAQ,uBAAsB;AACtD,SAASC,iBAAiB,EAAEC,aAAa,QAAQ,qBAAoB;AACrE,SACEC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,gBAAgB,QACX,eAAc"}
+11
View File
@@ -0,0 +1,11 @@
import type { ConsentState } 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 declare const CONSENT_COOKIE = "cookie-consent";
export declare const CONSENT_VERSION = 1;
export declare const CONSENT_MAX_AGE: number;
export declare function serializeConsent(state: ConsentState): string;
export declare function parseConsent(raw: string | undefined): ConsentState | null;
+42
View File
@@ -0,0 +1,42 @@
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
;
export function serializeConsent(state) {
const payload = {
state,
timestamp: new Date().toISOString(),
version: CONSENT_VERSION
};
return encodeURIComponent(JSON.stringify(payload));
}
export function parseConsent(raw) {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(decodeURIComponent(raw));
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;
}
}
//# sourceMappingURL=storage.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/storage.ts"],"sourcesContent":["import type { ConsentState } from './categories.js'\n\nimport { CONSENT_CATEGORIES, DEFAULT_CONSENT } from './categories.js'\n\n/**\n * Consent persistence in a cookie. Stored with a version so we can invalidate\n * old consents when the policy changes, and a timestamp (GDPR: consent must be\n * dated). Readable both client-side (banner) and server-side (script gating).\n */\nexport const CONSENT_COOKIE = 'cookie-consent'\nexport const CONSENT_VERSION = 1\nexport const CONSENT_MAX_AGE = 60 * 60 * 24 * 180 // 180 days\n\ntype StoredConsent = {\n state: ConsentState\n timestamp: string\n version: number\n}\n\nexport function serializeConsent(state: ConsentState): string {\n const payload: StoredConsent = {\n state,\n timestamp: new Date().toISOString(),\n version: CONSENT_VERSION,\n }\n return encodeURIComponent(JSON.stringify(payload))\n}\n\nexport function parseConsent(raw: string | undefined): ConsentState | null {\n if (!raw) {return null}\n try {\n const parsed = JSON.parse(decodeURIComponent(raw)) as StoredConsent\n if (parsed.version !== CONSENT_VERSION) {return null}\n const state = { ...DEFAULT_CONSENT }\n for (const cat of CONSENT_CATEGORIES) {\n if (typeof parsed.state?.[cat] === 'boolean') {state[cat] = parsed.state[cat]}\n }\n state.necessary = true\n return state\n } catch {\n return null\n }\n}\n"],"names":["CONSENT_CATEGORIES","DEFAULT_CONSENT","CONSENT_COOKIE","CONSENT_VERSION","CONSENT_MAX_AGE","serializeConsent","state","payload","timestamp","Date","toISOString","version","encodeURIComponent","JSON","stringify","parseConsent","raw","parsed","parse","decodeURIComponent","cat","necessary"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,eAAe,QAAQ,kBAAiB;AAErE;;;;CAIC,GACD,OAAO,MAAMC,iBAAiB,iBAAgB;AAC9C,OAAO,MAAMC,kBAAkB,EAAC;AAChC,OAAO,MAAMC,kBAAkB,KAAK,KAAK,KAAK,IAAI,WAAW;CAAZ;AAQjD,OAAO,SAASC,iBAAiBC,KAAmB;IAClD,MAAMC,UAAyB;QAC7BD;QACAE,WAAW,IAAIC,OAAOC,WAAW;QACjCC,SAASR;IACX;IACA,OAAOS,mBAAmBC,KAAKC,SAAS,CAACP;AAC3C;AAEA,OAAO,SAASQ,aAAaC,GAAuB;IAClD,IAAI,CAACA,KAAK;QAAC,OAAO;IAAI;IACtB,IAAI;QACF,MAAMC,SAASJ,KAAKK,KAAK,CAACC,mBAAmBH;QAC7C,IAAIC,OAAON,OAAO,KAAKR,iBAAiB;YAAC,OAAO;QAAI;QACpD,MAAMG,QAAQ;YAAE,GAAGL,eAAe;QAAC;QACnC,KAAK,MAAMmB,OAAOpB,mBAAoB;YACpC,IAAI,OAAOiB,OAAOX,KAAK,EAAE,CAACc,IAAI,KAAK,WAAW;gBAACd,KAAK,CAACc,IAAI,GAAGH,OAAOX,KAAK,CAACc,IAAI;YAAA;QAC/E;QACAd,MAAMe,SAAS,GAAG;QAClB,OAAOf;IACT,EAAE,OAAM;QACN,OAAO;IACT;AACF"}
+21
View File
@@ -0,0 +1,21 @@
import type { ConsentCategory } from './categories.js';
export type ConsentTexts = {
buttons: {
acceptAll: string;
back: string;
reject: string;
save: string;
settings: string;
};
categories: Record<ConsentCategory, {
description: string;
title: string;
}>;
message: string;
/** null = no privacy-policy link shown */
privacyLink: {
href: string;
label: string;
} | null;
settingsTitle: string;
};
+3
View File
@@ -0,0 +1,3 @@
export { };
//# sourceMappingURL=texts.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/texts.ts"],"sourcesContent":["import type { ConsentCategory } from './categories.js'\n\nexport type ConsentTexts = {\n buttons: {\n acceptAll: string\n back: string\n reject: string\n save: string\n settings: string\n }\n categories: Record<ConsentCategory, { description: string; title: string }>\n message: string\n /** null = no privacy-policy link shown */\n privacyLink: { href: string; label: string } | null\n settingsTitle: string\n}\n"],"names":[],"mappings":"AAEA,WAaC"}
+9
View File
@@ -0,0 +1,9 @@
import type { ConsentCategory, ConsentState } from './categories.js';
export declare function useConsent(): {
acceptAll: () => void;
decided: boolean;
rejectAll: () => void;
reopen: () => void;
savePreferences: (choices: Partial<Record<ConsentCategory, boolean>>) => void;
state: ConsentState;
};
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
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)=>{
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)=>persist({
...DEFAULT_CONSENT,
...choices,
necessary: true
}), [
persist
]);
const reopen = useCallback(()=>setDecided(false), []);
return {
acceptAll,
decided,
rejectAll,
reopen,
savePreferences,
state
};
}
//# sourceMappingURL=useConsent.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/useConsent.ts"],"sourcesContent":["'use client'\nimport { useCallback, useEffect, useState } from 'react'\n\nimport type { ConsentCategory, ConsentState } from './categories.js'\n\nimport { ACCEPT_ALL_CONSENT, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'\nimport { updateConsent } from './googleConsent.js'\nimport { CONSENT_COOKIE, CONSENT_MAX_AGE, parseConsent, serializeConsent } from './storage.js'\n\nexport function useConsent() {\n const [state, setState] = useState<ConsentState>(DEFAULT_CONSENT)\n const [decided, setDecided] = useState(false)\n\n useEffect(() => {\n const raw = document.cookie\n .split('; ')\n .find((c) => c.startsWith(`${CONSENT_COOKIE}=`))\n ?.split('=')[1]\n const parsed = parseConsent(raw)\n if (parsed) {\n setState(parsed)\n setDecided(true)\n }\n }, [])\n\n const persist = useCallback((next: ConsentState) => {\n document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`\n setState(next)\n setDecided(true)\n // Tell Google right away. Tags are already on the page holding whatever\n // defaults Analytics set at load; without this update they keep them for\n // the rest of the session and never write their cookies — the banner would\n // look like it worked while nothing changed.\n updateConsent(next)\n }, [])\n\n const acceptAll = useCallback(() => persist(ACCEPT_ALL_CONSENT), [persist])\n const rejectAll = useCallback(() => persist(REJECT_ALL_CONSENT), [persist])\n const savePreferences = useCallback(\n (choices: Partial<Record<ConsentCategory, boolean>>) =>\n persist({ ...DEFAULT_CONSENT, ...choices, necessary: true }),\n [persist],\n )\n const reopen = useCallback(() => setDecided(false), [])\n\n return { acceptAll, decided, rejectAll, reopen, savePreferences, state }\n}\n"],"names":["useCallback","useEffect","useState","ACCEPT_ALL_CONSENT","DEFAULT_CONSENT","REJECT_ALL_CONSENT","updateConsent","CONSENT_COOKIE","CONSENT_MAX_AGE","parseConsent","serializeConsent","useConsent","state","setState","decided","setDecided","raw","document","cookie","split","find","c","startsWith","parsed","persist","next","acceptAll","rejectAll","savePreferences","choices","necessary","reopen"],"mappings":"AAAA;AACA,SAASA,WAAW,EAAEC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAIxD,SAASC,kBAAkB,EAAEC,eAAe,EAAEC,kBAAkB,QAAQ,kBAAiB;AACzF,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,cAAc,EAAEC,eAAe,EAAEC,YAAY,EAAEC,gBAAgB,QAAQ,eAAc;AAE9F,OAAO,SAASC;IACd,MAAM,CAACC,OAAOC,SAAS,GAAGX,SAAuBE;IACjD,MAAM,CAACU,SAASC,WAAW,GAAGb,SAAS;IAEvCD,UAAU;QACR,MAAMe,MAAMC,SAASC,MAAM,CACxBC,KAAK,CAAC,MACNC,IAAI,CAAC,CAACC,IAAMA,EAAEC,UAAU,CAAC,GAAGf,eAAe,CAAC,CAAC,IAC5CY,MAAM,IAAI,CAAC,EAAE;QACjB,MAAMI,SAASd,aAAaO;QAC5B,IAAIO,QAAQ;YACVV,SAASU;YACTR,WAAW;QACb;IACF,GAAG,EAAE;IAEL,MAAMS,UAAUxB,YAAY,CAACyB;QAC3BR,SAASC,MAAM,GAAG,GAAGX,eAAe,CAAC,EAAEG,iBAAiBe,MAAM,gBAAgB,EAAEjB,gBAAgB,aAAa,CAAC;QAC9GK,SAASY;QACTV,WAAW;QACX,wEAAwE;QACxE,yEAAyE;QACzE,2EAA2E;QAC3E,6CAA6C;QAC7CT,cAAcmB;IAChB,GAAG,EAAE;IAEL,MAAMC,YAAY1B,YAAY,IAAMwB,QAAQrB,qBAAqB;QAACqB;KAAQ;IAC1E,MAAMG,YAAY3B,YAAY,IAAMwB,QAAQnB,qBAAqB;QAACmB;KAAQ;IAC1E,MAAMI,kBAAkB5B,YACtB,CAAC6B,UACCL,QAAQ;YAAE,GAAGpB,eAAe;YAAE,GAAGyB,OAAO;YAAEC,WAAW;QAAK,IAC5D;QAACN;KAAQ;IAEX,MAAMO,SAAS/B,YAAY,IAAMe,WAAW,QAAQ,EAAE;IAEtD,OAAO;QAAEW;QAAWZ;QAASa;QAAWI;QAAQH;QAAiBhB;IAAM;AACzE"}
+13
View File
@@ -0,0 +1,13 @@
import type { Field } from 'payload';
import type { ContentOption } 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 declare function buildArchiveFields(content: ContentOption, pagesSlug: string): Field[];
+27
View File
@@ -0,0 +1,27 @@
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, pagesSlug) {
return content.collections.map((collection)=>{
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
};
});
}
//# sourceMappingURL=archiveFields.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/content/archiveFields.ts"],"sourcesContent":["import type { Field, RelationshipField } from 'payload'\n\nimport type { ContentOption } from './types.js'\n\nimport { archiveFieldName } from './types.js'\n\n/**\n * Builds one relationship field per content collection, pointing at the\n * client's Pages collection.\n *\n * Sits alongside the system-page roles (homepage, privacy policy) because it's\n * the same idea: a page referenced by function rather than by slug. The\n * assignment is locale-agnostic — one page document — while the resulting path\n * is locale-aware, since the page's slug is localized. Assign the \"Artykuły\"\n * page once and you get /pl/artykuly and /en/articles from its own slugs.\n */\nexport function buildArchiveFields(content: ContentOption, pagesSlug: string): Field[] {\n return content.collections.map((collection): RelationshipField => {\n const label = collection.label ?? collection.slug\n\n return {\n name: archiveFieldName(collection.slug),\n type: 'relationship',\n admin: {\n description: `Page listing ${label}. Its slug becomes the URL segment for entries (e.g. /pl/artykuly/moj-wpis).`,\n },\n label: `${label} — archive page`,\n maxDepth: 1,\n relationTo: pagesSlug,\n }\n })\n}\n"],"names":["archiveFieldName","buildArchiveFields","content","pagesSlug","collections","map","collection","label","slug","name","type","admin","description","maxDepth","relationTo"],"mappings":"AAIA,SAASA,gBAAgB,QAAQ,aAAY;AAE7C;;;;;;;;;CASC,GACD,OAAO,SAASC,mBAAmBC,OAAsB,EAAEC,SAAiB;IAC1E,OAAOD,QAAQE,WAAW,CAACC,GAAG,CAAC,CAACC;QAC9B,MAAMC,QAAQD,WAAWC,KAAK,IAAID,WAAWE,IAAI;QAEjD,OAAO;YACLC,MAAMT,iBAAiBM,WAAWE,IAAI;YACtCE,MAAM;YACNC,OAAO;gBACLC,aAAa,CAAC,aAAa,EAAEL,MAAM,4EAA4E,CAAC;YAClH;YACAA,OAAO,GAAGA,MAAM,eAAe,CAAC;YAChCM,UAAU;YACVC,YAAYX;QACd;IACF;AACF"}
+39
View File
@@ -0,0 +1,39 @@
import type { BasePayload } from 'payload';
export type ArchiveEntries<T = Record<string, unknown>> = {
docs: T[];
hasNextPage: boolean;
hasPrevPage: boolean;
/** 1-based. */
page: number;
totalDocs: number;
totalPages: number;
};
type GetArchiveEntriesArgs = {
/** Collection to list, e.g. 'posts'. */
collection: string;
/** Relationship depth. Defaults to 1 — enough for a cover image. */
depth?: number;
locale: string;
/** 1-based. Values below 1 are clamped. */
page?: number;
payload: BasePayload;
/** Defaults to 10. */
perPage?: number;
/** Payload sort string. Defaults to newest first. */
sort?: string;
/** Extra constraints merged into the query, e.g. { category: { equals: id } }. */
where?: Record<string, unknown>;
};
/**
* Fetches one page of a collection's entries for an archive listing.
*
* Only published documents are returned when the collection has drafts enabled;
* Payload's `where` on `_status` is left to the caller, since a collection
* without drafts has no such field.
*
* Returns pagination facts rather than markup — the listing itself belongs to
* the client (as a block on the archive page, most likely), because how a list
* of posts should look isn't something a plugin can decide.
*/
export declare function getArchiveEntries<T = Record<string, unknown>>({ collection, depth, locale, page, payload, perPage, sort, where, }: GetArchiveEntriesArgs): Promise<ArchiveEntries<T>>;
export {};
+33
View File
@@ -0,0 +1,33 @@
/**
* 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 }) {
const result = await payload.find({
collection: collection,
depth,
limit: perPage,
locale: locale,
page: Math.max(1, page),
sort,
...where ? {
where: where
} : {}
});
return {
docs: result.docs,
hasNextPage: result.hasNextPage,
hasPrevPage: result.hasPrevPage,
page: result.page ?? 1,
totalDocs: result.totalDocs,
totalPages: result.totalPages
};
}
//# sourceMappingURL=getArchiveEntries.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/content/getArchiveEntries.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nexport type ArchiveEntries<T = Record<string, unknown>> = {\n docs: T[]\n hasNextPage: boolean\n hasPrevPage: boolean\n /** 1-based. */\n page: number\n totalDocs: number\n totalPages: number\n}\n\ntype GetArchiveEntriesArgs = {\n /** Collection to list, e.g. 'posts'. */\n collection: string\n /** Relationship depth. Defaults to 1 — enough for a cover image. */\n depth?: number\n locale: string\n /** 1-based. Values below 1 are clamped. */\n page?: number\n payload: BasePayload\n /** Defaults to 10. */\n perPage?: number\n /** Payload sort string. Defaults to newest first. */\n sort?: string\n /** Extra constraints merged into the query, e.g. { category: { equals: id } }. */\n where?: Record<string, unknown>\n}\n\n/**\n * Fetches one page of a collection's entries for an archive listing.\n *\n * Only published documents are returned when the collection has drafts enabled;\n * Payload's `where` on `_status` is left to the caller, since a collection\n * without drafts has no such field.\n *\n * Returns pagination facts rather than markup — the listing itself belongs to\n * the client (as a block on the archive page, most likely), because how a list\n * of posts should look isn't something a plugin can decide.\n */\nexport async function getArchiveEntries<T = Record<string, unknown>>({\n collection,\n depth = 1,\n locale,\n page = 1,\n payload,\n perPage = 10,\n sort = '-createdAt',\n where,\n}: GetArchiveEntriesArgs): Promise<ArchiveEntries<T>> {\n const result = await payload.find({\n collection: collection as never,\n depth,\n limit: perPage,\n locale: locale as never,\n page: Math.max(1, page),\n sort,\n ...(where ? { where: where as never } : {}),\n })\n\n return {\n docs: result.docs as T[],\n hasNextPage: result.hasNextPage,\n hasPrevPage: result.hasPrevPage,\n page: result.page ?? 1,\n totalDocs: result.totalDocs,\n totalPages: result.totalPages,\n }\n}\n"],"names":["getArchiveEntries","collection","depth","locale","page","payload","perPage","sort","where","result","find","limit","Math","max","docs","hasNextPage","hasPrevPage","totalDocs","totalPages"],"mappings":"AA6BA;;;;;;;;;;CAUC,GACD,OAAO,eAAeA,kBAA+C,EACnEC,UAAU,EACVC,QAAQ,CAAC,EACTC,MAAM,EACNC,OAAO,CAAC,EACRC,OAAO,EACPC,UAAU,EAAE,EACZC,OAAO,YAAY,EACnBC,KAAK,EACiB;IACtB,MAAMC,SAAS,MAAMJ,QAAQK,IAAI,CAAC;QAChCT,YAAYA;QACZC;QACAS,OAAOL;QACPH,QAAQA;QACRC,MAAMQ,KAAKC,GAAG,CAAC,GAAGT;QAClBG;QACA,GAAIC,QAAQ;YAAEA,OAAOA;QAAe,IAAI,CAAC,CAAC;IAC5C;IAEA,OAAO;QACLM,MAAML,OAAOK,IAAI;QACjBC,aAAaN,OAAOM,WAAW;QAC/BC,aAAaP,OAAOO,WAAW;QAC/BZ,MAAMK,OAAOL,IAAI,IAAI;QACrBa,WAAWR,OAAOQ,SAAS;QAC3BC,YAAYT,OAAOS,UAAU;IAC/B;AACF"}
+8
View File
@@ -0,0 +1,8 @@
export { buildArchiveFields } from './archiveFields.js';
export { getArchiveEntries } from './getArchiveEntries.js';
export type { ArchiveEntries } from './getArchiveEntries.js';
export { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js';
export { resolveRoute } from './resolveRoute.js';
export type { ResolvedRoute } from './resolveRoute.js';
export type { ContentCollectionOption, ContentOption } from './types.js';
export { archiveFieldName } from './types.js';
+7
View File
@@ -0,0 +1,7 @@
export { buildArchiveFields } from './archiveFields.js';
export { getArchiveEntries } from './getArchiveEntries.js';
export { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js';
export { resolveRoute } from './resolveRoute.js';
export { archiveFieldName } from './types.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/content/index.ts"],"sourcesContent":["export { buildArchiveFields } from './archiveFields.js'\nexport { getArchiveEntries } from './getArchiveEntries.js'\nexport type { ArchiveEntries } from './getArchiveEntries.js'\nexport { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js'\nexport { resolveRoute } from './resolveRoute.js'\nexport type { ResolvedRoute } from './resolveRoute.js'\nexport type { ContentCollectionOption, ContentOption } from './types.js'\nexport { archiveFieldName } from './types.js'\n"],"names":["buildArchiveFields","getArchiveEntries","buildArchivePath","buildEntryPath","parsePageParam","resolveRoute","archiveFieldName"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,qBAAoB;AACvD,SAASC,iBAAiB,QAAQ,yBAAwB;AAE1D,SAASC,gBAAgB,EAAEC,cAAc,EAAEC,cAAc,QAAQ,aAAY;AAC7E,SAASC,YAAY,QAAQ,oBAAmB;AAGhD,SAASC,gBAAgB,QAAQ,aAAY"}
+35
View File
@@ -0,0 +1,35 @@
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 declare function buildArchivePath({ archiveSlug, locale, page }: ArchivePathArgs): string;
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 declare function buildEntryPath({ archiveSlug, entrySlug, locale }: EntryPathArgs): string;
/**
* 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 declare function parsePageParam(value: string | string[] | undefined): number;
export {};
+28
View File
@@ -0,0 +1,28 @@
/**
* 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 }) {
const base = `/${locale}/${archiveSlug}`;
return page > 1 ? `${base}?page=${page}` : base;
}
/** Path to a single entry: /pl/artykuly/moj-post. */ export function buildEntryPath({ archiveSlug, entrySlug, locale }) {
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) {
const raw = Array.isArray(value) ? value[0] : value;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : 1;
}
//# sourceMappingURL=paths.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/content/paths.ts"],"sourcesContent":["type ArchivePathArgs = {\n /** Archive page's slug in this locale, e.g. 'artykuly'. */\n archiveSlug: string\n locale: string\n /** 1-based. Page 1 is omitted from the URL. */\n page?: number\n}\n\n/**\n * Path to an archive listing: /pl/artykuly, /pl/artykuly?page=2.\n *\n * Pagination goes in the query string rather than the path. A path segment\n * (/pl/artykuly/2) would be ambiguous with an entry slugged \"2\", and the\n * alternative (/pl/artykuly/strona/2) drags in yet another localized segment to\n * configure and translate. Google has understood ?page= for years, and\n * rel=next/prev — the reason people used to prefer path segments — was retired.\n */\nexport function buildArchivePath({ archiveSlug, locale, page = 1 }: ArchivePathArgs): string {\n const base = `/${locale}/${archiveSlug}`\n return page > 1 ? `${base}?page=${page}` : base\n}\n\ntype EntryPathArgs = {\n /** Archive page's slug in this locale, e.g. 'artykuly'. */\n archiveSlug: string\n /** Entry's slug in this locale, e.g. 'moj-post'. */\n entrySlug: string\n locale: string\n}\n\n/** Path to a single entry: /pl/artykuly/moj-post. */\nexport function buildEntryPath({ archiveSlug, entrySlug, locale }: EntryPathArgs): string {\n return `/${locale}/${archiveSlug}/${entrySlug}`\n}\n\n/**\n * Reads a page number out of a query parameter.\n *\n * Anything that isn't a positive integer is page 1 — `?page=abc`, `?page=-5`,\n * `?page=1.5` and a repeated `?page=1&page=2` all have to resolve to something,\n * and silently showing the first page beats a 500 on a URL a crawler invented.\n */\nexport function parsePageParam(value: string | string[] | undefined): number {\n const raw = Array.isArray(value) ? value[0] : value\n const n = Number(raw)\n return Number.isInteger(n) && n > 0 ? n : 1\n}\n"],"names":["buildArchivePath","archiveSlug","locale","page","base","buildEntryPath","entrySlug","parsePageParam","value","raw","Array","isArray","n","Number","isInteger"],"mappings":"AAQA;;;;;;;;CAQC,GACD,OAAO,SAASA,iBAAiB,EAAEC,WAAW,EAAEC,MAAM,EAAEC,OAAO,CAAC,EAAmB;IACjF,MAAMC,OAAO,CAAC,CAAC,EAAEF,OAAO,CAAC,EAAED,aAAa;IACxC,OAAOE,OAAO,IAAI,GAAGC,KAAK,MAAM,EAAED,MAAM,GAAGC;AAC7C;AAUA,mDAAmD,GACnD,OAAO,SAASC,eAAe,EAAEJ,WAAW,EAAEK,SAAS,EAAEJ,MAAM,EAAiB;IAC9E,OAAO,CAAC,CAAC,EAAEA,OAAO,CAAC,EAAED,YAAY,CAAC,EAAEK,WAAW;AACjD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,eAAeC,KAAoC;IACjE,MAAMC,MAAMC,MAAMC,OAAO,CAACH,SAASA,KAAK,CAAC,EAAE,GAAGA;IAC9C,MAAMI,IAAIC,OAAOJ;IACjB,OAAOI,OAAOC,SAAS,CAACF,MAAMA,IAAI,IAAIA,IAAI;AAC5C"}
+68
View File
@@ -0,0 +1,68 @@
import type { BasePayload } from 'payload';
import type { ContentOption } from './types.js';
export type ResolvedRoute =
/** Locale root — the page assigned as Homepage in System Pages. */
{
/** The archive page this entry lives under — its slug is the URL prefix. */
archive: Record<string, unknown>;
collection: string;
doc: Record<string, unknown>;
type: 'entry';
}
/** An ordinary page. */
| {
collection: string;
doc: Record<string, unknown>;
/** 1-based, from ?page=. */
page: number;
perPage: number;
type: 'archive';
}
/** A collection's archive page, e.g. /pl/artykuly. */
| {
doc: Record<string, unknown>;
type: 'home';
}
/** A single entry, e.g. /pl/artykuly/moj-post. */
| {
doc: Record<string, unknown>;
type: 'page';
};
type ResolveRouteArgs = {
content?: ContentOption;
locale: string;
/** Page number from the query string (?page=2). Defaults to 1. */
page?: number;
/** Client's Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string;
payload: BasePayload;
/** Route segments after the locale, e.g. ['artykuly', 'moj-post']. */
segments?: string[];
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string;
};
/**
* Works out what a URL points at: the home page, an ordinary page, a
* collection's archive, or a single entry.
*
* Routing only. An archive result says which collection to list and on which
* page, but doesn't fetch the entries — that's `getArchiveEntries`, called by
* whoever actually needs them. Keeping the two apart means listing changes
* (sorting, filtering, pagination) never touch routing rules, and metadata
* generation doesn't pay for a query it would discard.
*
* 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 declare function resolveRoute({ content, locale, page, pagesSlug, payload, segments, settingsSlug, }: ResolveRouteArgs): Promise<null | ResolvedRoute>;
export {};
+111
View File
@@ -0,0 +1,111 @@
import { archiveFieldName } from './types.js';
/**
* Works out what a URL points at: the home page, an ordinary page, a
* collection's archive, or a single entry.
*
* Routing only. An archive result says which collection to list and on which
* page, but doesn't fetch the entries — that's `getArchiveEntries`, called by
* whoever actually needs them. Keeping the two apart means listing changes
* (sorting, filtering, pagination) never touch routing rules, and metadata
* generation doesn't pay for a query it would discard.
*
* 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' }) {
const settings = await payload.findGlobal({
slug: settingsSlug,
depth: 1,
locale: locale
});
// Locale root → Homepage from System Pages.
if (!segments?.length) {
const homepage = settings.homepage;
if (homepage && typeof homepage === 'object') {
return {
type: 'home',
doc: homepage
};
}
return null;
}
const [first, ...rest] = segments;
// Does the first segment name an archive page?
for (const collection of content?.collections ?? []){
const archive = settings[archiveFieldName(collection.slug)];
if (!archive || typeof archive !== 'object') {
continue;
}
const archiveDoc = archive;
if (archiveDoc.slug !== first) {
continue;
}
// /pl/artykuly → the archive page itself.
if (rest.length === 0) {
return {
type: 'archive',
collection: collection.slug,
doc: archiveDoc,
page,
perPage: collection.perPage ?? 10
};
}
// /pl/artykuly/moj-post → an entry.
if (rest.length === 1) {
const found = await payload.find({
collection: collection.slug,
depth: 2,
limit: 1,
locale: locale,
where: {
slug: {
equals: rest[0]
}
}
});
const entry = found.docs[0];
if (!entry) {
return null;
}
return {
type: 'entry',
archive: archiveDoc,
collection: collection.slug,
doc: entry
};
}
// Deeper than {archive}/{entry}.
return null;
}
// An ordinary page. Joined, so nested slugs ('a/b') keep working.
const found = await payload.find({
collection: pagesSlug,
depth: 2,
limit: 1,
locale: locale,
where: {
slug: {
equals: segments.join('/')
}
}
});
const doc = found.docs[0];
if (!doc) {
return null;
}
return {
type: 'page',
doc: doc
};
}
//# sourceMappingURL=resolveRoute.js.map
File diff suppressed because one or more lines are too long
+29
View File
@@ -0,0 +1,29 @@
/**
* 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 declare function archiveFieldName(collectionSlug: string): string;
+17
View File
@@ -0,0 +1,17 @@
/**
* 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.
*/ /**
* Field name holding a collection's archive page in SiteSettings.
* `posts` → `postsArchive`.
*/ export function archiveFieldName(collectionSlug) {
return `${collectionSlug}Archive`;
}
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/content/types.ts"],"sourcesContent":["/**\n * A collection whose entries live under an archive page, e.g. blog posts at\n * /pl/artykuly/moj-post.\n *\n * The collection itself belongs to the client — Payload keeps its schema in\n * code, so adding one is always a code change. What this option adds is the\n * routing: the plugin exposes an \"archive page\" assignment in SiteSettings, and\n * whichever page an editor picks becomes the URL segment. Rename that page from\n * \"Artykuły\" to \"Wpisy\" and the path follows, per locale, with no deploy.\n */\nexport type ContentCollectionOption = {\n /** Slug of the client's collection, e.g. 'posts'. */\n slug: string\n /** Admin label for the archive assignment. Defaults to the slug. */\n label?: string\n /** Entries per page in listings. Defaults to 10. */\n perPage?: number\n}\n\n/**\n * Plugin option wiring archive-backed collections into routing.\n */\nexport type ContentOption = {\n collections: ContentCollectionOption[]\n}\n\n/**\n * Field name holding a collection's archive page in SiteSettings.\n * `posts` → `postsArchive`.\n */\nexport function archiveFieldName(collectionSlug: string): string {\n return `${collectionSlug}Archive`\n}\n"],"names":["archiveFieldName","collectionSlug"],"mappings":"AAAA;;;;;;;;;CASC,GAiBD;;;CAGC,GACD,OAAO,SAASA,iBAAiBC,cAAsB;IACrD,OAAO,GAAGA,eAAe,OAAO,CAAC;AACnC"}
+2
View File
@@ -0,0 +1,2 @@
export { sendEmail } from './sendEmail.js';
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js';
+5
View File
@@ -0,0 +1,5 @@
// 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';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["sendEmail"],"mappings":"AAAA,mFAAmF;AACnF,0DAA0D;AAC1D,SAASA,SAAS,QAAQ,iBAAgB"}
+32
View File
@@ -0,0 +1,32 @@
import type { PayloadEmailAdapter } from 'payload';
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 declare const panelSmtpAdapter: (args?: PanelSmtpAdapterArgs) => PayloadEmailAdapter;
+83
View File
@@ -0,0 +1,83 @@
import { getSiteIntegrations } from '../payload/index.js';
/**
* 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 = {})=>({ payload })=>({
name: 'ipal-panel-smtp',
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
defaultFromName: args.fallbackFromName ?? 'Website',
sendEmail: async (message)=>{
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)=>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
});
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.message}`);
return {
error: 'Failed to send email.',
sent: false
};
}
}
});
//# sourceMappingURL=panelSmtpAdapter.js.map
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
import 'server-only';
import type { BasePayload } from 'payload';
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 declare function sendEmail({ from, html, payload, replyTo, subject, text, to, }: SendEmailArgs): Promise<SendEmailResult>;

Some files were not shown because too many files have changed in this diff Show More