Add marketing hreflang tests and docs
This commit is contained in:
@@ -1 +1 @@
|
|||||||
fotospiel-app-e05
|
fotospiel-app-vel
|
||||||
|
|||||||
@@ -60,6 +60,20 @@
|
|||||||
- **Robots.txt**: Allow both locales; noindex for dev.
|
- **Robots.txt**: Allow both locales; noindex for dev.
|
||||||
- **Accessibility**: ARIA labels with `t()`; screen reader support for language switches.
|
- **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
|
## Migration from PHP to JSON
|
||||||
- Extract keys from `resources/lang/\{locale\}/marketing.php` to `public/lang/\{locale\}/marketing.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" } }`).
|
- Consolidate: Remove duplicates; use nested objects (e.g., `{ "header": { "login": "Anmelden" } }`).
|
||||||
|
|||||||
@@ -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).
|
- **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.
|
- **Responsive Design**: Tailwind: block md:hidden für Mobile-Carousel in Packages.
|
||||||
- **Fonts & Assets**: Vite-Plugin für Fonts/SVGs; preload in Layout.
|
- **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.
|
- **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).
|
- **Tests**: E2E mit Playwright (z.B. navigate to /packages, check header/footer presence).
|
||||||
|
|
||||||
|
|||||||
147
resources/js/layouts/__tests__/mainWebsite.seo.test.tsx
Normal file
147
resources/js/layouts/__tests__/mainWebsite.seo.test.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<a href={href}>{children}</a>
|
||||||
|
),
|
||||||
|
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 }) => <button type="button">{children}</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuRadioItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuSeparator: () => <div />,
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(
|
||||||
|
<MarketingLayout title="Kontakt">
|
||||||
|
<div>Test</div>
|
||||||
|
</MarketingLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<MarketingLayout title="Contact">
|
||||||
|
<div>Test</div>
|
||||||
|
</MarketingLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user