- Wired the checkout wizard for Google “comfort login”: added Socialite controller + dependency, new Google env

hooks in config/services.php/.env.example, and updated wizard steps/controllers to store session payloads,
attach packages, and surface localized success/error states.
- Retooled payment handling for both Stripe and PayPal, adding richer status management in CheckoutController/
PayPalController, fallback flows in the wizard’s PaymentStep.tsx, and fresh feature tests for intent
creation, webhooks, and the wizard CTA.
- Introduced a consent-aware Matomo analytics stack: new consent context, cookie-banner UI, useAnalytics/
useCtaExperiment hooks, and MatomoTracker component, then instrumented marketing pages (Home, Packages,
Checkout) with localized copy and experiment tracking.
- Polished package presentation across marketing UIs by centralizing formatting in PresentsPackages, surfacing
localized description tables/placeholders, tuning badges/layouts, and syncing guest/marketing translations.
- Expanded docs & reference material (docs/prp/*, TODOs, public gallery overview) and added a Playwright smoke
test for the hero CTA while reconciling outstanding checklist items.
This commit is contained in:
Codex Agent
2025-10-19 11:41:03 +02:00
parent ae9b9160ac
commit a949c8d3af
113 changed files with 5169 additions and 712 deletions

View File

@@ -0,0 +1,104 @@
import { useEffect } from 'react';
import { usePage } from '@inertiajs/react';
import { useConsent } from '@/contexts/consent';
export type MatomoConfig = {
enabled: boolean;
url?: string;
siteId?: string;
};
declare global {
interface Window {
_paq?: any[];
}
}
interface MatomoTrackerProps {
config: MatomoConfig | undefined;
}
const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
const page = usePage();
const { hasConsent } = useConsent();
const analyticsConsent = hasConsent('analytics');
useEffect(() => {
if (!config?.enabled || !config.url || !config.siteId || typeof window === 'undefined') {
return;
}
const base = config.url.replace(/\/$/, '');
const scriptSelector = `script[data-matomo="${base}"]`;
if (!analyticsConsent) {
const existing = document.querySelector<HTMLScriptElement>(scriptSelector);
existing?.remove();
if (window._paq) {
window._paq.length = 0;
}
delete (window as any).__matomoInitialized;
return;
}
window._paq = window._paq || [];
const { _paq } = window;
if (!(window as any).__matomoInitialized) {
_paq.push(['setTrackerUrl', `${base}/matomo.php`]);
_paq.push(['setSiteId', config.siteId]);
_paq.push(['disableCookies']);
_paq.push(['enableLinkTracking']);
if (!document.querySelector(scriptSelector)) {
const script = document.createElement('script');
script.async = true;
script.src = `${base}/matomo.js`;
script.dataset.matomo = base;
document.body.appendChild(script);
}
(window as any).__matomoInitialized = true;
}
}, [config, analyticsConsent]);
useEffect(() => {
if (
!config?.enabled ||
!config.url ||
!config.siteId ||
typeof window === 'undefined' ||
!analyticsConsent
) {
return;
}
window._paq = window._paq || [];
const { _paq } = window;
const currentUrl =
typeof window !== 'undefined' ? `${window.location.origin}${page.url}` : page.url;
_paq.push(['setCustomUrl', currentUrl]);
if (typeof document !== 'undefined') {
_paq.push(['setDocumentTitle', document.title]);
}
_paq.push(['trackPageView']);
}, [config, analyticsConsent, page.url]);
if (!config?.enabled || !config.url || !config.siteId || !analyticsConsent) {
return null;
}
const base = config.url.replace(/\/$/, '');
const noscriptSrc = `${base}/matomo.php?idsite=${encodeURIComponent(config.siteId)}&rec=1`;
return (
<noscript>
<p>
<img src={noscriptSrc} style={{ border: 0 }} alt="" />
</p>
</noscript>
);
};
export default MatomoTracker;

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { useConsent, ConsentPreferences } from '@/contexts/consent';
const CookieBanner: React.FC = () => {
const { t } = useTranslation('common');
const {
showBanner,
acceptAll,
rejectAll,
preferences,
savePreferences,
isPreferencesOpen,
openPreferences,
closePreferences,
} = useConsent();
const [draftPreferences, setDraftPreferences] = useState<ConsentPreferences>(preferences);
useEffect(() => {
if (isPreferencesOpen) {
setDraftPreferences(preferences);
}
}, [isPreferencesOpen, preferences]);
const analyticsDescription = useMemo(
() => t('consent.modal.analytics_desc'),
[t],
);
const handleSave = () => {
savePreferences({ analytics: draftPreferences.analytics });
};
const handleOpenChange = (open: boolean) => {
if (open) {
openPreferences();
return;
}
closePreferences();
};
return (
<>
{showBanner && !isPreferencesOpen && (
<div
className="fixed inset-x-0 bottom-0 z-40 px-4 pb-6 sm:px-6"
role="region"
aria-label={t('consent.accessibility.banner_label')}
>
<div className="mx-auto max-w-5xl rounded-3xl border border-gray-200 bg-white/95 p-6 shadow-2xl backdrop-blur-md dark:border-gray-700 dark:bg-gray-900/95">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('consent.banner.title')}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
{t('consent.banner.body')}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
className="min-w-[140px]"
onClick={rejectAll}
>
{t('consent.banner.reject')}
</Button>
<Button
variant="secondary"
className="min-w-[140px]"
onClick={openPreferences}
>
{t('consent.banner.customize')}
</Button>
<Button className="min-w-[140px]" onClick={acceptAll}>
{t('consent.banner.accept')}
</Button>
</div>
</div>
</div>
</div>
)}
<Dialog open={isPreferencesOpen} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-xl md:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('consent.modal.title')}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('consent.modal.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-800/60">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">
{t('consent.modal.functional')}
</p>
<p className="text-xs text-muted-foreground">
{t('consent.modal.functional_desc')}
</p>
</div>
<span className="rounded-full bg-gray-200 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:bg-gray-700 dark:text-gray-200">
{t('consent.modal.required')}
</span>
</div>
</div>
<div className="rounded-2xl border border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">
{t('consent.modal.analytics')}
</p>
<p className="text-xs text-muted-foreground">{analyticsDescription}</p>
</div>
<Switch
checked={draftPreferences.analytics}
onCheckedChange={(checked) =>
setDraftPreferences((prev) => ({
...prev,
analytics: checked,
functional: true,
}))
}
aria-label={t('consent.modal.analytics')}
/>
</div>
</div>
</div>
<Separator />
<DialogFooter className="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
<Button type="button" variant="outline" onClick={rejectAll}>
{t('consent.modal.reject_all')}
</Button>
<Button type="button" variant="secondary" onClick={acceptAll}>
{t('consent.modal.accept_all')}
</Button>
</div>
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => closePreferences()}
>
{t('consent.modal.cancel')}
</Button>
<Button type="button" onClick={handleSave}>
{t('consent.modal.save')}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default CookieBanner;