Compare commits
2 Commits
eeffe4c6f1
...
1443ff0d3a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1443ff0d3a | ||
|
|
e48ec3c564 |
@@ -1 +1 @@
|
||||
fotospiel-app-e05
|
||||
fotospiel-app-vel
|
||||
|
||||
@@ -192,5 +192,9 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
|
||||
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
|
||||
STORAGE_QUEUE_FAILED_EVENT_MINUTES=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
|
||||
|
||||
|
||||
|
||||
@@ -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" } }`).
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
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