Compare commits

...

2 Commits

Author SHA1 Message Date
Codex Agent
1443ff0d3a Add marketing hreflang tests and docs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:52:44 +01:00
Codex Agent
e48ec3c564 Add storage checksum env defaults 2026-01-30 11:52:20 +01:00
5 changed files with 167 additions and 1 deletions

View File

@@ -1 +1 @@
fotospiel-app-e05 fotospiel-app-vel

View File

@@ -192,5 +192,9 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2 STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30 STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
STORAGE_QUEUE_GUEST_ALERT_TTL=30 STORAGE_QUEUE_GUEST_ALERT_TTL=30
STORAGE_CHECKSUM_VALIDATION=true
STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES=60
STORAGE_CHECKSUM_WARNING=1
STORAGE_CHECKSUM_CRITICAL=5

View File

@@ -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" } }`).

View File

@@ -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).

View 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');
});
});