feat: complete UI rebuild, responsive fixes, dark mode UI, and Team block overhaul
This commit is contained in:
@@ -8,3 +8,31 @@ Skrypt w `package.json` ma wbudowane automatyczne ubijanie serwera działająceg
|
|||||||
`"db:reset": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true; rm -f dev.db && cross-env NODE_OPTIONS=--no-deprecation payload run ./scripts/seed.ts"`
|
`"db:reset": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true; rm -f dev.db && cross-env NODE_OPTIONS=--no-deprecation payload run ./scripts/seed.ts"`
|
||||||
|
|
||||||
Jeśli błąd się powtórzy, upewnij się, że nie działają inne ukryte procesy trzymające połączenie do `dev.db`.
|
Jeśli błąd się powtórzy, upewnij się, że nie działają inne ukryte procesy trzymające połączenie do `dev.db`.
|
||||||
|
|
||||||
|
## Migracje i podatność strony Kontakt (ID 5)
|
||||||
|
|
||||||
|
**Ostrzeżenie:** Przy pisaniu jakichkolwiek skryptów modyfikujących układ stron (`layout`) w Payload CMS, ZAWSZE sprawdzaj stan strony **Kontakt (ID 5)** przed i po migracji.
|
||||||
|
W przeszłości wadliwe skrypty (np. wczesna wersja `migrate_faq.ts`) przypisały do tej strony cudze bloki, co spowodowało całkowite nadpisanie układu (usunięcie `pageHeader` i `contactInfo`), a następnie skrypt "naprawczy" (`fix_kontakt.ts`) wyczyścił ten uszkodzony layout, zostawiając stronę z `layout: []` we wszystkich językach.
|
||||||
|
Zawsze sprawdzaj, czy modyfikując lub filtrując tablicę bloków dla jednej konkretnej strony (np. FAQ o ID 6), nie nadpisujesz omyłkowo strony sąsiedniej (np. Kontakt o ID 5).
|
||||||
|
|
||||||
|
## Protokół Testowy: Hero i PageHeader (Responsywność i Motywy) - ZAKOŃCZONY
|
||||||
|
|
||||||
|
### Rozwiązanie "Pustego Czarnego Obszaru"
|
||||||
|
Użytkownik słusznie zauważył, że w Dark Mode zamiast placeholdera pokazywał się pusty, czarny obszar z samym pływającym statem. Diagnoza wykazała, że klasa `.bg-paper-noise` w `globals.css` wymuszała `background-color: var(--bg-color);`. Sprawiało to, że element Hero zyskiwał kolor absolutnie identyczny z tłem całej strony (`#17140F`), co dawało efekt "próżni".
|
||||||
|
**Naprawa:** Usunięto sztywny `background-color` z `.bg-paper-noise`. Teraz przezroczysty szum nakłada się poprawnie na `bg-card` (które w ciemnym motywie ma `#1F1B14`), wyodrębniając wizualnie blok Hero na tle reszty witryny.
|
||||||
|
|
||||||
|
### Wykonanie Pełnego Testu (10 stron × 2 motywy)
|
||||||
|
Dla pewności użyto tymczasowego wymuszenia `forcedTheme="dark"` w `Providers/index.tsx`, po czym puszczono 10 stron (PL i DE) przez `impeccable detect` podczas działania serwera developerskiego w tle.
|
||||||
|
Macierz wynikowa:
|
||||||
|
1. `/pl` (Hero) - Light: OK | Dark: OK (0 occlusion/contrast errors)
|
||||||
|
2. `/pl/uslugi` (PageHeader) - Light: OK | Dark: OK
|
||||||
|
3. `/pl/jak-sie-umowic` (PageHeader) - Light: OK | Dark: OK
|
||||||
|
4. `/pl/o-nas` (PageHeader) - Light: OK | Dark: OK
|
||||||
|
5. `/pl/faq` (PageHeader) - Light: OK | Dark: OK
|
||||||
|
6. `/pl/kontakt` (PageHeader) - Light: OK | Dark: OK
|
||||||
|
7. `/de` (Hero) - Light: OK | Dark: OK
|
||||||
|
8. `/de/leistungen` (PageHeader) - Light: OK | Dark: OK
|
||||||
|
9. `/de/terminvereinbarung` (PageHeader) - Light: OK | Dark: OK
|
||||||
|
10. `/de/uber-uns` (PageHeader) - Light: OK | Dark: OK
|
||||||
|
|
||||||
|
Brak błędów na jakiejkolwiek stronie. Tło widoczne. Text-occlusion: wyeliminowane. Kontrast > 4.5:1.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ placeholderach niezależnie od tego, jak dobrze zbudowany będzie kod.
|
|||||||
| 7 | Logo/marka własnej firmy Janusza (jeśli istnieje osobne od b.b.h.) | `site-settings.logo`, favicon | brak potwierdzenia czy istnieje |
|
| 7 | Logo/marka własnej firmy Janusza (jeśli istnieje osobne od b.b.h.) | `site-settings.logo`, favicon | brak potwierdzenia czy istnieje |
|
||||||
| 8 | Forma prawna działalności, ew. Steuernummer/USt-IdNr. | strony prawne (Impressum) | nieustalone, patrz [09-strony-prawne.md](./09-strony-prawne.md) §1 |
|
| 8 | Forma prawna działalności, ew. Steuernummer/USt-IdNr. | strony prawne (Impressum) | nieustalone, patrz [09-strony-prawne.md](./09-strony-prawne.md) §1 |
|
||||||
| 9 | Czy istnieje formularz członkostwa/składka w ramach b.b.h. (dla treści strony Stowarzyszenie) | `/stowarzyszenie` | nieustalone, patrz [07-stowarzyszenie.md](./07-stowarzyszenie.md) §2 Sekcja D |
|
| 9 | Czy istnieje formularz członkostwa/składka w ramach b.b.h. (dla treści strony Stowarzyszenie) | `/stowarzyszenie` | nieustalone, patrz [07-stowarzyszenie.md](./07-stowarzyszenie.md) §2 Sekcja D |
|
||||||
| 10 | Skala firmy: czy to tylko Janusz, czy zespół | blok `team` na `/o-nas` | dane pokazują 1 osobę — potwierdzić, nie zgadywać (patrz [05-o-nas.md](./05-o-nas.md)) |
|
| 10 | Skala firmy: zespół ~20 osób | blok `team` na `/o-nas` | Potrzebne dane (imię, nazwisko, rola, opcjonalnie zdjęcie i krótki opis) dla każdej z ~20 osób, żeby wypełnić panel. |
|
||||||
| 11 | Aktualność progów `18 000 € / 36 000 €` i `§4 Nr. 11 StBerG` na bieżący rok podatkowy | blok `eligibility` | treść już jest, wymaga tylko potwierdzenia aktualności, nie przepisywania |
|
| 11 | Aktualność progów `18 000 € / 36 000 €` i `§4 Nr. 11 StBerG` na bieżący rok podatkowy | blok `eligibility` | treść już jest, wymaga tylko potwierdzenia aktualności, nie przepisywania |
|
||||||
| 12 | Teksty bannera cookies PL/DE | global `Cookie Settings` w panelu | puste w bazie — renderuje się angielski fallback z pluginu, wymaga uzupełnienia |
|
| 12 | Teksty bannera cookies PL/DE | global `Cookie Settings` w panelu | puste w bazie — renderuje się angielski fallback z pluginu, wymaga uzupełnienia |
|
||||||
|
|
||||||
@@ -49,6 +49,8 @@ button — patrz [02-strona-glowna.md](./02-strona-glowna.md) §0. Dane
|
|||||||
(`whatsappNumber`) już istnieją w globalu `Company` — tylko brakuje
|
(`whatsappNumber`) już istnieją w globalu `Company` — tylko brakuje
|
||||||
komponentu w `layout.tsx`.
|
komponentu w `layout.tsx`.
|
||||||
|
|
||||||
|
**Propozycja na przyszłość (Team Modal CTA):** W modalu komponentu Zespół (TeamBlock) można dodać dedykowany przycisk CTA "Napisz na WhatsApp" kierujący do `https://wa.me/...` z prefillowaną wiadomością (np. `?text=Dzień dobry, chciałbym umówić się z [Imię członka zespołu]`). Wymaga to akceptacji klienta.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Media — kolekcja i pipeline
|
## 3. Media — kolekcja i pipeline
|
||||||
|
|||||||
@@ -142,7 +142,7 @@
|
|||||||
{
|
{
|
||||||
"id": "0be1dd17cae558b75684137c",
|
"id": "0be1dd17cae558b75684137c",
|
||||||
"title": "Kontakt",
|
"title": "Kontakt",
|
||||||
"subtitle": "Masz pytania dotyczące rozliczenia podatkowego w Niemczech? Skontaktuj się ze mną — chętnie pomogę.",
|
"subtitle": "Masz pytania dotyczące rozliczenia podatkowego w Niemczech? Skontaktuj się z nami — chętnie pomożemy.",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"blockType": "pageHeader"
|
"blockType": "pageHeader"
|
||||||
},
|
},
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
{
|
{
|
||||||
"id": "5c16dbfeb63e8be48f313eb0",
|
"id": "5c16dbfeb63e8be48f313eb0",
|
||||||
"title": "Kontakt",
|
"title": "Kontakt",
|
||||||
"subtitle": "Haben Sie Fragen zu Ihrer Steuererklärung in Deutschland? Kontaktieren Sie mich — ich helfe Ihnen gerne.",
|
"subtitle": "Haben Sie Fragen zu Ihrer Steuererklärung in Deutschland? Kontaktieren Sie uns — wir helfen Ihnen gerne.",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"blockType": "pageHeader"
|
"blockType": "pageHeader"
|
||||||
},
|
},
|
||||||
@@ -286,7 +286,7 @@
|
|||||||
"id": "6a993b67a0e23e2a75a4cc85",
|
"id": "6a993b67a0e23e2a75a4cc85",
|
||||||
"eyebrow": null,
|
"eyebrow": null,
|
||||||
"title": "Gotowy na rozliczenie podatku?",
|
"title": "Gotowy na rozliczenie podatku?",
|
||||||
"subtitle": "Skontaktuj się ze mną już dziś. Przeanalizuję Twoją sytuację i pomogę uzyskać maksymalny zwrot z niemieckiego urzędu skarbowego.",
|
"subtitle": "Skontaktuj się z nami już dziś. Przeanalizujemy Twoją sytuację i pomożemy uzyskać maksymalny zwrot z niemieckiego urzędu skarbowego.",
|
||||||
"buttonText": "Napisz na WhatsApp",
|
"buttonText": "Napisz na WhatsApp",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"blockType": "finalCta"
|
"blockType": "finalCta"
|
||||||
@@ -361,7 +361,7 @@
|
|||||||
"id": "6a993b67a0e23e2a75a4cc8e",
|
"id": "6a993b67a0e23e2a75a4cc8e",
|
||||||
"eyebrow": null,
|
"eyebrow": null,
|
||||||
"title": "Bereit für Ihre Steuererklärung?",
|
"title": "Bereit für Ihre Steuererklärung?",
|
||||||
"subtitle": "Kontaktieren Sie mich noch heute. Ich analysiere Ihre Situation und unterstütze Sie bei der optimalen Erstattung.",
|
"subtitle": "Kontaktieren Sie uns noch heute. Wir analysieren Ihre Situation und unterstützen Sie bei der optimalen Erstattung.",
|
||||||
"buttonText": "Auf WhatsApp schreiben",
|
"buttonText": "Auf WhatsApp schreiben",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"blockType": "finalCta"
|
"blockType": "finalCta"
|
||||||
@@ -389,7 +389,7 @@
|
|||||||
"id": "6a995ca5ea2f1a36632134e0",
|
"id": "6a995ca5ea2f1a36632134e0",
|
||||||
"eyebrow": null,
|
"eyebrow": null,
|
||||||
"title": "Rozliczenie w 3 prostych krokach",
|
"title": "Rozliczenie w 3 prostych krokach",
|
||||||
"subtitle": "Zapewniam pełne wsparcie i jasną komunikację na każdym etapie.",
|
"subtitle": "Zapewniamy pełne wsparcie i jasną komunikację na każdym etapie.",
|
||||||
"ctaText": null,
|
"ctaText": null,
|
||||||
"ctaUrl": null,
|
"ctaUrl": null,
|
||||||
"image": 1,
|
"image": 1,
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
{
|
{
|
||||||
"id": "6a995ca5ea2f1a36632134dd",
|
"id": "6a995ca5ea2f1a36632134dd",
|
||||||
"title": "Napisz na WhatsApp",
|
"title": "Napisz na WhatsApp",
|
||||||
"description": "Skontaktuj się ze mną bezpośrednio przez WhatsApp lub formularz poniżej, aby rozpocząć i ustalić szczegóły Twojej sytuacji podatkowej."
|
"description": "Skontaktuj się z nami bezpośrednio przez WhatsApp lub formularz poniżej, aby rozpocząć i ustalić szczegóły Twojej sytuacji podatkowej."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "6a995ca5ea2f1a36632134de",
|
"id": "6a995ca5ea2f1a36632134de",
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
{
|
{
|
||||||
"id": "6a995ca5ea2f1a36632134df",
|
"id": "6a995ca5ea2f1a36632134df",
|
||||||
"title": "Odbierz gotowe rozliczenie do podpisu",
|
"title": "Odbierz gotowe rozliczenie do podpisu",
|
||||||
"description": "Przygotowuję kompletną deklarację, wyliczam przewidywany zwrot z urzędu skarbowego i przesyłam gotowy dokument do Twojej akceptacji i podpisu."
|
"description": "Przygotowujemy kompletną deklarację, wyliczamy przewidywany zwrot z urzędu skarbowego i przesyłamy gotowy dokument do Twojej akceptacji i podpisu."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"blockType": "process"
|
"blockType": "process"
|
||||||
@@ -426,7 +426,7 @@
|
|||||||
"id": "6a995ca5ea2f1a36632134e2",
|
"id": "6a995ca5ea2f1a36632134e2",
|
||||||
"eyebrow": null,
|
"eyebrow": null,
|
||||||
"title": "Chcesz szybko odzyskać podatek?",
|
"title": "Chcesz szybko odzyskać podatek?",
|
||||||
"subtitle": "Nie zwlekaj z rozliczeniem. Napisz teraz, a zajmę się formalnościami od początku do końca.",
|
"subtitle": "Nie zwlekaj z rozliczeniem. Napisz teraz, a zajmiemy się formalnościami od początku do końca.",
|
||||||
"buttonText": "Napisz na WhatsApp",
|
"buttonText": "Napisz na WhatsApp",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"blockType": "finalCta"
|
"blockType": "finalCta"
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
{
|
{
|
||||||
"id": "6a995ca5ea2f1a36632134e4",
|
"id": "6a995ca5ea2f1a36632134e4",
|
||||||
"title": "WhatsApp kontaktieren",
|
"title": "WhatsApp kontaktieren",
|
||||||
"description": "Schreiben Sie mir direkt über WhatsApp oder das untenstehende Formular, um Ihre Anfrage und steuerliche Situation zu besprechen."
|
"description": "Schreiben Sie uns direkt über WhatsApp oder das untenstehende Formular, um Ihre Anfrage und steuerliche Situation zu besprechen."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "6a995ca5ea2f1a36632134e5",
|
"id": "6a995ca5ea2f1a36632134e5",
|
||||||
@@ -470,7 +470,7 @@
|
|||||||
{
|
{
|
||||||
"id": "6a995ca5ea2f1a36632134e6",
|
"id": "6a995ca5ea2f1a36632134e6",
|
||||||
"title": "Fertige Erklärung zur Unterschrift",
|
"title": "Fertige Erklärung zur Unterschrift",
|
||||||
"description": "Ich erstelle Ihre vollständige Erklärung, berechne die voraussichtliche Erstattung und übermittle Ihnen die Unterlagen zur Freigabe."
|
"description": "Wir erstellen Ihre vollständige Erklärung, berechnen die voraussichtliche Erstattung und übermitteln Ihnen die Unterlagen zur Freigabe."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"blockType": "process"
|
"blockType": "process"
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
"id": "6a995ca5ea2f1a36632134e9",
|
"id": "6a995ca5ea2f1a36632134e9",
|
||||||
"eyebrow": null,
|
"eyebrow": null,
|
||||||
"title": "Möchten Sie Ihre Steuererstattung sichern?",
|
"title": "Möchten Sie Ihre Steuererstattung sichern?",
|
||||||
"subtitle": "Zögern Sie nicht mit Ihrer Steuererklärung. Kontaktieren Sie mich jetzt für eine schnelle Bearbeitung.",
|
"subtitle": "Zögern Sie nicht mit Ihrer Steuererklärung. Kontaktieren Sie uns jetzt für eine schnelle Bearbeitung.",
|
||||||
"buttonText": "Auf WhatsApp schreiben",
|
"buttonText": "Auf WhatsApp schreiben",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"blockType": "finalCta"
|
"blockType": "finalCta"
|
||||||
@@ -514,8 +514,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "6a988b8714359f1c1b30efd0",
|
"id": "6a988b8714359f1c1b30efd0",
|
||||||
"title": "Czym się zajmuję",
|
"title": "Czym się zajmujemy",
|
||||||
"subtitle": "Zapewniam rzetelne i kompleksowe wsparcie na każdym etapie rozliczenia podatku dochodowego w Niemczech.",
|
"subtitle": "Zapewniamy rzetelne i kompleksowe wsparcie na każdym etapie rozliczenia podatku dochodowego w Niemczech.",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
@@ -642,7 +642,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "6a988b8714359f1c1b30efe4",
|
"id": "6a988b8714359f1c1b30efe4",
|
||||||
"title": "Was ich anbiete",
|
"title": "Was wir anbieten",
|
||||||
"subtitle": "Umfassende und zuverlässige Betreuung bei allen Fragen rund um Ihre Einkommensteuererklärung.",
|
"subtitle": "Umfassende und zuverlässige Betreuung bei allen Fragen rund um Ihre Einkommensteuererklärung.",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"services": [
|
"services": [
|
||||||
@@ -789,7 +789,7 @@
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "6a988b8614359f1c1b30ef8d",
|
"id": "6a988b8614359f1c1b30ef8d",
|
||||||
"text": "Działam w ramach b.b.h.",
|
"text": "Działamy w ramach b.b.h.",
|
||||||
"icon": "shield"
|
"icon": "shield"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -814,7 +814,7 @@
|
|||||||
"id": "6a9917f7eb2fb5197987f4ba",
|
"id": "6a9917f7eb2fb5197987f4ba",
|
||||||
"eyebrow": null,
|
"eyebrow": null,
|
||||||
"title": "Rozliczenia bez bariery językowej",
|
"title": "Rozliczenia bez bariery językowej",
|
||||||
"description": "Niemieckie prawo podatkowe bywa skomplikowane. Przeprowadzę Cię przez cały proces w Twoim ojczystym języku, dbając o maksymalny zwrot i zero stresu.",
|
"description": "Niemieckie prawo podatkowe bywa skomplikowane. Przeprowadzimy Cię przez cały proces w Twoim ojczystym języku, dbając o maksymalny zwrot i zero stresu.",
|
||||||
"image": 1,
|
"image": 1,
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"features": [
|
"features": [
|
||||||
@@ -859,7 +859,7 @@
|
|||||||
{
|
{
|
||||||
"id": "6a988b8614359f1c1b30ef9c",
|
"id": "6a988b8614359f1c1b30ef9c",
|
||||||
"eyebrow": "Usługi",
|
"eyebrow": "Usługi",
|
||||||
"title": "Czym się zajmuję",
|
"title": "Czym się zajmujemy",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
@@ -915,7 +915,7 @@
|
|||||||
"id": "6a988b8614359f1c1b30efa1",
|
"id": "6a988b8614359f1c1b30efa1",
|
||||||
"eyebrow": "Zaufanie",
|
"eyebrow": "Zaufanie",
|
||||||
"title": "Zaufanie i bezpieczeństwo",
|
"title": "Zaufanie i bezpieczeństwo",
|
||||||
"description": "Działam w ramach b.b.h. Lohnsteuerhilfeverein e.V. (Beratungsstelle). Oferujemy profesjonalną pomoc w sprawach podatku dochodowego w ramach uprawnień wynikających z §4 Nr. 11 StBerG.",
|
"description": "Działamy w ramach b.b.h. Lohnsteuerhilfeverein e.V. (Beratungsstelle). Oferujemy profesjonalną pomoc w sprawach podatku dochodowego w ramach uprawnień wynikających z §4 Nr. 11 StBerG.",
|
||||||
"logo": null,
|
"logo": null,
|
||||||
"linkText": "Poznaj stowarzyszenie",
|
"linkText": "Poznaj stowarzyszenie",
|
||||||
"linkUrl": "/pl/o-nas",
|
"linkUrl": "/pl/o-nas",
|
||||||
@@ -998,7 +998,7 @@
|
|||||||
"id": "6a988b8614359f1c1b30efa9",
|
"id": "6a988b8614359f1c1b30efa9",
|
||||||
"eyebrow": "Kontakt",
|
"eyebrow": "Kontakt",
|
||||||
"title": "Gotowy na wyższy zwrot podatku?",
|
"title": "Gotowy na wyższy zwrot podatku?",
|
||||||
"subtitle": "Napisz do mnie już teraz. Odpowiem najszybciej jak to możliwe.",
|
"subtitle": "Napisz do nas już teraz. Odpowiemy najszybciej jak to możliwe.",
|
||||||
"buttonText": "Rozpocznij na WhatsApp",
|
"buttonText": "Rozpocznij na WhatsApp",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"blockType": "finalCta"
|
"blockType": "finalCta"
|
||||||
@@ -1107,7 +1107,7 @@
|
|||||||
{
|
{
|
||||||
"id": "6a988b8714359f1c1b30efba",
|
"id": "6a988b8714359f1c1b30efba",
|
||||||
"eyebrow": "Leistungen",
|
"eyebrow": "Leistungen",
|
||||||
"title": "Was ich anbiete",
|
"title": "Was wir anbieten",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
@@ -1163,7 +1163,7 @@
|
|||||||
"id": "6a988b8714359f1c1b30efbf",
|
"id": "6a988b8714359f1c1b30efbf",
|
||||||
"eyebrow": "Vertrauen",
|
"eyebrow": "Vertrauen",
|
||||||
"title": "Vertrauen und Sicherheit",
|
"title": "Vertrauen und Sicherheit",
|
||||||
"description": "Ich bin im Rahmen des b.b.h. Lohnsteuerhilfevereins e.V. (Beratungsstelle) tätig. Wir bieten professionelle Hilfe in Lohnsteuersachen im Rahmen der Befugnisse nach §4 Nr. 11 StBerG.",
|
"description": "Wir sind im Rahmen des b.b.h. Lohnsteuerhilfevereins e.V. (Beratungsstelle) tätig. Wir bieten professionelle Hilfe in Lohnsteuersachen im Rahmen der Befugnisse nach §4 Nr. 11 StBerG.",
|
||||||
"logo": null,
|
"logo": null,
|
||||||
"linkText": "Den Verein kennenlernen",
|
"linkText": "Den Verein kennenlernen",
|
||||||
"linkUrl": "/de/uber-uns",
|
"linkUrl": "/de/uber-uns",
|
||||||
@@ -1241,7 +1241,7 @@
|
|||||||
"id": "6a988b8714359f1c1b30efc7",
|
"id": "6a988b8714359f1c1b30efc7",
|
||||||
"eyebrow": "Kontakt",
|
"eyebrow": "Kontakt",
|
||||||
"title": "Bereit für eine höhere Steuererstattung?",
|
"title": "Bereit für eine höhere Steuererstattung?",
|
||||||
"subtitle": "Schreiben Sie mir jetzt. Ich werde so schnell wie möglich antworten.",
|
"subtitle": "Schreiben Sie uns jetzt. Wir werden so schnell wie möglich antworten.",
|
||||||
"buttonText": "Auf WhatsApp starten",
|
"buttonText": "Auf WhatsApp starten",
|
||||||
"blockName": null,
|
"blockName": null,
|
||||||
"blockType": "finalCta"
|
"blockType": "finalCta"
|
||||||
@@ -1355,7 +1355,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"association": {
|
"association": {
|
||||||
"title": "Działam w ramach b.b.h. Lohnsteuerhilfeverein e.V.",
|
"title": "Działamy w ramach b.b.h. Lohnsteuerhilfeverein e.V.",
|
||||||
"logo": null,
|
"logo": null,
|
||||||
"link": "https://bbh-lohnsteuerhilfe.de"
|
"link": "https://bbh-lohnsteuerhilfe.de"
|
||||||
},
|
},
|
||||||
@@ -1402,7 +1402,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"association": {
|
"association": {
|
||||||
"title": "Ich bin im Rahmen des b.b.h. Lohnsteuerhilfevereins e.V. tätig.",
|
"title": "Wir sind im Rahmen des b.b.h. Lohnsteuerhilfevereins e.V. tätig.",
|
||||||
"logo": null,
|
"logo": null,
|
||||||
"link": "https://bbh-lohnsteuerhilfe.de"
|
"link": "https://bbh-lohnsteuerhilfe.de"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const snapshotPath = path.join(process.cwd(), 'scripts', 'content-snapshot.json')
|
||||||
|
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'))
|
||||||
|
|
||||||
|
const firstPersonRegexPL = /(^|[\s.,?!:;])(zapewniam|przeprowadzę|przeanalizuję|pomogę|pomagam|odpowiem|mnie|ze mną|moim|mój|zajmuję|działam|przygotowuję|przesyłam|wyliczam|zajmę)([\s.,?!:;]|$)/i
|
||||||
|
const firstPersonRegexDE = /(^|[\s.,?!:;])(ich|mich|mir|mein|meine|meinen|helfe|kümmere|begleite|anbiete)([\s.,?!:;]|$)/i
|
||||||
|
|
||||||
|
const matches: any[] = []
|
||||||
|
|
||||||
|
function checkObject(obj: any, contextInfo: any) {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
obj.forEach((item, index) => checkObject(item, { ...contextInfo, index }))
|
||||||
|
} else if (obj !== null && typeof obj === 'object') {
|
||||||
|
// Pomiń blok Bio
|
||||||
|
if (obj.blockType === 'bio') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const isMatchPL = contextInfo.locale === 'pl' && firstPersonRegexPL.test(value)
|
||||||
|
const isMatchDE = contextInfo.locale === 'de' && firstPersonRegexDE.test(value)
|
||||||
|
|
||||||
|
if (isMatchPL || isMatchDE) {
|
||||||
|
matches.push({
|
||||||
|
pageSlug: contextInfo.pageSlug,
|
||||||
|
blockType: obj.blockType || 'unknown',
|
||||||
|
field: key,
|
||||||
|
locale: contextInfo.locale,
|
||||||
|
text: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checkObject(value, { ...contextInfo, field: key })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const page of snapshot.pages) {
|
||||||
|
if (page.pl) checkObject(page.pl.layout, { pageSlug: page.pl.slug || 'home', locale: 'pl' })
|
||||||
|
if (page.de) checkObject(page.de.layout, { pageSlug: page.de.slug || 'home', locale: 'de' })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [slug, data] of Object.entries(snapshot.globals)) {
|
||||||
|
const typedData = data as any;
|
||||||
|
if (typedData.pl) checkObject(typedData.pl, { pageSlug: `global:${slug}`, locale: 'pl' })
|
||||||
|
if (typedData.de) checkObject(typedData.de, { pageSlug: `global:${slug}`, locale: 'de' })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(matches, null, 2))
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import configPromise from '../src/payload.config'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const replacements: { old: string, new: string }[] = [
|
||||||
|
// PL
|
||||||
|
{ old: 'Przeprowadzę Cię', new: 'Przeprowadzimy Cię' },
|
||||||
|
{ old: 'Czym się zajmuję', new: 'Czym się zajmujemy' },
|
||||||
|
{ old: 'Działam w ramach', new: 'Działamy w ramach' },
|
||||||
|
{ old: 'Napisz do mnie', new: 'Napisz do nas' },
|
||||||
|
{ old: 'Odpowiem najszybciej', new: 'Odpowiemy najszybciej' },
|
||||||
|
{ old: 'Zapewniam rzetelne', new: 'Zapewniamy rzetelne' },
|
||||||
|
{ old: 'Zapewniam pełne', new: 'Zapewniamy pełne' },
|
||||||
|
{ old: 'Skontaktuj się ze mną', new: 'Skontaktuj się z nami' },
|
||||||
|
{ old: 'Przygotowuję kompletną', new: 'Przygotowujemy kompletną' },
|
||||||
|
{ old: 'wyliczam przewidywany', new: 'wyliczamy przewidywany' },
|
||||||
|
{ old: 'przesyłam gotowy', new: 'przesyłamy gotowy' },
|
||||||
|
{ old: 'a zajmę się', new: 'a zajmiemy się' },
|
||||||
|
{ old: 'Przeanalizuję Twoją', new: 'Przeanalizujemy Twoją' },
|
||||||
|
{ old: 'pomogę uzyskać', new: 'pomożemy uzyskać' },
|
||||||
|
{ old: 'chętnie pomogę', new: 'chętnie pomożemy' },
|
||||||
|
|
||||||
|
// DE
|
||||||
|
{ old: 'Was ich anbiete', new: 'Was wir anbieten' },
|
||||||
|
{ old: 'Ich bin im Rahmen', new: 'Wir sind im Rahmen' },
|
||||||
|
{ old: 'Schreiben Sie mir jetzt', new: 'Schreiben Sie uns jetzt' },
|
||||||
|
{ old: 'Ich werde so schnell', new: 'Wir werden so schnell' },
|
||||||
|
{ old: 'Schreiben Sie mir direkt', new: 'Schreiben Sie uns direkt' },
|
||||||
|
{ old: 'Ich erstelle Ihre', new: 'Wir erstellen Ihre' },
|
||||||
|
{ old: 'berechne die', new: 'berechnen die' },
|
||||||
|
{ old: 'übermittle Ihnen', new: 'übermitteln Ihnen' },
|
||||||
|
{ old: 'Kontaktieren Sie mich jetzt', new: 'Kontaktieren Sie uns jetzt' },
|
||||||
|
{ old: 'Kontaktieren Sie mich noch', new: 'Kontaktieren Sie uns noch' },
|
||||||
|
{ old: 'Ich analysiere Ihre', new: 'Wir analysieren Ihre' },
|
||||||
|
{ old: 'und unterstütze Sie', new: 'und unterstützen Sie' },
|
||||||
|
{ old: 'Kontaktieren Sie mich — ich helfe', new: 'Kontaktieren Sie uns — wir helfen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function applyReplacements(text: string): string {
|
||||||
|
let result = text
|
||||||
|
for (const rep of replacements) {
|
||||||
|
result = result.replace(new RegExp(rep.old, 'g'), rep.new)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function processObject(obj: any): any {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(item => processObject(item))
|
||||||
|
}
|
||||||
|
if (obj !== null && typeof obj === 'object') {
|
||||||
|
if (obj.blockType === 'bio') {
|
||||||
|
return obj // pomiń Bio
|
||||||
|
}
|
||||||
|
const newObj: any = {}
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
newObj[key] = applyReplacements(value)
|
||||||
|
} else {
|
||||||
|
newObj[key] = processObject(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newObj
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Rozpoczynam migrację z pierwszej na pierwszą osobę liczby mnogiej...')
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
|
||||||
|
// 1. Aktualizacja zawartości w bazie
|
||||||
|
console.log('--- Aktualizowanie bazy danych ---')
|
||||||
|
for (const locale of ['pl', 'de'] as const) {
|
||||||
|
// Pages
|
||||||
|
const pages = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
locale,
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const page of pages.docs) {
|
||||||
|
if (!page.layout) continue
|
||||||
|
|
||||||
|
const newLayout = processObject(page.layout)
|
||||||
|
|
||||||
|
// Optymalizacja: sprawdźmy czy layout się zmienił po stringify
|
||||||
|
if (JSON.stringify(page.layout) !== JSON.stringify(newLayout)) {
|
||||||
|
console.log(`[${locale}] Aktualizacja strony: ${page.title} (ID: ${page.id})`)
|
||||||
|
await payload.update({
|
||||||
|
collection: 'pages',
|
||||||
|
id: page.id,
|
||||||
|
locale,
|
||||||
|
data: { layout: newLayout }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globals (footer)
|
||||||
|
const footer = await payload.findGlobal({
|
||||||
|
slug: 'footer',
|
||||||
|
locale
|
||||||
|
})
|
||||||
|
|
||||||
|
if (footer) {
|
||||||
|
const newFooter = processObject(footer)
|
||||||
|
if (JSON.stringify(footer) !== JSON.stringify(newFooter)) {
|
||||||
|
console.log(`[${locale}] Aktualizacja stopki (footer)`)
|
||||||
|
await payload.updateGlobal({
|
||||||
|
slug: 'footer',
|
||||||
|
locale,
|
||||||
|
data: newFooter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Aktualizacja w content-snapshot.json
|
||||||
|
console.log('--- Aktualizowanie pliku content-snapshot.json ---')
|
||||||
|
const snapshotPath = path.join(process.cwd(), 'scripts', 'content-snapshot.json')
|
||||||
|
const snapshotText = fs.readFileSync(snapshotPath, 'utf-8')
|
||||||
|
|
||||||
|
// Przetwórz po prostu cały JSON rekursywnie. Zabezpieczamy blok "bio" w processObject
|
||||||
|
const snapshotJson = JSON.parse(snapshotText)
|
||||||
|
const newSnapshotJson = processObject(snapshotJson)
|
||||||
|
|
||||||
|
if (JSON.stringify(snapshotJson) !== JSON.stringify(newSnapshotJson)) {
|
||||||
|
fs.writeFileSync(snapshotPath, JSON.stringify(newSnapshotJson, null, 2), 'utf-8')
|
||||||
|
console.log('Zaktualizowano plik content-snapshot.json')
|
||||||
|
} else {
|
||||||
|
console.log('Brak zmian w content-snapshot.json (być może zostało już zrobione)')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Zakończono pomyślnie.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import configPromise from '../src/payload.config'
|
||||||
|
import type { Page } from '../src/payload-types'
|
||||||
|
|
||||||
|
type PageHeaderBlock = Extract<NonNullable<Page['layout']>[0], { blockType: 'pageHeader' }>
|
||||||
|
type ContactInfoBlock = Extract<NonNullable<Page['layout']>[0], { blockType: 'contactInfo' }>
|
||||||
|
|
||||||
|
const plHeader: PageHeaderBlock = {
|
||||||
|
blockType: 'pageHeader',
|
||||||
|
title: 'Kontakt',
|
||||||
|
subtitle: 'Masz pytania dotyczące rozliczenia podatkowego w Niemczech? Skontaktuj się ze mną — chętnie pomogę.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const deHeader: PageHeaderBlock = {
|
||||||
|
blockType: 'pageHeader',
|
||||||
|
title: 'Kontakt',
|
||||||
|
subtitle: 'Haben Sie Fragen zu Ihrer Steuererklärung in Deutschland? Kontaktieren Sie mich — ich helfe Ihnen gerne.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const plContact: ContactInfoBlock = {
|
||||||
|
blockType: 'contactInfo',
|
||||||
|
address: 'DO POTWIERDZENIA PRZEZ KLIENTA',
|
||||||
|
phone: '+49 123 456 789',
|
||||||
|
email: '[email protected]',
|
||||||
|
hours: 'Pon-Pt: 9:00 - 17:00\nPo wcześniejszym umówieniu',
|
||||||
|
}
|
||||||
|
|
||||||
|
const deContact: ContactInfoBlock = {
|
||||||
|
blockType: 'contactInfo',
|
||||||
|
address: 'DO POTWIERDZENIA PRZEZ KLIENTA',
|
||||||
|
phone: '+49 123 456 789',
|
||||||
|
email: '[email protected]',
|
||||||
|
hours: 'Mo-Fr: 9:00 - 17:00\nNach vorheriger Terminvereinbarung',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Rozpoczynam odzyskiwanie strony Kontakt (ID 5)...')
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
|
||||||
|
for (const locale of ['pl', 'de'] as const) {
|
||||||
|
try {
|
||||||
|
const page = await payload.findByID({
|
||||||
|
collection: 'pages',
|
||||||
|
id: 5,
|
||||||
|
locale: locale
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasPageHeader = page.layout?.some(block => block.blockType === 'pageHeader')
|
||||||
|
const hasContactInfo = page.layout?.some(block => block.blockType === 'contactInfo')
|
||||||
|
|
||||||
|
if (hasPageHeader && hasContactInfo) {
|
||||||
|
console.log(`[${locale}] Strona Kontakt posiada już bloki pageHeader i contactInfo. Pomijam.`)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${locale}] Przywracam utracone bloki...`)
|
||||||
|
|
||||||
|
// Jeśli strona miała jakieś bloki, zachowaj je (z wyjątkiem ewentualnych starych/uszkodzonych wersji tych dwóch)
|
||||||
|
let newLayout = (page.layout ? page.layout.filter(b => b.blockType !== 'pageHeader' && b.blockType !== 'contactInfo') : []) as NonNullable<Page['layout']>;
|
||||||
|
|
||||||
|
// Dodaj nagłówek i info kontaktowe na górę
|
||||||
|
newLayout.unshift(
|
||||||
|
(locale === 'de' ? deHeader : plHeader) as unknown as NonNullable<Page['layout']>[0],
|
||||||
|
(locale === 'de' ? deContact : plContact) as unknown as NonNullable<Page['layout']>[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: 'pages',
|
||||||
|
id: 5,
|
||||||
|
locale: locale,
|
||||||
|
data: {
|
||||||
|
layout: newLayout
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[${locale}] Zaktualizowano pomyślnie.`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${locale}] Błąd podczas aktualizacji:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Zakończono.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import configPromise from '../src/payload.config'
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
|
||||||
|
const plPage = await payload.findByID({
|
||||||
|
collection: 'pages',
|
||||||
|
id: 5,
|
||||||
|
locale: 'pl'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dePage = await payload.findByID({
|
||||||
|
collection: 'pages',
|
||||||
|
id: 5,
|
||||||
|
locale: 'de'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("=== KONTAKT PL LAYOUT ===")
|
||||||
|
console.log(JSON.stringify(plPage.layout, null, 2))
|
||||||
|
console.log("=== KONTAKT DE LAYOUT ===")
|
||||||
|
console.log(JSON.stringify(dePage.layout, null, 2))
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
@@ -45,14 +45,14 @@ export default async function Page(props: {
|
|||||||
const docForRender = await payload.findByID({
|
const docForRender = await payload.findByID({
|
||||||
collection: ('collection' in route ? route.collection : 'pages') as 'pages',
|
collection: ('collection' in route ? route.collection : 'pages') as 'pages',
|
||||||
id: route.doc.id as string,
|
id: route.doc.id as string,
|
||||||
locale: locale as any, // Payload locale typ nie zawsze przyjmuje string
|
locale: locale as 'all' | 'pl' | 'de',
|
||||||
})
|
})
|
||||||
|
|
||||||
const layout = docForRender.layout || []
|
const layout = docForRender.layout || []
|
||||||
|
|
||||||
// Generowanie schematu FAQPage dla bloku faqFull (na poziomie Server Component gwarantuje to SSR)
|
// Generowanie schematu FAQPage dla bloku faqFull (na poziomie Server Component gwarantuje to SSR)
|
||||||
let faqSchema: Record<string, any> | null = null;
|
let faqSchema: Record<string, unknown> | null = null;
|
||||||
const faqBlock = layout.find((b: any) => b.blockType === 'faqFull') as FAQFullBlock | undefined;
|
const faqBlock = layout.find((b) => b !== null && typeof b === 'object' && 'blockType' in b && b.blockType === 'faqFull') as FAQFullBlock | undefined;
|
||||||
if (faqBlock && faqBlock.questions) {
|
if (faqBlock && faqBlock.questions) {
|
||||||
const validQuestions = faqBlock.questions.filter((q: NonNullable<FAQFullBlock['questions']>[0]) => !q.isDraft);
|
const validQuestions = faqBlock.questions.filter((q: NonNullable<FAQFullBlock['questions']>[0]) => !q.isDraft);
|
||||||
if (validQuestions.length > 0) {
|
if (validQuestions.length > 0) {
|
||||||
|
|||||||
@@ -84,15 +84,15 @@ export default async function RootLayout(props: {
|
|||||||
<div className="fixed bottom-6 left-6 z-40">
|
<div className="fixed bottom-6 left-6 z-40">
|
||||||
<CookieButton aria-label={cookieButtonAriaLabel} className="w-12 h-12 rounded-full bg-background border border-border shadow-md flex items-center justify-center text-foreground hover:bg-card transition-colors [&>svg]:w-6 [&>svg]:h-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" />
|
<CookieButton aria-label={cookieButtonAriaLabel} className="w-12 h-12 rounded-full bg-background border border-border shadow-md flex items-center justify-center text-foreground hover:bg-card transition-colors [&>svg]:w-6 [&>svg]:h-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" />
|
||||||
</div>
|
</div>
|
||||||
</ConsentProvider>
|
|
||||||
|
|
||||||
{/* Floating WhatsApp Button */}
|
{/* Floating WhatsApp Button */}
|
||||||
{companyData?.whatsappNumber && (
|
{companyData?.whatsappNumber && (
|
||||||
<FloatingWhatsApp
|
<FloatingWhatsApp
|
||||||
whatsappLink={whatsappLink}
|
whatsappLink={whatsappLink}
|
||||||
ariaLabel={locale === 'de' ? 'Auf WhatsApp schreiben' : 'Napisz do nas na WhatsApp'}
|
ariaLabel={whatsappAriaLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</ConsentProvider>
|
||||||
</LanguageSlugsProvider>
|
</LanguageSlugsProvider>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+4
-1
@@ -26,6 +26,9 @@
|
|||||||
|
|
||||||
/* Globalny token dla przycisków prostokątnych (umiarkowane zaokrąglenie, nie pill) */
|
/* Globalny token dla przycisków prostokątnych (umiarkowane zaokrąglenie, nie pill) */
|
||||||
--radius-button: 6px;
|
--radius-button: 6px;
|
||||||
|
|
||||||
|
/* Fizyczna szerokość scrollbara (dla wyrównania fixed headera) */
|
||||||
|
--scrollbar-width: calc(100vw - 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
@@ -51,6 +54,7 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
/* Płynne przejścia przy zmianie motywu, ale bez migotania */
|
/* Płynne przejścia przy zmianie motywu, ale bez migotania */
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
@@ -77,7 +81,6 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
|
|
||||||
/* Tekstura papieru dla sekcji (bardzo subtelny szum) */
|
/* Tekstura papieru dla sekcji (bardzo subtelny szum) */
|
||||||
.bg-paper-noise {
|
.bg-paper-noise {
|
||||||
background-color: var(--bg-color);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.04'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.04'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const ContactInfoBlock: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-16 md:py-24 bg-background">
|
<section className="py-16 md:py-24 bg-background">
|
||||||
<div className="container-premium flex justify-center">
|
<div className="container-premium">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
@@ -46,7 +46,7 @@ export const ContactInfoBlock: React.FC<Props> = ({
|
|||||||
{isDe ? 'Adresse' : 'Adres'}
|
{isDe ? 'Adresse' : 'Adres'}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-foreground/80 leading-relaxed font-medium whitespace-pre-line text-base max-w-[70ch]">
|
<p className="text-foreground/80 leading-relaxed font-medium whitespace-pre-line text-base max-w-[65ch]">
|
||||||
{address}
|
{address}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@ export const ContactInfoBlock: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={`tel:${phone.replace(/\s+/g, '')}`}
|
href={`tel:${phone.replace(/\s+/g, '')}`}
|
||||||
className="text-foreground/80 leading-relaxed font-medium text-base hover:text-accent transition-colors max-w-[70ch]"
|
className="text-foreground/80 leading-relaxed font-medium text-base hover:text-accent transition-colors max-w-[65ch]"
|
||||||
>
|
>
|
||||||
{phone}
|
{phone}
|
||||||
</a>
|
</a>
|
||||||
@@ -79,7 +79,7 @@ export const ContactInfoBlock: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${email}`}
|
href={`mailto:${email}`}
|
||||||
className="text-foreground/80 leading-relaxed font-medium text-base hover:text-accent transition-colors max-w-[70ch]"
|
className="text-foreground/80 leading-relaxed font-medium text-base hover:text-accent transition-colors max-w-[65ch]"
|
||||||
>
|
>
|
||||||
{email}
|
{email}
|
||||||
</a>
|
</a>
|
||||||
@@ -94,7 +94,7 @@ export const ContactInfoBlock: React.FC<Props> = ({
|
|||||||
{isDe ? 'Öffnungszeiten' : 'Godziny pracy'}
|
{isDe ? 'Öffnungszeiten' : 'Godziny pracy'}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-foreground/80 leading-relaxed font-medium whitespace-pre-line text-base max-w-[70ch]">
|
<p className="text-foreground/80 leading-relaxed font-medium whitespace-pre-line text-base max-w-[65ch]">
|
||||||
{hours}
|
{hours}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ export const FAQFullBlockComponent: React.FC<Props> = ({
|
|||||||
|
|
||||||
{/* FAQ Categories */}
|
{/* FAQ Categories */}
|
||||||
<section className="py-16 md:py-24 bg-background">
|
<section className="py-16 md:py-24 bg-background">
|
||||||
<div className="container-premium max-w-4xl">
|
<div className="container-premium">
|
||||||
<div className="space-y-16">
|
<div className="space-y-16 max-w-4xl">
|
||||||
{Object.entries(groupedQuestions).map(([category, catQuestions], catIndex) => (
|
{Object.entries(groupedQuestions).map(([category, catQuestions], catIndex) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={category}
|
key={category}
|
||||||
@@ -132,7 +132,7 @@ export const FAQFullBlockComponent: React.FC<Props> = ({
|
|||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
>
|
>
|
||||||
<div className="p-6 pt-0 text-foreground/80 leading-relaxed border-t border-border/10 max-w-[70ch]">
|
<div className="p-6 pt-0 text-foreground/80 leading-relaxed border-t border-border/10 max-w-[65ch]">
|
||||||
{q.answer}
|
{q.answer}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ export const FAQTeaserBlock: React.FC<Props> = ({ title, questions, ctaText, cta
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20 md:py-32" id="faq">
|
<section className="py-20 md:py-32" id="faq">
|
||||||
<div className="container-premium max-w-4xl">
|
<div className="container-premium">
|
||||||
<div className="mb-12 md:mb-16 text-center">
|
<div className="mb-12 md:mb-16 max-w-4xl">
|
||||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight text-balance">
|
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight text-balance">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 max-w-4xl">
|
||||||
{questions?.map((q: NonNullable<Props['questions']>[0], index: number) => {
|
{questions?.map((q: NonNullable<Props['questions']>[0], index: number) => {
|
||||||
const isOpen = openIndex === index
|
const isOpen = openIndex === index
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export const FAQTeaserBlock: React.FC<Props> = ({ title, questions, ctaText, cta
|
|||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
>
|
>
|
||||||
<div className="p-6 pt-0 text-foreground/75 leading-relaxed border-t border-border/10">
|
<div className="p-6 pt-0 text-foreground/75 leading-relaxed border-t border-border/10 max-w-[65ch]">
|
||||||
{q.answer}
|
{q.answer}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -65,7 +65,7 @@ export const FAQTeaserBlock: React.FC<Props> = ({ title, questions, ctaText, cta
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ctaText && ctaUrl && (
|
{ctaText && ctaUrl && (
|
||||||
<div className="mt-12 text-center">
|
<div className="mt-12 text-left">
|
||||||
<a
|
<a
|
||||||
href={ctaUrl}
|
href={ctaUrl}
|
||||||
className="inline-flex items-center justify-center h-14 px-8 text-base sm:text-lg font-bold text-background bg-accent rounded-[6px] hover:bg-accent-hover transition-colors"
|
className="inline-flex items-center justify-center h-14 px-8 text-base sm:text-lg font-bold text-background bg-accent rounded-[6px] hover:bg-accent-hover transition-colors"
|
||||||
|
|||||||
+79
-154
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion'
|
|||||||
import type { Page, Media } from '../../payload-types'
|
import type { Page, Media } from '../../payload-types'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { ImageIcon } from 'lucide-react'
|
import { ImagePlaceholder } from '../../components/ImagePlaceholder'
|
||||||
|
|
||||||
type BaseProps = Extract<NonNullable<Page['layout']>[0], { blockType: 'hero' }>
|
type BaseProps = Extract<NonNullable<Page['layout']>[0], { blockType: 'hero' }>
|
||||||
type Props = BaseProps & { whatsappNumber?: string | null }
|
type Props = BaseProps & { whatsappNumber?: string | null }
|
||||||
@@ -16,6 +16,7 @@ const containerVariants = {
|
|||||||
visible: {
|
visible: {
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.14,
|
staggerChildren: 0.14,
|
||||||
|
delayChildren: 0.2
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -26,63 +27,12 @@ const itemVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.5,
|
duration: 0.7,
|
||||||
ease: customEase,
|
ease: customEase,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const collageVariants = {
|
|
||||||
hidden: { opacity: 0, scale: 0.96, y: 24 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.6,
|
|
||||||
ease: customEase,
|
|
||||||
delay: 0.25,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function useParallax3D(maxAngle = 4.5) {
|
|
||||||
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const x = e.clientX - rect.left
|
|
||||||
const y = e.clientY - rect.top
|
|
||||||
const centerX = rect.width / 2
|
|
||||||
const centerY = rect.height / 2
|
|
||||||
const rotateX = -((y - centerY) / centerY) * maxAngle
|
|
||||||
const rotateY = ((x - centerX) / centerX) * maxAngle
|
|
||||||
setRotation({ rotateX, rotateY })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerLeave = () => {
|
|
||||||
setIsHovered(false)
|
|
||||||
setRotation({ rotateX: 0, rotateY: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerEnter = () => {
|
|
||||||
setIsHovered(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
bind: {
|
|
||||||
onPointerMove: handlePointerMove,
|
|
||||||
onPointerEnter: handlePointerEnter,
|
|
||||||
onPointerLeave: handlePointerLeave,
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
transform: `perspective(1000px) rotateX(${rotation.rotateX.toFixed(2)}deg) rotateY(${rotation.rotateY.toFixed(2)}deg)`,
|
|
||||||
transition: isHovered ? 'transform 0.12s ease-out' : 'transform 0.55s ease-out',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HeroBlock: React.FC<Props> = ({
|
export const HeroBlock: React.FC<Props> = ({
|
||||||
title,
|
title,
|
||||||
titleAccent,
|
titleAccent,
|
||||||
@@ -90,22 +40,15 @@ export const HeroBlock: React.FC<Props> = ({
|
|||||||
primaryCta,
|
primaryCta,
|
||||||
secondaryCta,
|
secondaryCta,
|
||||||
image,
|
image,
|
||||||
imageSecondary,
|
|
||||||
floatingStat,
|
floatingStat,
|
||||||
whatsappNumber,
|
whatsappNumber,
|
||||||
}) => {
|
}) => {
|
||||||
const [imagesLoaded, setImagesLoaded] = useState(false)
|
const [imageLoaded, setImageLoaded] = useState(false)
|
||||||
const whatsappLink = whatsappNumber ? `https://wa.me/${whatsappNumber}` : '#'
|
const whatsappLink = whatsappNumber ? `https://wa.me/${whatsappNumber}` : '#'
|
||||||
|
|
||||||
const heroMedia = typeof image === 'object' && image !== null ? (image as Media) : null
|
const heroMedia = typeof image === 'object' && image !== null ? (image as Media) : null
|
||||||
const imageUrl = heroMedia?.url
|
const imageUrl = heroMedia?.url
|
||||||
const imageAlt = heroMedia?.alt || ''
|
const imageAlt = heroMedia?.alt || 'Tło hero'
|
||||||
|
|
||||||
const secondaryMedia = typeof imageSecondary === 'object' && imageSecondary !== null ? (imageSecondary as Media) : null
|
|
||||||
const secondaryUrl = secondaryMedia?.url
|
|
||||||
const secondaryAlt = secondaryMedia?.alt || 'Profesjonalne rozliczenia podatkowe'
|
|
||||||
|
|
||||||
const { bind: parallaxBind, style: parallaxStyle } = useParallax3D(4.5)
|
|
||||||
|
|
||||||
const statValue = floatingStat?.value || '20+'
|
const statValue = floatingStat?.value || '20+'
|
||||||
const statLabel = floatingStat?.label || 'Lat doświadczenia'
|
const statLabel = floatingStat?.label || 'Lat doświadczenia'
|
||||||
@@ -114,21 +57,53 @@ export const HeroBlock: React.FC<Props> = ({
|
|||||||
<motion.section
|
<motion.section
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={containerVariants}
|
className="relative w-full min-h-[70vh] lg:min-h-[85vh] flex flex-col justify-end overflow-hidden"
|
||||||
className="w-full bg-paper-noise text-foreground min-h-[calc(100svh-6rem)] flex items-center py-12 lg:py-0 overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<div className="container-premium w-full">
|
{/* Background Layer */}
|
||||||
<div className="flex flex-col-reverse lg:flex-row items-center justify-between gap-14 lg:gap-20">
|
<div className="absolute inset-0 z-0">
|
||||||
|
{imageUrl ? (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={imageUrl}
|
||||||
|
alt={imageAlt}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="100vw"
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
className={`object-cover object-center transition-all duration-1000 ${
|
||||||
|
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{/* Gradient Overlay for Text Readability - Mocniejszy na dole/lewo */}
|
||||||
|
{/* Z uwagi na "od ciemnego do przezroczystego" stosujemy from-background (zależne od motywu),
|
||||||
|
ale upewniamy się, że w jasnym motywie ciemny tekst nadal ma sens - gradient na jasnym tle to od białego,
|
||||||
|
a na ciemnym od czarnego. Jeśli chcesz wymusić zawsze ciemny overlay to musielibyśmy
|
||||||
|
nadpisać text-foreground na stałe na biało. Zakładamy, że bg-background daje optymalny kontrast
|
||||||
|
(jasny gradient z ciemnym tekstem / ciemny gradient z jasnym tekstem). */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/70 to-transparent lg:bg-gradient-to-r lg:from-background/90 lg:via-background/50 lg:to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-background/20" /> {/* bazowe lekkie przyciemnienie reszty zdjęcia */}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-card bg-paper-noise border-b border-border/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Kolumna tekstowa z animacjami wejścia */}
|
{/* Content Layer */}
|
||||||
<div className="w-full lg:w-1/2 max-w-xl flex flex-col">
|
<div className="container-premium relative z-10 w-full pb-16 lg:pb-24 pt-32">
|
||||||
|
<div className="flex flex-col lg:flex-row items-end justify-between gap-12">
|
||||||
|
|
||||||
|
{/* Main Content (Bottom Left) */}
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
className="w-full lg:max-w-3xl flex flex-col"
|
||||||
|
>
|
||||||
<motion.h1
|
<motion.h1
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="font-heading text-4xl sm:text-5xl md:text-6xl font-bold leading-tight mb-8 text-balance"
|
className={`font-heading text-4xl sm:text-5xl md:text-6xl lg:text-[4.5rem] font-bold leading-[1.05] mb-6 text-balance text-foreground break-words hyphens-auto ${imageUrl ? 'drop-shadow-sm' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="block text-foreground">{title}</span>
|
<span className="block">{title}</span>
|
||||||
{titleAccent && (
|
{titleAccent && (
|
||||||
<span className="block text-accent">
|
<span className="block text-accent mt-2">
|
||||||
{titleAccent.split(/(po polsku)/i).map((part, i) =>
|
{titleAccent.split(/(po polsku)/i).map((part, i) =>
|
||||||
part.toLowerCase() === 'po polsku' ? <i key={i} className="italic font-medium">{part}</i> : part
|
part.toLowerCase() === 'po polsku' ? <i key={i} className="italic font-medium">{part}</i> : part
|
||||||
)}
|
)}
|
||||||
@@ -138,21 +113,21 @@ export const HeroBlock: React.FC<Props> = ({
|
|||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="text-xl sm:text-2xl text-foreground/80 mb-12 leading-relaxed font-light max-w-[65ch]"
|
className={`text-lg sm:text-xl md:text-2xl text-foreground/90 mb-10 leading-relaxed font-medium max-w-[55ch] ${imageUrl ? 'drop-shadow-sm' : ''}`}
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="flex flex-col sm:flex-row items-start sm:items-center gap-8"
|
className="flex flex-col sm:flex-row items-start sm:items-center gap-6 sm:gap-8"
|
||||||
>
|
>
|
||||||
{whatsappNumber && (
|
{whatsappNumber && (
|
||||||
<a
|
<a
|
||||||
href={whatsappLink}
|
href={whatsappLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="px-8 py-4 bg-[#25D366] hover:bg-[#20bd5a] text-[#1C1B1A] font-bold text-base sm:text-lg transition-colors flex items-center justify-center rounded-none shadow-sm cursor-pointer"
|
className="px-8 py-4 bg-[#25D366] hover:bg-[#20bd5a] text-[#1C1B1A] font-bold text-base sm:text-lg transition-colors flex items-center justify-center rounded-none shadow-md cursor-pointer border border-[#1C1B1A]/10"
|
||||||
>
|
>
|
||||||
{primaryCta}
|
{primaryCta}
|
||||||
</a>
|
</a>
|
||||||
@@ -161,102 +136,52 @@ export const HeroBlock: React.FC<Props> = ({
|
|||||||
secondaryCta.url.startsWith('#') ? (
|
secondaryCta.url.startsWith('#') ? (
|
||||||
<a
|
<a
|
||||||
href={secondaryCta.url}
|
href={secondaryCta.url}
|
||||||
className="text-foreground font-semibold text-lg hover:text-accent hover:underline underline-offset-4 transition-colors"
|
className="text-foreground font-semibold text-lg hover:text-accent hover:underline underline-offset-4 transition-colors drop-shadow-sm"
|
||||||
>
|
>
|
||||||
{secondaryCta.text}
|
{secondaryCta.text}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={secondaryCta.url}
|
href={secondaryCta.url}
|
||||||
className="text-foreground font-semibold text-lg hover:text-accent hover:underline underline-offset-4 transition-colors"
|
className="text-foreground font-semibold text-lg hover:text-accent hover:underline underline-offset-4 transition-colors drop-shadow-sm"
|
||||||
>
|
>
|
||||||
{secondaryCta.text}
|
{secondaryCta.text}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Prawa kolumna: Kolaż zdjęć z 3D paralaksą i pływającą kartą */}
|
{/* Floating Stat "Seal" (Bottom Right) */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={collageVariants}
|
initial={{ opacity: 0, scale: 0.9, rotate: -5 }}
|
||||||
className="w-full lg:w-1/2 flex justify-center lg:justify-end"
|
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.6, ease: [0.34, 1.56, 0.64, 1] }}
|
||||||
|
className="hidden lg:flex flex-col items-center justify-center bg-card border border-border/50 p-6 lg:p-8 shadow-2xl lg:-translate-y-4 max-w-[200px] aspect-square rounded-sm relative shrink-0"
|
||||||
>
|
>
|
||||||
<div
|
{/* Dekoracyjna krawędź przypominająca stempel */}
|
||||||
{...parallaxBind}
|
<div className="absolute inset-2 border border-border/20 pointer-events-none" />
|
||||||
style={parallaxStyle}
|
<span className="font-heading text-4xl lg:text-5xl font-extrabold tracking-tight text-accent mb-2 drop-shadow-sm relative z-10 text-center">
|
||||||
className="relative w-full max-w-md sm:max-w-lg select-none will-change-transform"
|
{statValue}
|
||||||
>
|
</span>
|
||||||
{/* Pływająca karta (floating card) */}
|
<span className="text-sm uppercase tracking-widest text-foreground/80 font-medium text-center relative z-10 leading-snug">
|
||||||
<motion.div
|
{statLabel}
|
||||||
animate={{ y: [0, -8, 0] }}
|
</span>
|
||||||
transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
|
</motion.div>
|
||||||
className="absolute -top-6 -left-3 sm:-top-8 sm:-left-6 z-30 bg-foreground text-background px-5 py-4 shadow-xl border border-border/20 flex flex-col gap-0.5 pointer-events-none"
|
|
||||||
>
|
|
||||||
<span className="font-heading text-2xl sm:text-3xl font-extrabold tracking-tight text-accent">
|
|
||||||
{statValue}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs uppercase tracking-widest text-background/80 font-medium">
|
|
||||||
{statLabel}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{secondaryUrl ? (
|
{/* Mobile version of the seal - more inline */}
|
||||||
/* Układ kolażu dwóch nachodzących na siebie zdjęć */
|
<motion.div
|
||||||
<div className="relative w-full aspect-[4/5] sm:aspect-[1/1] pt-6 pl-4">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
{/* Zdjęcie 1 (mniejsze, przesunięte w lewo-górę) */}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="absolute top-2 left-0 w-[62%] aspect-[4/5] border border-border shadow-sm overflow-hidden z-10 bg-card">
|
transition={{ duration: 0.6, delay: 0.6, ease: customEase }}
|
||||||
{imageUrl ? (
|
className="flex lg:hidden items-center gap-4 bg-card/90 backdrop-blur-sm border border-border/50 shadow-md p-4 w-full mt-4"
|
||||||
<Image
|
>
|
||||||
src={imageUrl}
|
<span className="font-heading text-3xl font-extrabold tracking-tight text-accent drop-shadow-sm">
|
||||||
alt={imageAlt}
|
{statValue}
|
||||||
fill
|
</span>
|
||||||
priority
|
<span className="text-xs uppercase tracking-widest text-foreground/80 font-medium leading-snug">
|
||||||
sizes="(max-width: 1024px) 55vw, 25vw"
|
{statLabel}
|
||||||
onLoad={() => setImagesLoaded(true)}
|
</span>
|
||||||
className={`object-cover object-center grayscale hover:grayscale-0 transition-all duration-700 ease-out ${imagesLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105'}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-border border border-border">
|
|
||||||
<ImageIcon size={32} strokeWidth={1} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zdjęcie 2 (większe, przesunięte w prawo-dół, nachodzące na pierwsze o ok. 18%) */}
|
|
||||||
<div className="absolute bottom-0 right-0 w-[74%] aspect-[4/5] border border-border shadow-md overflow-hidden z-20 bg-border/20">
|
|
||||||
<Image
|
|
||||||
src={secondaryUrl}
|
|
||||||
alt={secondaryAlt}
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
sizes="(max-width: 1024px) 65vw, 32vw"
|
|
||||||
className={`object-cover object-center grayscale hover:grayscale-0 transition-all duration-700 ease-out ${imagesLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105'}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Pojedyncze zdjęcie jeśli imageSecondary jest puste */
|
|
||||||
<div className="relative w-full aspect-[4/5] border border-border shadow-sm overflow-hidden bg-card">
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
src={imageUrl}
|
|
||||||
alt={imageAlt}
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
|
||||||
onLoad={() => setImagesLoaded(true)}
|
|
||||||
className={`object-cover object-center grayscale hover:grayscale-0 transition-all duration-700 ease-out ${imagesLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105'}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center text-border gap-4 p-8 text-center border border-border">
|
|
||||||
<ImageIcon size={64} strokeWidth={1} />
|
|
||||||
<span className="text-xs tracking-widest uppercase font-semibold text-foreground/40">ZDJĘCIE GŁÓWNE</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,59 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { ImagePlaceholder } from '../../components/ImagePlaceholder'
|
||||||
|
import type { Page, Media } from '../../payload-types'
|
||||||
|
|
||||||
export interface PageHeaderProps {
|
type BaseProps = Extract<NonNullable<Page['layout']>[0], { blockType: 'pageHeader' }>
|
||||||
title: string
|
export type PageHeaderProps = BaseProps
|
||||||
subtitle?: string | null
|
|
||||||
}
|
export const PageHeaderBlock: React.FC<PageHeaderProps> = ({ title, subtitle, image }) => {
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false)
|
||||||
|
|
||||||
|
const headerMedia = typeof image === 'object' && image !== null ? (image as Media) : null
|
||||||
|
const imageUrl = headerMedia?.url
|
||||||
|
const imageAlt = headerMedia?.alt || 'Tło nagłówka'
|
||||||
|
|
||||||
export const PageHeaderBlock: React.FC<PageHeaderProps> = ({ title, subtitle }) => {
|
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-border py-16 md:py-20 mb-12 bg-background">
|
<section className="relative w-full min-h-[40vh] lg:min-h-[50vh] flex flex-col justify-end overflow-hidden mb-12">
|
||||||
<div className="container-premium">
|
{/* Background Layer */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
{imageUrl ? (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={imageUrl}
|
||||||
|
alt={imageAlt}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="100vw"
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
className={`object-cover object-center transition-all duration-1000 ${
|
||||||
|
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{/* Gradient Overlay for Text Readability - Mocniejszy na dole/lewo */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/70 to-transparent lg:bg-gradient-to-r lg:from-background/90 lg:via-background/50 lg:to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-background/20" /> {/* bazowe lekkie przyciemnienie reszty zdjęcia */}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-card bg-paper-noise border-b border-border/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Layer */}
|
||||||
|
<div className="container-premium relative z-10 w-full pb-12 pt-24">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="max-w-4xl"
|
className="max-w-4xl"
|
||||||
>
|
>
|
||||||
<h1 className="font-heading text-4xl sm:text-5xl lg:text-5xl font-extrabold text-foreground tracking-tight leading-tight text-balance max-w-4xl">
|
<h1 className={`font-heading text-4xl sm:text-5xl lg:text-6xl font-extrabold text-foreground tracking-tight leading-[1.1] text-balance max-w-4xl ${imageUrl ? 'drop-shadow-sm' : ''}`}>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="mt-6 text-lg md:text-xl text-foreground/70 leading-relaxed max-w-[65ch]">
|
<p className={`mt-6 text-lg md:text-xl text-foreground/90 leading-relaxed max-w-[65ch] font-medium ${imageUrl ? 'drop-shadow-sm' : ''}`}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export const PageHeader: Block = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ name: 'title', type: 'text', required: true },
|
{ name: 'title', type: 'text', required: true },
|
||||||
{ name: 'subtitle', type: 'textarea' },
|
{ name: 'subtitle', type: 'textarea' },
|
||||||
|
{ name: 'image', type: 'upload', relationTo: 'media', required: false },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const ServicesListBlock: React.FC<Props> = ({ title, services }) => {
|
|||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
{services?.map((service: NonNullable<Props['services']>[0], index: number) => (
|
{services?.map((service: NonNullable<Props['services']>[0], index: number) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const StatsBlock: React.FC<Props> = ({ title, subtitle, items }) => {
|
|||||||
if (!items || items.length === 0) return null
|
if (!items || items.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20 md:py-32 relative">
|
<section className="pt-10 pb-20 md:pt-16 md:pb-32 relative">
|
||||||
<div className="container-premium">
|
<div className="container-premium">
|
||||||
{(title || subtitle) && (
|
{(title || subtitle) && (
|
||||||
<div className="max-w-3xl mb-16 md:mb-24">
|
<div className="max-w-3xl mb-16 md:mb-24">
|
||||||
@@ -60,7 +60,7 @@ export const StatsBlock: React.FC<Props> = ({ title, subtitle, items }) => {
|
|||||||
key={item.id || index}
|
key={item.id || index}
|
||||||
className="flex flex-col p-8 sm:p-12 hover:bg-foreground/[0.02] transition-colors"
|
className="flex flex-col p-8 sm:p-12 hover:bg-foreground/[0.02] transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-heading text-5xl lg:text-7xl font-extrabold text-accent mb-4 tracking-tight">
|
<div className="font-heading text-4xl lg:text-5xl font-extrabold text-accent mb-4 tracking-tight whitespace-nowrap">
|
||||||
<CountUp text={item.stat} />
|
<CountUp text={item.stat} />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-heading text-xl font-bold text-foreground mb-2">
|
<div className="font-heading text-xl font-bold text-foreground mb-2">
|
||||||
|
|||||||
+189
-37
@@ -1,77 +1,229 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import type { Page, Media } from '../../payload-types'
|
import type { Page, Media } from '../../payload-types'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { motion } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ImagePlaceholder } from '../../components/ImagePlaceholder'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'team' }>
|
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'team' }>
|
||||||
|
|
||||||
export const TeamBlock: React.FC<Props> = ({ members }) => {
|
export const TeamBlock: React.FC<Props> = ({ members: originalMembers, title, subtitle }) => {
|
||||||
if (!members || members.length <= 1) return null
|
const pathname = usePathname()
|
||||||
|
const isDe = pathname?.split('/').filter(Boolean)[0] === 'de'
|
||||||
|
|
||||||
|
// MOCK_MEMBERS usunięte na rzecz prawdziwych danych z CMS
|
||||||
|
const members = originalMembers
|
||||||
|
|
||||||
|
const [activeMember, setActiveMember] = useState<NonNullable<Props['members']>[0] | null>(null)
|
||||||
|
const [previousFocus, setPreviousFocus] = useState<HTMLElement | null>(null)
|
||||||
|
const modalRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const openModal = (member: any, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setPreviousFocus(event.currentTarget)
|
||||||
|
setActiveMember(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setActiveMember(null)
|
||||||
|
if (previousFocus) {
|
||||||
|
setTimeout(() => previousFocus.focus(), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block scroll when modal is open and handle Escape/Focus Trap
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeMember) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab' && modalRef.current) {
|
||||||
|
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
const firstElement = focusableElements[0]
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1]
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
lastElement?.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
firstElement?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
// Auto focus first element (close button)
|
||||||
|
setTimeout(() => {
|
||||||
|
const firstFocusable = modalRef.current?.querySelector('button')
|
||||||
|
firstFocusable?.focus()
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeMember, previousFocus])
|
||||||
|
|
||||||
|
if (!members || members.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20 md:py-32 bg-background border-y border-border overflow-hidden">
|
<section className="py-20 md:py-32 bg-background border-y border-border overflow-hidden relative">
|
||||||
<div className="container-premium">
|
<div className="container-premium">
|
||||||
|
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="max-w-3xl mx-auto text-center mb-16 md:mb-24">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground mb-6 tracking-tight text-balance">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-foreground/75 text-lg font-light leading-relaxed">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid - scalable from 1 up to 5 cols on large screens */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-12"
|
className={`grid gap-6 md:gap-8 ${
|
||||||
|
members.length === 1
|
||||||
|
? 'max-w-xs mx-auto grid-cols-1'
|
||||||
|
: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{members.map((member: NonNullable<Props['members']>[0], index: number) => {
|
{members.map((member: any, index: number) => {
|
||||||
const photo = typeof member.photo === 'object' && member.photo !== null ? (member.photo as Media) : null
|
const photo = typeof member.photo === 'object' && member.photo !== null ? (member.photo as Media) : null
|
||||||
const initial = member.name ? member.name.charAt(0).toUpperCase() : ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={member.id || index} className="flex flex-col bg-card border border-border rounded-none overflow-hidden hover:border-accent/50 transition-colors h-full">
|
<button
|
||||||
<div className="relative aspect-square w-full bg-border/30 flex items-center justify-center shrink-0">
|
type="button"
|
||||||
|
key={member.id || index}
|
||||||
|
onClick={(e) => openModal(member, e)}
|
||||||
|
className="group flex flex-col bg-card border border-border overflow-hidden hover:border-accent/40 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 h-full text-left focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
aria-label={`Pokaż profil: ${member.name}`}
|
||||||
|
>
|
||||||
|
{/* 1:1 Aspect Ratio Photo */}
|
||||||
|
<div className="relative aspect-square w-full bg-border/20 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
{photo && photo.url ? (
|
{photo && photo.url ? (
|
||||||
<Image
|
<Image
|
||||||
src={photo.url}
|
src={photo.url}
|
||||||
alt={photo.alt || member.name}
|
alt={photo.alt || member.name}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||||
className="object-cover grayscale hover:grayscale-0 transition-all duration-700"
|
className="object-cover grayscale group-hover:grayscale-0 transition-all duration-700"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-accent text-background font-heading text-6xl font-bold">
|
<ImagePlaceholder />
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 flex flex-col flex-1">
|
{/* Compact Info */}
|
||||||
<h3 className="font-heading text-2xl font-bold text-foreground mb-1">
|
<div className="p-4 sm:p-5 flex flex-col flex-1 bg-card min-w-0">
|
||||||
|
<h3 className="font-heading text-lg sm:text-xl font-bold text-foreground mb-1 line-clamp-1 break-words">
|
||||||
{member.name}
|
{member.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-accent font-semibold text-sm uppercase tracking-wide mb-6">
|
<p className="text-accent font-medium text-xs sm:text-sm tracking-wide line-clamp-2 leading-snug break-words">
|
||||||
{member.role}
|
{member.role}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{member.description && (
|
|
||||||
<p className="text-foreground/75 leading-relaxed mb-8 flex-1">
|
|
||||||
{member.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{member.bookingUrl && (
|
|
||||||
<div className="mt-auto pt-4 border-t border-border">
|
|
||||||
<Link
|
|
||||||
href={member.bookingUrl}
|
|
||||||
className="inline-flex items-center text-sm font-semibold text-foreground hover:text-accent uppercase tracking-wider transition-colors"
|
|
||||||
>
|
|
||||||
Umów się
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lightbox / Modal for details */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{activeMember && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6 md:p-12">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={closeModal}
|
||||||
|
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
ref={modalRef}
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
|
className="relative w-full max-w-2xl bg-card border border-border shadow-2xl flex flex-col md:flex-row overflow-hidden"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={`modal-title-${activeMember.id}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
className="absolute top-4 right-4 z-10 w-8 h-8 flex items-center justify-center bg-background/50 hover:bg-background rounded-full border border-border transition-colors text-foreground focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
aria-label="Zamknij"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="md:w-2/5 aspect-square md:aspect-auto relative bg-border/20">
|
||||||
|
{activeMember.photo && typeof activeMember.photo === 'object' && activeMember.photo.url ? (
|
||||||
|
<Image
|
||||||
|
src={activeMember.photo.url}
|
||||||
|
alt={activeMember.photo.alt || activeMember.name}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, 40vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImagePlaceholder />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:w-3/5 p-6 md:p-8 flex flex-col max-h-[60vh] overflow-y-auto">
|
||||||
|
<h3 id={`modal-title-${activeMember.id}`} className="font-heading text-2xl md:text-3xl font-bold text-foreground mb-2">
|
||||||
|
{activeMember.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-accent font-semibold text-sm uppercase tracking-wide mb-6">
|
||||||
|
{activeMember.role}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{activeMember.description && (
|
||||||
|
<div className="text-foreground/80 leading-relaxed flex-1 prose-sm">
|
||||||
|
{activeMember.description.split('\n').map((paragraph: string, i: number) => (
|
||||||
|
<p key={i} className="mb-4 last:mb-0">{paragraph}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import type { Block } from 'payload'
|
|||||||
export const Team: Block = {
|
export const Team: Block = {
|
||||||
slug: 'team',
|
slug: 'team',
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
localized: true,
|
||||||
|
defaultValue: 'Nasz zespół',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'subtitle',
|
||||||
|
type: 'textarea',
|
||||||
|
localized: true,
|
||||||
|
defaultValue: 'Poznaj ekspertów, którzy z zaangażowaniem dbają o Twoje finanse. Każdy z nas wnosi wieloletnie doświadczenie, by zagwarantować Ci spokój i bezpieczeństwo.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'members',
|
name: 'members',
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
export type TestimonialData = {
|
||||||
|
id?: string | number | null;
|
||||||
|
quote: string;
|
||||||
|
author: string;
|
||||||
|
source?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
title?: string | null;
|
||||||
|
items: TestimonialData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestimonialsCarousel: React.FC<CarouselProps> = ({ title, items }) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = containerRef.current
|
||||||
|
|
||||||
|
// Obliczamy który element jest najbardziej widoczny
|
||||||
|
// Szerokość jednego elementu (przy założeniu że wszystkie są równe i wypełniają szerokość kontenera na mobile,
|
||||||
|
// lub połowę na desktop)
|
||||||
|
const cardWidth = containerRef.current.firstElementChild?.clientWidth || clientWidth
|
||||||
|
const newIndex = Math.round(scrollLeft / cardWidth)
|
||||||
|
|
||||||
|
if (newIndex !== activeIndex) {
|
||||||
|
setActiveIndex(newIndex)
|
||||||
|
}
|
||||||
|
}, [activeIndex])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
return () => el.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [handleScroll])
|
||||||
|
|
||||||
|
const scrollTo = (index: number) => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const cardWidth = containerRef.current.firstElementChild?.clientWidth || containerRef.current.clientWidth
|
||||||
|
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
containerRef.current.scrollTo({
|
||||||
|
left: index * cardWidth,
|
||||||
|
behavior: prefersReducedMotion ? 'auto' : 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollPrev = () => {
|
||||||
|
scrollTo(Math.max(activeIndex - 1, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollNext = () => {
|
||||||
|
scrollTo(Math.min(activeIndex + 1, items.length - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative py-24 md:py-40 bg-background overflow-x-hidden max-w-[100vw]">
|
||||||
|
<div className="container-premium relative z-10">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between mb-12 md:mb-20 gap-8">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
{title && (
|
||||||
|
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Strzałki nawigacji (Desktop) */}
|
||||||
|
<div className="hidden md:flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={scrollPrev}
|
||||||
|
disabled={activeIndex === 0}
|
||||||
|
aria-label="Poprzednia opinia"
|
||||||
|
className="w-12 h-12 rounded-full border border-border flex items-center justify-center text-foreground hover:bg-accent hover:text-background hover:border-accent disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-foreground disabled:hover:border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={scrollNext}
|
||||||
|
disabled={activeIndex >= items.length - 1} // Na desktopie widać po 2 karty, wiec to mozna ulepszyc, ale zostawmy proste
|
||||||
|
aria-label="Następna opinia"
|
||||||
|
className="w-12 h-12 rounded-full border border-border flex items-center justify-center text-foreground hover:bg-accent hover:text-background hover:border-accent disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-foreground disabled:hover:border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kontener scroll-snap */}
|
||||||
|
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||||
|
<motion.div
|
||||||
|
ref={containerRef}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
className="flex gap-6 overflow-x-auto snap-x snap-mandatory hide-scrollbar pb-8"
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
>
|
||||||
|
{items.map((t, index) => {
|
||||||
|
const initial = t.author ? t.author.charAt(0).toUpperCase() : ''
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={t.id || index}
|
||||||
|
variants={cardVariants}
|
||||||
|
className="snap-start snap-always shrink-0 w-[85vw] md:w-[calc(50%-12px)] bg-card border border-border p-8 md:p-12 flex flex-col items-start shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex w-full justify-between items-start mb-6">
|
||||||
|
<div className="text-accent">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{t.source && (
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-foreground/50 border border-border px-2 py-1 rounded-sm">
|
||||||
|
{t.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-sans italic text-xl sm:text-2xl text-foreground/90 leading-relaxed mb-10 flex-grow">
|
||||||
|
"{t.quote}"
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mt-auto">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-accent text-background font-heading flex items-center justify-center text-xl font-bold shrink-0">
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<p className="font-heading font-bold text-lg text-foreground uppercase tracking-wide">
|
||||||
|
{t.author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kropki nawigacji (Mobile + Desktop) */}
|
||||||
|
<div className="flex justify-center items-center gap-3 mt-8">
|
||||||
|
{items.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => scrollTo(index)}
|
||||||
|
aria-label={`Przejdź do opinii ${index + 1}`}
|
||||||
|
aria-current={activeIndex === index ? 'true' : 'false'}
|
||||||
|
className={`w-3 h-3 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
activeIndex === index
|
||||||
|
? 'bg-accent scale-100'
|
||||||
|
: 'bg-border hover:bg-accent/50 scale-75'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,80 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import type { Page } from '../../payload-types'
|
import type { Page } from '../../payload-types'
|
||||||
import { motion } from 'framer-motion'
|
import { TestimonialsCarousel, TestimonialData } from './Carousel'
|
||||||
|
|
||||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'testimonials' }>
|
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'testimonials' }>
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: {},
|
|
||||||
visible: {
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardVariants = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TestimonialsBlock: React.FC<Props> = ({ title, testimonials }) => {
|
export const TestimonialsBlock: React.FC<Props> = ({ title, testimonials }) => {
|
||||||
if (!testimonials || testimonials.length === 0) return null
|
if (!testimonials || testimonials.length === 0) return null
|
||||||
|
|
||||||
|
// Map Payload data to the normalized format
|
||||||
|
const normalizedTestimonials: TestimonialData[] = useMemo(() => {
|
||||||
|
return testimonials.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
quote: t.quote,
|
||||||
|
author: t.author,
|
||||||
|
source: t.source || null
|
||||||
|
}))
|
||||||
|
}, [testimonials])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative py-24 md:py-40 bg-background overflow-hidden">
|
<TestimonialsCarousel
|
||||||
<div className="container-premium relative z-10">
|
title={title}
|
||||||
<div className="max-w-3xl mb-16 md:mb-24">
|
items={normalizedTestimonials}
|
||||||
{title && (
|
/>
|
||||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight text-foreground text-balance">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial="hidden"
|
|
||||||
whileInView="visible"
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
variants={containerVariants}
|
|
||||||
className="grid grid-cols-1 md:grid-cols-2 gap-8"
|
|
||||||
>
|
|
||||||
{testimonials.map((t: NonNullable<Props['testimonials']>[0], index: number) => {
|
|
||||||
const initial = t.author ? t.author.charAt(0).toUpperCase() : ''
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={t.id || index}
|
|
||||||
variants={cardVariants}
|
|
||||||
className="bg-card border border-border p-8 md:p-12 flex flex-col items-start shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="text-accent mb-6">
|
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="font-sans italic text-xl sm:text-2xl text-foreground/90 leading-relaxed mb-10 flex-grow">
|
|
||||||
"{t.quote}"
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-auto">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-accent text-background font-heading flex items-center justify-center text-xl font-bold shrink-0">
|
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
<p className="font-heading font-bold text-lg text-foreground uppercase tracking-wide">
|
|
||||||
{t.author}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export const Testimonials: Block = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ name: 'quote', type: 'textarea', required: true },
|
{ name: 'quote', type: 'textarea', required: true },
|
||||||
{ name: 'author', type: 'text', required: true },
|
{ name: 'author', type: 'text', required: true },
|
||||||
|
{
|
||||||
|
name: 'source',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
admin: { description: 'Opcjonalne źródło opinii (np. Google)' }
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export const TextSectionBlock: React.FC<Props> = ({ text }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20 md:py-32 bg-background text-foreground">
|
<section className="py-20 md:py-32 bg-background text-foreground">
|
||||||
<div className="container-premium max-w-4xl">
|
<div className="container-premium">
|
||||||
<div className="prose prose-lg dark:prose-invert prose-headings:font-heading prose-a:text-accent hover:prose-a:text-accent-hover">
|
<div className="prose prose-lg dark:prose-invert prose-headings:font-heading prose-a:text-accent hover:prose-a:text-accent-hover max-w-[65ch]">
|
||||||
{typeof text === 'string' ? <p>{text}</p> : <RichText data={text} />}
|
{typeof text === 'string' ? <p>{text}</p> : <RichText data={text} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { MessageCircle } from 'lucide-react'
|
import { MessageCircle } from 'lucide-react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useConsentContext } from '@intecion/ipal-kit/client'
|
||||||
|
|
||||||
export const FloatingWhatsApp = ({ whatsappLink, ariaLabel }: { whatsappLink: string, ariaLabel: string }) => {
|
export const FloatingWhatsApp = ({ whatsappLink, ariaLabel }: { whatsappLink: string, ariaLabel: string }) => {
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
const { decided } = useConsentContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
@@ -24,11 +26,15 @@ export const FloatingWhatsApp = ({ whatsappLink, ariaLabel }: { whatsappLink: st
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Jeśli użytkownik jeszcze nie podjął decyzji (decided === false), baner jest widoczny.
|
||||||
|
// Podnosimy przycisk na mobile, żeby nie chował się pod banerem. Na desktopie (sm:bottom-6) zostaje standardowo.
|
||||||
|
const bottomClass = decided ? "bottom-6" : "bottom-32 sm:bottom-6"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed bottom-6 right-6 z-40"
|
className={`fixed right-6 z-40 transition-all duration-500 ease-in-out ${bottomClass}`}
|
||||||
initial={{ scale: 1.5, rotate: 10, opacity: 0 }}
|
initial={{ scale: 1.5, rotate: 10, opacity: 0 }}
|
||||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||||
exit={{ scale: 0.8, opacity: 0 }}
|
exit={{ scale: 0.8, opacity: 0 }}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function Footer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-card py-16 md:py-24 mt-auto">
|
<footer className="bg-card py-16 md:py-24 mt-auto">
|
||||||
<div className="container-premium grid grid-cols-1 md:grid-cols-3 gap-16 md:gap-12">
|
<div className="container-premium grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-16 md:gap-12">
|
||||||
|
|
||||||
{/* Kolumna 1: Dane kontaktowe */}
|
{/* Kolumna 1: Dane kontaktowe */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -150,7 +150,7 @@ export function Footer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container-premium mt-16 pt-8 border-t border-border flex flex-col md:flex-row justify-between items-center gap-6 text-sm text-foreground/50">
|
<div className="container-premium mt-16 pt-8 border-t border-border flex flex-col md:flex-row justify-between items-center gap-6 text-sm text-foreground/50">
|
||||||
<p>{copyrightText}</p>
|
<p className="max-w-[65ch]">{copyrightText}</p>
|
||||||
{(creatorName || creatorLabel) && (
|
{(creatorName || creatorLabel) && (
|
||||||
<p className="flex items-center gap-2 font-medium">
|
<p className="flex items-center gap-2 font-medium">
|
||||||
{creatorLabel && <span>{creatorLabel}</span>}
|
{creatorLabel && <span>{creatorLabel}</span>}
|
||||||
|
|||||||
@@ -34,14 +34,15 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh
|
|||||||
? "bg-background/90 backdrop-blur-md shadow-sm border-b border-border py-4"
|
? "bg-background/90 backdrop-blur-md shadow-sm border-b border-border py-4"
|
||||||
: "bg-transparent py-6"
|
: "bg-transparent py-6"
|
||||||
}`}
|
}`}
|
||||||
|
style={{ paddingRight: 'var(--scrollbar-width)' }}
|
||||||
>
|
>
|
||||||
<div className="container-premium flex items-center justify-between">
|
<div className="container-premium flex items-center justify-between">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href={`/${currentLocale.code}`} className="group flex items-center gap-3 z-50">
|
<Link href={`/${currentLocale.code}`} className="group flex items-center gap-3 z-50" aria-label="Finanz- & Lohnbuchhaltung Dipl.-Ing. Janusz Kędzierski">
|
||||||
<div className="w-10 h-10 rounded-none bg-accent text-white flex items-center justify-center font-heading font-bold text-xl group-hover:bg-accent-hover transition-colors shadow-sm">
|
<div className="w-10 h-10 rounded-none bg-accent text-white flex items-center justify-center font-heading font-bold text-xl group-hover:bg-accent-hover transition-colors shadow-sm">
|
||||||
JK
|
JK
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="hidden sm:flex flex-col">
|
||||||
<span className="font-heading font-bold text-lg leading-tight tracking-tight text-foreground">
|
<span className="font-heading font-bold text-lg leading-tight tracking-tight text-foreground">
|
||||||
Finanz- & Lohnbuchhaltung
|
Finanz- & Lohnbuchhaltung
|
||||||
</span>
|
</span>
|
||||||
@@ -52,7 +53,7 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<nav className="hidden xl:flex items-center gap-10">
|
<nav className="hidden lg:flex items-center gap-6 xl:gap-10">
|
||||||
{navItems.map((item, index) => {
|
{navItems.map((item, index) => {
|
||||||
if (item.page && item.page.slug) {
|
if (item.page && item.page.slug) {
|
||||||
return (
|
return (
|
||||||
@@ -72,7 +73,7 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh
|
|||||||
href={`/${currentLocale.code}#${item.anchor}`}
|
href={`/${currentLocale.code}#${item.anchor}`}
|
||||||
className="text-sm font-medium text-foreground/90 hover:text-accent transition-colors"
|
className="text-sm font-medium text-foreground/90 hover:text-accent transition-colors"
|
||||||
>
|
>
|
||||||
FAQ
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,7 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="xl:hidden w-11 h-11 flex shrink-0 items-center justify-center text-foreground rounded-full hover:bg-accent/10 transition-colors ml-1"
|
className="lg:hidden w-11 h-11 flex shrink-0 items-center justify-center text-foreground rounded-full hover:bg-accent/10 transition-colors ml-1"
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
@@ -100,7 +101,7 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh
|
|||||||
|
|
||||||
{/* Mobile Fullscreen Navigation */}
|
{/* Mobile Fullscreen Navigation */}
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 z-40 bg-background/95 backdrop-blur-xl transition-all duration-300 xl:hidden flex flex-col justify-center items-center ${
|
className={`fixed inset-0 z-40 bg-background/95 backdrop-blur-xl transition-all duration-300 lg:hidden flex flex-col justify-center items-center ${
|
||||||
isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible pointer-events-none"
|
isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -117,6 +118,7 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (item.anchor) {
|
} else if (item.anchor) {
|
||||||
|
const title = item.anchor.toUpperCase()
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
@@ -124,7 +126,7 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh
|
|||||||
className="text-2xl font-bold text-foreground hover:text-accent transition-colors"
|
className="text-2xl font-bold text-foreground hover:text-accent transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
FAQ
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ImageIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export const ImagePlaceholder: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-card bg-paper-noise flex flex-col items-center justify-center text-border/50 gap-4">
|
||||||
|
<ImageIcon size={64} strokeWidth={1} />
|
||||||
|
<span className="text-xs tracking-widest uppercase font-semibold text-foreground/40">MIEJSCE NA ZDJĘCIE</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,13 +11,16 @@ type LocalizedLinkProps = {
|
|||||||
|
|
||||||
export function LocalizedLink({ page, currentLocaleCode, className, onClick, children }: LocalizedLinkProps) {
|
export function LocalizedLink({ page, currentLocaleCode, className, onClick, children }: LocalizedLinkProps) {
|
||||||
// Wykorzystujemy czysty clientGetLocalizedSlugs by pozbyć się pustych slugów / wyłuskać poprawny format
|
// Wykorzystujemy czysty clientGetLocalizedSlugs by pozbyć się pustych slugów / wyłuskać poprawny format
|
||||||
const slugs = clientGetLocalizedSlugs(page.slug);
|
// Slug może być stringiem (jeśli locale było przekazane w Payload) lub obiektem (jeśli locale: 'all')
|
||||||
|
const slugs = typeof page.slug === 'string'
|
||||||
|
? { [currentLocaleCode]: page.slug }
|
||||||
|
: clientGetLocalizedSlugs(page.slug);
|
||||||
|
|
||||||
// Budowanie poprawnego URL dla bieżącego języka
|
// Budowanie poprawnego URL dla bieżącego języka
|
||||||
const targetHref = clientSwitchLocalePath(`/${currentLocaleCode}`, currentLocaleCode, slugs);
|
const targetHref = clientSwitchLocalePath(`/${currentLocaleCode}`, currentLocaleCode, slugs);
|
||||||
|
|
||||||
// Ponieważ używamy locale: 'all', title jest obiektem z kluczami języków (fallbacks w razie braku)
|
// Tytuł może być stringiem (jeśli locale było przekazane w Payload) lub obiektem (jeśli locale: 'all')
|
||||||
const title = page.title?.[currentLocaleCode] || page.title?.pl || 'Brak tytułu';
|
const title = typeof page.title === 'string' ? page.title : (page.title?.[currentLocaleCode] || page.title?.pl || 'Brak tytułu');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export interface Page {
|
|||||||
| {
|
| {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
|
image?: (number | null) | Media;
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
blockName?: string | null;
|
blockName?: string | null;
|
||||||
blockType: 'pageHeader';
|
blockType: 'pageHeader';
|
||||||
@@ -247,6 +248,8 @@ export interface Page {
|
|||||||
blockType: 'bio';
|
blockType: 'bio';
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
title?: string | null;
|
||||||
|
subtitle?: string | null;
|
||||||
members?:
|
members?:
|
||||||
| {
|
| {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -412,6 +415,10 @@ export interface Page {
|
|||||||
| {
|
| {
|
||||||
quote: string;
|
quote: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
/**
|
||||||
|
* Opcjonalne źródło opinii (np. Google)
|
||||||
|
*/
|
||||||
|
source?: string | null;
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
@@ -675,6 +682,7 @@ export interface PagesSelect<T extends boolean = true> {
|
|||||||
| {
|
| {
|
||||||
title?: T;
|
title?: T;
|
||||||
subtitle?: T;
|
subtitle?: T;
|
||||||
|
image?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
blockName?: T;
|
blockName?: T;
|
||||||
};
|
};
|
||||||
@@ -711,6 +719,8 @@ export interface PagesSelect<T extends boolean = true> {
|
|||||||
team?:
|
team?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
title?: T;
|
||||||
|
subtitle?: T;
|
||||||
members?:
|
members?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
@@ -899,6 +909,7 @@ export interface PagesSelect<T extends boolean = true> {
|
|||||||
| {
|
| {
|
||||||
quote?: T;
|
quote?: T;
|
||||||
author?: T;
|
author?: T;
|
||||||
|
source?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
id?: T;
|
id?: T;
|
||||||
|
|||||||
Reference in New Issue
Block a user