diff --git a/AGENTS.md b/AGENTS.md index b00b5ca..90216d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,15 +15,15 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex - **Dynamic Source:** All content MUST come dynamically from Payload CMS (collections, globals like `Company` and `Navigation`, block fields, seed script, or `@intecion/ipal-kit`). - If a prop is optional, render it conditionally only when present (`{prop && {prop}}`), NEVER provide fallback hardcoded Polish or English text inside JSX. -## 2. BRANDING & UI GUIDELINES (KÄRCHER STYLE) -- Colors: - - Primary Yellow: `#FFED00` (Hover: `#FAE100`) - - Dark Anthracite: `#1F1F1F` - - Deep Black: `#141414` - - Industrial Light Gray: `#F4F4F4` +## 2. BRANDING & UI GUIDELINES +- Colors (Managed centrally in `src/app/(frontend)/theme.css`): + - Primary Cyan: `#01aeee` (Hover: `#0098d4` / class `bg-primary`, `text-primary`, `border-primary`) + - Accent Red: `#ee1d23` (Hover: `#d9141a` / class `bg-accent`, `text-accent`, `border-accent`) + - Dark Anthracite/Black: `#231f20` (Hover: `#332d2e` / class `bg-dark`, `text-dark`, `border-dark`) + - Industrial Light Gray: `#f4f4f4` (class `bg-surface-gray`) - Architecture: - - Sharp, geometric corners (`rounded-none` / `0px radius`). No bubbly rounded-3xl or generic AI circles. - - Checkboxes: Square with 2px black border, yellow `#FFED00` checked background, and thick black checkmark (`stroke-[3]`). + - Sharp, geometric corners (`rounded-none` / `0px radius`). No bubbly rounded-3xl or generic circles. + - Checkboxes: Square with 2px dark border (`border-dark`), primary cyan (`bg-primary`) checked background, and thick dark checkmark (`stroke-[3] text-dark`). - High contrast typography: Uppercase, bold/black weights with letter-spacing. ## 3. UX & ACCESSIBILITY diff --git a/public/images/logo-icon.webp b/public/images/logo-icon.webp new file mode 100644 index 0000000..821dd0f Binary files /dev/null and b/public/images/logo-icon.webp differ diff --git a/public/images/logo-white.webp b/public/images/logo-white.webp new file mode 100644 index 0000000..a0443d8 Binary files /dev/null and b/public/images/logo-white.webp differ diff --git a/public/images/logo.webp b/public/images/logo.webp new file mode 100644 index 0000000..64f0bfb Binary files /dev/null and b/public/images/logo.webp differ diff --git a/public/images/paving-hero.jpg b/public/images/paving-hero.jpg deleted file mode 100644 index d942e74..0000000 Binary files a/public/images/paving-hero.jpg and /dev/null differ diff --git a/public/images/paving-parallax.webp b/public/images/paving-parallax.webp new file mode 100644 index 0000000..7c11c6d Binary files /dev/null and b/public/images/paving-parallax.webp differ diff --git a/public/images/process-parallax.webp b/public/images/process-parallax.webp new file mode 100644 index 0000000..6015a72 Binary files /dev/null and b/public/images/process-parallax.webp differ diff --git a/src/app/(frontend)/[locale]/layout.tsx b/src/app/(frontend)/[locale]/layout.tsx index 47d821e..67802cc 100644 --- a/src/app/(frontend)/[locale]/layout.tsx +++ b/src/app/(frontend)/[locale]/layout.tsx @@ -58,18 +58,6 @@ export default async function LocaleLayout({ children, params }: LayoutProps) { // 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, @@ -83,19 +71,69 @@ export default async function LocaleLayout({ children, params }: LayoutProps) { getAnalyticsConfig(payload), ]) + const privacyPath = privacyPage + ? getSystemPagePath({ config: i18nConfig, locale, page: privacyPage }) + : undefined + const privacyHref = privacyPath || `/${locale}/polityka-prywatnosci` + const privacyLabel = + (typeof privacyPage === 'object' && privacyPage && 'title' in privacyPage && privacyPage.title + ? (privacyPage.title as string) + : texts.privacyLink?.label) || (locale === 'pl' ? 'Polityka prywatności' : 'Privacy Policy') + + const cookiePath = cookiePage + ? getSystemPagePath({ config: i18nConfig, locale, page: cookiePage }) + : undefined + const cookieHref = cookiePath || `/${locale}/polityka-cookies` + const cookieLabel = + (typeof cookiePage === 'object' && cookiePage && 'title' in cookiePage && cookiePage.title + ? (cookiePage.title as string) + : undefined) || (locale === 'pl' ? 'Polityka cookies' : 'Cookie Policy') + + const termsPage = (settings as { termsOfService?: unknown })?.termsOfService + const termsPath = termsPage + ? getSystemPagePath({ config: i18nConfig, locale, page: termsPage }) + : undefined + const termsHref = termsPath || `/${locale}/regulamin` + const termsLabel = + (typeof termsPage === 'object' && termsPage && 'title' in termsPage && termsPage.title + ? (termsPage.title as string) + : undefined) || (locale === 'pl' ? 'Regulamin strony' : 'Terms of Service') + + const homeHref = homePage + ? getSystemPagePath({ config: i18nConfig, locale, page: homePage }) || '/' + : '/' + const headerItems: NavItem[] = - navigation?.headerItems?.map((item) => ({ - id: item.id || undefined, - label: item.label, - href: resolveNavItemHref(item), - })) || [] + navigation?.headerItems + ?.filter((item) => { + if (!item.label) return false + if (item.url) return true + if (item.page && typeof item.page === 'object' && 'slug' in item.page && Boolean(item.page.slug)) { + return true + } + return false + }) + .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), - })) || [] + navigation?.footerItems + ?.filter((item) => { + if (!item.label) return false + if (item.url) return true + if (item.page && typeof item.page === 'object' && 'slug' in item.page && Boolean(item.page.slug)) { + return true + } + return false + }) + .map((item) => ({ + id: item.id || undefined, + label: item.label, + href: resolveNavItemHref(item), + })) || [] const contactHref = navigation?.ctaButton?.url || @@ -104,7 +142,7 @@ export default async function LocaleLayout({ children, params }: LayoutProps) { return ( - +
- + diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index eb00681..34adb65 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -1,22 +1,7 @@ @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; -} +@import "./theme.css"; html { scroll-behavior: smooth; @@ -26,7 +11,7 @@ html { body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #ffffff; - color: #222222; + color: var(--color-brand-dark); } h1, h2, h3, h4, .font-karcher { diff --git a/src/app/(frontend)/theme.css b/src/app/(frontend)/theme.css new file mode 100644 index 0000000..cbfe9c0 --- /dev/null +++ b/src/app/(frontend)/theme.css @@ -0,0 +1,33 @@ +/* Centralized Brand Color Palette & Theme Tokens */ +@theme { + /* Core 3 Brand Colors */ + --color-brand-cyan: #01aeee; + --color-brand-cyan-hover: #0098d4; + --color-brand-red: #ee1d23; + --color-brand-red-hover: #d9141a; + --color-brand-dark: #231f20; + --color-brand-dark-hover: #332d2e; + + /* Semantic Aliases */ + --color-primary: #01aeee; + --color-primary-hover: #0098d4; + --color-accent: #ee1d23; + --color-accent-hover: #d9141a; + --color-dark: #231f20; + --color-dark-hover: #332d2e; + + /* Neutrals & Industrial Surfaces */ + --color-surface-white: #ffffff; + --color-surface-gray: #f4f4f4; + --color-surface-gray-alt: #f8f8f8; + --color-surface-border: #e0e0e0; + --color-muted-text: #666666; +} + +:root { + /* IPAL Kit / Framework Variables */ + --ipal-primary: #01aeee; + --ipal-primary-hover: #0098d4; + --ipal-secondary: #ee1d23; + --ipal-radius: 0px; +} diff --git a/src/app/actions/submitFormAction.ts b/src/app/actions/submitFormAction.ts new file mode 100644 index 0000000..85b5a1d --- /dev/null +++ b/src/app/actions/submitFormAction.ts @@ -0,0 +1,49 @@ +'use server' + +import { submitForm } from '@intecion/ipal-kit/server' +import { getCachedPayload } from '@/lib/content' +import { getNotificationTexts, resolveFormMessage } from '@intecion/ipal-kit' +import { headers } from 'next/headers' + +export async function submitFormAction({ + formId, + data, + locale = 'pl', +}: { + formId: string | number + data: Record + locale?: string +}) { + try { + const payload = await getCachedPayload() + const headerList = await headers() + const ip = headerList.get('x-forwarded-for')?.split(',')[0]?.trim() || headerList.get('x-real-ip') || '127.0.0.1' + + const result = await submitForm({ + payload, + formId: String(formId), + data, + ip, + }) + + if (!result.success) { + const notifications = await getNotificationTexts({ payload, locale }) + const errorMessage = resolveFormMessage(result, notifications?.form) + return { + success: false, + message: errorMessage || 'Wystąpił błąd podczas wysyłania formularza. Spróbuj ponownie później.', + } + } + + return { + success: true, + submissionId: result.submissionId, + } + } catch (err) { + console.error('Error executing submitFormAction:', err) + return { + success: false, + message: 'Wystąpił nieoczekiwany błąd serwera. Skontaktuj się z nami telefonicznie.', + } + } +} diff --git a/src/blocks/BeforeAfterGallery/Component.tsx b/src/blocks/BeforeAfterGallery/Component.tsx index 60437df..63f5b39 100644 --- a/src/blocks/BeforeAfterGallery/Component.tsx +++ b/src/blocks/BeforeAfterGallery/Component.tsx @@ -20,19 +20,19 @@ export function BeforeAfterGalleryBlockComponent(props: BeforeAfterGalleryBlockP const { badge, heading, subheading, items } = props return ( -
+
{badge && ( -
+
{badge}
)} -

+

{heading}

{subheading && ( -

+

{subheading}

)} @@ -43,15 +43,15 @@ export function BeforeAfterGalleryBlockComponent(props: BeforeAfterGalleryBlockP {items.map((item, idx) => (
{item.category && ( - + {item.category} )} -

+

{item.title}

{item.description && ( @@ -61,13 +61,13 @@ export function BeforeAfterGalleryBlockComponent(props: BeforeAfterGalleryBlockP )}
-
-
+
+
Czyszczenie gorącowodne Mosmatic
-
+
Szczegóły procedury - +
diff --git a/src/blocks/BlogList/Component.tsx b/src/blocks/BlogList/Component.tsx index 4ac0ebc..3c22455 100644 --- a/src/blocks/BlogList/Component.tsx +++ b/src/blocks/BlogList/Component.tsx @@ -23,20 +23,20 @@ export function BlogListBlockComponent(props: BlogListBlockProps) { const { badge, heading, subheading, articles } = props return ( -
+
{badge && ( -
+
{badge}
)} -

+

{heading}

{subheading && ( -

+

{subheading}

)} @@ -48,42 +48,44 @@ export function BlogListBlockComponent(props: BlogListBlockProps) { {articles.map((art, idx) => (
-
- {/* Top Row: Category Badge & Read Time (No Wrapping / No Overlap) */} -
- +
+ {/* Top Row: Category Badge */} +
+ {art.category} +
+ +
+

+ {art.title} +

{art.readTime && ( - - +
+ {art.readTime} - +
)}
-

- {art.title} -

- -

+

{art.excerpt}

-
+
{art.date && ( - + {art.date} )} {props.readMoreLabel && ( -
+
{props.readMoreLabel} - +
)}
diff --git a/src/blocks/Content/Component.tsx b/src/blocks/Content/Component.tsx index 26d621b..7d3bd57 100644 --- a/src/blocks/Content/Component.tsx +++ b/src/blocks/Content/Component.tsx @@ -10,15 +10,15 @@ export function ContentBlockComponent(props: ContentBlockProps) { const { badge, heading, body } = props return ( -
+
{badge && ( -
+
{badge}
)} {heading && ( -

+

{heading}

)} diff --git a/src/blocks/FaqAccordion/Component.tsx b/src/blocks/FaqAccordion/Component.tsx index d00520e..2a07045 100644 --- a/src/blocks/FaqAccordion/Component.tsx +++ b/src/blocks/FaqAccordion/Component.tsx @@ -25,20 +25,20 @@ export function FaqAccordionBlockComponent(props: FaqAccordionBlockProps) { } return ( -
+
{badge && ( -
+
{badge}
)} -

+

{heading}

{subheading && ( -

+

{subheading}

)} @@ -52,17 +52,17 @@ export function FaqAccordionBlockComponent(props: FaqAccordionBlockProps) { return (
diff --git a/src/blocks/Form/Component.tsx b/src/blocks/Form/Component.tsx index 9fab884..fe34c50 100644 --- a/src/blocks/Form/Component.tsx +++ b/src/blocks/Form/Component.tsx @@ -1,32 +1,49 @@ 'use client' import React, { useState } from 'react' -import { Phone, Mail, Clock, Send, Check } from 'lucide-react' +import { Phone, Mail, Clock, MapPin, CheckCircle, AlertCircle, ArrowRight, Loader2 } from 'lucide-react' import { AnimateIn } from '@/components/AnimateIn' +import { submitFormAction } from '@/app/actions/submitFormAction' + +interface FormField { + id?: string + blockType: string + name?: string + label?: string + required?: boolean + width?: number + defaultValue?: string + options?: { label: string; value: string; id?: string }[] + message?: unknown +} + +interface FormDoc { + id: string | number + title?: string + fields?: FormField[] + submitButtonLabel?: string + confirmationType?: 'message' | 'redirect' + confirmationMessage?: unknown +} interface FormBlockProps { - badge?: string - heading: string - subheading?: string - phone?: string - directPhoneLabel?: string - email?: string - emailContactLabel?: string - workingHours?: string - workingHoursLabel?: string - nameLabel?: string - phoneInputLabel?: string - locationLabel?: string - areaLabel?: string - messageLabel?: string - submitButtonText?: string - consentText?: string - successHeading?: string - successMessage?: string + form?: FormDoc | string | number | null + badge?: string | null + heading?: string | null + subheading?: string | null + phone?: string | null + directPhoneLabel?: string | null + email?: string | null + emailContactLabel?: string | null + workingHours?: string | null + workingHoursLabel?: string | null + address?: string | null + addressLabel?: string | null } export function FormBlockComponent(props: FormBlockProps) { const { + form, badge, heading, subheading, @@ -36,280 +53,326 @@ export function FormBlockComponent(props: FormBlockProps) { emailContactLabel, workingHours, workingHoursLabel, - nameLabel, - phoneInputLabel, - locationLabel, - areaLabel, - messageLabel, - submitButtonText, - consentText, - successHeading, - successMessage, + address, + addressLabel, } = props - const [submitted, setSubmitted] = useState(false) - const [loading, setLoading] = useState(false) - const [consentChecked, setConsentChecked] = useState(true) + const formDoc = typeof form === 'object' && form !== null ? form : null + const formId = formDoc?.id || (typeof form === 'string' || typeof form === 'number' ? form : null) + const fields = formDoc?.fields || [] - const handleSubmit = (e: React.FormEvent) => { + const [formData, setFormData] = useState>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSubmitted, setIsSubmitted] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const handleInputChange = (name: string, value: unknown) => { + setFormData((prev) => ({ + ...prev, + [name]: value, + })) + if (errorMessage) setErrorMessage(null) + } + + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!consentChecked) return - setLoading(true) - setTimeout(() => { - setLoading(false) - setSubmitted(true) - }, 600) + if (!formId) return + + setIsSubmitting(true) + setErrorMessage(null) + + try { + const res = await submitFormAction({ + formId, + data: formData, + }) + + if (res.success) { + setIsSubmitted(true) + } else { + setErrorMessage(res.message || 'Wystąpił błąd podczas wysyłania.') + } + } catch { + setErrorMessage('Nie udało się wysłać formularza. Spróbuj ponownie później.') + } finally { + setIsSubmitting(false) + } } return ( -
+
-
- {/* Left Info Column */} -
+
+ {/* Left Column: Industrial Contact Info */} +
{badge && ( -
+
{badge}
)} -

- {heading} -

+ {heading && ( +

+ {heading} +

+ )} {subheading && ( -

+

{subheading}

)}
-
- {phone && ( - -
-
+ + - - )} + + )} - {email && ( - -
-
+ {/* Email Card */} + {email && ( + +
+ + )} + + {/* Address Card */} + {address && ( +
+
+ +
+
+ {addressLabel && ( + + {addressLabel} + + )} + + {address} +
- - )} + )} - {workingHours && ( - -
-
+ {/* Working Hours Card */} + {workingHours && ( +
+
{workingHoursLabel && ( -
+ {workingHoursLabel} -
+ )} -
+ {workingHours} -
+
- - )} -
+ )} +
+
- {/* Right Form Box */} -
+ {/* Right Column: Built-in Form Engine Form */} +
-
- {submitted ? ( -
-
- +
+ {isSubmitted ? ( +
+
+
- {successHeading && ( -

- {successHeading} -

- )} - {successMessage && ( -

- {successMessage} -

- )} +

+ Dziękujemy za zgłoszenie! +

+

+ Twoja wiadomość została zapisana w systemie. Nasz doradca techniczny skontaktuje się z Tobą w najbliższym czasie. +

) : ( -
-
-
- {nameLabel && ( - - )} - + + {/* Error Banner */} + {errorMessage && ( +
+ + {errorMessage}
-
- {phoneInputLabel && ( - - )} - -
-
+ )} -
-
- {locationLabel && ( - - )} - -
-
- {areaLabel && ( - - )} - -
-
+ {/* Dynamic Fields Grid */} +
+ {fields.map((field, idx) => { + const fieldName = field.name || `field_${idx}` + const isFullWidth = + field.blockType === 'textarea' || + field.blockType === 'checkbox' || + field.blockType === 'message' || + (field.width && field.width >= 100) -
- {messageLabel && ( - - )} -