Compare commits

...
9 Commits
9 changed files with 521 additions and 259 deletions
+22 -14
View File
@@ -89,18 +89,19 @@ import { getSiteIntegrations } from '../payload/index.js';
sent: false sent: false
}; };
} }
// The panel's from-address is used as Reply-To, NOT as the message From. // Display name on the From, WITHOUT triggering Send-As.
// //
// Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a // The trick: we may set a `from` as long as its ADDRESS stays the sender
// `from` that differs from that mailbox, Exchange demands "Send As" // mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
// permission on it and rejects with ErrorSendAsDenied otherwise. So we // demands Send-As when the from ADDRESS differs from the mailbox, so a
// never override `from` — Graph stamps the mail as GRAPH_SENDER (the // same-address / custom-name From is allowed and gives each project its
// mailbox we legitimately own) — and route replies to the panel address // own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
// via Reply-To. Recipients see the mail from forms@… but replying reaches //
// the real destination. No Send-As needed. // The panel's from-address becomes Reply-To (so replies reach the client),
// and the panel's from-name becomes the sender display name.
const panel = await getSiteIntegrations(payload); const panel = await getSiteIntegrations(payload);
const replyToAddress = panel.smtpFromAddress || undefined; const replyToAddress = panel.smtpFromAddress || undefined;
const replyToName = panel.smtpFromName || undefined; const senderName = panel.smtpFromName || undefined;
const to = toRecipients(message.to); const to = toRecipients(message.to);
if (to.length === 0) { if (to.length === 0) {
payload.logger.error('[ipal] Email not sent: no valid recipient.'); payload.logger.error('[ipal] Email not sent: no valid recipient.');
@@ -116,10 +117,7 @@ import { getSiteIntegrations } from '../payload/index.js';
const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [ const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [
{ {
emailAddress: { emailAddress: {
address: replyToAddress, address: replyToAddress
...replyToName ? {
name: replyToName
} : {}
} }
} }
] : []; ] : [];
@@ -136,7 +134,17 @@ import { getSiteIntegrations } from '../payload/index.js';
...message.bcc ? { ...message.bcc ? {
bccRecipients: toRecipients(message.bcc) bccRecipients: toRecipients(message.bcc)
} : {}, } : {},
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As. // From with the sender's OWN address (no Send-As) plus an optional
// display name from the panel. Omit entirely when no name is set —
// Graph then uses the mailbox's default name.
...senderName ? {
from: {
emailAddress: {
name: senderName,
address: env.sender
}
}
} : {},
...replyTo.length > 0 ? { ...replyTo.length > 0 ? {
replyTo replyTo
} : {} } : {}
File diff suppressed because one or more lines are too long
+239
View File
@@ -0,0 +1,239 @@
# Playbook wdrożenia — ipal-kit
Sztywna procedura dla pracownika albo AI (Antigravity). Mówi CO robić, W JAKIEJ
KOLEJNOŚCI, i CZYM SIĘ KIEROWAĆ. Zasady są twarde, przykłady realne — wzięte z
faktycznych błędów, które się zdarzyły. Odstępstwa tylko za świadomą decyzją.
Powiązane: [publishing.md](./publishing.md) (cykl publikacji), [getting-started.md](./getting-started.md)
(nowy projekt), ../ANTIGRAVITY-ZASADY-AGENT.md (zasady dla AI).
---
## ZŁOTE ZASADY
1. **Nic na sztywno.** Tekst, obraz, link, dane firmy → panel/baza, nie kod.
2. **Logika w pluginie, projekt podłącza.** Jeśli piszesz w projekcie coś, co
robi już plugin — zatrzymaj się, użyj pluginu.
3. **Next 16 = proxy.ts.** NIGDY middleware.ts. Jeśli istnieje — usuń.
4. **Weryfikuj każdy etap grepem.** Nie zakładaj, że zadziałało. Sprawdź.
5. **Napraw u źródła, nie łataj.** Bez `as any`, `@ts-ignore`, kopii logiki.
6. **Zmiana w pluginie nie działa, dopóki nie: build → publish → wciągnięcie.**
---
## CZĘŚĆ A — ŁAŃCUCH ZMIANY W PLUGINIE (najważniejsze)
Najczęstsze źródło frustracji tej sesji: „zmieniłem kod, a nie działa". Prawie
zawsze przyczyna: **przerwany łańcuch**. Zmiana w pluginie przechodzi przez
PIĘĆ etapów. Pominięcie któregokolwiek = stara wersja w projekcie.
```
źródła (src) → build (dist) → publish (rejestr) → wciągnięcie (node_modules) → restart
```
### Sztywna procedura zmiany w pluginie
```bash
cd ~/payload-cms/ipal-kit
# 1. ŹRÓDŁA — nanieś zmianę, ZWERYFIKUJ że jest
grep -c "<symbol-zmiany>" src/<ścieżka> # MUSI być >0
# 2. BUILD — zbuduj, ZWERYFIKUJ że dist ma zmianę
pnpm build
grep -c "<symbol-zmiany>" dist/<ścieżka> # MUSI być >0
# 3. COMMIT (PRZED version — inaczej "working directory not clean")
git add -A && git commit -m "opis"
# 4. VERSION + PUBLISH
npm version patch # czyste repo wymagane
npm publish
# 5. PUSH
git push && git push --tags
# 6. PROJEKT — wciągnij, ZWERYFIKUJ że node_modules ma zmianę
cd ~/<projekt>
pnpm add @intecion/ipal-kit@<nowa-wersja>
grep -c "<symbol-zmiany>" node_modules/@intecion/ipal-kit/dist/<ścieżka> # MUSI być >0
# 7. RESTART dev (Payload buduje adaptery/config przy starcie!)
pnpm dev
```
### TRZY punkty kontrolne grep (nie pomijaj żadnego)
| Etap | Grep | Jeśli 0 |
|---|---|---|
| po edycji | `src/...` | zmiana nie zapisana / zły plik |
| po build | `dist/...` | build nie złapał / błąd typów |
| po pnpm add | `node_modules/...` | projekt ma starą wersję |
**Realny przykład (z tej sesji):** `buildSecurityHeaders is not a function`.
Przyczyna: moduł istniał w `src`, ale NIE był wyeksportowany w `src/index.ts`
→ `dist` go nie miał → import w projekcie = undefined. Grep `dist/index.js`
pokazał 0. Naprawa: dodać eksport, przejść łańcuch od nowa.
### Pułapki kolejności (realne błędy sesji)
- **`npm version` przed commitem** → "Git working directory not clean". ZAWSZE
commit przed version.
- **`npm publish` bez `pnpm build`** → publikujesz STARY dist. ZAWSZE build przed
publish, grep dist po buildzie.
- **`pnpm add` przy działającym dev** → proces ma stary adapter w pamięci.
Payload czyta email/config przy starcie. ZAWSZE restart po wciągnięciu.
---
## CZĘŚĆ B — GREP JAKO NARZĘDZIE (jak weryfikować dobrze)
Grep był w tej sesji głównym narzędziem diagnozy. Ale trzeba go używać mądrze.
### Reguła: grepuj TOKENY, nie całe frazy z kolejnością
**Realny błąd:** grep `"env.sender, name: senderName"` dał 0, choć kod był OK —
bo plik miał odwróconą kolejność kluczy (`name: senderName, address: env.sender`).
Obiekt JS ignoruje kolejność, ale grep nie.
```bash
# ŹLE — zależny od kolejności/formatowania:
grep -c "env.sender, name: senderName" plik.ts # 0 mimo poprawnego kodu
# DOBRZE — pojedynczy token, odporny:
grep -c "senderName" plik.ts # 3 ✓
```
Grepuj **nazwę symbolu** (funkcja, zmienna, eksport), nie całą linię z interpunkcją.
---
## CZĘŚĆ C — DIAGNOSTYKA „KOD DOBRY, ZACHOWANIE ZŁE"
Gdy grep potwierdza kod, wersja nowa, a zachowanie stare — przejdź listę:
1. **Dev nie zrestartowany?** Payload buduje adaptery/config przy starcie.
Ctrl+C + `pnpm dev`. (Najczęstsza przyczyna.)
2. **Zmiana zapisana w panelu?** Endpointy czytają z BAZY, nie z pola na ekranie.
Kliknij Save.
3. **Zdublowana zależność?** `@payloadcms/ui` w node_modules pluginu = dwie
instancje = hooki bez kontekstu. Sprawdź:
`ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui`
Jest? → peerDependency problem (patrz Część D).
4. **Cache klienta?** Np. klient pocztowy pokazuje zapamiętaną nazwę nadawcy
mimo poprawnych nagłówków. Sprawdź surowe źródło (View Source), wyślij na
inny adres.
5. **Import map nieaktualny?** Custom komponenty Payload:
`npx payload generate:importmap`.
**Realny przykład:** MaskedField rzucał "Cannot destructure property 'config'".
Kod OK. Przyczyna: dublet `@payloadcms/ui` (plugin miał własną kopię) →
`useField` z jednej instancji nie widział kontekstu z drugiej. Naprawa w Część D.
---
## CZĘŚĆ D — peerDependencies (dublety zależności)
**Zasada:** wszystko, co dostarcza PROJEKT, jest `peerDependency` w pluginie,
NIE `dependency`. Inaczej menedżer instaluje własną kopię dla pluginu → dublet
→ React/Payload context się rozjeżdża (dwie instancje nie widzą się nawzajem).
Peer (projekt dostarcza): `payload`, `@payloadcms/ui`, `@payloadcms/next`,
`@payloadcms/plugin-*`, `react`, `react-dom`, `next`.
**Realny błąd:** `@payloadcms/ui` był tylko w devDependencies (brak w peer) →
pnpm dołożył kopię pluginowi → MaskedField/TestEmailButton/CookieBanner
wszystkie się psuły (hooki bez kontekstu). Naprawa: dodać do peerDependencies,
opublikować, w projekcie `rm -rf node_modules/@intecion/ipal-kit && pnpm add`.
Weryfikacja braku dubletu:
```bash
ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui 2>/dev/null \
&& echo "DUBLET ✗" || echo "OK ✓"
```
---
## CZĘŚĆ E — NOWY PROJEKT KLIENCKI (kolejność)
Pełne szczegóły: [getting-started.md](./getting-started.md). Tu skrót kolejności.
1. **Szkielet** Payload 3 + Next 16, pnpm, Node 22
2. **`.npmrc`** — `legacy-peer-deps=true` + rejestr `@intecion`
3. **`pnpm add @intecion/ipal-kit`** + zależności peer
4. **build script z `--webpack`** (Next 16 + Payload; Turbopack konfliktuje)
5. **i18n.config.ts** — jedno źródło locale
6. **payload.config.ts** — ipalKit({...}), `email: mailAdapter()`
7. **Kolekcje/globale** — wszystko localized/upload (nic na sztywno)
8. **lib/content.ts + lib/payload.ts** — helpery, jedno źródło getCachedPayload
9. **proxy.ts** (NIE middleware.ts) — routing locale, obsługa roota
10. **Bloki** — dane przez enhanceProps, nie import lib (cykl)
11. **buildSlugField** zamiast ręcznego slug
12. **getLocalizedSlugs** zamiast zaszytej mapy ścieżek
13. **buildSecurityHeaders** w next.config
14. **Test:** root `/` przekierowuje, formularz wysyła, panel działa
---
## CZĘŚĆ F — EMAIL (SMTP vs Graph)
Pełne szczegóły: [email.md](./email.md). Decyzja transportu:
- **Klient na M365/Exchange** → Graph (SMTP AUTH na M365 często wyłączony)
- **Klient z własnym SMTP / Gmail** → SMTP
- **Przełącznik:** panel → Site Integrations → SMTP → Email Transport
- **Dyspozytor:** `email: mailAdapter()` czyta wybór przy każdej wysyłce
### Graph — checklist wdrożenia (Wasza strona, jednorazowo)
1. Azure: App registration → tenantId, clientId, clientSecret
2. Azure: Mail.Send APPLICATION permission + **Grant admin consent**
3. `.env` projektu: GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER
4. Panel: From Name (nazwa nadawcy), From Address (→ reply-to)
### Realne pułapki Graph (wszystkie zdarzyły się w sesji)
| Błąd | Przyczyna | Naprawa |
|---|---|---|
| `ErrorSendAsDenied` | `from` ≠ sender | from.address = GRAPH_SENDER, klient w replyTo |
| nazwa „Noreply" mimo panelu | Exchange nadpisuje / cache klienta | display name skrzynki / sprawdź nagłówki |
| `Insufficient privileges` | brak admin consent | Grant admin consent w Azure |
| `AADSTS1002012` | zły scope | scope = `.../.default`, nie Mail.Send |
**Zasada from/replyTo:** `from.address` ZAWSZE = GRAPH_SENDER (wspólna skrzynka,
zero Send-As). Nazwa (`from.name`) z panelu — różna per projekt. Adres klienta
→ replyTo (odpowiedzi trafiają do klienta).
---
## CZĘŚĆ G — CO NALEŻY DO PLUGINU, A CO DO PROJEKTU
Powtarzalne pytanie. Reguła: **jeśli zależy od danych/domen konkretnego projektu
→ projekt. Jeśli identyczne wszędzie → plugin.**
| Rzecz | Gdzie | Dlaczego |
|---|---|---|
| i18n, SEO meta, forms, consent, blog | plugin | uniwersalne |
| Powiadomienia (teksty wyników) | plugin | uniwersalne, per język z panelu |
| Zgoda RODO (enforcement) | plugin | uniwersalne, server-side |
| Nagłówki bezpieczeństwa (HSTS...) | plugin | identyczne wszędzie |
| Email (SMTP + Graph) | plugin | uniwersalne, konfiguracja z panelu/env |
| **CSP** | **projekt** | zależy od domen projektu |
| **schema.org / JSON-LD** | **projekt** | zależy od danych firmy |
| **Breadcrumbs** | **projekt** | render z danych routingu projektu |
| **Dane rejestrowe firmy** | **projekt** | różne per typ firmy |
---
## CZĘŚĆ H — CHECKLIST PRZED „GOTOWE"
Nie mów „działa", dopóki:
- [ ] `pnpm build --webpack` przechodzi lokalnie (nie tylko dev)
- [ ] root `/` przekierowuje na locale (bez middleware.ts)
- [ ] formularz wysyła (test przez panel: Send test)
- [ ] panel: wszystkie teksty/obrazy edytowalne (nic na sztywno)
- [ ] brak dubletu @payloadcms/ui (Część D)
- [ ] grep potwierdza wersję pluginu w node_modules
- [ ] sekrety w .env (nie w repo), maskowane w panelu
- [ ] brak plików middleware.ts, brak zaszytej mapy slugów
+3 -1
View File
@@ -1,5 +1,7 @@
# IPAL — Dokumentacja modułów # IPAL — Dokumentacja modułów
> **Zaczynasz wdrożenie?** Przeczytaj najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md) — sztywna procedura, kolejność, realne przykłady błędów.
**Instalacja pakietu** (token Gitea, rejestr vs repozytorium) → główny **Instalacja pakietu** (token Gitea, rejestr vs repozytorium) → główny
[README](../README.md). [README](../README.md).
**Nowy projekt krok po kroku** → [getting-started.md](./getting-started.md). **Nowy projekt krok po kroku** → [getting-started.md](./getting-started.md).
@@ -118,7 +120,7 @@ export default buildConfig({
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) | | content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md) Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
Referencja wdrożenia frontu: [frontend-setup.md](./frontend-setup.md) Referencja wdrożenia frontu: [getting-started.md](./getting-started.md)
Wydawanie nowych wersji wtyczki: [publishing.md](./publishing.md) Wydawanie nowych wersji wtyczki: [publishing.md](./publishing.md)
Jak komendy łączą się z Gitea (dla instalujących): [gitea-commands.md](./gitea-commands.md) Jak komendy łączą się z Gitea (dla instalujących): [gitea-commands.md](./gitea-commands.md)
Working with a project repo on Gitea (clone/pull/push): [gitea-workflow.md](./gitea-workflow.md) · [🇵🇱 PL](./gitea-workflow.pl.md) Working with a project repo on Gitea (clone/pull/push): [gitea-workflow.md](./gitea-workflow.md) · [🇵🇱 PL](./gitea-workflow.pl.md)
+27
View File
@@ -150,3 +150,30 @@ Jeśli `from` w panelu = cudza domena (np. `[email protected]`), a sender =
domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres
klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze
domeny nie jest potrzebny. domeny nie jest potrzebny.
## Przełącznik transportu — mailAdapter
`mailAdapter()` to dyspozytor: jeden adapter wpięty w config, wybiera transport
(SMTP/Graph) przy KAŻDEJ wysyłce, czytając ustawienie z panelu. Dzięki temu
przełącznik działa w panelu (Payload buduje adapter raz przy starcie, więc nie
da się podmieniać osobnych adapterów w runtime — dyspozytor deleguje wewnątrz).
```ts
// payload.config.ts — JEDEN adapter, wybór wewnątrz
import { mailAdapter } from '@intecion/ipal-kit'
email: mailAdapter()
```
Panel → Site Integrations → SMTP → **Email Transport** (SMTP / Microsoft Graph).
Dyspozytor czyta ten wybór per wysyłka. Guard: jeśli wybrano Graph, ale brak
sekretów w .env → log + fallback na SMTP (nie cicha awaria).
## Test wysyłki — przycisk w panelu
W tabie SMTP jest przycisk **Send test**: podaj adres, kliknij, wyślij testowy
mail przez AKTUALNY transport. Pokazuje wynik (✓/✗ z błędem). Endpoint
`POST /api/ipal/test-email` (admin-only). Zapisz zmiany przed testem — endpoint
czyta z bazy, nie z pola na ekranie.
> Bezcenne przy diagnozie Graph — od razu widzisz `ErrorSendAsDenied`,
> `Insufficient privileges` itp. zamiast zgadywać.
+204 -221
View File
@@ -1,14 +1,18 @@
# Nowy projekt — krok po kroku # Setup projektu — od zera do wdrożenia
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest opisana w głównym Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z
blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność
kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata).
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest w głównym
> [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest > [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest
> już zainstalowany, i przeprowadza przez **konfigurację** projektu. > zainstalowany, i przeprowadza przez konfigurację.
>
> **Zaczynasz wdrożenie produkcyjne?** Najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md)
> — zasady, procedura, pułapki.
Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy,
formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16.
bazy, importMap, kolejność wpięcia).
Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
--- ---
@@ -16,21 +20,22 @@ Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
```bash ```bash
npx create-payload-app@latest moj-projekt npx create-payload-app@latest moj-projekt
# → Blank, SQLite # → Blank, SQLite (dev) / Postgres (prod)
cd moj-projekt cd moj-projekt
``` ```
## 2. Plugin i zależności ## 2. Plugin i zależności
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md) (rejestr Gitea Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md). Dodaj
albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności zależności współdzielone z Payloadem, których plugin nie zaciąga sam:
współdzielone z Payloadem, których plugin nie zaciąga sam:
```bash ```bash
pnpm add @payloadcms/plugin-seo@3.84.1 @payloadcms/plugin-form-builder@3.84.1 \ pnpm add @payloadcms/plugin-seo @payloadcms/plugin-form-builder \
nodemailer lucide-react slugify server-only nodemailer lucide-react slugify server-only
``` ```
### Spójność wersji @payloadcms/* (KRYTYCZNE)
Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej
zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo
projekt się wywala. Wymuś w `package.json`: projekt się wywala. Wymuś w `package.json`:
@@ -38,13 +43,13 @@ projekt się wywala. Wymuś w `package.json`:
```json ```json
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"payload": "3.84.1", "payload": "3.88.0",
"@payloadcms/ui": "3.84.1", "@payloadcms/ui": "3.88.0",
"@payloadcms/next": "3.84.1", "@payloadcms/next": "3.88.0",
"@payloadcms/db-sqlite": "3.84.1", "@payloadcms/db-postgres": "3.88.0",
"@payloadcms/richtext-lexical": "3.84.1", "@payloadcms/richtext-lexical": "3.88.0",
"@payloadcms/plugin-seo": "3.84.1", "@payloadcms/plugin-seo": "3.88.0",
"@payloadcms/plugin-form-builder": "3.84.1" "@payloadcms/plugin-form-builder": "3.88.0"
} }
} }
``` ```
@@ -53,11 +58,17 @@ projekt się wywala. Wymuś w `package.json`:
rm -rf node_modules pnpm-lock.yaml && pnpm install rm -rf node_modules pnpm-lock.yaml && pnpm install
``` ```
### Build script z --webpack (Next 16)
Next 16 domyślnie Turbopack, który konfliktuje z withPayload. W `package.json`:
```json
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack"
```
## 3. Konfiguracja locale — jedno źródło ## 3. Konfiguracja locale — jedno źródło
Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie
nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie:
miejscach:
```ts ```ts
// src/i18n.config.ts // src/i18n.config.ts
@@ -67,25 +78,24 @@ export const i18nConfig = {
{ code: 'pl', label: 'Polski' }, { code: 'pl', label: 'Polski' },
{ code: 'en', label: 'English' }, { code: 'en', label: 'English' },
], ],
} as const } as const // as const — inaczej TS nie uzna locales za niepustą tuple
``` ```
`as const` jest konieczne — bez niego TS nie uzna `locales` za niepustą listę. Importuj w: `payload.config` (ipalKit({ i18n: i18nConfig })) i `proxy.ts`.
## 4. payload.config.ts ## 4. payload.config.ts
```ts ```ts
import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit' import { ipalKit, mailAdapter } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config' import { i18nConfig } from '@/i18n.config'
import { Pages } from '@/collections/Pages' import { Pages } from '@/collections/Pages'
export default buildConfig({ export default buildConfig({
// …reszta z template'u
collections: [Users, Media, Pages], collections: [Users, Media, Pages],
// SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu. // Dyspozytor email: czyta transport (SMTP/Graph) z panelu przy każdej wysyłce.
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka). // Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
email: panelSmtpAdapter(), email: mailAdapter(),
plugins: [ plugins: [
ipalKit({ ipalKit({
@@ -113,11 +123,11 @@ export const Pages: CollectionConfig = {
access: { read: () => true }, access: { read: () => true },
fields: [ fields: [
{ name: 'title', type: 'text', required: true, localized: true }, { name: 'title', type: 'text', required: true, localized: true },
buildSlugField({ from: 'title' }), buildSlugField({ from: 'title' }), // NIGDY ręczny slug — plugin to ma
{ {
name: 'layout', name: 'layout',
type: 'blocks', type: 'blocks',
blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala blocks: [ContentBlock], // NIGDY pusta lista — Payload crashuje
}, },
], ],
} }
@@ -158,15 +168,28 @@ import type { BlockComponentMap } from '@intecion/ipal-kit/rsc'
import { ContentBlockComponent } from '@/blocks/Content/Component' import { ContentBlockComponent } from '@/blocks/Content/Component'
export const blockRegistry: BlockComponentMap = { export const blockRegistry: BlockComponentMap = {
content: ContentBlockComponent, content: ContentBlockComponent, // klucz = slug bloku
} }
``` ```
Klucz w rejestrze = `slug` bloku. **Puste `blocks: []` crashuje** (traverseFields) — zawsze co najmniej jeden blok.
## 7. Tailwind ### enhanceProps — wstrzykiwanie danych server-side do bloków
Blank template go nie ma, a komponenty pluginu (banner cookies) są w Tailwindzie. Bloki NIE importują `lib/*` (cykl importów). Wartości server-side (turnstileSiteKey,
odbiorca formularza) wstrzykuje się przez enhanceProps — bez wiedzy pluginu:
```ts
const enhanceProps = ({ block }) => {
if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo }
return {}
}
```
## 7. Tailwind (WYMÓG)
Blank template go nie ma, a komponenty pluginu (banner cookies, Turnstile) są w
czystym Tailwindzie.
```bash ```bash
pnpm add tailwindcss @tailwindcss/postcss pnpm add tailwindcss @tailwindcss/postcss
@@ -183,16 +206,30 @@ export default { plugins: { '@tailwindcss/postcss': {} } }
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js"; @source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
``` ```
`@source` jest **konieczny** — Tailwind nie skanuje `node_modules`, więc bez **`@source` jest KONIECZNY** — Tailwind nie skanuje `node_modules`, więc bez
niego klasy komponentów pluginu nie powstaną i banner wyrenderuje się goły. niego klasy komponentów pluginu nie powstaną (banner wyrenderuje się goły).
Ścieżka jest relatywna do pliku CSS. Ścieżka relatywna do pliku CSS.
## 8. Proxy (dawniej middleware) ### Przestylowanie pod klienta
> **Next 16:** konwencja `middleware.ts` jest przestarzała — nazwa pliku to teraz Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom
> `proxy.ts`, a funkcja `proxy` zamiast `middleware`. Logika pluginu bez zmian: properties (fallbacki wbudowane):
> `createLocaleMiddleware` działa tak samo. Migracja jednej komendy: ```css
> `npx @next/codemod@canary middleware-to-proxy .` :root {
--ipal-primary: #16a34a;
--ipal-radius: 1rem;
}
```
Pełna lista tokenów + opcja classNames: [consent.md](./consent.md).
## 8. Proxy (routing locale) — NIGDY middleware.ts
> **Next 16 używa `proxy.ts`, NIE `middleware.ts`.** Plik `proxy.ts`, funkcja
> `proxy`. `middleware.ts` jest przestarzały — jeśli istnieje, USUŃ go. Nigdy
> obu naraz. Migracja starego: `npx @next/codemod@canary middleware-to-proxy .`
>
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
> subpath eksportu, NIE nazwa pliku. Nie myl ich.
```ts ```ts
// src/proxy.ts // src/proxy.ts
@@ -205,104 +242,89 @@ const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const result = localeMiddleware(request) const result = localeMiddleware(request)
if (result.type === 'next') return NextResponse.next()
const response = NextResponse.redirect(result.location) // Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie
// cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined // języka), TYLKO gdy jest zgoda na functional.
if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value) const response =
result.type === 'next'
? NextResponse.next()
: NextResponse.redirect(result.location)
if (result.cookie) {
response.cookies.set(result.cookie.name, result.cookie.value)
}
return response return response
} }
// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje // Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów.
// importów. Importowana stała zostanie zignorowana, proxy złapie /admin // Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500.
// i /_next, i wszystko zwróci 500. // Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki.
export const config = { export const config = {
matcher: ['/((?!api|admin|_next|.*\\..*).*)'], matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
} }
``` ```
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa ### Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa)
> subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa się
> `middleware.ts` czy `proxy.ts`.
## 9. Warstwa dostępu do danych Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów.
Slugi są w bazie (pole `slug` localized):
Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()`
sprawia, że nie pytają bazy dwa razy o to samo.
```ts ```ts
// src/lib/payload.ts import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit'
import { cache } from 'react'
import { getPayload } from 'payload'
import config from '@/payload.config'
export const getCachedPayload = cache(async () => getPayload({ config: await config })) const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
const slugs = getLocalizedSlugs({ slugField: doc.slug, config: i18nConfig })
export const getSettings = cache(async (locale: string) => switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig }) // → '/en/about'
(await getCachedPayload()).findGlobal({
slug: 'site-settings',
locale: locale as 'pl' | 'en',
depth: 2,
}),
)
``` ```
```ts ## 9. Warstwa dostępu do danych — lib/ (jedno źródło)
// src/lib/locales.ts
import { cache } from 'react'
import config from '@/payload.config'
export const getConfiguredLocales = cache(async (): Promise<string[]> => { ```ts
const payloadConfig = await config // src/lib/content.ts — JEDYNE źródło helperów pluginu
return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : [] import { createContentHelpers } from '@intecion/ipal-kit'
import payloadConfig from '@/payload.config'
import { i18nConfig } from '@/i18n.config'
export const {
getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries, robots,
} = createContentHelpers({
config: payloadConfig, // PAYLOAD config (nie i18n!)
content: { collections: [] },
i18n: i18nConfig, // i18n OSOBNO
}) })
``` ```
```ts ```ts
// src/lib/pages.ts // src/lib/payload.ts — funkcje projektu, typowane
import { cache } from 'react' import { cache } from 'react'
import type { Page, SiteSetting } from '@/payload-types' import { getSiteSettings } from '@intecion/ipal-kit'
import { getCachedPayload, getSettings } from './payload' import { getCachedPayload } from './content' // z content, nie osobny getPayload
import type { SiteSetting } from '@/payload-types'
export const resolvePage = cache( export const getSettings = cache(async (locale: string) =>
async (locale: string, slugPath: string | null): Promise<Page | null> => { getSiteSettings<SiteSetting>(await getCachedPayload(), { locale: locale as never, depth: 2 }),
if (!slugPath) {
// Strona główna z System Pages — edytor może ją zmienić bez zmiany kodu.
const settings = (await getSettings(locale)) as SiteSetting
const homepage = settings.homepage
return homepage && typeof homepage === 'object' ? homepage : null
}
const payload = await getCachedPayload()
const result = await payload.find({
collection: 'pages',
where: { slug: { equals: slugPath } },
locale: locale as 'pl' | 'en',
depth: 2,
limit: 1,
})
return result.docs[0] ?? null
},
) )
``` ```
> NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma
> `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd.
## 10. Trasy ## 10. Trasy
Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje
layout locale, bo `<html lang>` musi znać język, a `(frontend)` jest ponad layout locale (bo `<html lang>` musi znać język).
segmentem `[locale]`. Każdy trafia na ścieżkę z locale — middleware przekierowuje.
``` ```
src/app/(frontend)/ src/app/(frontend)/
styles.css styles.css
[locale]/ [locale]/
layout.tsx layout.tsx # walidacja locale + ConsentProvider + Analytics
[[...slug]]/ [[...slug]]/
page.tsx page.tsx # render bloków
``` ```
`[[...slug]]` — **podwójne** nawiasy. Pojedyncze `[slug]` dają string zamiast **`[[...slug]]` — PODWÓJNE nawiasy** (opcjonalny catch-all). Pojedyncze `[slug]`
tablicy (`slug.join is not a function`) i nie łapią samego `/pl`. dają string (`slug.join is not a function`) i nie łapią samego `/pl`.
```tsx ```tsx
// src/app/(frontend)/[locale]/layout.tsx // src/app/(frontend)/[locale]/layout.tsx
@@ -310,8 +332,8 @@ import { notFound } from 'next/navigation'
import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit' import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit'
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client' import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client'
import { i18nConfig } from '@/i18n.config' import { i18nConfig } from '@/i18n.config'
import { getCachedPayload, getSettings } from '@/lib/payload' import { getCachedPayload, getConfiguredLocales } from '@/lib/content'
import { getConfiguredLocales } from '@/lib/locales' import { getSettings } from '@/lib/payload'
import '../styles.css' import '../styles.css'
export default async function LocaleLayout({ children, params }) { export default async function LocaleLayout({ children, params }) {
@@ -325,13 +347,9 @@ export default async function LocaleLayout({ children, params }) {
const [texts, analytics] = await Promise.all([ const [texts, analytics] = await Promise.all([
getConsentTexts({ getConsentTexts({
config: i18nConfig, config: i18nConfig, locale, payload,
locale, privacyPolicy: privacyPage && typeof privacyPage === 'object'
payload, ? { page: privacyPage, label: 'Polityka prywatności' } : undefined,
privacyPolicy:
privacyPage && typeof privacyPage === 'object'
? { page: privacyPage, label: 'Polityka prywatności' }
: undefined,
}), }),
getAnalyticsConfig(payload), getAnalyticsConfig(payload),
]) ])
@@ -343,7 +361,7 @@ export default async function LocaleLayout({ children, params }) {
<main>{children}</main> <main>{children}</main>
<CookieBanner /> <CookieBanner />
<CookieButton /> <CookieButton />
<Analytics {...analytics} /> <Analytics {...analytics} /> {/* WEWNĄTRZ ConsentProvider */}
</ConsentProvider> </ConsentProvider>
</body> </body>
</html> </html>
@@ -364,8 +382,7 @@ import { RenderBlocks } from '@intecion/ipal-kit/rsc'
import { createPageMetadata } from '@intecion/ipal-kit' import { createPageMetadata } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config' import { i18nConfig } from '@/i18n.config'
import { blockRegistry } from '@/blocks/registry' import { blockRegistry } from '@/blocks/registry'
import { getCachedPayload } from '@/lib/payload' import { getCachedPayload, resolveRoute } from '@/lib/content'
import { resolvePage } from '@/lib/pages'
const pageMetadata = createPageMetadata({ const pageMetadata = createPageMetadata({
config: i18nConfig, config: i18nConfig,
@@ -377,104 +394,92 @@ export async function generateMetadata({ params }): Promise<Metadata> {
return pageMetadata({ payload: await getCachedPayload(), locale, slug }) return pageMetadata({ payload: await getCachedPayload(), locale, slug })
} }
export default async function Page({ params }) { export default async function Page({ params, searchParams }) {
const { locale, slug } = await params const { locale, slug } = await params
const page = await resolvePage(locale, slug?.length ? slug.join('/') : null) const { page } = await searchParams
if (!page) notFound() const route = await resolveRoute(locale, slug ?? [], page) // 3 args
if (!route) notFound()
return <RenderBlocks blocks={page.layout as never} components={blockRegistry} /> return <RenderBlocks blocks={route.doc.layout as never} components={blockRegistry} />
} }
``` ```
## 11. Środowisko - brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
- `depth: 2` → relacje w blokach (form) się populują
```bash ## 11. Metadata / SEO (szczegóły)
# .env
DATABASE_URL=file:./moj-projekt.db `createPageMetadata` obsługuje hreflang. Kluczowe: resolveDocument pobiera
PAYLOAD_SECRET=<losowy-ciąg> dokument z **`locale: 'all'`** — wtedy `slug` jest mapą locale→wartość, z której
NEXT_PUBLIC_SERVER_URL=http://localhost:3000 budują się hreflang alternates. Zwykły fetch (jeden locale) → tylko string,
``` hreflang nie powstanie.
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne. Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne.
## 12. Generowanie i start ## 12. Nagłówki bezpieczeństwa
```ts
// next.config.ts
import { buildSecurityHeaders } from '@intecion/ipal-kit'
const securityHeaders = buildSecurityHeaders({
hsts: process.env.NODE_ENV === 'production', // off w dev (http)
additional: [ /* CSP projektu — zna swoje domeny */ ],
})
// async headers() { return [{ source: '/:path*', headers: securityHeaders }] }
```
Szczegóły: [security.md](./security.md).
## 13. Środowisko
```bash
# .env
DATABASE_URI=<postgres albo file:./dev.db>
PAYLOAD_SECRET=<losowy-ciąg>
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Email przez Graph (opcjonalnie — sekrety agencyjne):
# GRAPH_TENANT_ID=... GRAPH_CLIENT_ID=... GRAPH_CLIENT_SECRET=... GRAPH_SENDER=...
```
## 14. Generowanie i start
```bash ```bash
pnpm generate:types pnpm generate:types
pnpm payload generate:importmap # pola SEO to komponenty admina pnpm payload generate:importmap # pola SEO + custom komponenty (MaskedField...)
pnpm dev pnpm dev
``` ```
`generate:importmap` powtarzaj po każdej zmianie, która dokłada komponenty `generate:importmap` powtarzaj po każdej zmianie dokładającej komponenty admina.
admina.
## 13. Konfiguracja w panelu ## 15. Konfiguracja w panelu
`http://localhost:3000/admin` `http://localhost:3000/admin`
1. **Utwórz pierwszego użytkownika** (dostanie rolę admin). 1. **Utwórz pierwszego użytkownika** (rola admin).
2. **Site Settings → General** — nazwa witryny, kolejność i separator tytułu. 2. **Site Settings → General** — nazwa witryny, tytuł.
3. **Pages** — utwórz stronę główną. Wypełnij tytuł **w każdym locale** 3. **Pages** — strona główna. Tytuł **w każdym locale** (slug per język; pusty
(przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza slug EN = 404 na `/en/…`).
404 na `/en/…`. 4. **Site Settings → System Pages** — wskaż Homepage (bez tego `/pl` → 404).
4. **Site Settings → System Pages** — wskaż Homepage. Bez tego `/pl` da 404. 5. **Cookie Settings** — treść bannera per język.
5. **Cookie Settings** — treść bannera (bez tego lecą angielskie domyślne). 6. **Notifications** — teksty wyników formularza per język (opcjonalne, ma fallback).
7. **Site Integrations → SMTP** — transport (SMTP/Graph), From Name, From Address.
Wejdź na `/` — powinno przekierować na `/pl` i pokazać stronę. Wejdź na `/` — powinno przekierować na `/pl`.
--- ---
## Rzeczy opcjonalne ## Opcjonalne
### Formularz z Turnstile ### Formularz z Turnstile
Wymaga bloku formularza (patrz [forms.md](./forms.md)) + Site Integrations →
Turnstile (klucze testowe Cloudflare: site `1x00000000000000000000AA`, secret
`1x0000000000000000000000000000000AA`). Maile wysyła form-builder przez
mailAdapter — nie pisze się ich w kodzie. Zgoda RODO: checkbox o nazwie `consent`.
Wymaga bloku formularza w projekcie (patrz forms.md) oraz: ### Blog / archiwum
Pełny opis: [content.md](./content.md). Kolekcja + content.config.ts +
- **Site Integrations → Turnstile** — site key i secret. Klucze testowe przypisanie strony-archiwum w System Pages.
Cloudflare (zawsze przechodzą): site `1x00000000000000000000AA`, secret
`1x0000000000000000000000000000000AA`.
- **Site Integrations → SMTP** — host, port, user, hasło, adres nadawcy.
- **Forms → dany formularz → Emails** — odbiorca, temat, treść (`{{*:table}}`
wypisze wszystkie pola tabelką). Maile wysyła form-builder przez
`panelSmtpAdapter` — nie pisze się ich w kodzie.
### Blog / archiwum (kolekcja pod stroną-archiwum)
Pełny opis: content.md. W skrócie:
1. **Kolekcja** `src/collections/Posts.ts` — tytuł (localized), `buildSlugField`,
pola, bloki. Dodaj ją do `collections` w payload.config.
2. **content.config.ts** obok i18n.config.ts:
```ts
import type { ContentOption } from '@intecion/ipal-kit'
export const contentConfig: ContentOption = {
collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }],
}
```
3. **payload.config** — `content: contentConfig`, plus `posts` w `seo.collections`.
4. **Front** — `createContentHelpers` w `src/lib/content.ts`, `resolveRoute`
w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList).
5. **Baza + typy** — nowa kolekcja to nowy schemat:
```bash
rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev
```
6. **W panelu** — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok
listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy.
Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny
typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config.
### Sitemapa i robots.txt
`createContentHelpers` oddaje gotowe handlery — dodaj `i18n` i `baseUrl` do jego
argumentów (patrz seo.md), potem dwa pliki po jednej linii:
### Sitemapa i robots
```ts ```ts
// app/sitemap.ts // app/sitemap.ts
export { sitemap as default } from '@/lib/content' export { sitemap as default } from '@/lib/content'
@@ -482,44 +487,22 @@ export { sitemap as default } from '@/lib/content'
export { robots as default } from '@/lib/content' export { robots as default } from '@/lib/content'
``` ```
Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex.
### Analytics
**Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują
się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie
zaakceptuje kategorii Analytics.
### Przestylowanie pod klienta
```css
/* styles.css */
:root {
--ipal-primary: #16a34a;
--ipal-radius: 1rem;
}
```
Pełna lista tokenów: consent.md.
--- ---
## Kiedy coś nie działa ## Kiedy coś nie działa
| Objaw | Przyczyna | | Objaw | Przyczyna |
|---|---| |---|---|
| Pusty tab SEO / brak kolekcji Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` | | Pusty tab SEO / brak Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
| `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` | | `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
| Banner bez stylów | brak `@source` na `node_modules/@intecion/ipal-kit` albo brak Tailwinda | | `Cannot destructure property 'config'` (custom pole) | dublet `@payloadcms/ui` — peerDependency (playbook D) |
| `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline | | Banner bez stylów | brak `@source` na node_modules albo brak Tailwinda |
| `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` | | `/admin` i `/_next` → 500 | matcher w proxy nie jest inline |
| `slug.join is not a function` | `[slug]` zamiast `[[...slug]]` |
| `/pl` → 404 | Homepage nieustawiony w System Pages | | `/pl` → 404 | Homepage nieustawiony w System Pages |
| `/en/cokolwiek` → 404, `/pl/cokolwiek` działa | pusty tytuł (a więc i slug) w locale EN | | `/en/*` → 404, `/pl/*` działa | pusty tytuł/slug w locale EN |
| `Missing <html> and <body>` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma | | `Missing <html> and <body>` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma |
| `SQLITE_ERROR: index … already exists` | zmiana schematu — usuń `*.db *.db-shm *.db-wal` | | Zmiany w pluginie nie widać | `rm -rf .next`; sprawdź czy wciągnięto wersję (grep node_modules) |
| Zmiany w pluginie nie widać | Turbopack cache — `rm -rf .next` | | Maile nie wychodzą | brak `email: mailAdapter()` albo pusty SMTP/Graph |
| Maile nie wychodzą | brak `email: panelSmtpAdapter()` w configu albo pusty SMTP w panelu | | istnieje `middleware.ts` | USUŃ — Next 16 to `proxy.ts` |
| GTM ładuje się, brak `_ga` | pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 | | zaszyta mapa `localizedRoutes` | antywzorzec — `getLocalizedSlugs` z bazy |
| `/pl/artykuly` → 404 | strona nieprzypisana jako archiwum w System Pages |
| brak pola „archive page" w panelu | brak `content` w configu albo `generate:importmap` po dodaniu |
| wpis 404 mimo że istnieje | slug pusty w tym locale — wypełnij tytuł w danym języku |
+7 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@intecion/ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.0.11", "version": "1.0.16",
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.", "description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@@ -38,7 +38,8 @@
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "files": [
"dist" "dist",
"docs"
], ],
"scripts": { "scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc", "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
@@ -65,11 +66,14 @@
"slugify": "^1.6.6" "slugify": "^1.6.6"
}, },
"peerDependencies": { "peerDependencies": {
"@payloadcms/next": "^3.88.0",
"@payloadcms/plugin-form-builder": "^3.88.0", "@payloadcms/plugin-form-builder": "^3.88.0",
"@payloadcms/plugin-seo": "^3.88.0", "@payloadcms/plugin-seo": "^3.88.0",
"@payloadcms/ui": "^3.88.0",
"next": ">=15", "next": ">=15",
"payload": "^3.88.0", "payload": "^3.88.0",
"react": "^19.0.0" "react": "^19.0.0",
"react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
+17 -18
View File
@@ -115,18 +115,19 @@ export const graphAdapter =
return { error: 'Graph is not configured (missing env vars).', sent: false } return { error: 'Graph is not configured (missing env vars).', sent: false }
} }
// The panel's from-address is used as Reply-To, NOT as the message From. // Display name on the From, WITHOUT triggering Send-As.
// //
// Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a // The trick: we may set a `from` as long as its ADDRESS stays the sender
// `from` that differs from that mailbox, Exchange demands "Send As" // mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
// permission on it and rejects with ErrorSendAsDenied otherwise. So we // demands Send-As when the from ADDRESS differs from the mailbox, so a
// never override `from` — Graph stamps the mail as GRAPH_SENDER (the // same-address / custom-name From is allowed and gives each project its
// mailbox we legitimately own) — and route replies to the panel address // own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
// via Reply-To. Recipients see the mail from forms@… but replying reaches //
// the real destination. No Send-As needed. // The panel's from-address becomes Reply-To (so replies reach the client),
// and the panel's from-name becomes the sender display name.
const panel = await getSiteIntegrations<GraphIntegrations>(payload) const panel = await getSiteIntegrations<GraphIntegrations>(payload)
const replyToAddress = panel.smtpFromAddress || undefined const replyToAddress = panel.smtpFromAddress || undefined
const replyToName = panel.smtpFromName || undefined const senderName = panel.smtpFromName || undefined
const to = toRecipients(message.to) const to = toRecipients(message.to)
if (to.length === 0) { if (to.length === 0) {
@@ -142,14 +143,7 @@ export const graphAdapter =
const replyTo = message.replyTo const replyTo = message.replyTo
? toRecipients(message.replyTo as SendEmailOptions['to']) ? toRecipients(message.replyTo as SendEmailOptions['to'])
: replyToAddress : replyToAddress
? [ ? [{ emailAddress: { address: replyToAddress } }]
{
emailAddress: {
address: replyToAddress,
...(replyToName ? { name: replyToName } : {}),
},
},
]
: [] : []
const graphMessage: Record<string, unknown> = { const graphMessage: Record<string, unknown> = {
@@ -158,7 +152,12 @@ export const graphAdapter =
toRecipients: to, toRecipients: to,
...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}), ...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}),
...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}), ...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}),
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As. // From with the sender's OWN address (no Send-As) plus an optional
// display name from the panel. Omit entirely when no name is set —
// Graph then uses the mailbox's default name.
...(senderName
? { from: { emailAddress: { name: senderName, address: env.sender } } }
: {}),
...(replyTo.length > 0 ? { replyTo } : {}), ...(replyTo.length > 0 ? { replyTo } : {}),
} }