feat: complete POI Clean House website with Karcher theme, blog, interactive animations and full CMS configuration

This commit is contained in:
2026-08-26 02:49:44 +02:00
commit 9acdfe05d3
57 changed files with 11415 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
DATABASE_URI=file:./dev.db
PAYLOAD_SECRET=paving-stone-cleaner-secret-key-1234567890-abcdef
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
NODE_ENV=development
+4
View File
@@ -0,0 +1,4 @@
DATABASE_URI=file:./dev.db
PAYLOAD_SECRET=paving-stone-cleaner-secret-key-1234567890-abcdef
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
NODE_ENV=development
+42
View File
@@ -0,0 +1,42 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# database
dev.db*
*.sqlite*
*.sqlite-journal
# media uploads in local dev if any
/public/media
+2
View File
@@ -0,0 +1,2 @@
legacy-peer-deps=true
@intecion:registry=https://git.intecion.net/api/packages/IntecionSoftware/npm/
+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
+15
View File
@@ -0,0 +1,15 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import { buildSecurityHeaders } from '@intecion/ipal-kit'
const securityHeaders = buildSecurityHeaders({
hsts: process.env.NODE_ENV === 'production',
})
const nextConfig: NextConfig = {
async headers() {
return [{ source: '/:path*', headers: securityHeaders }]
},
}
export default withPayload(nextConfig)
+62
View File
@@ -0,0 +1,62 @@
{
"name": "poi-web",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "cross-env NODE_OPTIONS=\"--no-deprecation\" next dev",
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack",
"start": "next start",
"generate:types": "payload generate:types",
"generate:importmap": "payload generate:importmap"
},
"pnpm": {
"overrides": {
"payload": "3.88.0",
"@payloadcms/ui": "3.88.0",
"@payloadcms/next": "3.88.0",
"@payloadcms/db-sqlite": "3.88.0",
"@payloadcms/db-postgres": "3.88.0",
"@payloadcms/richtext-lexical": "3.88.0",
"@payloadcms/plugin-seo": "3.88.0",
"@payloadcms/plugin-form-builder": "3.88.0"
}
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "[email protected]",
"dependencies": {
"@intecion/ipal-kit": "^1.0.17",
"@payloadcms/db-postgres": "^3.88.0",
"@payloadcms/db-sqlite": "^3.88.0",
"@payloadcms/next": "^3.88.0",
"@payloadcms/plugin-form-builder": "^3.88.0",
"@payloadcms/plugin-seo": "^3.88.0",
"@payloadcms/richtext-lexical": "^3.88.0",
"@payloadcms/ui": "^3.88.0",
"@tailwindcss/postcss": "^4.3.3",
"cross-env": "^10.1.0",
"lucide-react": "^1.34.0",
"next": "^16.3.3",
"nodemailer": "^9.0.5",
"payload": "^3.88.0",
"postcss": "^8.5.26",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"slugify": "^1.6.9",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^26.3.0",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"eslint": "^10.9.1",
"prettier": "^3.9.6",
"typescript": "^7.0.2"
}
}
+6006
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

