- 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:
104
resources/js/components/analytics/MatomoTracker.tsx
Normal file
104
resources/js/components/analytics/MatomoTracker.tsx
Normal 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;
|
||||
175
resources/js/components/consent/CookieBanner.tsx
Normal file
175
resources/js/components/consent/CookieBanner.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user