feat: setup globals (Footer), sqlite migration, seed update and access rules

This commit is contained in:
MichalC
2026-08-31 22:37:06 +02:00
parent 5f2eaa0b37
commit 5a9128e2f5
31 changed files with 2281 additions and 362 deletions
+15
View File
@@ -0,0 +1,15 @@
# Problemy i Wymagane Rozbudowy Pluginu ipal-kit
Zgodnie z zasadą A0b, poniżej znajduje się rejestr realnych braków i problemów napotkanych podczas integracji z `@intecion/ipal-kit`. Zgłoszenia te powinny docelowo trafić do autorów pluginu, a po ich wydaniu należy usunąć lokalne obejścia (workarounds).
## 1. Brak client-safe entry pointu dla i18n utils
**Opis problemu:**
Brak client-safe entry pointu dla i18n utils (`switchLocalePath`, `getConfiguredLocales`, `buildLocalizedPath`). Główny plik `index.js` ciągnie `panelSmtpAdapter` i inne zależności Node-only (`fs`, `net`, `dns`) do bundla klienckiego przez Webpacka. Użycie ich w komponentach z `"use client"` powoduje natychmiastowy błąd kompilacji.
**Wymagane rozwiązanie:**
Potrzebny jest nowy subpath, np. `@intecion/ipal-kit/client` (lub `@intecion/ipal-kit/i18n`), zawierający wyłącznie czyste, bezpieczne dla przeglądarki utilsy.
**Obejście na ten moment:**
Lokalna kopia logiki wyliczania ścieżek znajduje się w pliku `src/utils/clientSwitchLocalePath.ts` oraz kontekst `LanguageSlugsProvider` do przesyłania danych.
**Akcja po naprawie pluginu:** DO USUNIĘCIA `clientSwitchLocalePath.ts` i zamiana na bezpośredni import z nowo dodanego entry pointu `ipal-kit`.
+8
View File
@@ -0,0 +1,8 @@
---
description: Zasady dotyczące decyzji architektonicznych i infrastrukturalnych
---
# Zgłaszanie decyzji infrastrukturalnych
1. **Brak zgody na samowolkę architektoniczną**: Nigdy nie podejmuj decyzji o wyborze bazy danych (np. Postgres vs SQLite), usług hostingowych, czy providerów zewnętrznych (np. Resend vs SMTP) na podstawie własnych domysłów, nawet jeśli oficjalna dokumentacja wybranego stacku (np. Payload CMS, Next.js) mocno sugeruje dane rozwiązanie.
2. **Pytaj przed wdrożeniem**: Każdą nową decyzję infrastrukturalną lub wybór narzędzia/usługi trzeciej zawsze zasygnalizuj jako otwarte pytanie do użytkownika ZANIM wygenerujesz kod inicjalizacyjny (np. argumenty `create-payload-app`). Brak zakazu w briefie nie oznacza domyślnej zgody.
+1 -1
View File
@@ -1,3 +1,3 @@
DATABASE_URL=postgres://127.0.0.1:5432/j_kedzierski DATABASE_URI=file:./dev.db
PAYLOAD_SECRET=YOUR_SECRET_HERE PAYLOAD_SECRET=YOUR_SECRET_HERE
NEXT_PUBLIC_WHATSAPP_NUMBER= NEXT_PUBLIC_WHATSAPP_NUMBER=
+1
View File
@@ -48,3 +48,4 @@ node_modules/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
dev.db
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
-43
View File
@@ -1,43 +0,0 @@
version: '3'
services:
payload:
image: node:20-alpine
ports:
- '3000:3000'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
depends_on:
- mongo
# - postgres
env_file:
- .env
# Ensure your DATABASE_URL uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name
mongo:
image: mongo:latest
ports:
- '27017:27017'
command:
- --storageEngine=wiredTiger
volumes:
- data:/data/db
logging:
driver: none
# Uncomment the following to use postgres
# postgres:
# restart: always
# image: postgres:latest
# volumes:
# - pgdata:/var/lib/postgresql/data
# ports:
# - "5432:5432"
volumes:
data:
# pgdata:
node_modules:
+10 -1
View File
@@ -20,18 +20,27 @@
"dependencies": { "dependencies": {
"@intecion/ipal-kit": "^1.1.2", "@intecion/ipal-kit": "^1.1.2",
"@payloadcms/db-postgres": "latest", "@payloadcms/db-postgres": "latest",
"@payloadcms/db-sqlite": "^3.88.0",
"@payloadcms/next": "latest", "@payloadcms/next": "latest",
"@payloadcms/plugin-mcp": "latest", "@payloadcms/plugin-mcp": "latest",
"@payloadcms/richtext-lexical": "latest", "@payloadcms/richtext-lexical": "latest",
"@payloadcms/ui": "latest", "@payloadcms/ui": "latest",
"@tailwindcss/postcss": "^4.3.3",
"clsx": "^2.1.1",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"dotenv": "16.4.7", "dotenv": "16.4.7",
"framer-motion": "^13.1.1",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"lucide-react": "^1.37.0",
"next": "16.3.3", "next": "16.3.3",
"next-themes": "^0.4.6",
"payload": "latest", "payload": "latest",
"postcss": "^8.5.26",
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6", "react-dom": "19.2.6",
"sharp": "0.34.2" "sharp": "0.34.2",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
+725 -74
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+137
View File
@@ -0,0 +1,137 @@
import { getPayload } from 'payload'
import configPromise from '../src/payload.config'
import { toSlug } from '@intecion/ipal-kit'
const PAGES = [
{ pl: 'Strona główna', de: 'Startseite', isHome: true },
{ pl: 'Usługi', de: 'Leistungen', isHome: false },
{ pl: 'Jak się umówić', de: 'Terminvereinbarung', isHome: false },
{ pl: 'O mnie', de: 'Über mich', isHome: false },
{ pl: 'FAQ', de: 'FAQ', isHome: false },
{ pl: 'Kontakt', de: 'Kontakt', isHome: false },
{ pl: 'Stowarzyszenie', de: 'Verein', isHome: false },
{ pl: 'Polityka prywatności', de: 'Datenschutzerklärung', isHome: false },
{ pl: 'Polityka cookies', de: 'Cookie-Richtlinie', isHome: false },
{ pl: 'Impressum', de: 'Impressum', isHome: false },
];
async function run() {
const payload = await getPayload({ config: configPromise })
const existingPages = await payload.find({
collection: 'pages',
limit: 1,
})
if (existingPages.totalDocs === 0) {
console.log('Seeding pages...')
const pageIds: number[] = [];
for (const page of PAGES) {
// @ts-ignore
const createdPage = await payload.create({
collection: 'pages',
data: {
title: page.pl,
slug: page.isHome ? 'home' : toSlug(page.pl),
layout: [
{
blockType: 'TextSection',
text: page.isHome
? 'Witaj na nowej stronie głównej opartej o mechanizm klocków (blocks) i Payload CMS.'
: 'Treść w przygotowaniu',
}
]
},
locale: 'pl',
});
// Rzutowanie na number: Payload domyślnie może traktować ID jako 'string | number',
// jednak przy adapterze Postgres (domyślnie serial) ID to zawsze 'number',
// czego z kolei ściśle wymaga pole relacyjne w updateGlobal.
pageIds.push(createdPage.id as unknown as number);
// @ts-ignore
await payload.update({
collection: 'pages',
id: createdPage.id,
data: {
title: page.de,
slug: page.isHome ? 'home' : toSlug(page.de),
layout: [
{
blockType: 'TextSection',
text: page.isHome
? 'Willkommen auf der neuen Startseite basierend auf dem Blöcke-Mechanismus und Payload CMS.'
: 'Inhalt in Vorbereitung',
}
]
},
locale: 'de',
});
console.log(`- Created ${page.pl} / ${page.de}`);
}
// Dodanie ich do globalnego nagłówka (pominąć 'Stowarzyszenie' i strony prawne jako główne menu wg życzenia)
// "Domyślna zawartość na start: Strona główna / Usługi / Jak się umówić / O mnie / FAQ / Kontakt"
console.log('Seeding global header...')
// @ts-ignore
await payload.updateGlobal({
slug: 'header',
data: {
navigation: pageIds.slice(0, 6).map(id => ({ page: id })),
},
});
console.log('Seeding global company info...')
// @ts-ignore
await payload.updateGlobal({
slug: 'company',
data: {
address: 'Beratungsstelle b.b.h.\nLohnsteuerhilfeverein e.V.\nJanusz Kedzierski\nPrzykładowa ulica 123\n12345 Przykładowe Miasto',
phone: '+49 123 456 789',
email: '[email protected]',
hours: 'Pon-Pt: 9:00 - 17:00\nPo wcześniejszym umówieniu',
whatsappNumber: '49123456789',
}
});
console.log('Seeding global footer...')
// Wszystkie pozycje z headera + 3 strony prawne (Indeksy: od 0 do 5 oraz od 7 do 9)
const footerNavIndices = [0, 1, 2, 3, 4, 5, 7, 8, 9];
const footerNavPages = footerNavIndices.map(index => ({ page: pageIds[index] }));
// @ts-ignore
await payload.updateGlobal({
slug: 'footer',
data: {
navigation: footerNavPages,
association: {
title: 'Członek b.b.h. Lohnsteuerhilfeverein e.V.',
link: 'https://bbh-lohnsteuerhilfe.de',
}
}
});
console.log('Seeding site-settings (homepage)...')
// @ts-ignore
await payload.updateGlobal({
slug: 'site-settings',
data: {
siteName: 'Janusz Kędzierski',
titleOrder: 'page-first',
titleSeparator: '|',
homepage: pageIds[0], // Pierwsza strona to Strona główna (isHome: true)
}
});
console.log('Seed completed.')
} else {
console.log('Pages already seeded.')
}
}
run().catch(console.error).finally(() => process.exit(0))
@@ -0,0 +1,72 @@
import React from 'react'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { resolveRoute } from '@intecion/ipal-kit'
import { getLocalizedSlugs } from '@intecion/ipal-kit'
import { i18n } from '../../../../i18n.config'
import { SetLanguageSlugs } from '../../../../components/LanguageSlugsProvider'
export default async function Page(props: {
params: Promise<{ locale: string; slug?: string[] }>
}) {
const params = await props.params;
const { locale, slug } = params;
const payload = await getPayload({ config: configPromise })
// Rozwiązanie routingu z użyciem mechanizmów ipal-kit
const route = await resolveRoute({ payload, locale, segments: slug || [] })
// Jeśli strona nie istnieje, 404
if (!route) {
notFound()
}
// Pobranie pełnego rekordu strony we wszystkich językach (locale: 'all'),
// by przekazać prawidłowe zlokalizowane slugi do LanguageSwitcher
const doc = await payload.findByID({
collection: ('collection' in route ? route.collection : 'pages') as 'pages',
id: route.doc.id as string,
locale: 'all',
})
// getLocalizedSlugs oczekuje pola slug jako obiektu ze slugami we wszystkich językach
// Rzutowanie na any jest zabronione przez wytyczne, rzutujemy na Record<string, unknown> (zgodnie z .d.ts)
const slugs = getLocalizedSlugs({
config: i18n,
slugField: doc.slug as unknown as Record<string, unknown>,
})
// Dla obecnego języka potrzebujemy konkretnych danych do renderowania (fallback default locale behavior)
const docForRender = await payload.findByID({
collection: ('collection' in route ? route.collection : 'pages') as 'pages',
id: route.doc.id as string,
locale: locale as any, // Payload locale typ nie zawsze przyjmuje string
})
const layout = docForRender.layout || []
return (
<div className="pt-8 pb-24">
{/* Rejestracja zlokalizowanych slugów w globalnym kontekście dla LanguageSwitcher */}
<SetLanguageSlugs slugs={slugs} />
{layout.length === 0 && (
<div className="container-premium text-center text-muted-foreground mt-20">
Strona jest pusta. Skonfiguruj układ (layout) w panelu CMS.
</div>
)}
{layout.map((block: any, index: number) => {
if (block.blockType === 'TextSection') {
return (
<div key={index} className="container-premium py-12">
<p className="text-lg">{block.text}</p>
</div>
)
}
return <div key={index}>Nieznany blok: {block.blockType}</div>
})}
</div>
)
}
+53 -7
View File
@@ -1,18 +1,64 @@
import React from 'react' import React from 'react'
import './styles.css' import { Inter, Outfit } from 'next/font/google'
import { Providers } from '../../components/Providers'
import { LanguageSlugsProvider } from '../../components/LanguageSlugsProvider'
import { Header } from '../../components/Header'
import { Footer } from '../../components/Footer'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import '../globals.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
const outfit = Outfit({ subsets: ['latin'], variable: '--font-heading' })
export const metadata = { export const metadata = {
description: 'A blank template using Payload in a Next.js app.', title: 'Janusz Kędzierski - Beratungsstelle b.b.h. Lohnsteuerhilfeverein e.V.',
title: 'Payload Blank Template', description: 'Profesjonalne doradztwo podatkowe.',
} }
export default async function RootLayout(props: { children: React.ReactNode }) { export default async function RootLayout({
const { children } = props children,
}: Readonly<{
children: React.ReactNode
}>) {
const payload = await getPayload({ config: configPromise })
// Pobieramy Globals z locale 'all', by od razu otrzymać zlokalizowane dane we wszystkich obsługiwanych językach
// Używamy overrideAccess: false, żeby Payload nie zwracał stron, które nie są opublikowane
const [headerData, footerData, companyData] = await Promise.all([
payload.findGlobal({
slug: 'header',
locale: 'all',
depth: 1,
overrideAccess: false,
}),
payload.findGlobal({
slug: 'footer',
locale: 'all',
depth: 1,
overrideAccess: false,
}),
payload.findGlobal({
slug: 'company',
locale: 'all',
overrideAccess: false,
}),
]);
// Filtrujemy tylko poprawnie spopulowane strony (chroni przed brakiem dostępu lub usuniętymi relacjami)
const headerNavItems = (headerData.navigation || []).map((item: any) => item.page).filter((page: any) => typeof page === 'object' && page !== null);
const footerNavItems = (footerData.navigation || []).map((item: any) => item.page).filter((page: any) => typeof page === 'object' && page !== null);
return ( return (
<html lang="en"> <html lang="pl" suppressHydrationWarning className={`${inter.variable} ${outfit.variable}`}>
<body> <body>
<main>{children}</main> <Providers>
<LanguageSlugsProvider>
<Header navItems={headerNavItems} />
<main className="min-h-screen pt-24">{children}</main>
<Footer navItems={footerNavItems} companyInfo={companyData} association={footerData.association} />
</LanguageSlugsProvider>
</Providers>
</body> </body>
</html> </html>
) )
-62
View File
@@ -1,62 +0,0 @@
import { headers as getHeaders } from 'next/headers.js'
import Image from 'next/image'
import { getPayload } from 'payload'
import React from 'react'
import { fileURLToPath } from 'url'
import config from '@/payload.config'
import './styles.css'
export default async function HomePage() {
const headers = await getHeaders()
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
const { user } = await payload.auth({ headers })
const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
return (
<div className="home">
<div className="content">
<picture>
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" />
<Image
alt="Payload Logo"
height={65}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg"
width={65}
/>
</picture>
{!user || !('email' in user) ? (
<h1>Welcome to your new project.</h1>
) : (
<h1>Welcome back, {user.email}</h1>
)}
<div className="links">
<a
className="admin"
href={payloadConfig.routes.admin}
rel="noopener noreferrer"
target="_blank"
>
Go to admin panel
</a>
<a
className="docs"
href="https://payloadcms.com/docs"
rel="noopener noreferrer"
target="_blank"
>
Documentation
</a>
</div>
</div>
<div className="footer">
<p>Update this page by editing</p>
<a className="codeLink" href={fileURL}>
<code>app/(frontend)/page.tsx</code>
</a>
</div>
</div>
)
}
-46
View File
@@ -1,55 +1,9 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { MaskedField as MaskedField_630f031911beff97691b963f10a20a86 } from '@intecion/ipal-kit/client' import { MaskedField as MaskedField_630f031911beff97691b963f10a20a86 } from '@intecion/ipal-kit/client'
import { TestEmailButton as TestEmailButton_630f031911beff97691b963f10a20a86 } from '@intecion/ipal-kit/client' import { TestEmailButton as TestEmailButton_630f031911beff97691b963f10a20a86 } from '@intecion/ipal-kit/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */ /** @type import('payload').ImportMap */
export const importMap = { export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@intecion/ipal-kit/client#MaskedField": MaskedField_630f031911beff97691b963f10a20a86, "@intecion/ipal-kit/client#MaskedField": MaskedField_630f031911beff97691b963f10a20a86,
"@intecion/ipal-kit/client#TestEmailButton": TestEmailButton_630f031911beff97691b963f10a20a86, "@intecion/ipal-kit/client#TestEmailButton": TestEmailButton_630f031911beff97691b963f10a20a86,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
+61 -48
View File
@@ -1,59 +1,72 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @theme {
--color-background: var(--bg-color);
--color-foreground: var(--fg-color);
--color-accent: var(--accent-color);
--color-accent-hover: var(--accent-hover-color);
--color-card: var(--card-color);
--color-border: var(--border-color);
--font-sans: 'Inter', sans-serif;
--font-heading: 'Outfit', sans-serif;
}
@layer base {
:root { :root {
--background: 0 0% 100%; /* Jasny motyw */
--foreground: 222.2 84% 4.9%; --bg-color: #FAFAFA;
--card: 0 0% 100%; --fg-color: #18181B;
--card-foreground: 222.2 84% 4.9%; --card-color: #FFFFFF;
--popover: 0 0% 100%; --border-color: #E4E4E7;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%; /* Ciemniejszy złoty/ochra dla zachowania kontrastu na jasnym tle */
--primary-foreground: 210 40% 98%; --accent-color: #7B5C35;
--secondary: 210 40% 96.1%; --accent-hover-color: #614828;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
} }
[data-theme="dark"] { [data-theme="dark"] {
--background: 222.2 84% 4.9%; /* Ciemny motyw: głęboki, ciepły granat/antracyt */
--foreground: 210 40% 98%; --bg-color: #0F172A;
--card: 222.2 84% 4.9%; --fg-color: #F8FAFC;
--card-foreground: 210 40% 98%; --card-color: #1E293B;
--popover: 222.2 84% 4.9%; --border-color: #334155;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%; /* Jasny złoty/ochra dla zachowania kontrastu na ciemnym tle */
--primary-foreground: 222.2 47.4% 11.2%; --accent-color: #C5A880;
--secondary: 217.2 32.6% 17.5%; --accent-hover-color: #D4B995;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
} }
@layer base { /* Globalne style dla HTML i Body */
* { html {
@apply border-border; scroll-behavior: smooth;
} }
body { body {
@apply bg-background text-foreground; background-color: var(--bg-color);
color: var(--fg-color);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
/* Płynne przejścia przy zmianie motywu, ale bez migotania */
transition: background-color 0.3s ease, color 0.3s ease;
} }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 600;
letter-spacing: -0.02em;
}
/* Stylizacja elementów interaktywnych dla "premium feel" */
.btn-primary {
@apply inline-flex items-center justify-center rounded-lg px-6 py-3 font-medium text-white transition-all duration-300;
background-color: var(--accent-color);
}
.btn-primary:hover {
background-color: var(--accent-hover-color);
@apply shadow-lg transform -translate-y-0.5;
}
/* Utility dla szerokich kontenerów i dużego oddechu (whitespace) */
.container-premium {
@apply max-w-7xl mx-auto px-6 md:px-12 lg:px-24;
} }
+22 -3
View File
@@ -1,7 +1,14 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { buildSlugField } from '@intecion/ipal-kit'
export const Pages: CollectionConfig = { export const Pages: CollectionConfig = {
slug: 'pages', slug: 'pages',
admin: {
useAsTitle: 'title',
},
access: {
read: () => true,
},
fields: [ fields: [
{ {
name: 'title', name: 'title',
@@ -9,10 +16,22 @@ export const Pages: CollectionConfig = {
localized: true, localized: true,
required: true, required: true,
}, },
buildSlugField(),
{ {
name: 'content', name: 'layout',
type: 'richText', type: 'blocks',
localized: true, minRows: 1,
blocks: [
{
slug: 'TextSection',
fields: [
{
name: 'text',
type: 'text',
}
]
}
]
} }
], ],
} }
+142
View File
@@ -0,0 +1,142 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { i18n } from '../../i18n.config';
import { LocalizedLink } from '../LocalizedLink';
import { MapPin, Phone, Mail, Clock, ExternalLink } from "lucide-react";
export function Footer({
navItems = [],
companyInfo = {},
association = {}
}: {
navItems?: any[];
companyInfo?: any;
association?: any;
}) {
const pathname = usePathname();
// Zabezpieczenie przed błędem, gdy pathname jest puste podczas SSR
const currentLocaleCode = (pathname?.split('/').filter(Boolean)[0]) || i18n.defaultLocale;
// Helper do pobierania tekstów z lokalizowanego obiektu (locale: 'all')
const getLocText = (field: any) => field?.[currentLocaleCode] || field?.pl || '';
const address = getLocText(companyInfo.address);
const phone = getLocText(companyInfo.phone);
const email = getLocText(companyInfo.email);
const hours = getLocText(companyInfo.hours);
const whatsapp = companyInfo.whatsappNumber?.[currentLocaleCode] || companyInfo.whatsappNumber?.pl || process.env.NEXT_PUBLIC_WHATSAPP_NUMBER || '';
const assocTitle = getLocText(association.title);
const assocLink = association.link;
const assocLogo = association.logo;
return (
<footer className="bg-muted py-12 md:py-16 border-t border-border mt-auto">
<div className="container-premium grid grid-cols-1 md:grid-cols-3 gap-12 md:gap-8">
{/* Kolumna 1: Dane kontaktowe */}
<div className="flex flex-col gap-4">
<Link href={`/${currentLocaleCode}`} className="group flex items-center gap-3 w-fit mb-2">
<div className="w-10 h-10 rounded bg-[var(--accent-color)] text-white flex items-center justify-center font-heading font-bold text-xl group-hover:bg-[var(--accent-hover-color)] transition-colors shadow-sm">
JK
</div>
<div className="flex flex-col">
<span className="font-heading font-bold text-lg leading-tight tracking-tight text-foreground">
Janusz Kędzierski
</span>
<span className="text-xs text-muted-foreground uppercase tracking-widest font-medium">
Doradztwo Podatkowe
</span>
</div>
</Link>
<ul className="flex flex-col gap-3 text-sm text-muted-foreground mt-2">
{address && (
<li className="flex items-start gap-3">
<MapPin size={18} className="shrink-0 text-foreground/50 mt-0.5" />
<span className="whitespace-pre-line">{address}</span>
</li>
)}
{phone && (
<li className="flex items-center gap-3">
<Phone size={18} className="shrink-0 text-foreground/50" />
<a href={`tel:${phone.replace(/\s+/g, '')}`} className="hover:text-foreground transition-colors">{phone}</a>
</li>
)}
{email && (
<li className="flex items-center gap-3">
<Mail size={18} className="shrink-0 text-foreground/50" />
<a href={`mailto:${email}`} className="hover:text-foreground transition-colors">{email}</a>
</li>
)}
{hours && (
<li className="flex items-start gap-3">
<Clock size={18} className="shrink-0 text-foreground/50 mt-0.5" />
<span className="whitespace-pre-line">{hours}</span>
</li>
)}
</ul>
</div>
{/* Kolumna 2: Nawigacja / Linki */}
<div className="flex flex-col gap-4 md:px-8">
<h3 className="font-heading font-semibold text-lg text-foreground">
{currentLocaleCode === 'de' ? 'Navigation' : 'Nawigacja'}
</h3>
<nav className="flex flex-col gap-3">
{navItems.map((page, index) => (
<LocalizedLink
key={index}
page={page}
currentLocaleCode={currentLocaleCode}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
/>
))}
</nav>
</div>
{/* Kolumna 3: Stowarzyszenie */}
<div className="flex flex-col gap-4">
<h3 className="font-heading font-semibold text-lg text-foreground">
{currentLocaleCode === 'de' ? 'Mitgliedschaft' : 'Członkostwo'}
</h3>
<div className="bg-background border border-border p-5 rounded-xl shadow-sm flex flex-col gap-4 items-start">
<span className="text-sm text-muted-foreground leading-relaxed">
{assocTitle}
</span>
{assocLogo && typeof assocLogo === 'object' && assocLogo.url && (
<img
src={assocLogo.url}
alt={assocLogo.alt || 'Logo'}
className="h-12 w-auto object-contain"
/>
)}
{assocLink && (
<a
href={assocLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm font-semibold text-[var(--accent-color)] hover:text-[var(--accent-hover-color)] transition-colors mt-2 group"
>
<span>{currentLocaleCode === 'de' ? 'Website besuchen' : 'Odwiedź stronę'}</span>
<ExternalLink size={14} className="group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform" />
</a>
)}
</div>
</div>
</div>
<div className="container-premium mt-12 pt-8 border-t border-border/50 flex flex-col sm:flex-row justify-between items-center gap-4 text-xs text-muted-foreground">
<p>© {new Date().getFullYear()} Janusz Kędzierski. {currentLocaleCode === 'de' ? 'Alle Rechte vorbehalten.' : 'Wszelkie prawa zastrzeżone.'}</p>
<p className="flex items-center gap-1">
{currentLocaleCode === 'de' ? 'Erstellt von' : 'Realizacja:'}
<a href="#" className="font-medium hover:text-foreground transition-colors">Intecion</a>
</p>
</div>
</footer>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import Link from "next/link";
import { ThemeSwitcher } from "../ThemeSwitcher";
import { LanguageSwitcher } from "../LanguageSwitcher";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { Menu, X, MessageCircle } from "lucide-react";
import { i18n } from '../../i18n.config';
import { LocalizedLink } from '../LocalizedLink';
export function Header({ navItems = [] }: { navItems?: any[] }) {
const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Zabezpieczenie przed błędem, gdy pathname jest puste podczas SSR
const currentLocaleCode = (pathname?.split('/').filter(Boolean)[0]) || i18n.defaultLocale;
const currentLocale = i18n.locales.find(l => l.code === currentLocaleCode) || i18n.locales[0];
return (
<header className="fixed top-0 left-0 right-0 z-40 bg-background/80 backdrop-blur-md border-b border-border">
<div className="container-premium h-24 flex items-center justify-between">
{/* Logo */}
<Link href={`/${currentLocale.code}`} className="group flex items-center gap-3 z-50 relative">
<div className="w-10 h-10 rounded bg-[var(--accent-color)] text-white flex items-center justify-center font-heading font-bold text-xl group-hover:bg-[var(--accent-hover-color)] transition-colors shadow-sm">
JK
</div>
<div className="flex flex-col">
<span className="font-heading font-bold text-lg leading-tight tracking-tight text-foreground">
Janusz Kędzierski
</span>
<span className="text-xs text-muted-foreground uppercase tracking-widest font-medium">
Doradztwo Podatkowe
</span>
</div>
</Link>
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-8 absolute left-1/2 -translate-x-1/2">
{navItems.map((page, index) => (
<LocalizedLink
key={index}
page={page}
currentLocaleCode={currentLocale.code}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
/>
))}
</nav>
{/* Right Actions & Hamburger */}
<div className="flex items-center gap-3 z-50 relative">
<div className="hidden sm:flex items-center gap-3 mr-2">
<Link
href={process.env.NEXT_PUBLIC_WHATSAPP_NUMBER ? `https://wa.me/${process.env.NEXT_PUBLIC_WHATSAPP_NUMBER}` : '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 rounded-full bg-green-500 hover:bg-green-600 text-white transition-colors"
aria-label="WhatsApp"
>
<MessageCircle size={20} />
</Link>
<div className="w-px h-6 bg-border" />
</div>
<ThemeSwitcher />
<LanguageSwitcher />
<button
className="lg:hidden w-10 h-10 flex items-center justify-center text-foreground rounded-full hover:bg-accent transition-colors ml-2"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu"
>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</div>
{/* Mobile Navigation */}
{isMobileMenuOpen && (
<div className="lg:hidden absolute top-24 left-0 right-0 bg-background border-b border-border p-6 shadow-xl flex flex-col gap-6 animate-in fade-in slide-in-from-top-4 duration-200">
<nav className="flex flex-col gap-4">
{navItems.map((page, index) => (
<LocalizedLink
key={index}
page={page}
currentLocaleCode={currentLocale.code}
className="text-lg font-medium text-foreground py-2 border-b border-border/50"
onClick={() => setIsMobileMenuOpen(false)}
/>
))}
</nav>
<div className="flex items-center justify-center sm:hidden mt-4 pt-4 border-t border-border/50">
<Link
href={process.env.NEXT_PUBLIC_WHATSAPP_NUMBER ? `https://wa.me/${process.env.NEXT_PUBLIC_WHATSAPP_NUMBER}` : '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-full transition-colors w-full justify-center"
>
<MessageCircle size={20} />
<span className="font-medium">Napisz na WhatsApp</span>
</Link>
</div>
</div>
)}
</header>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { ArrowRight, Calculator } from "lucide-react";
import Link from "next/link";
export function Hero() {
return (
<section className="relative overflow-hidden pt-12 md:pt-20 lg:pt-32 pb-16 lg:pb-32">
{/* Subtelne tło dekoracyjne, bez przesadzonych efektów - oddychający layout */}
<div className="absolute inset-0 z-0 bg-[radial-gradient(ellipse_at_top_right,var(--accent-color)_0%,transparent_50%)] opacity-[0.03] dark:opacity-[0.05]" />
<div className="container-premium relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
{/* Typografia i treść */}
<div className="flex flex-col items-start max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--accent-color)]/10 text-[var(--accent-color)] text-sm font-medium mb-6">
<Calculator className="w-4 h-4" />
Lohnsteuerhilfeverein e.V.
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl/tight text-foreground mb-6">
Twój spokój w świecie{" "}
<span className="text-[var(--accent-color)]">niemieckich podatków</span>
</h1>
<p className="text-lg md:text-xl text-muted-foreground mb-10 leading-relaxed font-light max-w-xl">
Profesjonalne doradztwo podatkowe, rozliczenia i pełne wsparcie w kontaktach z Finanzamt.
Zadbamy o Twoje finanse, abyś Ty mógł skupić się na tym, co ważne.
</p>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<Link href="/kontakt" className="btn-primary group">
Umów konsultację
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href="/uslugi"
className="inline-flex items-center justify-center rounded-lg px-6 py-3 font-medium text-foreground bg-transparent border border-border hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
Poznaj nasze usługi
</Link>
</div>
</div>
{/* Miejsce na realne zdjęcie Janusza - neutralny placeholder */}
<div className="relative mx-auto lg:mx-0 w-full max-w-md aspect-[4/5] rounded-2xl overflow-hidden bg-black/5 dark:bg-white/5 border border-border flex items-center justify-center shadow-2xl">
<div className="text-center text-muted-foreground opacity-50 flex flex-col items-center">
<span className="text-8xl font-heading font-bold mb-4">JK</span>
<span className="text-sm font-medium uppercase tracking-widest">Miejsce na zdjęcie</span>
<span className="text-xs mt-1">Zostanie dodane przez panel CMS</span>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,44 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from "react";
type Slugs = Record<string, string>;
interface LanguageSlugsContextType {
slugs: Slugs;
setSlugs: (slugs: Slugs) => void;
}
const LanguageSlugsContext = createContext<LanguageSlugsContextType>({
slugs: {},
setSlugs: () => {},
});
export function LanguageSlugsProvider({ children }: { children: React.ReactNode }) {
const [slugs, setSlugs] = useState<Slugs>({});
return (
<LanguageSlugsContext.Provider value={{ slugs, setSlugs }}>
{children}
</LanguageSlugsContext.Provider>
);
}
export function useLanguageSlugs() {
return useContext(LanguageSlugsContext);
}
/**
* Komponent pomocniczy do wstrzykiwania slugów danej strony z Server Component (page.tsx)
* do globalnego kontekstu (z którego czyta Header/LanguageSwitcher).
*/
export function SetLanguageSlugs({ slugs }: { slugs: Slugs }) {
const { setSlugs } = useLanguageSlugs();
useEffect(() => {
setSlugs(slugs);
return () => setSlugs({}); // czyszczenie przy unmount
}, [slugs, setSlugs]);
return null;
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { usePathname } from "next/navigation";
import { i18n } from "../../i18n.config";
import { clientSwitchLocalePath } from "../../utils/clientSwitchLocalePath";
import { useLanguageSlugs } from "../LanguageSlugsProvider";
import Link from "next/link";
import { Globe } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
export function LanguageSwitcher() {
const pathname = usePathname();
const { slugs } = useLanguageSlugs();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Zamykanie po kliknięciu na zewnątrz
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Obecny język z pierwszego segmentu URL, default to 'pl'
const currentLocaleCode = pathname.split("/")[1] || i18n.defaultLocale;
const currentLocale = i18n.locales.find((l) => l.code === currentLocaleCode) || i18n.locales[0];
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
aria-label="Wybierz język"
>
<Globe className="w-5 h-5 text-[var(--accent-color)]" />
<span className="text-sm font-medium uppercase">{currentLocale.code}</span>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className="absolute right-0 mt-2 w-32 bg-card rounded-xl shadow-lg border border-border overflow-hidden z-50"
>
{i18n.locales.map((locale) => {
const newPath = clientSwitchLocalePath(pathname, locale.code, slugs);
return (
<Link
key={locale.code}
href={newPath}
onClick={() => setIsOpen(false)}
className={`block px-4 py-2 text-sm transition-colors hover:bg-black/5 dark:hover:bg-white/10 ${
currentLocaleCode === locale.code
? "text-[var(--accent-color)] font-semibold"
: "text-foreground"
}`}
>
{locale.label}
</Link>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import Link from 'next/link';
import { clientSwitchLocalePath, clientGetLocalizedSlugs } from '../../utils/clientSwitchLocalePath';
type LocalizedLinkProps = {
page: any;
currentLocaleCode: string;
className?: string;
onClick?: () => void;
children?: React.ReactNode;
};
export function LocalizedLink({ page, currentLocaleCode, className, onClick, children }: LocalizedLinkProps) {
// Wykorzystujemy czysty clientGetLocalizedSlugs by pozbyć się pustych slugów / wyłuskać poprawny format
const slugs = clientGetLocalizedSlugs(page.slug);
// Budowanie poprawnego URL dla bieżącego języka
const targetHref = clientSwitchLocalePath(`/${currentLocaleCode}`, currentLocaleCode, slugs);
// Ponieważ używamy locale: 'all', title jest obiektem z kluczami języków (fallbacks w razie braku)
const title = page.title?.[currentLocaleCode] || page.title?.pl || 'Brak tytułu';
return (
<Link
href={targetHref}
className={className}
onClick={onClick}
>
{children || title}
</Link>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { ThemeProvider } from "next-themes";
import React from "react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="data-theme" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { useEffect, useState } from "react";
export function ThemeSwitcher() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
// Omijamy problem z hydratacją – renderujemy przycisk po zamontowaniu na kliencie
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
if (!mounted) {
return <div className="w-9 h-9" />; // Placeholder zapobiegający CLS
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
aria-label="Przełącz motyw"
>
{theme === "dark" ? (
<Sun className="w-5 h-5 text-[var(--accent-color)]" />
) : (
<Moon className="w-5 h-5 text-[var(--accent-color)]" />
)}
</button>
);
}
+3
View File
@@ -2,6 +2,9 @@ import type { GlobalConfig } from 'payload'
export const Company: GlobalConfig = { export const Company: GlobalConfig = {
slug: 'company', slug: 'company',
access: {
read: () => true,
},
fields: [ fields: [
{ {
name: 'address', name: 'address',
+44
View File
@@ -0,0 +1,44 @@
import type { GlobalConfig } from 'payload'
export const Footer: GlobalConfig = {
slug: 'footer',
access: {
read: () => true,
},
fields: [
{
name: 'navigation',
type: 'array',
fields: [
{
name: 'page',
type: 'relationship',
relationTo: 'pages',
required: true,
},
],
},
{
name: 'association',
type: 'group',
fields: [
{
name: 'title',
type: 'text',
localized: true,
defaultValue: 'Członek b.b.h. Lohnsteuerhilfeverein e.V.',
},
{
name: 'logo',
type: 'upload',
relationTo: 'media',
},
{
name: 'link',
type: 'text',
defaultValue: 'https://bbh-lohnsteuerhilfe.de',
}
]
}
],
}
+22
View File
@@ -0,0 +1,22 @@
import type { GlobalConfig } from 'payload'
export const Header: GlobalConfig = {
slug: 'header',
access: {
read: () => true,
},
fields: [
{
name: 'navigation',
type: 'array',
fields: [
{
name: 'page',
type: 'relationship',
relationTo: 'pages',
required: true,
},
],
},
],
}
+504 -59
View File
@@ -64,13 +64,14 @@ export type SupportedTimezones =
export interface Config { export interface Config {
auth: { auth: {
users: UserAuthOperations; users: UserAuthOperations;
'payload-mcp-api-keys': PayloadMcpApiKeyAuthOperations;
}; };
blocks: {}; blocks: {};
collections: { collections: {
users: User; users: User;
media: Media; media: Media;
folders: Folder; pages: Page;
tags: Tag; 'payload-mcp-api-keys': PayloadMcpApiKey;
'payload-kv': PayloadKv; 'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
@@ -80,24 +81,40 @@ export interface Config {
collectionsSelect: { collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>; media: MediaSelect<false> | MediaSelect<true>;
folders: FoldersSelect<false> | FoldersSelect<true>; pages: PagesSelect<false> | PagesSelect<true>;
tags: TagsSelect<false> | TagsSelect<true>; 'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>; 'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
}; };
db: { db: {
defaultIDType: string; defaultIDType: number;
}; };
fallbackLocale: ('false' | 'none' | 'null') | false | null | 'en' | 'en'[]; fallbackLocale: ('false' | 'none' | 'null') | false | null | ('pl' | 'de') | ('pl' | 'de')[];
globals: {}; globals: {
globalsSelect: {}; company: Company;
locale: 'en'; header: Header;
footer: Footer;
'site-settings': SiteSetting;
'site-integrations': SiteIntegration;
'cookie-settings': CookieSetting;
notifications: Notification;
};
globalsSelect: {
company: CompanySelect<false> | CompanySelect<true>;
header: HeaderSelect<false> | HeaderSelect<true>;
footer: FooterSelect<false> | FooterSelect<true>;
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
'site-integrations': SiteIntegrationsSelect<false> | SiteIntegrationsSelect<true>;
'cookie-settings': CookieSettingsSelect<false> | CookieSettingsSelect<true>;
notifications: NotificationsSelect<false> | NotificationsSelect<true>;
};
locale: 'pl' | 'de';
widgets: { widgets: {
collections: CollectionsWidget; collections: CollectionsWidget;
}; };
user: User; user: User | PayloadMcpApiKey;
jobs: { jobs: {
tasks: unknown; tasks: unknown;
workflows: unknown; workflows: unknown;
@@ -121,8 +138,30 @@ export interface UserAuthOperations {
password: string; password: string;
}; };
} }
export interface PayloadMcpApiKeyAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User { export interface User {
id: string; id: number;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@@ -147,10 +186,8 @@ export interface User {
* via the `definition` "media". * via the `definition` "media".
*/ */
export interface Media { export interface Media {
id: string; id: number;
alt: string; alt: string;
_h_folders?: (string | null) | Folder;
_h_tags?: (string | Tag)[] | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -165,32 +202,59 @@ export interface Media {
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "folders". * via the `definition` "pages".
*/ */
export interface Folder { export interface Page {
id: string; id: number;
_h_folders?: (string | null) | Folder; title: string;
name: string; /**
* Auto-generated from the title when left empty. You can override it.
*/
slug: string;
layout?:
| {
text?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'TextSection';
}[]
| null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_h_slugPath?: string | null; }
_h_titlePath?: string | null; /**
* API keys control which collections, resources, tools, and prompts MCP clients can access
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-mcp-api-keys".
*/
export interface PayloadMcpApiKey {
id: number;
/**
* The user that the API key is associated with.
*/
user: number | User;
/**
* A useful label for the API key.
*/
label?: string | null;
/**
* The purpose of the API key.
*/
description?: string | null;
updatedAt: string;
createdAt: string;
enableAPIKey?: boolean | null;
apiKey?: string | null;
apiKeyIndex?: string | null;
collection: 'payload-mcp-api-keys';
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags". * via the `definition` "payload-kv".
*/ */
export interface Tag {
id: string;
_h_tags?: (string | null) | Tag;
name: string;
updatedAt: string;
createdAt: string;
_h_slugPath?: string | null;
_h_titlePath?: string | null;
}
export interface PayloadKv { export interface PayloadKv {
id: string; id: number;
key: string; key: string;
data: data:
| { | {
@@ -207,28 +271,33 @@ export interface PayloadKv {
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
*/ */
export interface PayloadLockedDocument { export interface PayloadLockedDocument {
id: string; id: number;
document?: document?:
| ({ | ({
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
} | null) } | null)
| ({ | ({
relationTo: 'media'; relationTo: 'media';
value: string | Media; value: number | Media;
} | null) } | null)
| ({ | ({
relationTo: 'folders'; relationTo: 'pages';
value: string | Folder; value: number | Page;
} | null) } | null)
| ({ | ({
relationTo: 'tags'; relationTo: 'payload-mcp-api-keys';
value: string | Tag; value: number | PayloadMcpApiKey;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user:
| {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: number | PayloadMcpApiKey;
}; };
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -238,10 +307,15 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences". * via the `definition` "payload-preferences".
*/ */
export interface PayloadPreference { export interface PayloadPreference {
id: string; id: number;
user: { user:
| {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: number | PayloadMcpApiKey;
}; };
key?: string | null; key?: string | null;
value?: value?:
@@ -261,7 +335,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
*/ */
export interface PayloadMigration { export interface PayloadMigration {
id: string; id: number;
name?: string | null; name?: string | null;
batch?: number | null; batch?: number | null;
updatedAt: string; updatedAt: string;
@@ -295,8 +369,6 @@ export interface UsersSelect<T extends boolean = true> {
*/ */
export interface MediaSelect<T extends boolean = true> { export interface MediaSelect<T extends boolean = true> {
alt?: T; alt?: T;
_h_folders?: T;
_h_tags?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
url?: T; url?: T;
@@ -311,28 +383,43 @@ export interface MediaSelect<T extends boolean = true> {
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "folders_select". * via the `definition` "pages_select".
*/ */
export interface FoldersSelect<T extends boolean = true> { export interface PagesSelect<T extends boolean = true> {
_h_folders?: T; title?: T;
name?: T; slug?: T;
layout?:
| T
| {
TextSection?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_h_slugPath?: T;
_h_titlePath?: T;
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags_select". * via the `definition` "payload-mcp-api-keys_select".
*/ */
export interface TagsSelect<T extends boolean = true> { export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
_h_tags?: T; user?: T;
name?: T; label?: T;
description?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_h_slugPath?: T; enableAPIKey?: T;
_h_titlePath?: T; apiKey?: T;
apiKeyIndex?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> { export interface PayloadKvSelect<T extends boolean = true> {
key?: T; key?: T;
data?: T; data?: T;
@@ -369,6 +456,364 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "company".
*/
export interface Company {
id: number;
address?: string | null;
phone?: string | null;
email?: string | null;
hours?: string | null;
whatsappNumber?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "header".
*/
export interface Header {
id: number;
navigation?:
| {
page: number | Page;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "footer".
*/
export interface Footer {
id: number;
navigation?:
| {
page: number | Page;
id?: string | null;
}[]
| null;
association?: {
title?: string | null;
logo?: (number | null) | Media;
link?: string | null;
};
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings".
*/
export interface SiteSetting {
id: number;
/**
* Used in page titles and Open Graph metadata.
*/
siteName: string;
/**
* Which comes first in browser tabs.
*/
titleOrder?: ('page-first' | 'site-first') | null;
/**
* Separates the page title from the site name in browser tabs.
*/
titleSeparator?: ('|' | '–' | '-' | '·' | '/') | null;
/**
* Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).
*/
logo?: (number | null) | Media;
/**
* Fallback Open Graph image when a page has none.
*/
defaultShareImage?: (number | null) | Media;
/**
* Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.
*/
favicon?: (number | null) | Media;
/**
* Theme applied on a visitor’s first visit.
*/
defaultTheme: 'light' | 'dark';
/**
* Show a light/dark switch on the site.
*/
allowThemeToggle?: boolean | null;
/**
* Page served at the locale root (e.g. /pl, /en).
*/
homepage?: (number | null) | Page;
/**
* Page linked as the privacy policy.
*/
privacyPolicy?: (number | null) | Page;
/**
* Page linked from the cookie consent banner.
*/
cookiePolicy?: (number | null) | Page;
/**
* Page linked as the terms of service / site regulations.
*/
termsOfService?: (number | null) | Page;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-integrations".
*/
export interface SiteIntegration {
id: number;
/**
* Google Analytics 4 Measurement ID.
*/
ga4MeasurementId?: string | null;
/**
* Google Tag Manager container ID.
*/
gtmContainerId?: string | null;
/**
* Public site key rendered in the Turnstile widget.
*/
turnstileSiteKey?: string | null;
/**
* Secret key used for server-side verification.
*/
turnstileSecretKey?: string | null;
/**
* How outbound email is sent. "Microsoft Graph" is only available when configured by the administrator (Intecion).
*/
emailTransport?: ('smtp' | 'graph') | null;
smtpHost?: string | null;
smtpPort?: number | null;
/**
* SMTP account username.
*/
smtpUser?: string | null;
/**
* SMTP account password.
*/
smtpPassword?: string | null;
/**
* Default "from" address for outgoing mail.
*/
smtpFromAddress?: string | null;
/**
* Default "from" display name.
*/
smtpFromName?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cookie-settings".
*/
export interface CookieSetting {
id: number;
/**
* Main consent message shown in the banner.
*/
message?: string | null;
/**
* Heading for the detailed settings panel.
*/
settingsTitle?: string | null;
/**
* Button labels.
*/
buttons?: {
acceptAll?: string | null;
reject?: string | null;
settings?: string | null;
save?: string | null;
back?: string | null;
};
/**
* Per-category titles and descriptions.
*/
categories?:
| {
key: 'necessary' | 'functional' | 'analytics' | 'marketing';
title?: string | null;
description?: string | null;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "notifications".
*/
export interface Notification {
id: number;
/**
* Messages shown after a form is submitted. Leave a field empty to use the built-in default.
*/
form?: {
success?: string | null;
error?: string | null;
rateLimited?: string | null;
turnstile?: string | null;
/**
* Shown on a validation error. Use {field} to insert the offending field name.
*/
validation?: string | null;
/**
* Shown when the GDPR consent checkbox is left unchecked.
*/
consent?: string | null;
notFound?: string | null;
};
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "company_select".
*/
export interface CompanySelect<T extends boolean = true> {
address?: T;
phone?: T;
email?: T;
hours?: T;
whatsappNumber?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "header_select".
*/
export interface HeaderSelect<T extends boolean = true> {
navigation?:
| T
| {
page?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "footer_select".
*/
export interface FooterSelect<T extends boolean = true> {
navigation?:
| T
| {
page?: T;
id?: T;
};
association?:
| T
| {
title?: T;
logo?: T;
link?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select".
*/
export interface SiteSettingsSelect<T extends boolean = true> {
siteName?: T;
titleOrder?: T;
titleSeparator?: T;
logo?: T;
defaultShareImage?: T;
favicon?: T;
defaultTheme?: T;
allowThemeToggle?: T;
homepage?: T;
privacyPolicy?: T;
cookiePolicy?: T;
termsOfService?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-integrations_select".
*/
export interface SiteIntegrationsSelect<T extends boolean = true> {
ga4MeasurementId?: T;
gtmContainerId?: T;
turnstileSiteKey?: T;
turnstileSecretKey?: T;
emailTransport?: T;
smtpHost?: T;
smtpPort?: T;
smtpUser?: T;
smtpPassword?: T;
smtpFromAddress?: T;
smtpFromName?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cookie-settings_select".
*/
export interface CookieSettingsSelect<T extends boolean = true> {
message?: T;
settingsTitle?: T;
buttons?:
| T
| {
acceptAll?: T;
reject?: T;
settings?: T;
save?: T;
back?: T;
};
categories?:
| T
| {
key?: T;
title?: T;
description?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "notifications_select".
*/
export interface NotificationsSelect<T extends boolean = true> {
form?:
| T
| {
success?: T;
error?: T;
rateLimited?: T;
turnstile?: T;
validation?: T;
consent?: T;
notFound?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget". * via the `definition` "collections_widget".
+8 -6
View File
@@ -1,4 +1,4 @@
import { postgresAdapter } from '@payloadcms/db-postgres' import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { mcpPlugin } from '@payloadcms/plugin-mcp' import { mcpPlugin } from '@payloadcms/plugin-mcp'
import { ipalKit } from '@intecion/ipal-kit' import { ipalKit } from '@intecion/ipal-kit'
import { lexicalEditor } from '@payloadcms/richtext-lexical' import { lexicalEditor } from '@payloadcms/richtext-lexical'
@@ -11,6 +11,8 @@ import { Users } from './collections/Users'
import { Media } from './collections/Media' import { Media } from './collections/Media'
import { Pages } from './collections/Pages' import { Pages } from './collections/Pages'
import { Company } from './globals/Company' import { Company } from './globals/Company'
import { Header } from './globals/Header'
import { Footer } from './globals/Footer'
import { i18n } from './i18n.config' import { i18n } from './i18n.config'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
@@ -24,15 +26,15 @@ export default buildConfig({
}, },
}, },
collections: [Users, Media, Pages], collections: [Users, Media, Pages],
globals: [Company], globals: [Company, Header, Footer],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '', secret: process.env.PAYLOAD_SECRET || '',
typescript: { typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'), outputFile: path.resolve(dirname, 'payload-types.ts'),
}, },
db: postgresAdapter({ db: sqliteAdapter({
pool: { client: {
connectionString: process.env.DATABASE_URL || '', url: process.env.DATABASE_URI || '',
}, },
}), }),
sharp, sharp,
@@ -41,5 +43,5 @@ export default buildConfig({
fallback: true, fallback: true,
defaultLocale: i18n.defaultLocale, defaultLocale: i18n.defaultLocale,
}, },
plugins: [mcpPlugin({}), ipalKit({ i18n })], plugins: [mcpPlugin({}), ipalKit({ i18n, pages: { slug: 'pages' } })],
}) })
+62
View File
@@ -0,0 +1,62 @@
import { i18n } from "../i18n.config";
type LocalizedSlugs = Record<string, string>;
export function clientGetLocalizedSlugs(slugField: any): LocalizedSlugs {
const result: LocalizedSlugs = {};
if (!slugField || typeof slugField !== 'object') {
return result;
}
for (const [locale, value] of Object.entries(slugField)){
if (!i18n.locales.some(l => l.code === locale)) {
continue;
}
if (typeof value !== 'string') {
continue;
}
const slug = value.trim();
if (!slug) {
continue;
}
result[locale] = slug;
}
return result;
}
/**
* Klient-safe wersja funkcji switchLocalePath z ipal-kit, omijająca problem
* importowania modułów Node (np. fs, net) z głównego pliku index.js ipal-kit
* w komponentach "use client".
*/
export function clientSwitchLocalePath(
pathname: string,
targetLocale: string,
slugs?: LocalizedSlugs,
prefix?: LocalizedSlugs,
homeSlug: string = "home"
): string {
// Jeśli mamy zlokalizowane slugi ze strony (przekazane z page.tsx -> Context -> LanguageSwitcher)
if (slugs && slugs[targetLocale]) {
const targetSlug = slugs[targetLocale];
// Jeśli mamy prefix (dla kolekcji, np. /pl/uslugi/...)
if (prefix && prefix[targetLocale]) {
return `/${targetLocale}/${prefix[targetLocale]}/${targetSlug}`;
}
if (targetSlug === homeSlug) {
return `/${targetLocale}`;
}
return `/${targetLocale}/${targetSlug}`;
}
// Fallback (zwykły prefix swap), użyteczny dla stron nienależących do CMS lub
// gdy slugs nie zostały (jeszcze) przekazane do kontekstu
const segments = pathname.split('/').filter(Boolean);
if (segments.length > 0 && i18n.locales.some((l: any) => l.code === segments[0])) {
segments[0] = targetLocale;
} else {
segments.unshift(targetLocale);
}
return `/${segments.join('/')}`;
}