diff --git a/.agents/context/operations.md b/.agents/context/operations.md index 04c3f79..2ec2372 100644 --- a/.agents/context/operations.md +++ b/.agents/context/operations.md @@ -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"` 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. diff --git a/prompts/10-architektura-cms.md b/prompts/10-architektura-cms.md index 2da5088..e2c5ff1 100644 --- a/prompts/10-architektura-cms.md +++ b/prompts/10-architektura-cms.md @@ -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 | | 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 | -| 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 | | 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 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 diff --git a/scripts/content-snapshot.json b/scripts/content-snapshot.json index f5d3bec..d71d4a4 100644 --- a/scripts/content-snapshot.json +++ b/scripts/content-snapshot.json @@ -142,7 +142,7 @@ { "id": "0be1dd17cae558b75684137c", "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, "blockType": "pageHeader" }, @@ -167,7 +167,7 @@ { "id": "5c16dbfeb63e8be48f313eb0", "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, "blockType": "pageHeader" }, @@ -286,7 +286,7 @@ "id": "6a993b67a0e23e2a75a4cc85", "eyebrow": null, "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", "blockName": null, "blockType": "finalCta" @@ -361,7 +361,7 @@ "id": "6a993b67a0e23e2a75a4cc8e", "eyebrow": null, "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", "blockName": null, "blockType": "finalCta" @@ -389,7 +389,7 @@ "id": "6a995ca5ea2f1a36632134e0", "eyebrow": null, "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, "ctaUrl": null, "image": 1, @@ -398,7 +398,7 @@ { "id": "6a995ca5ea2f1a36632134dd", "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", @@ -408,7 +408,7 @@ { "id": "6a995ca5ea2f1a36632134df", "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" @@ -426,7 +426,7 @@ "id": "6a995ca5ea2f1a36632134e2", "eyebrow": null, "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", "blockName": null, "blockType": "finalCta" @@ -460,7 +460,7 @@ { "id": "6a995ca5ea2f1a36632134e4", "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", @@ -470,7 +470,7 @@ { "id": "6a995ca5ea2f1a36632134e6", "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" @@ -488,7 +488,7 @@ "id": "6a995ca5ea2f1a36632134e9", "eyebrow": null, "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", "blockName": null, "blockType": "finalCta" @@ -514,8 +514,8 @@ }, { "id": "6a988b8714359f1c1b30efd0", - "title": "Czym się zajmuję", - "subtitle": "Zapewniam rzetelne i kompleksowe wsparcie na każdym etapie rozliczenia podatku dochodowego w Niemczech.", + "title": "Czym się zajmujemy", + "subtitle": "Zapewniamy rzetelne i kompleksowe wsparcie na każdym etapie rozliczenia podatku dochodowego w Niemczech.", "blockName": null, "services": [ { @@ -642,7 +642,7 @@ }, { "id": "6a988b8714359f1c1b30efe4", - "title": "Was ich anbiete", + "title": "Was wir anbieten", "subtitle": "Umfassende und zuverlässige Betreuung bei allen Fragen rund um Ihre Einkommensteuererklärung.", "blockName": null, "services": [ @@ -789,7 +789,7 @@ "items": [ { "id": "6a988b8614359f1c1b30ef8d", - "text": "Działam w ramach b.b.h.", + "text": "Działamy w ramach b.b.h.", "icon": "shield" }, { @@ -814,7 +814,7 @@ "id": "6a9917f7eb2fb5197987f4ba", "eyebrow": null, "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, "blockName": null, "features": [ @@ -859,7 +859,7 @@ { "id": "6a988b8614359f1c1b30ef9c", "eyebrow": "Usługi", - "title": "Czym się zajmuję", + "title": "Czym się zajmujemy", "blockName": null, "services": [ { @@ -915,7 +915,7 @@ "id": "6a988b8614359f1c1b30efa1", "eyebrow": "Zaufanie", "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, "linkText": "Poznaj stowarzyszenie", "linkUrl": "/pl/o-nas", @@ -998,7 +998,7 @@ "id": "6a988b8614359f1c1b30efa9", "eyebrow": "Kontakt", "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", "blockName": null, "blockType": "finalCta" @@ -1107,7 +1107,7 @@ { "id": "6a988b8714359f1c1b30efba", "eyebrow": "Leistungen", - "title": "Was ich anbiete", + "title": "Was wir anbieten", "blockName": null, "services": [ { @@ -1163,7 +1163,7 @@ "id": "6a988b8714359f1c1b30efbf", "eyebrow": "Vertrauen", "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, "linkText": "Den Verein kennenlernen", "linkUrl": "/de/uber-uns", @@ -1241,7 +1241,7 @@ "id": "6a988b8714359f1c1b30efc7", "eyebrow": "Kontakt", "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", "blockName": null, "blockType": "finalCta" @@ -1355,7 +1355,7 @@ } ], "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, "link": "https://bbh-lohnsteuerhilfe.de" }, @@ -1402,7 +1402,7 @@ } ], "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, "link": "https://bbh-lohnsteuerhilfe.de" }, diff --git a/scripts/find_first_person.ts b/scripts/find_first_person.ts new file mode 100644 index 0000000..c593fe4 --- /dev/null +++ b/scripts/find_first_person.ts @@ -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)) diff --git a/scripts/migrate_first_person.ts b/scripts/migrate_first_person.ts new file mode 100644 index 0000000..fa8bd9f --- /dev/null +++ b/scripts/migrate_first_person.ts @@ -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() diff --git a/scripts/restore_kontakt.ts b/scripts/restore_kontakt.ts new file mode 100644 index 0000000..e042722 --- /dev/null +++ b/scripts/restore_kontakt.ts @@ -0,0 +1,86 @@ +import { getPayload } from 'payload' +import configPromise from '../src/payload.config' +import type { Page } from '../src/payload-types' + +type PageHeaderBlock = Extract[0], { blockType: 'pageHeader' }> +type ContactInfoBlock = Extract[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: 'kontakt@example.com', + 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: 'kontakt@example.com', + 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; + + // Dodaj nagłówek i info kontaktowe na górę + newLayout.unshift( + (locale === 'de' ? deHeader : plHeader) as unknown as NonNullable[0], + (locale === 'de' ? deContact : plContact) as unknown as NonNullable[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() diff --git a/scripts/verify_kontakt_db.ts b/scripts/verify_kontakt_db.ts new file mode 100644 index 0000000..5421f13 --- /dev/null +++ b/scripts/verify_kontakt_db.ts @@ -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() diff --git a/src/app/(frontend)/[locale]/[[...slug]]/page.tsx b/src/app/(frontend)/[locale]/[[...slug]]/page.tsx index 62be328..ca31af3 100644 --- a/src/app/(frontend)/[locale]/[[...slug]]/page.tsx +++ b/src/app/(frontend)/[locale]/[[...slug]]/page.tsx @@ -45,14 +45,14 @@ export default async function Page(props: { const docForRender = await payload.findByID({ collection: ('collection' in route ? route.collection : 'pages') as 'pages', id: route.doc.id as string, - locale: locale as any, // Payload locale typ nie zawsze przyjmuje string + locale: locale as 'all' | 'pl' | 'de', }) const layout = docForRender.layout || [] // Generowanie schematu FAQPage dla bloku faqFull (na poziomie Server Component gwarantuje to SSR) - let faqSchema: Record | null = null; - const faqBlock = layout.find((b: any) => b.blockType === 'faqFull') as FAQFullBlock | undefined; + let faqSchema: Record | null = null; + const faqBlock = layout.find((b) => b !== null && typeof b === 'object' && 'blockType' in b && b.blockType === 'faqFull') as FAQFullBlock | undefined; if (faqBlock && faqBlock.questions) { const validQuestions = faqBlock.questions.filter((q: NonNullable[0]) => !q.isDraft); if (validQuestions.length > 0) { diff --git a/src/app/(frontend)/[locale]/layout.tsx b/src/app/(frontend)/[locale]/layout.tsx index b4a7ef4..b9248ad 100644 --- a/src/app/(frontend)/[locale]/layout.tsx +++ b/src/app/(frontend)/[locale]/layout.tsx @@ -84,15 +84,15 @@ export default async function RootLayout(props: {
+ + {/* Floating WhatsApp Button */} + {companyData?.whatsappNumber && ( + + )} - - {/* Floating WhatsApp Button */} - {companyData?.whatsappNumber && ( - - )} diff --git a/src/app/globals.css b/src/app/globals.css index af7d859..ce24070 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -26,6 +26,9 @@ /* Globalny token dla przycisków prostokątnych (umiarkowane zaokrąglenie, nie pill) */ --radius-button: 6px; + + /* Fizyczna szerokość scrollbara (dla wyrównania fixed headera) */ + --scrollbar-width: calc(100vw - 100%); } [data-theme="dark"] { @@ -51,6 +54,7 @@ body { -webkit-font-smoothing: antialiased; /* Płynne przejścia przy zmianie motywu, ale bez migotania */ transition: background-color 0.3s ease, color 0.3s ease; + overflow-x: hidden; } h1, h2, h3, h4, h5, h6 { @@ -77,7 +81,6 @@ h1, h2, h3, h4, h5, h6 { /* Tekstura papieru dla sekcji (bardzo subtelny szum) */ .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"); } diff --git a/src/blocks/ContactInfo/Component.tsx b/src/blocks/ContactInfo/Component.tsx index 2055dd0..9faed1d 100644 --- a/src/blocks/ContactInfo/Component.tsx +++ b/src/blocks/ContactInfo/Component.tsx @@ -19,7 +19,7 @@ export const ContactInfoBlock: React.FC = ({ return (
-
+
= ({ {isDe ? 'Adresse' : 'Adres'}
-

+

{address}

@@ -62,7 +62,7 @@ export const ContactInfoBlock: React.FC = ({ {phone} @@ -79,7 +79,7 @@ export const ContactInfoBlock: React.FC = ({ {email} @@ -94,7 +94,7 @@ export const ContactInfoBlock: React.FC = ({ {isDe ? 'Öffnungszeiten' : 'Godziny pracy'} -

+

{hours}

diff --git a/src/blocks/FAQFull/Component.tsx b/src/blocks/FAQFull/Component.tsx index 9c2692c..7e953d5 100644 --- a/src/blocks/FAQFull/Component.tsx +++ b/src/blocks/FAQFull/Component.tsx @@ -86,8 +86,8 @@ export const FAQFullBlockComponent: React.FC = ({ {/* FAQ Categories */}
-
-
+
+
{Object.entries(groupedQuestions).map(([category, catQuestions], catIndex) => ( = ({ exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3, ease: 'easeInOut' }} > -
+
{q.answer}
diff --git a/src/blocks/FAQTeaser/Component.tsx b/src/blocks/FAQTeaser/Component.tsx index ab34918..a2f227e 100644 --- a/src/blocks/FAQTeaser/Component.tsx +++ b/src/blocks/FAQTeaser/Component.tsx @@ -15,14 +15,14 @@ export const FAQTeaserBlock: React.FC = ({ title, questions, ctaText, cta return (
-
-
+
+

{title}

-
+
{questions?.map((q: NonNullable[0], index: number) => { const isOpen = openIndex === index @@ -53,7 +53,7 @@ export const FAQTeaserBlock: React.FC = ({ title, questions, ctaText, cta exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3, ease: 'easeInOut' }} > -
+
{q.answer}
@@ -65,7 +65,7 @@ export const FAQTeaserBlock: React.FC = ({ title, questions, ctaText, cta
{ctaText && ctaUrl && ( -
+
[0], { blockType: 'hero' }> type Props = BaseProps & { whatsappNumber?: string | null } @@ -16,6 +16,7 @@ const containerVariants = { visible: { transition: { staggerChildren: 0.14, + delayChildren: 0.2 }, }, } @@ -26,63 +27,12 @@ const itemVariants = { opacity: 1, y: 0, transition: { - duration: 0.5, + duration: 0.7, 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) => { - 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 = ({ title, titleAccent, @@ -90,22 +40,15 @@ export const HeroBlock: React.FC = ({ primaryCta, secondaryCta, image, - imageSecondary, floatingStat, whatsappNumber, }) => { - const [imagesLoaded, setImagesLoaded] = useState(false) + const [imageLoaded, setImageLoaded] = useState(false) const whatsappLink = whatsappNumber ? `https://wa.me/${whatsappNumber}` : '#' const heroMedia = typeof image === 'object' && image !== null ? (image as Media) : null const imageUrl = heroMedia?.url - const imageAlt = heroMedia?.alt || '' - - 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 imageAlt = heroMedia?.alt || 'Tło hero' const statValue = floatingStat?.value || '20+' const statLabel = floatingStat?.label || 'Lat doświadczenia' @@ -114,21 +57,53 @@ export const HeroBlock: React.FC = ({ -
-
+ {/* Background Layer */} +
+ {imageUrl ? ( + <> + {imageAlt} 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). */} +
+
{/* bazowe lekkie przyciemnienie reszty zdjęcia */} + + ) : ( +
+ )} +
+ + {/* Content Layer */} +
+
- {/* Kolumna tekstowa z animacjami wejścia */} - + - {/* Prawa kolumna: Kolaż zdjęć z 3D paralaksą i pływającą kartą */} + {/* Floating Stat "Seal" (Bottom Right) */} -
- {/* Pływająca karta (floating card) */} - - - {statValue} - - - {statLabel} - - - - {secondaryUrl ? ( - /* Układ kolażu dwóch nachodzących na siebie zdjęć */ -
- {/* Zdjęcie 1 (mniejsze, przesunięte w lewo-górę) */} -
- {imageUrl ? ( - {imageAlt} 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'}`} - /> - ) : ( -
- -
- )} -
- - {/* Zdjęcie 2 (większe, przesunięte w prawo-dół, nachodzące na pierwsze o ok. 18%) */} -
- {secondaryAlt} -
-
- ) : ( - /* Pojedyncze zdjęcie jeśli imageSecondary jest puste */ -
- {imageUrl ? ( - {imageAlt} 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'}`} - /> - ) : ( -
- - ZDJĘCIE GŁÓWNE -
- )} -
- )} -
+ {/* Dekoracyjna krawędź przypominająca stempel */} +
+ + {statValue} + + + {statLabel} + + + + {/* Mobile version of the seal - more inline */} + + + {statValue} + + + {statLabel} +
diff --git a/src/blocks/PageHeader/Component.tsx b/src/blocks/PageHeader/Component.tsx index f2f62b8..6c13d93 100644 --- a/src/blocks/PageHeader/Component.tsx +++ b/src/blocks/PageHeader/Component.tsx @@ -1,27 +1,59 @@ 'use client' -import React from 'react' +import React, { useState } from 'react' 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 { - title: string - subtitle?: string | null -} +type BaseProps = Extract[0], { blockType: 'pageHeader' }> +export type PageHeaderProps = BaseProps + +export const PageHeaderBlock: React.FC = ({ 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 = ({ title, subtitle }) => { return ( -
-
+
+ {/* Background Layer */} +
+ {imageUrl ? ( + <> + {imageAlt} 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 */} +
+
{/* bazowe lekkie przyciemnienie reszty zdjęcia */} + + ) : ( +
+ )} +
+ + {/* Content Layer */} +
-

+

{title}

{subtitle && ( -

+

{subtitle}

)} diff --git a/src/blocks/PageHeader/config.ts b/src/blocks/PageHeader/config.ts index 2631ac4..fa84b1d 100644 --- a/src/blocks/PageHeader/config.ts +++ b/src/blocks/PageHeader/config.ts @@ -5,5 +5,6 @@ export const PageHeader: Block = { fields: [ { name: 'title', type: 'text', required: true }, { name: 'subtitle', type: 'textarea' }, + { name: 'image', type: 'upload', relationTo: 'media', required: false }, ], } diff --git a/src/blocks/ServicesList/Component.tsx b/src/blocks/ServicesList/Component.tsx index e4b7a58..3e43f65 100644 --- a/src/blocks/ServicesList/Component.tsx +++ b/src/blocks/ServicesList/Component.tsx @@ -39,7 +39,7 @@ export const ServicesListBlock: React.FC = ({ title, services }) => { whileInView="visible" viewport={{ once: true, margin: "-50px" }} 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[0], index: number) => ( = ({ title, subtitle, items }) => { if (!items || items.length === 0) return null return ( -
+
{(title || subtitle) && (
@@ -60,7 +60,7 @@ export const StatsBlock: React.FC = ({ title, subtitle, items }) => { key={item.id || index} className="flex flex-col p-8 sm:p-12 hover:bg-foreground/[0.02] transition-colors" > -
+
diff --git a/src/blocks/Team/Component.tsx b/src/blocks/Team/Component.tsx index e518239..19da5b7 100644 --- a/src/blocks/Team/Component.tsx +++ b/src/blocks/Team/Component.tsx @@ -1,77 +1,229 @@ 'use client' -import React from 'react' +import React, { useState, useEffect } from 'react' import type { Page, Media } from '../../payload-types' import Image from 'next/image' 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[0], { blockType: 'team' }> -export const TeamBlock: React.FC = ({ members }) => { - if (!members || members.length <= 1) return null +export const TeamBlock: React.FC = ({ members: originalMembers, title, subtitle }) => { + 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[0] | null>(null) + const [previousFocus, setPreviousFocus] = useState(null) + const modalRef = React.useRef(null) + + const openModal = (member: any, event: React.MouseEvent) => { + 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( + '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 ( -
+
+ + {/* Section Header */} +
+ + {title && ( +

+ {title} +

+ )} + {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ + {/* Grid - scalable from 1 up to 5 cols on large screens */} - {members.map((member: NonNullable[0], index: number) => { + {members.map((member: any, index: number) => { const photo = typeof member.photo === 'object' && member.photo !== null ? (member.photo as Media) : null - const initial = member.name ? member.name.charAt(0).toUpperCase() : '' return ( -
-
+ ) })}
+ + {/* Lightbox / Modal for details */} + + {activeMember && ( +
+
+ )} +
) } + diff --git a/src/blocks/Team/config.ts b/src/blocks/Team/config.ts index 8814033..57f5401 100644 --- a/src/blocks/Team/config.ts +++ b/src/blocks/Team/config.ts @@ -3,6 +3,18 @@ import type { Block } from 'payload' export const Team: Block = { slug: 'team', 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', type: 'array', diff --git a/src/blocks/Testimonials/Carousel.tsx b/src/blocks/Testimonials/Carousel.tsx new file mode 100644 index 0000000..2bc7007 --- /dev/null +++ b/src/blocks/Testimonials/Carousel.tsx @@ -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 = ({ title, items }) => { + const containerRef = useRef(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 ( +
+
+
+
+ {title && ( +

+ {title} +

+ )} +
+ + {/* Strzałki nawigacji (Desktop) */} +
+ + +
+
+ + {/* Kontener scroll-snap */} +
+ + {items.map((t, index) => { + const initial = t.author ? t.author.charAt(0).toUpperCase() : '' + return ( + +
+
+ + + +
+ {t.source && ( + + {t.source} + + )} +
+ +

+ "{t.quote}" +

+ +
+
+ {initial} +
+

+ {t.author} +

+
+
+ ) + })} +
+
+ + {/* Kropki nawigacji (Mobile + Desktop) */} +
+ {items.map((_, index) => ( +
+
+
+ ) +} diff --git a/src/blocks/Testimonials/Component.tsx b/src/blocks/Testimonials/Component.tsx index dddb0a5..3217f16 100644 --- a/src/blocks/Testimonials/Component.tsx +++ b/src/blocks/Testimonials/Component.tsx @@ -1,80 +1,28 @@ 'use client' -import React from 'react' +import React, { useMemo } from 'react' import type { Page } from '../../payload-types' -import { motion } from 'framer-motion' +import { TestimonialsCarousel, TestimonialData } from './Carousel' type Props = Extract[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 = ({ title, testimonials }) => { 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 ( -
-
-
- {title && ( -

- {title} -

- )} -
- - - {testimonials.map((t: NonNullable[0], index: number) => { - const initial = t.author ? t.author.charAt(0).toUpperCase() : '' - return ( - -
- - - -
- -

- "{t.quote}" -

- -
-
- {initial} -
-

- {t.author} -

-
-
- ) - })} -
-
-
+ ) } + diff --git a/src/blocks/Testimonials/config.ts b/src/blocks/Testimonials/config.ts index ef4a5b1..e04a3c0 100644 --- a/src/blocks/Testimonials/config.ts +++ b/src/blocks/Testimonials/config.ts @@ -11,6 +11,12 @@ export const Testimonials: Block = { fields: [ { name: 'quote', type: 'textarea', required: true }, { name: 'author', type: 'text', required: true }, + { + name: 'source', + type: 'text', + required: false, + admin: { description: 'Opcjonalne źródło opinii (np. Google)' } + }, ], }, ], diff --git a/src/blocks/TextSection/Component.tsx b/src/blocks/TextSection/Component.tsx index 6e947cc..0d16ce9 100644 --- a/src/blocks/TextSection/Component.tsx +++ b/src/blocks/TextSection/Component.tsx @@ -10,8 +10,8 @@ export const TextSectionBlock: React.FC = ({ text }) => { return (
-
-
+
+
{typeof text === 'string' ?

{text}

: }
diff --git a/src/components/FloatingWhatsApp/index.tsx b/src/components/FloatingWhatsApp/index.tsx index 777844e..0d96069 100644 --- a/src/components/FloatingWhatsApp/index.tsx +++ b/src/components/FloatingWhatsApp/index.tsx @@ -2,9 +2,11 @@ import React, { useEffect, useState } from 'react' import { MessageCircle } from 'lucide-react' import { motion, AnimatePresence } from 'framer-motion' +import { useConsentContext } from '@intecion/ipal-kit/client' export const FloatingWhatsApp = ({ whatsappLink, ariaLabel }: { whatsappLink: string, ariaLabel: string }) => { const [isVisible, setIsVisible] = useState(true) + const { decided } = useConsentContext() useEffect(() => { 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 ( {isVisible && ( -
+
{/* Kolumna 1: Dane kontaktowe */}
@@ -150,7 +150,7 @@ export function Footer({
-

{copyrightText}

+

{copyrightText}

{(creatorName || creatorLabel) && (

{creatorLabel && {creatorLabel}} diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 7dcb029..995fdab 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -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-transparent py-6" }`} + style={{ paddingRight: 'var(--scrollbar-width)' }} >

{/* Logo */} - +
JK
-
+
Finanz- & Lohnbuchhaltung @@ -52,7 +53,7 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh {/* Desktop Navigation */} -