diff --git a/.beads/last-touched b/.beads/last-touched index cdadab4..4dddbd8 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-e05 +fotospiel-app-vel diff --git a/docs/archive/prp/12-i18n.md b/docs/archive/prp/12-i18n.md index b0ca103..e21ff4c 100644 --- a/docs/archive/prp/12-i18n.md +++ b/docs/archive/prp/12-i18n.md @@ -60,6 +60,20 @@ - **Robots.txt**: Allow both locales; noindex for dev. - **Accessibility**: ARIA labels with `t()`; screen reader support for language switches. +### SEO Implementation Notes (Marketing) +- **Source of tags**: `resources/js/layouts/mainWebsite.tsx` renders canonical + hreflang links for Inertia marketing pages. +- **URL building**: `resources/js/lib/localizedPath.ts` handles locale rewrites (e.g., `/kontakt` ↔ `/contact`) and prefixing. +- **Data inputs**: `supportedLocales`, `locale`, and `appUrl` are shared via `app/Http/Middleware/HandleInertiaRequests.php`. +- **SSR**: Inertia SSR is disabled (`config/inertia.php`), so canonical/hreflang tags are client-rendered. + +### Validation Checklist (Search Console + Lighthouse) +- Verify canonical + hreflang output on key marketing pages (home, contact, packages, blog, occasions). +- Use Search Console URL inspection to confirm rendered HTML contains canonical + hreflang tags. +- Run Lighthouse SEO audit on both `/de/*` and `/en/*` routes (spot-check canonical + alternate links). + +### Tests +- **Vitest**: `resources/js/layouts/__tests__/mainWebsite.seo.test.tsx` validates canonical/hreflang output and localized slug rewrites. + ## Migration from PHP to JSON - Extract keys from `resources/lang/\{locale\}/marketing.php` to `public/lang/\{locale\}/marketing.json`. - Consolidate: Remove duplicates; use nested objects (e.g., `{ "header": { "login": "Anmelden" } }`). diff --git a/docs/archive/prp/marketing-frontend-unification.md b/docs/archive/prp/marketing-frontend-unification.md index ccda1dc..9e84c32 100644 --- a/docs/archive/prp/marketing-frontend-unification.md +++ b/docs/archive/prp/marketing-frontend-unification.md @@ -40,6 +40,7 @@ Ziel: Vollständige Migration zu Inertia.js für SPA-ähnliche Konsistenz, mit e - **Auth-Integration**: usePage().props.auth für CTAs (z.B. Login-Button im Header). - **Responsive Design**: Tailwind: block md:hidden für Mobile-Carousel in Packages. - **Fonts & Assets**: Vite-Plugin für Fonts/SVGs; preload in Layout. +- **SEO (hreflang/canonical)**: `resources/js/layouts/mainWebsite.tsx` rendert canonical + hreflang auf Basis von `supportedLocales`, `locale`, `appUrl` und `resources/js/lib/localizedPath.ts` (Locale-Rewrites). - **Analytics (Matomo)**: Aktivierung via `.env` (`MATOMO_ENABLED=true`, `MATOMO_URL`, `MATOMO_SITE_ID`). `AppServiceProvider` teilt die Konfiguration als `analytics.matomo`; `MarketingLayout` rendert `MatomoTracker`, der das Snippet aus `/docs/piwik-trackingcode.txt` nur bei erteilter Analyse-Zustimmung lädt, `disableCookies` setzt und bei jedem Inertia-Navigationsevent `trackPageView` sendet. Ein lokalisierter Consent-Banner (DE/EN) übernimmt die DSGVO-konforme Einwilligung und ist über den Footer erneut erreichbar. - **Tests**: E2E mit Playwright (z.B. navigate to /packages, check header/footer presence). diff --git a/resources/js/layouts/__tests__/mainWebsite.seo.test.tsx b/resources/js/layouts/__tests__/mainWebsite.seo.test.tsx new file mode 100644 index 0000000..8767ae3 --- /dev/null +++ b/resources/js/layouts/__tests__/mainWebsite.seo.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +const page = { + url: '/de/kontakt', + props: { + locale: 'de', + supportedLocales: ['de', 'en'], + appUrl: 'https://fotospiel.app', + translations: { + marketing: { + title: 'Fotospiel', + description: 'Sammle Gastfotos für Events mit QR-Codes', + }, + }, + auth: {}, + }, +}; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | { defaultValue?: unknown }) => { + if (typeof fallback === 'string') { + return fallback; + } + if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) { + return fallback.defaultValue; + } + return key; + }, + i18n: { + language: 'de', + changeLanguage: vi.fn(), + }, + ready: true, + }), +})); + +vi.mock('@inertiajs/react', async () => { + const ReactModule = await import('react'); + const ReactDom = await import('react-dom'); + return { + Head: ({ children }: { children?: React.ReactNode }) => + ReactDom.createPortal(children, document.head), + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), + router: { + post: vi.fn(), + visit: vi.fn(), + }, + usePage: () => page, + }; +}); + +vi.mock('@/components/analytics/MatomoTracker', () => ({ + default: () => null, +})); + +vi.mock('@/layouts/app/Footer', () => ({ + default: () => null, +})); + +vi.mock('@/hooks/use-appearance', () => ({ + useAppearance: () => ({ + appearance: 'light', + updateAppearance: vi.fn(), + }), +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children }: { children: React.ReactNode }) => , +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuRadioItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('react-hot-toast', () => ({ + default: { + success: vi.fn(), + }, +})); + +vi.mock('lucide-react', () => ({ + MoreHorizontal: () => null, + Sun: () => null, + Moon: () => null, + Languages: () => null, + LayoutDashboard: () => null, + LogOut: () => null, + LogIn: () => null, +})); + +import MarketingLayout from '../mainWebsite'; + +describe('MarketingLayout SEO tags', () => { + it('renders canonical and hreflang links for the active locale', () => { + page.url = '/de/kontakt'; + page.props.locale = 'de'; + + render( + +
Test
+
+ ); + + const canonical = document.head.querySelector('link[rel="canonical"]'); + const alternateDe = document.head.querySelector('link[rel="alternate"][hreflang="de"]'); + const alternateEn = document.head.querySelector('link[rel="alternate"][hreflang="en"]'); + const alternateDefault = document.head.querySelector('link[rel="alternate"][hreflang="x-default"]'); + + expect(canonical).toHaveAttribute('href', 'https://fotospiel.app/de/kontakt'); + expect(alternateDe).toHaveAttribute('href', 'https://fotospiel.app/de/kontakt'); + expect(alternateEn).toHaveAttribute('href', 'https://fotospiel.app/en/contact'); + expect(alternateDefault).toHaveAttribute('href', 'https://fotospiel.app/de/kontakt'); + }); + + it('preserves query params and rewrites localized slugs across locales', () => { + page.url = '/en/contact?ref=ad'; + page.props.locale = 'en'; + + render( + +
Test
+
+ ); + + const canonical = document.head.querySelector('link[rel="canonical"]'); + const alternateDe = document.head.querySelector('link[rel="alternate"][hreflang="de"]'); + const alternateEn = document.head.querySelector('link[rel="alternate"][hreflang="en"]'); + const alternateDefault = document.head.querySelector('link[rel="alternate"][hreflang="x-default"]'); + + expect(canonical).toHaveAttribute('href', 'https://fotospiel.app/en/contact?ref=ad'); + expect(alternateEn).toHaveAttribute('href', 'https://fotospiel.app/en/contact?ref=ad'); + expect(alternateDe).toHaveAttribute('href', 'https://fotospiel.app/de/kontakt?ref=ad'); + expect(alternateDefault).toHaveAttribute('href', 'https://fotospiel.app/de/kontakt?ref=ad'); + }); +});