feat: complete POI Clean House website with Karcher theme, blog, interactive animations and full CMS configuration
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
legacy-peer-deps=true
|
||||
@intecion:registry=https://git.intecion.net/api/packages/IntecionSoftware/npm/
|
||||
@@ -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 -->
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+6006
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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ę',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 }),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'] },
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -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|.*\\..*).*)'],
|
||||
}
|
||||
@@ -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: [email protected], 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!')
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user