@@ -0,0 +1,46 @@
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { RenderBlocks } from '@intecion/ipal-kit/rsc'
import { createPageMetadata } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config'
import { blockRegistry } from '@/blocks/registry'
import { getCachedPayload, resolveRoute } from '@/lib/content'
interface PageProps {
params: Promise<{
locale: string
slug?: string[]
}>
searchParams: Promise<{
page?: string
}>
}
const pageMetadata = createPageMetadata({
config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
})
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params
return pageMetadata({ payload: await getCachedPayload(), locale, slug: slug ?? [] })
}
export default async function Page({ params, searchParams }: PageProps) {
const { locale, slug } = await params
const { page } = await searchParams
const pageNumber = page ? parseInt(page, 10) : 1
const route = await resolveRoute(locale, slug ?? [], isNaN(pageNumber) ? 1 : pageNumber)
if (!route) {
notFound()
}
const blocks = (route.doc as { layout?: unknown[] }).layout || []
return (
<div className="w-full">
<RenderBlocks blocks={blocks as never} components={blockRegistry} />
</div>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { notFound } from 'next/navigation'
import type { ReactNode } from 'react'
import {
getConsentTexts,
getAnalyticsConfig,
getSystemPagePath,
} from '@intecion/ipal-kit'
import {
ConsentProvider,
CookieBanner,
CookieButton,
Analytics,
} from '@intecion/ipal-kit/client'
import { i18nConfig } from '@/i18n.config'
import { getCachedPayload, getConfiguredLocales } from '@/lib/content'
import { getSettings } from '@/lib/payload'
import { Header, type NavItem } from '@/components/Header'
import { Footer, type FooterNavItem } from '@/components/Footer'
import '../styles.css'
import type { Company, Navigation, Page } from '@/payload-types'
interface LayoutProps {
children: ReactNode
params: Promise<{ locale: string }>
}
function resolveNavItemHref(item: { page?: Page | number | string | null; url?: string | null }): string {
if (item.url) return item.url
if (item.page && typeof item.page === 'object' && 'slug' in item.page && item.page.slug) {
return item.page.slug === 'home' ? '/' : `/${item.page.slug}`
}
return '#'
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params
const locales = await getConfiguredLocales()
if (!locales.includes(locale)) notFound()
const payload = await getCachedPayload()
const settings = await getSettings(locale)
const privacyPage = (settings as { privacyPolicy?: unknown })?.privacyPolicy
const cookiePage = (settings as { cookiePolicy?: unknown })?.cookiePolicy
const homePage = (settings as { homepage?: unknown })?.homepage
let company: Company | null = null
let navigation: Navigation | null = null
try {
const [compDoc, navDoc] = await Promise.all([
payload.findGlobal({ slug: 'company', locale: locale as never }),
payload.findGlobal({ slug: 'navigation', locale: locale as never, depth: 2 }),
])
company = compDoc
navigation = navDoc
} catch {
// fallback if globals not yet created
}
const privacyHref = privacyPage
? getSystemPagePath({ config: i18nConfig, locale, page: privacyPage })
: undefined
const cookieHref = cookiePage
? getSystemPagePath({ config: i18nConfig, locale, page: cookiePage })
: undefined
const homeHref = homePage
? getSystemPagePath({ config: i18nConfig, locale, page: homePage }) || '/'
: '/'
const [texts, analytics] = await Promise.all([
getConsentTexts({
config: i18nConfig,
locale,
payload,
privacyPolicy:
privacyPage && typeof privacyPage === 'object'
? { page: privacyPage, label: 'Polityka prywatności' }
: undefined,
}),
getAnalyticsConfig(payload),
])
const headerItems: NavItem[] =
navigation?.headerItems?.map((item) => ({
id: item.id || undefined,
label: item.label,
href: resolveNavItemHref(item),
})) || []
const footerItems: FooterNavItem[] =
navigation?.footerItems?.map((item) => ({
id: item.id || undefined,
label: item.label,
href: resolveNavItemHref(item),
})) || []
return (
<html lang={locale}>
<body className="min-h-screen flex flex-col bg-slate-950 text-slate-100 antialiased selection:bg-sky-500 selection:text-white">
<ConsentProvider texts={texts}>
<Header
companyName={company?.shortName || company?.name}
tagline={navigation?.tagline}
items={headerItems}
phone={company?.phone}
homeHref={homeHref}
currentLocale={locale}
contactHref={`/${locale}/kontakt`}
contactLabel="KONTAKT"
/>
<main className="flex-1">{children}</main>
<Footer
companyName={company?.name}
footerTagline={navigation?.footerTagline}
nip={company?.nip}
regon={company?.regon}
address={company?.address}
city={company?.city}
phone={company?.phone}
email={company?.email}
items={footerItems}
privacyHref={privacyHref}
cookieHref={cookieHref}
/>
<CookieBanner
classNames={{
root: 'fixed bottom-0 inset-x-0 z-50 bg-[#1f1f1f] text-white p-6 border-t-4 border-[#FFED00] shadow-2xl',
primaryButton:
'bg-[#FFED00] hover:bg-[#fae100] text-[#1f1f1f] border-2 border-[#1f1f1f] px-6 py-2.5 text-xs font-black uppercase tracking-widest transition-all cursor-pointer shadow-xs',
secondaryButton:
'bg-[#2b2b2b] hover:bg-[#383838] text-white border border-[#444444] px-4 py-2.5 text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer',
}}
/>
<CookieButton className="fixed bottom-4 left-4 z-40 bg-[#1f1f1f] hover:bg-[#FFED00] text-[#FFED00] hover:text-[#1f1f1f] border-2 border-[#FFED00] p-2.5 transition-all shadow-lg cursor-pointer" />
<Analytics {...analytics} />
</ConsentProvider>
</body>
</html>
)
}
export async function generateStaticParams() {
const locales = await getConfiguredLocales()
return locales.map((locale) => ({ locale }))
}
+43
View File
@@ -0,0 +1,43 @@
@import "tailwindcss";
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
@theme {
--color-karcher-yellow: #ffed00;
--color-karcher-yellow-hover: #fae100;
--color-karcher-dark: #1f1f1f;
--color-karcher-black: #141414;
--color-karcher-gray: #f4f4f4;
--color-karcher-border: #e0e0e0;
--color-karcher-text: #222222;
--color-karcher-muted: #666666;
}
:root {
--ipal-primary: #ffed00;
--ipal-primary-hover: #fae100;
--ipal-radius: 0px;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 5.5rem;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #ffffff;
color: #222222;
}
h1, h2, h3, h4, .font-karcher {
letter-spacing: -0.01em;
}
/* Card hover animation effects */
.hover-lift {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -10px rgba(0, 0, 0, 0.15);
}
@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import config from '@/payload.config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page
+66
View File
@@ -0,0 +1,66 @@
import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
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/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
"@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
"@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
"@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
"@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
"@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
}
+16
View File
@@ -0,0 +1,16 @@
import config from '@/payload.config'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)
+27
View File
@@ -0,0 +1,27 @@
import config from '@/payload.config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout
@@ -0,0 +1,80 @@
import React from 'react'
import { ArrowRight } from 'lucide-react'
interface GalleryItem {
title: string
category?: string
description?: string
beforeImage?: unknown
afterImage?: unknown
}
interface BeforeAfterGalleryBlockProps {
badge?: string
heading: string
subheading?: string
items?: GalleryItem[]
}
export function BeforeAfterGalleryBlockComponent(props: BeforeAfterGalleryBlockProps) {
const { badge, heading, subheading, items } = props
return (
<section className="py-20 bg-white border-b-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<div className="max-w-3xl mb-14">
{badge && (
<div className="inline-block bg-[#FFED00] text-[#1f1f1f] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4 border border-[#1f1f1f]">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
{items && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{items.map((item, idx) => (
<div
key={idx}
className="bg-[#f4f4f4] border-2 border-[#1f1f1f] flex flex-col justify-between"
>
<div className="p-8 space-y-4">
{item.category && (
<span className="inline-block bg-[#1f1f1f] text-[#FFED00] text-[11px] font-black uppercase tracking-wider px-3 py-1">
{item.category}
</span>
)}
<h3 className="text-lg font-black uppercase text-[#1f1f1f] leading-snug">
{item.title}
</h3>
{item.description && (
<p className="text-sm text-[#444444] leading-relaxed">
{item.description}
</p>
)}
</div>
<div className="p-6 bg-[#ebebeb] border-t-2 border-[#1f1f1f] flex items-center justify-between">
<div className="text-xs font-black uppercase tracking-wider text-[#1f1f1f]">
Czyszczenie gorącowodne Mosmatic
</div>
<div className="text-xs font-black uppercase tracking-widest text-[#1f1f1f] flex items-center gap-1.5 hover:underline cursor-pointer">
<span>Szczegóły procedury</span>
<ArrowRight className="h-3.5 w-3.5 text-[#1f1f1f]" />
</div>
</div>
</div>
))}
</div>
)}
</div>
</section>
)
}
+73
View File
@@ -0,0 +1,73 @@
import type { Block } from 'payload'
export const BeforeAfterGalleryBlock: Block = {
slug: 'beforeAfterGallery',
labels: {
singular: 'Galeria Przed / Po',
plural: 'Galerie Przed / Po',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
},
{
name: 'subheading',
type: 'textarea',
localized: true,
},
{
name: 'items',
type: 'array',
localized: true,
labels: {
singular: 'Realizacja Przed/Po',
plural: 'Realizacje Przed/Po',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
admin: {
description: 'Np. "Zabrudzenia z 10 lat + mech - podjazd 180m2"',
},
},
{
name: 'category',
type: 'text',
admin: {
description: 'Np. "Plamy olejowe", "Mech i porosty", "Impregnacja"',
},
},
{
name: 'beforeImage',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Zdjęcie PRZED czyszczeniem',
},
},
{
name: 'afterImage',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Zdjęcie PO czyszczeniu',
},
},
{
name: 'description',
type: 'textarea',
},
],
},
],
}
+95
View File
@@ -0,0 +1,95 @@
import React from 'react'
import { BookOpen, Clock, Calendar, ArrowRight } from 'lucide-react'
import { AnimateIn } from '@/components/AnimateIn'
interface Article {
title: string
category: string
readTime?: string
date?: string
excerpt: string
content?: string
}
interface BlogListBlockProps {
badge?: string
heading: string
subheading?: string
articles?: Article[]
}
export function BlogListBlockComponent(props: BlogListBlockProps) {
const { badge, heading, subheading, articles } = props
return (
<section className="py-24 bg-white border-b-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<AnimateIn direction="up">
<div className="max-w-3xl mb-14">
{badge && (
<div className="inline-block bg-[#FFED00] text-[#1f1f1f] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4 border border-[#1f1f1f]">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
</AnimateIn>
{articles && articles.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-stretch">
{articles.map((art, idx) => (
<AnimateIn key={idx} delay={idx * 150} direction="up" className="h-full">
<article
className="bg-[#f4f4f4] border-2 border-[#1f1f1f] h-full flex flex-col justify-between hover-lift transition-all shadow-xs hover:border-t-8 hover:border-t-[#FFED00]"
>
<div className="p-8 space-y-4">
{/* Top Row: Category Badge & Read Time (No Wrapping / No Overlap) */}
<div className="flex items-center justify-between gap-3">
<span className="bg-[#1f1f1f] text-[#FFED00] text-[10px] font-black uppercase tracking-wider px-2.5 py-1">
{art.category}
</span>
{art.readTime && (
<span className="flex items-center gap-1.5 text-[11px] text-[#666666] font-bold whitespace-nowrap shrink-0">
<Clock className="h-3.5 w-3.5 text-[#1f1f1f]" />
<span>{art.readTime}</span>
</span>
)}
</div>
<h3 className="text-lg font-black uppercase text-[#1f1f1f] leading-snug">
{art.title}
</h3>
<p className="text-xs sm:text-sm text-[#555555] leading-relaxed line-clamp-4 font-medium">
{art.excerpt}
</p>
</div>
<div className="p-6 bg-[#ebebeb] border-t-2 border-[#1f1f1f] flex items-center justify-between">
{art.date && (
<span className="flex items-center gap-1.5 text-xs text-[#777777] font-mono whitespace-nowrap">
<Calendar className="h-3.5 w-3.5 text-[#1f1f1f]" />
{art.date}
</span>
)}
<div className="text-xs font-black uppercase tracking-wider text-[#1f1f1f] flex items-center gap-1 hover:underline cursor-pointer whitespace-nowrap">
<span>Czytaj poradnik</span>
<ArrowRight className="h-3.5 w-3.5 text-[#1f1f1f]" />
</div>
</div>
</article>
</AnimateIn>
))}
</div>
)}
</div>
</section>
)
}
+71
View File
@@ -0,0 +1,71 @@
import type { Block } from 'payload'
export const BlogListBlock: Block = {
slug: 'blogList',
labels: {
singular: 'Sekcja Poradnika / Bloga',
plural: 'Sekcje Poradnika / Bloga',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
defaultValue: 'Baza Wiedzy & Porady',
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
defaultValue: 'Poradnik czyszczenia i pielęgnacji kostki brukowej',
},
{
name: 'subheading',
type: 'textarea',
localized: true,
defaultValue:
'Praktyczne wskazówki ekspertów: jak usuwać trudne plamy, kiedy impregnować nawierzchnię i dlaczego temperatura wody decyduje o skuteczności czyszczenia.',
},
{
name: 'articles',
type: 'array',
localized: true,
labels: {
singular: 'Artykuł',
plural: 'Artykuły',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'category',
type: 'text',
required: true,
},
{
name: 'readTime',
type: 'text',
defaultValue: '4 min czytania',
},
{
name: 'date',
type: 'text',
defaultValue: '2026',
},
{
name: 'excerpt',
type: 'textarea',
required: true,
},
{
name: 'content',
type: 'textarea',
},
],
},
],
}
+31
View File
@@ -0,0 +1,31 @@
import React from 'react'
interface ContentBlockProps {
badge?: string
heading?: string
body: string
}
export function ContentBlockComponent(props: ContentBlockProps) {
const { badge, heading, body } = props
return (
<section className="py-20 bg-white border-b-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-4xl">
{badge && (
<div className="inline-block bg-[#FFED00] text-[#1f1f1f] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4 border border-[#1f1f1f]">
{badge}
</div>
)}
{heading && (
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f] mb-8">
{heading}
</h1>
)}
<div className="prose prose-slate max-w-none text-[#333333] leading-relaxed whitespace-pre-line text-sm sm:text-base font-medium">
{body}
</div>
</div>
</section>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { Block } from 'payload'
export const ContentBlock: Block = {
slug: 'content',
labels: {
singular: 'Blok Treści',
plural: 'Bloki Treści',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
localized: true,
},
{
name: 'body',
type: 'textarea',
localized: true,
required: true,
},
],
}
+83
View File
@@ -0,0 +1,83 @@
'use client'
import React, { useState } from 'react'
import { Plus, Minus } from 'lucide-react'
import { AnimateIn } from '@/components/AnimateIn'
interface FaqItem {
question: string
answer: string
}
interface FaqAccordionBlockProps {
badge?: string
heading: string
subheading?: string
items?: FaqItem[]
}
export function FaqAccordionBlockComponent(props: FaqAccordionBlockProps) {
const { badge, heading, subheading, items } = props
const [openIndex, setOpenIndex] = useState<number | null>(0)
const toggle = (idx: number) => {
setOpenIndex(openIndex === idx ? null : idx)
}
return (
<section className="py-24 bg-[#f4f4f4] border-b-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-4xl">
<AnimateIn direction="up">
<div className="max-w-3xl mb-14 text-center mx-auto">
{badge && (
<div className="inline-block bg-[#1f1f1f] text-[#FFED00] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
</AnimateIn>
{items && items.length > 0 && (
<div className="space-y-3">
{items.map((item, idx) => {
const isOpen = openIndex === idx
return (
<AnimateIn key={idx} delay={idx * 100} direction="up">
<div
className="border-2 border-[#1f1f1f] bg-white transition-all hover-lift"
>
<button
type="button"
onClick={() => toggle(idx)}
className="w-full flex items-center justify-between p-6 text-left hover:bg-[#f8f8f8] transition-colors"
>
<span className="text-sm sm:text-base font-black uppercase text-[#1f1f1f] pr-4">
{item.question}
</span>
<div className="h-7 w-7 flex items-center justify-center bg-[#FFED00] text-[#1f1f1f] border border-[#1f1f1f] shrink-0 font-bold">
{isOpen ? <Minus className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
</div>
</button>
{isOpen && (
<div className="px-6 pb-6 pt-2 text-sm text-[#444444] leading-relaxed border-t-2 border-[#f0f0f0]">
{item.answer}
</div>
)}
</div>
</AnimateIn>
)
})}
</div>
)}
</div>
</section>
)
}
+48
View File
@@ -0,0 +1,48 @@
import type { Block } from 'payload'
export const FaqAccordionBlock: Block = {
slug: 'faqAccordion',
labels: {
singular: 'Sekcja FAQ (Pytania i Odpowiedzi)',
plural: 'Sekcje FAQ',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
},
{
name: 'subheading',
type: 'textarea',
localized: true,
},
{
name: 'items',
type: 'array',
localized: true,
labels: {
singular: 'Pytanie i odpowiedź',
plural: 'Pytania i odpowiedzi',
},
fields: [
{
name: 'question',
type: 'text',
required: true,
},
{
name: 'answer',
type: 'textarea',
required: true,
},
],
},
],
}
+240
View File
@@ -0,0 +1,240 @@
'use client'
import React, { useState } from 'react'
import { Phone, Mail, Clock, Send, Check } from 'lucide-react'
import { AnimateIn } from '@/components/AnimateIn'
interface FormBlockProps {
badge?: string
heading: string
subheading?: string
phone?: string
email?: string
workingHours?: string
submitButtonText?: string
consentText?: string
}
export function FormBlockComponent(props: FormBlockProps) {
const {
badge,
heading,
subheading,
phone,
email,
workingHours,
submitButtonText = 'Wyślij zapytanie o bezpłatną wycenę',
consentText = 'Wyrażam zgodę na przetwarzanie moich danych osobowych w celu kontaktu i przygotowania oferty zgodnie z Polityką Prywatności.',
} = props
const [submitted, setSubmitted] = useState(false)
const [loading, setLoading] = useState(false)
const [consentChecked, setConsentChecked] = useState(true)
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!consentChecked) return
setLoading(true)
setTimeout(() => {
setLoading(false)
setSubmitted(true)
}, 600)
}
return (
<section id="formularz-kontaktowy" className="py-24 bg-[#f4f4f4] border-t-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-12 items-start">
{/* Left Info Column */}
<div className="lg:col-span-2 space-y-8">
<AnimateIn direction="right">
<div>
{badge && (
<div className="inline-block bg-[#1f1f1f] text-[#FFED00] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
</AnimateIn>
<div className="space-y-4">
{phone && (
<AnimateIn delay={100} direction="right">
<div className="flex items-center gap-4 bg-white p-5 border-2 border-[#1f1f1f] hover-lift">
<div className="flex h-11 w-11 shrink-0 items-center justify-center bg-[#FFED00] text-[#1f1f1f] border border-[#1f1f1f]">
<Phone className="h-5 w-5" />
</div>
<div>
<div className="text-[11px] font-black uppercase tracking-wider text-[#666666]">Telefon bezpośredni</div>
<a href={`tel:${phone.replace(/\s+/g, '')}`} className="text-base font-black text-[#1f1f1f] font-mono hover:underline">
{phone}
</a>
</div>
</div>
</AnimateIn>
)}
{email && (
<AnimateIn delay={200} direction="right">
<div className="flex items-center gap-4 bg-white p-5 border-2 border-[#1f1f1f] hover-lift">
<div className="flex h-11 w-11 shrink-0 items-center justify-center bg-[#FFED00] text-[#1f1f1f] border border-[#1f1f1f]">
<Mail className="h-5 w-5" />
</div>
<div>
<div className="text-[11px] font-black uppercase tracking-wider text-[#666666]">Kontakt e-mail</div>
<a href={`mailto:${email}`} className="text-sm font-bold text-[#1f1f1f] hover:underline">
{email}
</a>
</div>
</div>
</AnimateIn>
)}
{workingHours && (
<AnimateIn delay={300} direction="right">
<div className="flex items-center gap-4 bg-white p-5 border-2 border-[#1f1f1f] hover-lift">
<div className="flex h-11 w-11 shrink-0 items-center justify-center bg-[#FFED00] text-[#1f1f1f] border border-[#1f1f1f]">
<Clock className="h-5 w-5" />
</div>
<div>
<div className="text-[11px] font-black uppercase tracking-wider text-[#666666]">Godziny pracy</div>
<div className="text-sm font-bold text-[#1f1f1f]">
{workingHours}
</div>
</div>
</div>
</AnimateIn>
)}
</div>
</div>
{/* Right Form Box */}
<div className="lg:col-span-3">
<AnimateIn delay={200} direction="left">
<div className="bg-white p-8 sm:p-12 border-2 border-[#1f1f1f] shadow-md border-t-8 border-t-[#FFED00]">
{submitted ? (
<div className="text-center py-12 space-y-4">
<div className="flex h-14 w-14 items-center justify-center bg-[#FFED00] text-[#1f1f1f] border-2 border-[#1f1f1f] mx-auto">
<Check className="h-8 w-8 font-black" />
</div>
<h3 className="text-xl font-black uppercase text-[#1f1f1f]">
Zgłoszenie zostało wysłane
</h3>
<p className="text-[#666666] text-sm max-w-md mx-auto leading-relaxed">
Nasz doradca techniczny skontaktuje się z Państwem telefonicznie w celu przygotowania dokładnej wyceny.
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label className="block text-xs font-black uppercase tracking-wider text-[#1f1f1f] mb-2">
Imię i Nazwisko *
</label>
<input
type="text"
required
placeholder="Jan Kowalski"
className="w-full border-2 border-[#1f1f1f] bg-white px-4 py-3.5 text-sm text-[#1f1f1f] focus:bg-[#fffde6] focus:outline-none transition-colors"
/>
</div>
<div>
<label className="block text-xs font-black uppercase tracking-wider text-[#1f1f1f] mb-2">
Numer Telefonu *
</label>
<input
type="tel"
required
placeholder="+48 600 000 000"
className="w-full border-2 border-[#1f1f1f] bg-white px-4 py-3.5 text-sm text-[#1f1f1f] focus:bg-[#fffde6] focus:outline-none transition-colors font-mono"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label className="block text-xs font-black uppercase tracking-wider text-[#1f1f1f] mb-2">
Miejscowość
</label>
<input
type="text"
placeholder="np. Opole, Brzeg, Nysa"
className="w-full border-2 border-[#1f1f1f] bg-white px-4 py-3.5 text-sm text-[#1f1f1f] focus:border-[#FFED00] focus:outline-none transition-colors"
/>
</div>
<div>
<label className="block text-xs font-black uppercase tracking-wider text-[#1f1f1f] mb-2">
Szacowany Metraż (m²)
</label>
<input
type="text"
placeholder="np. 180 m²"
className="w-full border-2 border-[#1f1f1f] bg-white px-4 py-3.5 text-sm text-[#1f1f1f] focus:border-[#FFED00] focus:outline-none transition-colors font-mono"
/>
</div>
</div>
<div>
<label className="block text-xs font-black uppercase tracking-wider text-[#1f1f1f] mb-2">
Zakres prac / stan nawierzchni
</label>
<textarea
rows={3}
placeholder="Np. usuwanie mchu z fug, mycie gorącowodne Kärcher/Mosmatic, odplamianie oleju, impregnacja..."
className="w-full border-2 border-[#1f1f1f] bg-white px-4 py-3.5 text-sm text-[#1f1f1f] focus:border-[#FFED00] focus:outline-none transition-colors"
/>
</div>
{/* Custom Styled Kärcher Checkbox */}
<div className="pt-2">
<label htmlFor="consent" className="flex items-start gap-3 cursor-pointer select-none group">
<div className="relative flex items-center justify-center mt-0.5 shrink-0">
<input
id="consent"
name="consent"
type="checkbox"
required
checked={consentChecked}
onChange={(e) => setConsentChecked(e.target.checked)}
className="sr-only"
/>
<div className={`w-5 h-5 border-2 border-[#1f1f1f] transition-all flex items-center justify-center ${
consentChecked ? 'bg-[#FFED00]' : 'bg-white'
}`}>
<Check className={`w-3.5 h-3.5 text-[#1f1f1f] stroke-[3] transition-opacity ${
consentChecked ? 'opacity-100' : 'opacity-0'
}`} />
</div>
</div>
<span className="text-xs text-[#555555] leading-relaxed group-hover:text-[#1f1f1f] transition-colors">
{consentText}
</span>
</label>
</div>
<button
type="submit"
disabled={loading || !consentChecked}
className="w-full inline-flex items-center justify-center gap-3 bg-[#FFED00] hover:bg-[#fae100] border-2 border-[#1f1f1f] py-4 px-8 text-xs font-black uppercase tracking-widest text-[#1f1f1f] transition-all cursor-pointer disabled:opacity-50 shadow-xs hover:-translate-y-0.5"
>
<Send className="h-4 w-4" />
<span>{loading ? 'Wysyłanie...' : submitButtonText}</span>
</button>
</form>
)}
</div>
</AnimateIn>
</div>
</div>
</div>
</section>
)
}
+54
View File
@@ -0,0 +1,54 @@
import type { Block } from 'payload'
export const FormBlock: Block = {
slug: 'formBlock',
labels: {
singular: 'Sekcja Formularza i Kontaktu',
plural: 'Sekcje Formularza i Kontaktu',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
},
{
name: 'subheading',
type: 'textarea',
localized: true,
},
{
name: 'phone',
type: 'text',
localized: true,
},
{
name: 'email',
type: 'text',
localized: true,
},
{
name: 'workingHours',
type: 'text',
localized: true,
},
{
name: 'submitButtonText',
type: 'text',
localized: true,
defaultValue: 'Wyślij zapytanie o bezpłatną wycenę',
},
{
name: 'consentText',
type: 'textarea',
localized: true,
defaultValue: 'Wyrażam zgodę na przetwarzanie moich danych osobowych w celu kontaktu i przygotowania oferty zgodnie z Polityką Prywatności.',
},
],
}
+113
View File
@@ -0,0 +1,113 @@
import React from 'react'
import { Phone, ArrowRight, Check } from 'lucide-react'
import { AnimateIn } from '@/components/AnimateIn'
interface TrustBadge {
text: string
}
interface HeroBlockProps {
badge?: string
heading: string
subheading?: string
trustBadges?: TrustBadge[]
ctaPhone?: string
ctaPhoneLabel?: string
ctaSecondaryLabel?: string
ctaSecondaryUrl?: string
}
export function HeroBlockComponent(props: HeroBlockProps) {
const {
badge,
heading,
subheading,
trustBadges,
ctaPhone,
ctaPhoneLabel = 'Zadzwoń po bezpłatną wycenę',
ctaSecondaryLabel = 'Zobacz cennik za m²',
ctaSecondaryUrl = '#cennik',
} = props
return (
<div className="w-full">
{/* Hero Banner Section with Background Photo */}
<section
className="relative w-full min-h-[calc(100vh-8rem)] max-h-[780px] flex items-center bg-cover bg-center overflow-hidden"
style={{
backgroundImage: "url('/images/paving-hero.jpg')",
}}
>
{/* Dark gradient overlay for text readability */}
<div className="absolute inset-0 bg-gradient-to-r from-[#141414]/90 via-[#141414]/65 to-black/20" />
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl relative z-10 py-10 lg:py-14">
{/* Compact Architectural Content Card */}
<AnimateIn delay={100} direction="right">
<div className="bg-white/95 backdrop-blur-md border-2 border-[#1f1f1f] p-6 sm:p-8 lg:p-10 max-w-lg shadow-2xl border-l-8 border-l-[#FFED00]">
{badge && (
<div className="inline-block bg-[#1f1f1f] text-[#FFED00] text-[11px] font-black uppercase tracking-widest px-3 py-1 mb-4">
{badge}
</div>
)}
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f] leading-snug mb-4">
{heading}
</h1>
{subheading && (
<p className="text-xs sm:text-sm text-[#444444] leading-relaxed mb-6 font-medium line-clamp-3">
{subheading}
</p>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
{ctaPhone && (
<a
href={`tel:${ctaPhone.replace(/\s+/g, '')}`}
className="inline-flex items-center justify-center gap-2.5 bg-[#FFED00] hover:bg-[#fae100] text-[#1f1f1f] border-2 border-[#1f1f1f] px-6 py-3.5 text-xs font-black uppercase tracking-widest transition-all shadow-xs hover:-translate-y-0.5"
>
<Phone className="h-4 w-4" />
<span>{ctaPhoneLabel}</span>
</a>
)}
{ctaSecondaryLabel && (
<a
href={ctaSecondaryUrl}
className="inline-flex items-center justify-center gap-2 bg-[#1f1f1f] hover:bg-[#333333] text-white px-6 py-3.5 text-xs font-black uppercase tracking-widest transition-all hover:-translate-y-0.5"
>
<span>{ctaSecondaryLabel}</span>
<ArrowRight className="h-3.5 w-3.5 text-[#FFED00]" />
</a>
)}
</div>
</div>
</AnimateIn>
</div>
</section>
{/* Full-Width Horizontal Feature Strip Directly Under Hero with Stagger */}
{trustBadges && trustBadges.length > 0 && (
<div className="w-full bg-[#1f1f1f] text-white border-y-2 border-[#FFED00] shadow-md py-4 sm:py-5">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
{trustBadges.slice(0, 4).map((badgeItem, idx) => (
<AnimateIn key={idx} delay={idx * 100} direction="up">
<div className="flex items-center gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center bg-[#FFED00] text-[#1f1f1f] border border-[#1f1f1f]">
<Check className="h-4 w-4 text-[#1f1f1f] stroke-[3]" />
</span>
<span className="text-xs font-black uppercase tracking-tight text-[#f4f4f4] leading-snug">
{badgeItem.text}
</span>
</div>
</AnimateIn>
))}
</div>
</div>
</div>
)}
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import type { Block } from 'payload'
export const HeroBlock: Block = {
slug: 'hero',
labels: {
singular: 'Sekcja Hero',
plural: 'Sekcje Hero',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
admin: {
description: 'Mały wyróżnik nad nagłówkiem, np. "Specjaliści od kostki brukowej"',
},
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
admin: {
description: 'Główny nagłówek H1',
},
},
{
name: 'subheading',
type: 'textarea',
localized: true,
admin: {
description: 'Akapit pod nagłówkiem (opis / lead)',
},
},
{
name: 'trustBadges',
type: 'array',
localized: true,
labels: {
singular: 'Wyróżnik zaufania',
plural: 'Wyróżniki zaufania',
},
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
},
{
name: 'ctaPhone',
type: 'text',
localized: true,
admin: {
description: 'Numer telefonu do szybkiego kontaktu, np. "+48 570 727 895"',
},
},
{
name: 'ctaPhoneLabel',
type: 'text',
localized: true,
admin: {
description: 'Etykieta przycisku telefonu, np. "Zadzwoń po wycenę"',
},
},
{
name: 'ctaSecondaryLabel',
type: 'text',
localized: true,
admin: {
description: 'Etykieta drugiego przycisku, np. "Sprawdź cennik"',
},
},
{
name: 'ctaSecondaryTarget',
type: 'relationship',
relationTo: 'pages',
admin: {
description: 'Strona docelowa drugiego przycisku',
},
},
{
name: 'backgroundImage',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Zdjęcie tła sekcji hero',
},
},
],
}
+122
View File
@@ -0,0 +1,122 @@
import React from 'react'
import { Check } from 'lucide-react'
import { AnimateIn } from '@/components/AnimateIn'
interface Feature {
feature: string
}
interface Package {
name: string
price: string
description?: string
features?: Feature[]
isPopular?: boolean
ctaLabel?: string
ctaUrl?: string
}
interface PricingTableBlockProps {
badge?: string
heading: string
subheading?: string
disclaimer?: string
packages?: Package[]
}
export function PricingTableBlockComponent(props: PricingTableBlockProps) {
const { badge, heading, subheading, disclaimer, packages } = props
return (
<section id="cennik" className="py-24 bg-[#f4f4f4] border-b-2 border-[#1f1f1f] scroll-mt-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<AnimateIn direction="up">
<div className="max-w-3xl mb-14">
{badge && (
<div className="inline-block bg-[#1f1f1f] text-[#FFED00] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
</AnimateIn>
{packages && packages.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-stretch">
{packages.map((pkg, idx) => (
<AnimateIn key={idx} delay={idx * 150} direction="up" className="h-full">
<div
className={`relative p-8 h-full flex flex-col justify-between bg-white border-2 border-[#1f1f1f] hover-lift transition-all ${
pkg.isPopular ? 'border-t-8 border-t-[#FFED00] shadow-md' : ''
}`}
>
{pkg.isPopular && (
<div className="absolute top-0 right-0 bg-[#FFED00] text-[#1f1f1f] border-b-2 border-l-2 border-[#1f1f1f] text-[10px] font-black uppercase tracking-widest px-4 py-1">
Polecany Pakiet
</div>
)}
<div>
<h3 className="text-lg font-black uppercase text-[#1f1f1f]">
{pkg.name}
</h3>
{pkg.description && (
<p className="mt-2 text-xs text-[#666666] leading-relaxed min-h-[36px]">
{pkg.description}
</p>
)}
<div className="mt-6 mb-8 pb-6 border-b-2 border-[#f0f0f0] flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl font-black text-[#1f1f1f] tracking-tight font-mono">
{pkg.price}
</span>
</div>
{pkg.features && pkg.features.length > 0 && (
<ul className="space-y-3 text-xs sm:text-sm">
{pkg.features.map((feat, fIdx) => (
<li key={fIdx} className="flex items-start gap-3 text-[#222222]">
<Check className="h-4 w-4 text-[#1f1f1f] bg-[#FFED00] p-0.5 shrink-0 mt-0.5 font-bold" />
<span className="font-semibold">{feat.feature}</span>
</li>
))}
</ul>
)}
</div>
<div className="mt-10 pt-6 border-t-2 border-[#f0f0f0]">
<a
href={pkg.ctaUrl || '#formularz-kontaktowy'}
className={`w-full inline-flex items-center justify-center py-4 px-6 text-xs font-black uppercase tracking-widest transition-all ${
pkg.isPopular
? 'bg-[#FFED00] hover:bg-[#fae100] text-[#1f1f1f] border-2 border-[#1f1f1f] shadow-xs'
: 'bg-[#1f1f1f] text-white hover:bg-[#333333]'
}`}
>
{pkg.ctaLabel || 'Wybierz pakiet'}
</a>
</div>
</div>
</AnimateIn>
))}
</div>
)}
{disclaimer && (
<AnimateIn delay={400} direction="none">
<p className="mt-10 text-xs text-[#666666] font-medium">
{disclaimer}
</p>
</AnimateIn>
)}
</div>
</section>
)
}
+84
View File
@@ -0,0 +1,84 @@
import type { Block } from 'payload'
export const PricingTableBlock: Block = {
slug: 'pricingTable',
labels: {
singular: 'Tabela Cennika',
plural: 'Tabele Cennika',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
},
{
name: 'subheading',
type: 'textarea',
localized: true,
},
{
name: 'disclaimer',
type: 'text',
localized: true,
admin: {
description: 'Np. "*Podane ceny są cenami orientacyjnymi netto za m2."',
},
},
{
name: 'packages',
type: 'array',
localized: true,
labels: {
singular: 'Pakiet cennika',
plural: 'Pakiety cennika',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'price',
type: 'text',
required: true,
admin: {
description: 'Np. "od 5 zł / m2"',
},
},
{
name: 'description',
type: 'textarea',
},
{
name: 'isPopular',
type: 'checkbox',
defaultValue: false,
},
{
name: 'features',
type: 'array',
fields: [
{
name: 'feature',
type: 'text',
required: true,
},
],
},
{
name: 'ctaLabel',
type: 'text',
defaultValue: 'Zamów wycenę',
},
],
},
],
}
+91
View File
@@ -0,0 +1,91 @@
import React from 'react'
import { X, Check } from 'lucide-react'
import { AnimateIn } from '@/components/AnimateIn'
interface ComparisonItem {
problemTitle: string
problemDescription?: string
solutionTitle: string
solutionDescription?: string
}
interface ProblemSolutionBlockProps {
badge?: string
heading: string
subheading?: string
items?: ComparisonItem[]
}
export function ProblemSolutionBlockComponent(props: ProblemSolutionBlockProps) {
const { badge, heading, subheading, items } = props
return (
<section className="py-24 bg-white border-b-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<AnimateIn direction="up">
<div className="max-w-3xl mb-14">
{badge && (
<div className="inline-block bg-[#FFED00] text-[#1f1f1f] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4 border border-[#1f1f1f]">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
</AnimateIn>
{items && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-stretch">
{items.map((item, idx) => (
<AnimateIn key={idx} delay={idx * 150} direction="up" className="h-full">
<div
className="bg-[#f8f8f8] border-2 border-[#1f1f1f] p-8 space-y-6 shadow-sm hover-lift h-full flex flex-col justify-between"
>
{/* Problem */}
<div className="flex items-start gap-4 pb-6 border-b border-[#e5e5e5]">
<div className="flex h-8 w-8 shrink-0 items-center justify-center bg-rose-600 text-white font-black">
<X className="h-5 w-5" />
</div>
<div>
<h4 className="text-sm font-black uppercase tracking-wide text-rose-950">
{item.problemTitle}
</h4>
{item.problemDescription && (
<p className="mt-1 text-xs sm:text-sm text-[#666666] leading-relaxed font-medium">
{item.problemDescription}
</p>
)}
</div>
</div>
{/* Solution */}
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center bg-[#FFED00] text-[#1f1f1f] border border-[#1f1f1f] font-black">
<Check className="h-5 w-5" />
</div>
<div>
<h4 className="text-sm font-black uppercase tracking-wide text-[#1f1f1f]">
{item.solutionTitle}
</h4>
{item.solutionDescription && (
<p className="mt-1 text-xs sm:text-sm text-[#444444] leading-relaxed font-medium">
{item.solutionDescription}
</p>
)}
</div>
</div>
</div>
</AnimateIn>
))}
</div>
)}
</div>
</section>
)
}
+58
View File
@@ -0,0 +1,58 @@
import type { Block } from 'payload'
export const ProblemSolutionBlock: Block = {
slug: 'problemSolution',
labels: {
singular: 'Sekcja Problem vs Rozwiązanie',
plural: 'Sekcje Problem vs Rozwiązanie',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
},
{
name: 'subheading',
type: 'textarea',
localized: true,
},
{
name: 'items',
type: 'array',
localized: true,
labels: {
singular: 'Karta Problem-Rozwiązanie',
plural: 'Karty Problem-Rozwiązanie',
},
fields: [
{
name: 'problemTitle',
type: 'text',
required: true,
},
{
name: 'problemDescription',
type: 'textarea',
required: true,
},
{
name: 'solutionTitle',
type: 'text',
required: true,
},
{
name: 'solutionDescription',
type: 'textarea',
required: true,
},
],
},
],
}
+69
View File
@@ -0,0 +1,69 @@
import React from 'react'
import { AnimateIn } from '@/components/AnimateIn'
interface Step {
stepNumber: string
title: string
description?: string
}
interface ProcessStepsBlockProps {
badge?: string
heading: string
subheading?: string
steps?: Step[]
}
export function ProcessStepsBlockComponent(props: ProcessStepsBlockProps) {
const { badge, heading, subheading, steps } = props
return (
<section className="py-24 bg-[#f4f4f4] border-b-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<AnimateIn direction="up">
<div className="max-w-3xl mb-14">
{badge && (
<div className="inline-block bg-[#1f1f1f] text-[#FFED00] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
</AnimateIn>
{steps && steps.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 items-stretch">
{steps.map((step, idx) => (
<AnimateIn key={idx} delay={idx * 100} direction="up" className="h-full">
<div
className="bg-white border-2 border-[#1f1f1f] p-6 h-full flex flex-col justify-between border-t-8 border-t-[#FFED00] hover-lift transition-all"
>
<div>
<div className="text-2xl font-black text-[#1f1f1f] bg-[#FFED00] border border-[#1f1f1f] w-10 h-10 flex items-center justify-center mb-5 font-mono">
{step.stepNumber}
</div>
<h4 className="text-sm font-black uppercase tracking-tight text-[#1f1f1f] mb-3">
{step.title}
</h4>
{step.description && (
<p className="text-xs text-[#555555] leading-relaxed">
{step.description}
</p>
)}
</div>
</div>
</AnimateIn>
))}
</div>
)}
</div>
</section>
)
}
+56
View File
@@ -0,0 +1,56 @@
import type { Block } from 'payload'
export const ProcessStepsBlock: Block = {
slug: 'processSteps',
labels: {
singular: 'Sekcja Etapów Realizacji',
plural: 'Sekcje Etapów Realizacji',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
},
{
name: 'subheading',
type: 'textarea',
localized: true,
},
{
name: 'steps',
type: 'array',
localized: true,
labels: {
singular: 'Krok procesu',
plural: 'Kroki procesu',
},
fields: [
{
name: 'stepNumber',
type: 'text',
required: true,
admin: {
description: 'Np. "01", "Krok 1"',
},
},
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
],
},
],
}
+79
View File
@@ -0,0 +1,79 @@
import React from 'react'
import { Star } from 'lucide-react'
import { AnimateIn } from '@/components/AnimateIn'
interface ReviewItem {
author: string
location?: string
rating?: number
content: string
}
interface ReviewsBlockProps {
badge?: string
heading: string
subheading?: string
items?: ReviewItem[]
}
export function ReviewsBlockComponent(props: ReviewsBlockProps) {
const { badge, heading, subheading, items } = props
return (
<section className="py-24 bg-white border-b-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<AnimateIn direction="up">
<div className="max-w-3xl mb-14">
{badge && (
<div className="inline-block bg-[#FFED00] text-[#1f1f1f] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4 border border-[#1f1f1f]">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
</AnimateIn>
{items && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-stretch">
{items.map((item, idx) => (
<AnimateIn key={idx} delay={idx * 150} direction="up" className="h-full">
<div
className="bg-[#f4f4f4] border-2 border-[#1f1f1f] p-8 h-full flex flex-col justify-between hover-lift transition-all"
>
<div>
<div className="flex items-center gap-1 text-[#1f1f1f] mb-6">
{Array.from({ length: item.rating || 5 }).map((_, sIdx) => (
<Star key={sIdx} className="h-5 w-5 fill-[#FFED00] stroke-[#1f1f1f]" />
))}
</div>
<p className="text-sm text-[#333333] leading-relaxed font-medium">
"{item.content}"
</p>
</div>
<div className="mt-8 pt-6 border-t-2 border-[#e0e0e0]">
<div className="font-black uppercase text-[#1f1f1f] text-xs tracking-wider">
{item.author}
</div>
{item.location && (
<div className="text-xs text-[#666666] mt-0.5 font-medium">
{item.location}
</div>
)}
</div>
</div>
</AnimateIn>
))}
</div>
)}
</div>
</section>
)
}
+62
View File
@@ -0,0 +1,62 @@
import type { Block } from 'payload'
export const ReviewsBlock: Block = {
slug: 'reviews',
labels: {
singular: 'Sekcja Opinii Klientów',
plural: 'Sekcje Opinii Klientów',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
},
{
name: 'subheading',
type: 'textarea',
localized: true,
},
{
name: 'items',
type: 'array',
localized: true,
labels: {
singular: 'Opinia klienta',
plural: 'Opinie klientów',
},
fields: [
{
name: 'author',
type: 'text',
required: true,
},
{
name: 'location',
type: 'text',
admin: {
description: 'Np. "Wrocław, Krzyki - Dom jednorodzinny"',
},
},
{
name: 'rating',
type: 'number',
min: 1,
max: 5,
defaultValue: 5,
},
{
name: 'content',
type: 'textarea',
required: true,
},
],
},
],
}
+69
View File
@@ -0,0 +1,69 @@
import React from 'react'
import { Check } from 'lucide-react'
import { AnimateIn } from '@/components/AnimateIn'
interface TechItem {
title: string
description?: string
}
interface TechEquipmentBlockProps {
badge?: string
heading: string
subheading?: string
items?: TechItem[]
}
export function TechEquipmentBlockComponent(props: TechEquipmentBlockProps) {
const { badge, heading, subheading, items } = props
return (
<section className="py-24 bg-white border-b-2 border-[#1f1f1f]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<AnimateIn direction="up">
<div className="max-w-3xl mb-14">
{badge && (
<div className="inline-block bg-[#FFED00] text-[#1f1f1f] text-xs font-black uppercase tracking-widest px-3 py-1 mb-4 border border-[#1f1f1f]">
{badge}
</div>
)}
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-black uppercase tracking-tight text-[#1f1f1f]">
{heading}
</h2>
{subheading && (
<p className="mt-4 text-base text-[#666666] leading-relaxed font-medium">
{subheading}
</p>
)}
</div>
</AnimateIn>
{items && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-stretch">
{items.map((item, idx) => (
<AnimateIn key={idx} delay={idx * 150} direction="up" className="h-full">
<div
className="bg-[#f4f4f4] border-2 border-[#1f1f1f] p-8 h-full flex flex-col justify-between hover-lift transition-all"
>
<div>
<div className="flex h-10 w-10 items-center justify-center bg-[#FFED00] text-[#1f1f1f] border border-[#1f1f1f] mb-6">
<Check className="h-5 w-5 font-black" />
</div>
<h3 className="text-base font-black uppercase text-[#1f1f1f] mb-3">
{item.title}
</h3>
{item.description && (
<p className="text-xs sm:text-sm text-[#555555] leading-relaxed">
{item.description}
</p>
)}
</div>
</div>
</AnimateIn>
))}
</div>
)}
</div>
</section>
)
}
+53
View File
@@ -0,0 +1,53 @@
import type { Block } from 'payload'
export const TechEquipmentBlock: Block = {
slug: 'techEquipment',
labels: {
singular: 'Sekcja Technologia i Sprzęt',
plural: 'Sekcje Technologia i Sprzęt',
},
fields: [
{
name: 'badge',
type: 'text',
localized: true,
},
{
name: 'heading',
type: 'text',
required: true,
localized: true,
},
{
name: 'subheading',
type: 'textarea',
localized: true,
},
{
name: 'items',
type: 'array',
localized: true,
labels: {
singular: 'Element sprzętowy',
plural: 'Elementy sprzętowe',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
],
},
],
}
+26
View File
@@ -0,0 +1,26 @@
import type { BlockComponentMap } from '@intecion/ipal-kit/rsc'
import { HeroBlockComponent } from '@/blocks/Hero/Component'
import { ProblemSolutionBlockComponent } from '@/blocks/ProblemSolution/Component'
import { ProcessStepsBlockComponent } from '@/blocks/ProcessSteps/Component'
import { BeforeAfterGalleryBlockComponent } from '@/blocks/BeforeAfterGallery/Component'
import { PricingTableBlockComponent } from '@/blocks/PricingTable/Component'
import { TechEquipmentBlockComponent } from '@/blocks/TechEquipment/Component'
import { FaqAccordionBlockComponent } from '@/blocks/FaqAccordion/Component'
import { ReviewsBlockComponent } from '@/blocks/Reviews/Component'
import { FormBlockComponent } from '@/blocks/Form/Component'
import { ContentBlockComponent } from '@/blocks/Content/Component'
import { BlogListBlockComponent } from '@/blocks/BlogList/Component'
export const blockRegistry: BlockComponentMap = {
hero: HeroBlockComponent as never,
problemSolution: ProblemSolutionBlockComponent as never,
processSteps: ProcessStepsBlockComponent as never,
beforeAfterGallery: BeforeAfterGalleryBlockComponent as never,
pricingTable: PricingTableBlockComponent as never,
techEquipment: TechEquipmentBlockComponent as never,
faqAccordion: FaqAccordionBlockComponent as never,
reviews: ReviewsBlockComponent as never,
formBlock: FormBlockComponent as never,
content: ContentBlockComponent as never,
blogList: BlogListBlockComponent as never,
}
+43
View File
@@ -0,0 +1,43 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
upload: {
staticDir: 'media',
adminThumbnail: 'thumbnail',
imageSizes: [
{
name: 'thumbnail',
width: 300,
height: 300,
position: 'centre',
},
{
name: 'card',
width: 768,
height: 512,
position: 'centre',
},
{
name: 'hero',
width: 1920,
height: 1080,
position: 'centre',
},
],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
localized: true,
admin: {
description: 'Opis alternatywny dla dostępności (SEO i czytniki ekranu)',
},
},
],
}
+50
View File
@@ -0,0 +1,50 @@
import type { CollectionConfig } from 'payload'
import { buildSlugField } from '@intecion/ipal-kit'
import { HeroBlock } from '@/blocks/Hero/config'
import { ProblemSolutionBlock } from '@/blocks/ProblemSolution/config'
import { ProcessStepsBlock } from '@/blocks/ProcessSteps/config'
import { BeforeAfterGalleryBlock } from '@/blocks/BeforeAfterGallery/config'
import { PricingTableBlock } from '@/blocks/PricingTable/config'
import { TechEquipmentBlock } from '@/blocks/TechEquipment/config'
import { FaqAccordionBlock } from '@/blocks/FaqAccordion/config'
import { ReviewsBlock } from '@/blocks/Reviews/config'
import { FormBlock } from '@/blocks/Form/config'
import { ContentBlock } from '@/blocks/Content/config'
import { BlogListBlock } from '@/blocks/BlogList/config'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
buildSlugField({ from: 'title' }),
{
name: 'layout',
type: 'blocks',
blocks: [
HeroBlock,
ProblemSolutionBlock,
ProcessStepsBlock,
BeforeAfterGalleryBlock,
PricingTableBlock,
TechEquipmentBlock,
FaqAccordionBlock,
ReviewsBlock,
FormBlock,
ContentBlock,
BlogListBlock,
],
},
],
}
+15
View File
@@ -0,0 +1,15 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'name',
type: 'text',
},
],
}
+82
View File
@@ -0,0 +1,82 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
interface AnimateInProps {
children: React.ReactNode
delay?: number // in ms
direction?: 'up' | 'down' | 'left' | 'right' | 'none'
className?: string
threshold?: number
}
export function AnimateIn({
children,
delay = 0,
direction = 'up',
className = '',
threshold = 0.15,
}: AnimateInProps) {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.unobserve(entry.target)
}
},
{
threshold,
rootMargin: '0px 0px -40px 0px',
}
)
const currentRef = ref.current
if (currentRef) {
observer.observe(currentRef)
}
return () => {
if (currentRef) {
observer.unobserve(currentRef)
}
}
}, [threshold])
const getTransform = () => {
if (isVisible) return 'translate3d(0, 0, 0) scale(1)'
switch (direction) {
case 'up':
return 'translate3d(0, 32px, 0)'
case 'down':
return 'translate3d(0, -32px, 0)'
case 'left':
return 'translate3d(32px, 0, 0)'
case 'right':
return 'translate3d(-32px, 0, 0)'
case 'none':
return 'scale(0.96)'
default:
return 'translate3d(0, 32px, 0)'
}
}
return (
<div
ref={ref}
style={{
transitionDuration: '700ms',
transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
transitionDelay: `${delay}ms`,
transform: getTransform(),
opacity: isVisible ? 1 : 0,
}}
className={`transition-all ${className}`}
>
{children}
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import React from 'react'
import { MapPin, Phone, Mail, ShieldCheck, Cookie } from 'lucide-react'
export interface FooterNavItem {
id?: string
label: string
href: string
}
interface FooterProps {
companyName?: string | null
footerTagline?: string | null
nip?: string | null
regon?: string | null
address?: string | null
city?: string | null
phone?: string | null
email?: string | null
items?: FooterNavItem[]
privacyHref?: string
privacyLabel?: string
cookieHref?: string
cookieLabel?: string
}
export function Footer({
companyName,
footerTagline,
nip,
regon,
address,
city,
phone,
email,
items,
privacyHref = '/polityka-prywatnosci',
privacyLabel = 'Polityka Prywatności',
cookieHref = '/polityka-cookies',
cookieLabel = 'Polityka Cookies',
}: FooterProps) {
return (
<footer className="bg-[#1f1f1f] text-[#d0d0d0] py-16 border-t-4 border-[#FFED00]">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl">
<div className="grid grid-cols-1 md:grid-cols-4 gap-12">
{/* Company Info */}
<div className="md:col-span-2 space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center bg-[#FFED00] text-[#1f1f1f] font-black text-xl">
POI
</div>
{companyName && (
<span className="text-xl font-black uppercase tracking-tight text-white">
{companyName}
</span>
)}
</div>
{footerTagline && (
<p className="text-xs sm:text-sm text-[#a0a0a0] leading-relaxed max-w-md">
{footerTagline}
</p>
)}
<div className="text-xs text-[#888888] font-mono space-y-1 pt-2 border-t border-[#333333]">
{nip && <div>NIP: <span className="text-[#e0e0e0] font-semibold">{nip}</span></div>}
{regon && <div>REGON: <span className="text-[#e0e0e0] font-semibold">{regon}</span></div>}
</div>
</div>
{/* Navigation Links */}
{items && items.length > 0 && (
<div className="space-y-4">
<div className="text-xs font-black uppercase tracking-widest text-[#FFED00] border-b border-[#333333] pb-2">
Nawigacja
</div>
<ul className="space-y-2.5 text-xs font-bold uppercase tracking-wider">
{items.map((item, idx) => (
<li key={item.id || idx}>
<a href={item.href} className="text-[#d0d0d0] hover:text-[#FFED00] transition-colors block py-0.5">
{item.label}
</a>
</li>
))}
</ul>
</div>
)}
{/* Contact Details */}
<div className="space-y-4">
<div className="text-xs font-black uppercase tracking-widest text-[#FFED00] border-b border-[#333333] pb-2">
Kontakt
</div>
<ul className="space-y-3 text-xs">
{phone && (
<li className="flex items-center gap-2.5">
<Phone className="h-4 w-4 text-[#FFED00] shrink-0" />
<a href={`tel:${phone.replace(/\s+/g, '')}`} className="text-white hover:text-[#FFED00] font-mono font-black text-sm">
{phone}
</a>
</li>
)}
{email && (
<li className="flex items-center gap-2.5">
<Mail className="h-4 w-4 text-[#FFED00] shrink-0" />
<a href={`mailto:${email}`} className="text-[#d0d0d0] hover:text-white font-medium">
{email}
</a>
</li>
)}
{(address || city) && (
<li className="flex items-start gap-2.5">
<MapPin className="h-4 w-4 text-[#FFED00] shrink-0 mt-0.5" />
<span className="text-[#a0a0a0]">
{address}
{address && city ? ', ' : ''}
{city}
</span>
</li>
)}
</ul>
</div>
</div>
{/* Bottom Bar with Privacy & Cookie Links */}
<div className="mt-12 pt-8 border-t border-[#333333] flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-[#888888]">
<div>
© {new Date().getFullYear()} {companyName}. Wszelkie prawa zastrzeżone.
</div>
<div className="flex items-center gap-6 text-xs font-bold uppercase tracking-wider">
{privacyHref && (
<a href={privacyHref} className="text-[#a0a0a0] hover:text-[#FFED00] transition-colors flex items-center gap-1.5">
<ShieldCheck className="h-3.5 w-3.5 text-[#FFED00]" />
<span>{privacyLabel}</span>
</a>
)}
{cookieHref && (
<a href={cookieHref} className="text-[#a0a0a0] hover:text-[#FFED00] transition-colors flex items-center gap-1.5">
<Cookie className="h-3.5 w-3.5 text-[#FFED00]" />
<span>{cookieLabel}</span>
</a>
)}
</div>
</div>
</div>
</footer>
)
}
+176
View File
@@ -0,0 +1,176 @@
'use client'
import React, { useState, useRef, useEffect } from 'react'
import { ArrowRight, Globe, ChevronDown } from 'lucide-react'
import { usePathname } from 'next/navigation'
export interface NavItem {
id?: string
label: string
href: string
}
interface HeaderProps {
companyName?: string | null
tagline?: string | null
items?: NavItem[]
phone?: string | null
homeHref?: string
currentLocale?: string
contactHref?: string
contactLabel?: string
}
export function Header({
companyName,
tagline,
items,
phone,
homeHref = '/',
currentLocale = 'pl',
contactHref = '/kontakt',
contactLabel = 'KONTAKT',
}: HeaderProps) {
const pathname = usePathname() || ''
const [langDropdownOpen, setLangDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setLangDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Function to switch locale in URL
const getSwitchedLocalePath = (targetLocale: string) => {
if (currentLocale === targetLocale) return pathname
if (pathname.startsWith(`/${currentLocale}`)) {
return pathname.replace(`/${currentLocale}`, `/${targetLocale}`)
}
return `/${targetLocale}${pathname}`
}
// Filter out duplicated "Kontakt" from menu items since it has a dedicated right CTA button
const filteredNavItems = items?.filter(
(item) => !item.label.toLowerCase().includes('kontakt')
)
const languages = [
{ code: 'pl', label: 'PL - Polski' },
{ code: 'en', label: 'UK - English' },
]
const activeLang = languages.find((l) => l.code === currentLocale) || languages[0]
return (
<header className="sticky top-0 z-50 w-full bg-white border-b-2 border-[#1f1f1f] shadow-xs">
{/* Top Black Utility Bar */}
<div className="bg-[#1f1f1f] text-white text-xs py-2 hidden md:block">
<div className="container mx-auto px-4 sm:px-6 lg:px-12 max-w-7xl flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="inline-block w-2.5 h-2.5 bg-[#FFED00]" />
<span className="font-bold uppercase tracking-wider text-[11px] text-[#e0e0e0]">
{tagline || 'Profesjonalne Mycie i Renowacja Nawierzchni Brukowych'}
</span>
</div>
{phone && (
<div className="flex items-center gap-2">
<span className="font-bold uppercase tracking-wider text-[11px] text-[#e0e0e0]">
Infolinia / Bezpłatna wycena:
</span>
<a
href={`tel:${phone.replace(/\s+/g, '')}`}
className="font-black uppercase tracking-wider text-[11px] text-[#FFED00] hover:underline font-mono"
>
{phone}
</a>
</div>
)}
</div>
</div>
{/* Main Navigation Bar */}
<div className="container mx-auto flex h-20 items-center justify-between px-4 sm:px-6 lg:px-12 max-w-7xl">
<a href={homeHref} className="flex items-center gap-3.5">
<div className="flex h-11 w-11 items-center justify-center bg-[#FFED00] text-[#1f1f1f] font-black text-xl border-2 border-[#1f1f1f] shadow-xs">
POI
</div>
<div>
{companyName && (
<span className="text-xl font-black uppercase tracking-tight text-[#1f1f1f] block leading-none">
{companyName}
</span>
)}
<span className="text-[10px] uppercase tracking-widest text-[#666666] font-bold block mt-1">
Czyszczenie Kostki Brukowej
</span>
</div>
</a>
{/* Navigation Items (Without duplicated Kontakt) */}
{filteredNavItems && filteredNavItems.length > 0 && (
<nav className="hidden lg:flex items-center gap-8 text-xs font-black uppercase tracking-wider text-[#1f1f1f]">
{filteredNavItems.map((item, idx) => (
<a
key={item.id || idx}
href={item.href}
className="hover:text-[#1f1f1f] hover:border-b-2 hover:border-[#FFED00] transition-all py-2"
>
{item.label}
</a>
))}
</nav>
)}
<div className="flex items-center gap-4">
{/* Wysuwany przełącznik języków (Dropdown Language Switcher) */}
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setLangDropdownOpen(!langDropdownOpen)}
className="flex items-center gap-2 border-2 border-[#1f1f1f] bg-white px-3 py-2 text-xs font-black text-[#1f1f1f] hover:bg-[#f4f4f4] transition-colors cursor-pointer"
>
<Globe className="h-3.5 w-3.5 text-[#1f1f1f]" />
<span className="uppercase">{activeLang.code.toUpperCase()}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${langDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{langDropdownOpen && (
<div className="absolute right-0 mt-1 w-36 bg-white border-2 border-[#1f1f1f] shadow-xl z-50 py-1">
{languages.map((lang) => (
<a
key={lang.code}
href={getSwitchedLocalePath(lang.code)}
onClick={() => setLangDropdownOpen(false)}
className={`flex items-center justify-between px-3 py-2 text-xs font-bold transition-colors ${
currentLocale === lang.code
? 'bg-[#1f1f1f] text-[#FFED00]'
: 'text-[#1f1f1f] hover:bg-[#FFED00]/20'
}`}
>
<span>{lang.label}</span>
{currentLocale === lang.code && <span className="w-1.5 h-1.5 bg-[#FFED00]" />}
</a>
))}
</div>
)}
</div>
{/* Right CTA Button: KONTAKT */}
<a
href={contactHref}
className="inline-flex items-center gap-2 bg-[#FFED00] hover:bg-[#fae100] text-[#1f1f1f] border-2 border-[#1f1f1f] px-6 py-3 text-xs font-black uppercase tracking-widest transition-colors shadow-xs"
>
<span>{contactLabel}</span>
<ArrowRight className="h-4 w-4" />
</a>
</div>
</div>
</header>
)
}
+66
View File
@@ -0,0 +1,66 @@
import type { GlobalConfig } from 'payload'
export const Company: GlobalConfig = {
slug: 'company',
label: 'Dane Firmy',
access: {
read: () => true,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
defaultValue: 'POI CLEAN HOUSE Dariusz Król',
localized: true,
},
{
name: 'shortName',
type: 'text',
defaultValue: 'POI Clean House',
localized: true,
},
{
name: 'nip',
type: 'text',
defaultValue: '7542827484',
},
{
name: 'regon',
type: 'text',
defaultValue: '521920102',
},
{
name: 'address',
type: 'text',
defaultValue: 'ul. Opolska 7B',
},
{
name: 'city',
type: 'text',
defaultValue: '45-960 Opole',
},
{
name: 'region',
type: 'text',
defaultValue: 'Opole, woj. opolskie, dolnośląskie i śląskie',
localized: true,
},
{
name: 'phone',
type: 'text',
defaultValue: '+48 668 973 737',
},
{
name: 'email',
type: 'text',
defaultValue: '[email protected]',
},
{
name: 'workingHours',
type: 'text',
defaultValue: 'Poniedziałek - Sobota: 7:00 - 20:00',
localized: true,
},
],
}
+104
View File
@@ -0,0 +1,104 @@
import type { GlobalConfig } from 'payload'
export const Navigation: GlobalConfig = {
slug: 'navigation',
label: 'Nawigacja i Menu',
access: {
read: () => true,
},
fields: [
{
name: 'tagline',
type: 'text',
localized: true,
defaultValue: 'Czyszczenie Kostki Brukowej',
admin: {
description: 'Podtytuł pod logo w nagłówku',
},
},
{
name: 'headerItems',
type: 'array',
localized: true,
labels: {
singular: 'Pozycja menu głównego',
plural: 'Pozycje menu głównego',
},
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'page',
type: 'relationship',
relationTo: 'pages',
},
{
name: 'url',
type: 'text',
admin: {
description: 'Użyj jeśli link prowadzi do zewnętrznego URL lub kotwicy #',
},
},
],
},
{
name: 'ctaButton',
type: 'group',
localized: true,
fields: [
{
name: 'label',
type: 'text',
defaultValue: 'Zadzwoń po wycenę',
},
{
name: 'page',
type: 'relationship',
relationTo: 'pages',
},
{
name: 'url',
type: 'text',
},
],
},
{
name: 'footerTagline',
type: 'textarea',
localized: true,
defaultValue:
'Wyspecjalizowane usługi mycia gorącowodnego, usuwania mchu, odplamiania oleju, spoinowania i impregnacji hydrofobowej nawierzchni brukowych.',
admin: {
description: 'Krótki opis firmy w lewej kolumnie stopki',
},
},
{
name: 'footerItems',
type: 'array',
localized: true,
labels: {
singular: 'Pozycja menu stopki',
plural: 'Pozycje menu stopki',
},
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'page',
type: 'relationship',
relationTo: 'pages',
},
{
name: 'url',
type: 'text',
},
],
},
],
}
+9
View File
@@ -0,0 +1,9 @@
import type { I18nConfig } from '@intecion/ipal-kit'
export const i18nConfig = {
defaultLocale: 'pl',
locales: [
{ code: 'pl', label: 'PL' },
{ code: 'en', label: 'UK' },
],
} as const satisfies I18nConfig
+16
View File
@@ -0,0 +1,16 @@
import { createContentHelpers } from '@intecion/ipal-kit'
import payloadConfig from '@/payload.config'
import { i18nConfig } from '@/i18n.config'
export const {
getCachedPayload,
getSettings,
getConfiguredLocales,
resolveRoute,
getEntries,
robots,
} = createContentHelpers({
config: payloadConfig,
content: { collections: [] },
i18n: i18nConfig,
})
+7
View File
@@ -0,0 +1,7 @@
import { cache } from 'react'
import { getSiteSettings } from '@intecion/ipal-kit'
import { getCachedPayload } from './content'
export const getSettings = cache(async (locale: string) =>
getSiteSettings(await getCachedPayload(), { locale: locale as never, depth: 2 }),
)
+1647
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
import path from 'path'
import { fileURLToPath } from 'url'
import { buildConfig } from 'payload'
import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { ipalKit, mailAdapter } from '@intecion/ipal-kit'
import sharp from 'sharp'
import { i18nConfig } from './i18n.config'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Pages } from './collections/Pages'
import { Company } from './globals/Company'
import { Navigation } from './globals/Navigation'
import { seed } from './seed'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media, Pages],
globals: [Company, Navigation],
editor: lexicalEditor(),
sharp,
secret: process.env.PAYLOAD_SECRET || 'fallback-secret-for-development-mode-12345',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: sqliteAdapter({
client: {
url: process.env.DATABASE_URI || 'file:./dev.db',
},
}),
email: mailAdapter(),
onInit: async (payload) => {
try {
await seed(payload)
} catch (err) {
payload.logger.error(`Błąd podczas seedowania: ${err}`)
}
},
plugins: [
ipalKit({
i18n: i18nConfig,
access: { authCollection: 'users' },
pages: { slug: 'pages' },
seo: { collections: ['pages'] },
forms: { redirectRelationships: ['pages'] },
}),
],
})
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createLocaleMiddleware } from '@intecion/ipal-kit/next/middleware'
import { i18nConfig } from '@/i18n.config'
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
export function proxy(request: NextRequest) {
const result = localeMiddleware(request)
const response =
result.type === 'next'
? NextResponse.next()
: NextResponse.redirect(result.location)
if (result.cookie) {
response.cookies.set(result.cookie.name, result.cookie.value)
}
return response
}
export const config = {
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
}
+639
View File
@@ -0,0 +1,639 @@
import type { Payload } from 'payload'
export async function seed(payload: Payload) {
payload.logger.info('Rozpoczynam seedowanie danych początkowych dla POI Clean House...')
// 1. Sprawdź czy użytkownik admin już istnieje
const existingUsers = await payload.find({
collection: 'users',
limit: 1,
})
if (existingUsers.totalDocs === 0) {
payload.logger.info('Tworzę konto administratora ([email protected])...')
await payload.create({
collection: 'users',
data: {
email: '[email protected]',
password: '[email protected]',
name: 'Dariusz Król - POI Clean House',
} as never,
})
}
// 2. Aktualizacja globala Company
payload.logger.info('Aktualizuję dane firmy...')
await payload.updateGlobal({
slug: 'company',
locale: 'pl',
data: {
name: 'POI CLEAN HOUSE Dariusz Król',
shortName: 'POI Clean House',
nip: '7542827484',
regon: '521920102',
address: 'ul. Opolska 7B',
city: '45-960 Opole',
region: 'Opole oraz całe woj. opolskie, dolnośląskie i śląskie',
phone: '+48 668 973 737',
email: '[email protected]',
workingHours: 'Poniedziałek - Sobota: 7:00 - 20:00',
},
})
// 3. Sprawdź czy strony już istnieją
const existingPages = await payload.find({
collection: 'pages',
limit: 1,
})
if (existingPages.totalDocs > 0) {
payload.logger.info('Strony już istnieją w bazie. Pomijam seedowanie stron.')
return
}
payload.logger.info('Tworzę strony serwisu...')
// A. Strona Główna
const homePage = await payload.create({
collection: 'pages',
locale: 'pl',
data: {
title: 'Strona Główna',
slug: 'home',
layout: [
{
blockType: 'hero',
badge: 'Eksperci Mycia i Renowacji Kostki Brukowej',
heading: 'Przywracamy fabryczny wygląd Twojej kostki brukowej – bez uszkodzeń i bałaganu.',
subheading:
'Usuwamy wieloletni mech, plamy z oleju silnikowego, zacieki pobudowlane i zabrudzenia atmosferyczne. Myjemy gorącą wodą do 100°C talerzami bezrozbryzgowymi, uzupełniamy spoiny piaskiem kwarcowym i zabezpieczamy trwałą impregnacją hydrofobową.',
trustBadges: [
{ text: 'Mycie gorącą wodą do 100°C pod ciśnieniem' },
{ text: 'Talerze bezrozbryzgowe Mosmatic – czyste elewacje' },
{ text: 'Biodegradowalne i bezpieczne środki biobójcze' },
{ text: 'Darmowa wycena na posesji lub ze zdjęcia' },
],
ctaPhone: '+48 668 973 737',
ctaPhoneLabel: 'Zadzwoń po bezpłatną wycenę',
ctaSecondaryLabel: 'Zobacz cennik za m²',
},
{
blockType: 'problemSolution',
badge: 'Dlaczego mycie profesjonalne?',
heading: 'Dlaczego domowa myjka nie poradzi sobie z trudnymi zabrudzeniami?',
subheading:
'Zwykłe mycie zimną wodą pod przypadkowym ciśnieniem najczęściej kończy się wypłukaniem piasku, zniszczeniem wierzchniej warstwy betonu i powrotem mchu po kilku tygodniach.',
items: [
{
problemTitle: 'Wypłukane spoiny i błyskawiczny powrót mchu',
problemDescription:
'Zimna woda jedynie urywa zieloną część rośliny. Zarodniki mchu i grzybów wnikają w głąb szczelin i odrastają ze zdwojoną siłą.',
solutionTitle: 'Chemia biobójcza + Piaskowanie kwarcowe',
solutionDescription:
'Stosujemy preparaty niszczące korzenie i zarodniki na poziomie biologicznym, a po myciu maszynowo zamiatamy suszony piasek kwarcowy.',
},
{
problemTitle: 'Plamy z oleju silnikowego i tłuszczu z grilla',
problemDescription:
'Olej wnika w mikroskopijne pory kostki. Mycie bez wysokiej temperatury jedynie rozciera tłuszcz po większej powierzchni.',
solutionTitle: 'Woda 90-100°C + Odtłuszczacze przemysłowe',
solutionDescription:
'Wysoka temperatura emulguje tłuszcze, a przemysłowa chemia rozbija wiązania węglowodorowe, wyciągając olej z głębi kostki.',
},
{
problemTitle: 'Zmatowienie i podatność na pękanie zimą',
problemDescription:
'Otwarta po myciu struktura kostki chłonie wilgoć. Woda zamarzając w mikro-szczelinach kruszy beton od środka.',
solutionTitle: 'Impregnacja hydrofobowa "Mokra Kostka"',
solutionDescription:
'Tworzymy barierę odpychającą wodę, sól drogową i promieniowanie UV, uwydatniając głęboki, nasycony kolor kostki.',
},
],
},
{
blockType: 'processSteps',
badge: 'Proces realizacji',
heading: 'Nasz 5-etapowy standard renowacji nawierzchni',
subheading:
'Działamy metodycznie, dbając o czystość wokół Twojej posesji – od przygotowania po finalną impregnację.',
steps: [
{
stepNumber: '01',
title: 'Inspekcja i przygotowanie terenu',
description:
'Oceniamy rodzaj kostki (betonowa, granitowa, klinkier), stopień degradacji i zabezpieczamy rośliny ogrodowe oraz elementy małej architektury.',
},
{
stepNumber: '02',
title: 'Aplikacja chemii aktywnej',
description:
'Nanosimy bezpieczne dla środowiska preparaty biobójcze i odtłuszczające, dając im czas na rozpuszczenie nalotów organicznych i tłuszczowych.',
},
{
stepNumber: '03',
title: 'Mycie gorącowodne talerzami Mosmatic',
description:
'Czyścimy nawierzchnię strumieniem gorącej wody pod kontrolowanym ciśnieniem. Osłona talerza zapobiega chlapaniu na elewację i ogrodzenie.',
},
{
stepNumber: '04',
title: 'Doczyszczanie krawędzi i spłukiwanie',
description:
'Precyzyjnie domywamy palisady, krawężniki, schody i obrzeża, spłukując cały urobek do odpływów kanalizacyjnych.',
},
{
stepNumber: '05',
title: 'Fugowanie i Impregnacja',
description:
'Po wyschnięciu uzupełniamy fugi czystym piaskiem kwarcowym oraz aplikujemy impregnat hydrofobowy zabezpieczający podjazd na sezony.',
},
],
},
{
blockType: 'pricingTable',
badge: 'Przejrzyste zasady',
heading: 'Cennik mycia i renowacji kostki brukowej',
subheading:
'Wybierz pakiet dopasowany do stanu Twojego podjazdu lub skontaktuj się po bezpłatną wycenę indywidualną.',
disclaimer: '*Ceny netto za m². Ostateczna cena zależy od całkowitego metrażu oraz stopnia i rodzaju zabrudzeń.',
packages: [
{
name: 'Pakiet Podstawowy',
price: 'od 5 zł / m²',
description: 'Idealny dla posesji mytych regularnie, bez trudnych plam olejowych.',
isPopular: false,
ctaLabel: 'Wybierz pakiet podstawowy',
features: [
{ feature: 'Mycie hydrodynamiczne talerzem bezrozbryzgowym' },
{ feature: 'Usunięcie brudu atmosferycznego, kurzu i liści' },
{ feature: 'Zmycie świeżych nalotów mchu' },
{ feature: 'Spłukanie i uprzątnięcie posesji po pracy' },
],
},
{
name: 'Kompleksowa Renowacja',
price: 'od 9 zł / m²',
description: 'Najbardziej opłacalny i kompleksowy wariant przywracający idealny stan.',
isPopular: true,
ctaLabel: 'Zamów kompleksową renowację',
features: [
{ feature: 'Wszystko z Pakietu Podstawowego' },
{ feature: 'Mycie gorącą wodą do 100°C' },
{ feature: 'Aplikacja biobójczego preparatu przeciw zarodnikom' },
{ feature: 'Punktowe usuwanie plam z oleju silnikowego' },
{ feature: 'Uzupełnienie fug piaskiem kwarcowym (z materiałem)' },
],
},
{
name: 'Maksymalna Ochrona',
price: 'od 16 zł / m²',
description: 'Pełna renowacja z podwójną warstwą profesjonalnego impregnatu.',
isPopular: false,
ctaLabel: 'Wybierz pakiet z impregnacją',
features: [
{ feature: 'Wszystko z Kompleksowej Renowacji' },
{ feature: 'Głęboka impregnacja hydrofobowa lub "Mokra Kostka"' },
{ feature: 'Ochrona przed plamami oleju, soli i promieniowaniem UV' },
{ feature: 'Gwarancja odporności na zarastanie mchem' },
{ feature: 'Trwały efekt odświeżonego koloru na 3-5 lat' },
],
},
],
},
{
blockType: 'techEquipment',
badge: 'Park Maszynowy',
heading: 'Sprzęt przemysłowy = Bezpieczeństwo i skuteczność',
subheading:
'Dysponujemy zaawansowaną technologią hydrodynamiczną, która gwarantuje najwyższą kulturę pracy.',
items: [
{
title: 'Spalinowe myjki gorącowodne z regulacją ciśnienia',
description:
'Niezależne od zasilania elektrycznego agregaty grzewcze podgrzewające wodę do temperatury pary, co pozwala rozpuszczać tłuszcze bez niszczenia spoiwa cementowego.',
},
{
title: 'Talerze powierzchniowe Mosmatic Surface Cleaner',
description:
'Podwójne ramiona rotacyjne pracujące w hermetycznej osłonie ze szczotkami uniemożliwiają wydostawanie się brudnej wody na ściany budynków i okna.',
},
{
title: 'Atestowany piasek kwarcowy i chemia biodegradowalna',
description:
'Stosujemy wyłącznie suszony piecowo piasek kwarcowy o frakcji 0.2-0.8mm oraz ekologiczne preparaty bezpieczne dla trawników i zwierząt.',
},
],
},
{
blockType: 'faqAccordion',
badge: 'Pytania i odpowiedzi',
heading: 'Wszystko, co warto wiedzieć przed zamówieniem usługi',
subheading: 'Odpowiedzi na najczęstsze pytania naszych klientów.',
items: [
{
question: 'Czy mycie ciśnieniowe nie uszkodzi powierzchni kostki brukowej?',
answer:
'Nie, ponieważ nie używamy agresywnych dysz punktowych z bliskiej odległości. Zastosowanie talerzy myjących Mosmatic z regulacją ciśnienia i gorącą wodą zapewnia równomierne, bezpieczne zmywanie brudu bez wymywania wierzchniej warstwy betonu.',
},
{
question: 'Czy po czyszczeniu konieczne jest ponowne piaskowanie fug?',
answer:
'Tak, w większości przypadków wysokie ciśnienie usuwa ze szczelin nie tylko mech i chwasty, ale również stary, zabrudzony piasek. Uzupełnienie spoin czystym piaskiem kwarcowym stabilizuje kostkę i zapobiega jej przesuwaniu się oraz zapadaniu.',
},
{
question: 'Co daje impregnacja kostki brukowej?',
answer:
'Impregnat wnika w strukturę kapilarną betonu, zamykając pory. Dzięki temu woda spływa po powierzchni (efekt perlenia), plamy z oleju nie wsiąkają w podłoże, a kostka jest zabezpieczona przed mrozem i promieniowaniem UV. Opcjonalny efekt "mokrej kostki" dodatkowo wyciąga głębię koloru.',
},
{
question: 'Czy potrzebny jest dostęp do wody i prądu na posesji?',
answer:
'Zapewnienie dostępu do zwykłego kranu ogrodowego pozwala na najbardziej efektywną realizację. Posiadamy myjki ze spalinowymi silnikami, więc nie potrzebujemy dostępu do prądu.',
},
],
},
{
blockType: 'reviews',
badge: 'Opinie Klientów',
heading: 'Co mówią właściciele wyczyszczonych posesji?',
subheading: 'Zaufali nam właściciele domów jednorodzinnych, osiedla mieszkaniowe i firmy.',
items: [
{
author: 'Piotr Wiśniewski',
location: 'Wrocław, Jagodno – Podjazd 220 m²',
rating: 5,
content:
'Kostka miała 12 lat i była cała w czarnym nalocie i mchu. Po wizycie ekipy POI Clean House wygląda dokładnie jak w dniu układania. Elewacja i ogrodzenie pozostały idealnie czyste!',
},
{
author: 'Katarzyna Nowak',
location: 'Długołęka – Taras i podjazd 160 m²',
rating: 5,
content:
'Impregnacja z efektem mokrej kostki przerosła moje oczekiwania. Kolor jest żywy, a plamy po oleju z kosiarki zniknęły bez śladu. Bardzo polecam!',
},
{
author: 'Tomasz Grabowski',
location: 'Trzebnica – Posesja firmowa 450 m²',
rating: 5,
content:
'Szybka, profesjonalna realizacja w uzgodnionym terminie. Wypłukany piasek został w pełni uzupełniony, a parking lśni. Pełen profesjonalizm.',
},
],
},
{
blockType: 'formBlock',
badge: 'Kontakt i Wycena',
heading: 'Skontaktuj się i otrzymaj bezpłatną wycenę',
subheading:
'Zadzwoń do nas lub wypełnij formularz – odezwiemy się w ciągu maksymalnie 2 godzin ze szczegółową kalkulacją kosztów.',
phone: '+48 668 973 737',
email: '[email protected]',
workingHours: 'Pon - Sob: 7:00 - 20:00',
submitButtonText: 'Wyślij zapytanie o bezpłatną wycenę',
consentText:
'Wyrażam zgodę na przetwarzanie moich danych osobowych w celu kontaktu i przygotowania oferty zgodnie z Polityką Prywatności.',
},
],
},
})
// C. Strona Realizacje
const realizacjePage = await payload.create({
collection: 'pages',
locale: 'pl',
data: {
title: 'Realizacje',
slug: 'realizacje',
layout: [
{
blockType: 'beforeAfterGallery',
badge: 'Efekty Prac',
heading: 'Galeria naszych realizacji Przed i Po',
subheading: 'Zobacz, jak nawet mocno zaniedbana i zarośnięta kostka brukowa odzyskuje fabryczny blask.',
items: [
{
title: 'Czyszczenie zarośniętego podjazdu z kostki betonowej (180 m²)',
category: 'Usuwanie mchu i porostów',
description:
'Wieloletni mech i porosty w szczelinach. Zastosowano mycie gorącowodne talerzem Mosmatic, chemię biobójczą oraz zasypanie suszonym piaskiem kwarcowym.',
},
{
title: 'Usuwanie plam z oleju silnikowego i tłuszczu (stanowisko garażowe)',
category: 'Odplamianie chemiczne',
description:
'Głębokie, 3-letnie plamy z oleju. Zastosowano odtłuszczacz penetrujący kapilary betonu oraz mycie parowe o temperaturze 100°C.',
},
],
},
{
blockType: 'formBlock',
badge: 'Wycena',
heading: 'Chcesz, aby Twój podjazd wyglądał jak nowy?',
subheading: 'Skontaktuj się z nami już dziś i umów dogodny termin realizacji.',
phone: '+48 668 973 737',
email: '[email protected]',
workingHours: 'Pon - Sob: 7:00 - 20:00',
},
],
},
})
// D. Strona O nas / Technologia
const oNasPage = await payload.create({
collection: 'pages',
locale: 'pl',
data: {
title: 'Technologia i Sprzęt',
slug: 'o-nas',
layout: [
{
blockType: 'techEquipment',
badge: 'O nas & Sprzęt',
heading: 'Technologia czyszczenia nawierzchni brukowych',
subheading:
'Jesteśmy wyspecjalizowaną firmą skupioną w 100% na nawierzchniach brukowych. Nie myjemy dywanów ani elewacji – całą naszą wiedzę i sprzęt inwestujemy w perfekcyjne odnawianie kostki.',
items: [
{
title: 'Przemysłowe myjki spalinowe z podgrzewaniem wody',
description:
'Niezależne od prądu urządzenia generujące ciśnienie do 250 barów i temperaturę wody do 100°C. Rozpuszczają brud bez niszczenia struktury materiału.',
},
{
title: 'Talerze powierzchniowe Mosmatic Surface Cleaner',
description:
'Opatentowana szwajcarska technologia bezrozbryzgowa. Cały brud i woda zostają pod kopułą talerza, chroniąc fasady budynków, cokoły i roślinność ogrodową.',
},
{
title: 'Certyfikowane impregnaty hydrofobowe',
description:
'Głęboko penetrujące impregnaty silikonowo-siloksanowe oraz polimerowe, zabezpieczające kostkę przed erozją mrozową, solą i zanieczyszczeniami.',
},
],
},
{
blockType: 'processSteps',
badge: 'Standard Jakości',
heading: 'Precyzja na każdym etapie prac',
subheading: 'Dbamy o każdy metr kwadratowy Twojej posesji.',
steps: [
{
stepNumber: '01',
title: 'Darmowa inspekcja',
description: 'Weryfikacja podłoża i dopasowanie odpowiednich preparatów chemicznych.',
},
{
stepNumber: '02',
title: 'Bezpieczne wykonanie',
description: 'Mycie z osłoną otoczenia i z zachowaniem bezpieczeństwa roślin ogrodowych.',
},
{
stepNumber: '03',
title: 'Zabezpieczenie na lata',
description: 'Uzupełnienie fug piaskiem kwarcowym oraz hydrofobizacja nawierzchni.',
},
],
},
{
blockType: 'formBlock',
badge: 'Zamówienie',
heading: 'Skontaktuj się ze specjalistami',
subheading: 'Zadzwoń lub napisz – chętnie odpowiemy na wszystkie pytania techniczne.',
phone: '+48 668 973 737',
email: '[email protected]',
workingHours: 'Pon - Sob: 7:00 - 20:00',
},
],
},
})
// E. Strona Kontakt
const kontaktPage = await payload.create({
collection: 'pages',
locale: 'pl',
data: {
title: 'Kontakt',
slug: 'kontakt',
layout: [
{
blockType: 'formBlock',
badge: 'Kontakt',
heading: 'Skontaktuj się z POI Clean House',
subheading:
'Działamy na terenie Opola oraz całego województwa opolskiego, dolnośląskiego i śląskiego. Wycena jest zawsze bezpłatna i niezobowiązująca.',
phone: '+48 668 973 737',
email: '[email protected]',
workingHours: 'Poniedziałek - Sobota: 7:00 - 20:00',
},
{
blockType: 'faqAccordion',
badge: 'FAQ',
heading: 'Często zadawane pytania',
subheading: 'Wszystko co warto wiedzieć przed przyjazdem ekipy.',
items: [
{
question: 'Jak szybko możecie zrealizować zlecenie?',
answer: 'W sezonie wiosenno-letnim staramy się realizować zlecenia w ciągu 3-7 dni roboczych od akceptacji wyceny.',
},
{
question: 'Czy muszę być obecny podczas prac?',
answer: 'Wystarczy udostępnić źródło wody na posesji. Po zakończeniu prac kontaktujemy się z Państwem w celu odbioru.',
},
],
},
],
},
})
// F. Strona Polityka Prywatności (System Page: privacyPolicy)
const privacyPage = await payload.create({
collection: 'pages',
locale: 'pl',
data: {
title: 'Polityka Prywatności',
slug: 'polityka-prywatnosci',
layout: [
{
blockType: 'content',
badge: 'RODO / GDPR',
heading: 'Polityka Prywatności i Ochrony Danych Osobowych',
body: `1. Administrator Danych Osobowych
Administratorem Państwa danych osobowych jest firma POI CLEAN HOUSE Dariusz Król z siedzibą w Opolu, ul. Opolska 7B, 45-960 Opole, NIP: 7542827484, REGON: 521920102, e-mail: it@intecion.com, tel: +48 668 973 737.
2. Cel przetwarzania danych
Dane osobowe podane w formularzu kontaktowym (imię, numer telefonu, miejscowość, metraż) przetwarzane są wyłącznie w celu przygotowania wyceny, kontaktu zwrotnego oraz realizacji zamówionej usługi czyszczenia nawierzchni brukowej (art. 6 ust. 1 lit. b RODO).
3. Prawa osoby, której dane dotyczą
Każda osoba ma prawo dostępu do swoich danych, ich sprostowania, usunięcia, ograniczenia przetwarzania oraz wniesienia sprzeciwu wobec przetwarzania danych.
4. Okres przechowywania danych
Dane przetwarzane na podstawie zgody będą przechowywane do czasu jej wycofania lub zakończenia realizacji umowy i upływu okresu przedawnienia ewentualnych roszczeń.`,
},
],
},
})
// G. Strona Polityka Cookies (System Page: cookiePolicy)
const cookiePage = await payload.create({
collection: 'pages',
locale: 'pl',
data: {
title: 'Polityka Cookies',
slug: 'polityka-cookies',
layout: [
{
blockType: 'content',
badge: 'Pliki Cookies',
heading: 'Polityka Plików Cookies',
body: `1. Czym są pliki cookies?
Pliki cookies (ciasteczka) to małe pliki tekstowe zapisywane na urządzeniu użytkownika podczas przeglądania serwisu internetowego.
2. Rodzaje wykorzystywanych cookies:
- Niezbędne: konieczne do prawidłowego działania strony, zapamiętania preferencji zgody oraz bezpieczeństwa sesji.
- Funkcjonalne: umożliwiają zapamiętanie wybranych ustawień (np. język serwisu).
- Analityczne: pomagają nam zrozumieć, jak użytkownicy korzystają ze strony, co pozwala na jej ciągłe ulepszanie.
3. Zarządzanie zgodą
W każdej chwili możesz zmienić swoje preferencje dotyczące plików cookies klikając w przycisk "Ustawienia cookies" w lewym dolnym rogu strony.`,
},
],
},
})
// H. Strona Blog / Poradnik
const blogPage = await payload.create({
collection: 'pages',
locale: 'pl',
data: {
title: 'Blog i Porady',
slug: 'blog',
layout: [
{
blockType: 'blogList',
badge: 'Baza Wiedzy i Poradnik',
heading: 'Eksperckie porady dotyczące pielęgnacji i czyszczenia nawierzchni brukowych',
subheading:
'Dowiedz się, jak prawidłowo dbać o podjazd, dlaczego temperatura wody ma kluczowe znaczenie przy odtłuszczaniu i jak zabezpieczyć kostkę przed zarastaniem.',
articles: [
{
title: 'Jak usunąć plamy z oleju silnikowego z kostki brukowej? Porównanie metod',
category: 'Odplamianie Chemiczne',
readTime: '5 min czytania',
date: '12 Lutego 2026',
excerpt:
'Olej silnikowy błyskawicznie wnika w pory betonu. Zwykłe detergenty domowe rozcierają plamę, powiększając jej powierzchnię. Sprawdź, jak przemysłowa woda 100°C i zasadowe odtłuszczacze rozbijają cząsteczki węglowodorów w kapilarach.',
},
{
title: 'Dlaczego mech odrasta po 3 tygodniach od mycia myjką ciśnieniową?',
category: 'Usuwanie Mchu i Biobójczość',
readTime: '4 min czytania',
date: '28 Stycznia 2026',
excerpt:
'Zimna woda pod ciśnieniem urywa jedynie nadziemną część mchu. Zarodniki i grzybnia pozostają nienaruszone w szczelinach. Rozwiązaniem jest dwuetapowa procedura: oprysk biobójczy i zasypanie spoin suchym piaskiem kwarcowym.',
},
{
title: 'Impregnacja hydrofobowa kostki brukowej – czy warto inwestować?',
category: 'Impregnacja i Ochrona',
readTime: '6 min czytania',
date: '15 Stycznia 2026',
excerpt:
'Woda zamarzająca zimą w mikroszczelinach betonu powoduje jego pękanie i łuszczenie. Impregnaty siloksanowe tworzą barierę odpychającą ciecze oraz wydobywają głęboki, nasycony kolor kostki.',
},
],
},
{
blockType: 'formBlock',
badge: 'Wycena',
heading: 'Potrzebujesz profesjonalnego czyszczenia?',
subheading: 'Skontaktuj się z naszym zespołem i otrzymaj darmową wycenę w ciągu 2 godzin.',
phone: '+48 668 973 737',
email: '[email protected]',
workingHours: 'Pon - Sob: 7:00 - 20:00',
},
],
},
})
// 4. Przypisanie stron systemowych do globala site-settings
payload.logger.info('Konfiguruję strony systemowe w SiteSettings...')
try {
await payload.updateGlobal({
slug: 'site-settings',
locale: 'pl',
data: {
siteName: 'POI Clean House',
homepage: homePage.id,
privacyPolicy: privacyPage.id,
cookiePolicy: cookiePage.id,
} as never,
})
} catch (err) {
payload.logger.warn(`Nie udało się automatycznie zaktualizować siteSettings: ${err}`)
}
// 5. Przypisanie menu do globala navigation
payload.logger.info('Konfiguruję menu w Navigation...')
try {
await payload.updateGlobal({
slug: 'navigation',
locale: 'pl',
data: {
tagline: 'Czyszczenie Kostki Brukowej',
headerItems: [
{
label: 'Cennik',
url: '#cennik',
},
{
label: 'Realizacje',
page: realizacjePage.id,
},
{
label: 'Technologia',
page: oNasPage.id,
},
{
label: 'Blog',
page: blogPage.id,
},
],
ctaButton: {
label: 'Kontakt',
page: kontaktPage.id,
},
footerTagline:
'Wyspecjalizowane usługi mycia gorącowodnego, usuwania mchu, odplamiania oleju, spoinowania i impregnacji hydrofobowej nawierzchni brukowych.',
footerItems: [
{
label: 'Strona główna',
page: homePage.id,
},
{
label: 'Cennik usług',
url: '#cennik',
},
{
label: 'Galeria realizacji',
page: realizacjePage.id,
},
{
label: 'Technologia i sprzęt',
page: oNasPage.id,
},
{
label: 'Poradnik i Blog',
page: blogPage.id,
},
{
label: 'Kontakt i wycena',
page: kontaktPage.id,
},
],
} as never,
})
} catch (err) {
payload.logger.warn(`Nie udało się zaktualizować navigation: ${err}`)
}
payload.logger.info('Seedowanie zakończone pomyślnie!')
}
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}