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
@@ -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 './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 = {
description: 'A blank template using Payload in a Next.js app.',
title: 'Payload Blank Template',
title: 'Janusz Kędzierski - Beratungsstelle b.b.h. Lohnsteuerhilfeverein e.V.',
description: 'Profesjonalne doradztwo podatkowe.',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
export default async function RootLayout({
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 (
<html lang="en">
<html lang="pl" suppressHydrationWarning className={`${inter.variable} ${outfit.variable}`}>
<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>
</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 { TestEmailButton as TestEmailButton_630f031911beff97691b963f10a20a86 } from '@intecion/ipal-kit/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').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#TestEmailButton": TestEmailButton_630f031911beff97691b963f10a20a86,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
+68 -55
View File
@@ -1,59 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--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"] {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--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%;
}
@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 {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
:root {
/* Jasny motyw */
--bg-color: #FAFAFA;
--fg-color: #18181B;
--card-color: #FFFFFF;
--border-color: #E4E4E7;
/* Ciemniejszy złoty/ochra dla zachowania kontrastu na jasnym tle */
--accent-color: #7B5C35;
--accent-hover-color: #614828;
}
[data-theme="dark"] {
/* Ciemny motyw: głęboki, ciepły granat/antracyt */
--bg-color: #0F172A;
--fg-color: #F8FAFC;
--card-color: #1E293B;
--border-color: #334155;
/* Jasny złoty/ochra dla zachowania kontrastu na ciemnym tle */
--accent-color: #C5A880;
--accent-hover-color: #D4B995;
}
/* Globalne style dla HTML i Body */
html {
scroll-behavior: smooth;
}
body {
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 { buildSlugField } from '@intecion/ipal-kit'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
@@ -9,10 +16,22 @@ export const Pages: CollectionConfig = {
localized: true,
required: true,
},
buildSlugField(),
{
name: 'content',
type: 'richText',
localized: true,
name: 'layout',
type: 'blocks',
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 = {
slug: 'company',
access: {
read: () => true,
},
fields: [
{
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,
},
],
},
],
}
+509 -64
View File
@@ -64,13 +64,14 @@ export type SupportedTimezones =
export interface Config {
auth: {
users: UserAuthOperations;
'payload-mcp-api-keys': PayloadMcpApiKeyAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
folders: Folder;
tags: Tag;
pages: Page;
'payload-mcp-api-keys': PayloadMcpApiKey;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -80,24 +81,40 @@ export interface Config {
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
folders: FoldersSelect<false> | FoldersSelect<true>;
tags: TagsSelect<false> | TagsSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
fallbackLocale: ('false' | 'none' | 'null') | false | null | 'en' | 'en'[];
globals: {};
globalsSelect: {};
locale: 'en';
fallbackLocale: ('false' | 'none' | 'null') | false | null | ('pl' | 'de') | ('pl' | 'de')[];
globals: {
company: Company;
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: {
collections: CollectionsWidget;
};
user: User;
user: User | PayloadMcpApiKey;
jobs: {
tasks: unknown;
workflows: unknown;
@@ -121,8 +138,30 @@ export interface UserAuthOperations {
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 {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -147,10 +186,8 @@ export interface User {
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
alt: string;
_h_folders?: (string | null) | Folder;
_h_tags?: (string | Tag)[] | null;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -165,32 +202,59 @@ export interface Media {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "folders".
* via the `definition` "pages".
*/
export interface Folder {
id: string;
_h_folders?: (string | null) | Folder;
name: string;
export interface Page {
id: number;
title: 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;
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
* 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 {
id: string;
id: number;
key: string;
data:
| {
@@ -207,29 +271,34 @@ export interface PayloadKv {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null)
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'folders';
value: string | Folder;
relationTo: 'pages';
value: number | Page;
} | null)
| ({
relationTo: 'tags';
value: string | Tag;
relationTo: 'payload-mcp-api-keys';
value: number | PayloadMcpApiKey;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
user:
| {
relationTo: 'users';
value: number | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: number | PayloadMcpApiKey;
};
updatedAt: string;
createdAt: string;
}
@@ -238,11 +307,16 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
id: number;
user:
| {
relationTo: 'users';
value: number | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: number | PayloadMcpApiKey;
};
key?: string | null;
value?:
| {
@@ -261,7 +335,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -295,8 +369,6 @@ export interface UsersSelect<T extends boolean = true> {
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
_h_folders?: T;
_h_tags?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
@@ -311,28 +383,43 @@ export interface MediaSelect<T extends boolean = true> {
}
/**
* 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> {
_h_folders?: T;
name?: T;
export interface PagesSelect<T extends boolean = true> {
title?: T;
slug?: T;
layout?:
| T
| {
TextSection?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
};
updatedAt?: T;
createdAt?: T;
_h_slugPath?: T;
_h_titlePath?: T;
}
/**
* 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> {
_h_tags?: T;
name?: T;
export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
user?: T;
label?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
_h_slugPath?: T;
_h_titlePath?: T;
enableAPIKey?: 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> {
key?: T;
data?: T;
@@ -369,6 +456,364 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
updatedAt?: 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
* via the `definition` "collections_widget".
@@ -390,4 +835,4 @@ export interface Auth {
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
}
+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 { ipalKit } from '@intecion/ipal-kit'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
@@ -11,6 +11,8 @@ import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Pages } from './collections/Pages'
import { Company } from './globals/Company'
import { Header } from './globals/Header'
import { Footer } from './globals/Footer'
import { i18n } from './i18n.config'
const filename = fileURLToPath(import.meta.url)
@@ -24,15 +26,15 @@ export default buildConfig({
},
},
collections: [Users, Media, Pages],
globals: [Company],
globals: [Company, Header, Footer],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL || '',
db: sqliteAdapter({
client: {
url: process.env.DATABASE_URI || '',
},
}),
sharp,
@@ -41,5 +43,5 @@ export default buildConfig({
fallback: true,
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('/')}`;
}