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.
186 lines
4.4 KiB
TypeScript
186 lines
4.4 KiB
TypeScript
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from 'react';
|
|
|
|
const CONSENT_STORAGE_KEY = 'fotospiel.consent';
|
|
const CONSENT_VERSION = '2025-10-17-1';
|
|
|
|
export type ConsentCategory = 'functional' | 'analytics';
|
|
|
|
export type ConsentPreferences = Record<ConsentCategory, boolean>;
|
|
|
|
interface StoredConsent {
|
|
version: string;
|
|
preferences: ConsentPreferences;
|
|
decisionMade: boolean;
|
|
updatedAt: string | null;
|
|
}
|
|
|
|
const defaultPreferences: ConsentPreferences = {
|
|
functional: true,
|
|
analytics: false,
|
|
};
|
|
|
|
const defaultState: StoredConsent = {
|
|
version: CONSENT_VERSION,
|
|
preferences: { ...defaultPreferences },
|
|
decisionMade: false,
|
|
updatedAt: null,
|
|
};
|
|
|
|
interface ConsentContextValue {
|
|
preferences: ConsentPreferences;
|
|
decisionMade: boolean;
|
|
showBanner: boolean;
|
|
acceptAll: () => void;
|
|
rejectAll: () => void;
|
|
savePreferences: (preferences: Partial<ConsentPreferences>) => void;
|
|
hasConsent: (category: ConsentCategory) => boolean;
|
|
openPreferences: () => void;
|
|
closePreferences: () => void;
|
|
isPreferencesOpen: boolean;
|
|
}
|
|
|
|
const ConsentContext = createContext<ConsentContextValue | undefined>(undefined);
|
|
|
|
function normalizeState(state: StoredConsent | null): StoredConsent {
|
|
if (!state || state.version !== CONSENT_VERSION) {
|
|
return { ...defaultState };
|
|
}
|
|
|
|
return {
|
|
version: CONSENT_VERSION,
|
|
decisionMade: state.decisionMade ?? false,
|
|
updatedAt: state.updatedAt ?? null,
|
|
preferences: {
|
|
...defaultPreferences,
|
|
...state.preferences,
|
|
functional: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getInitialState(): StoredConsent {
|
|
if (typeof window === 'undefined') {
|
|
return { ...defaultState };
|
|
}
|
|
|
|
try {
|
|
const raw = window.localStorage.getItem(CONSENT_STORAGE_KEY);
|
|
if (!raw) {
|
|
return { ...defaultState };
|
|
}
|
|
|
|
const parsed = JSON.parse(raw) as StoredConsent;
|
|
return normalizeState(parsed);
|
|
} catch {
|
|
return { ...defaultState };
|
|
}
|
|
}
|
|
|
|
export const ConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
const [state, setState] = useState<StoredConsent>(() => getInitialState());
|
|
const [isPreferencesOpen, setPreferencesOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
window.localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(state));
|
|
}, [state]);
|
|
|
|
const acceptAll = useCallback(() => {
|
|
setState({
|
|
version: CONSENT_VERSION,
|
|
preferences: { functional: true, analytics: true },
|
|
decisionMade: true,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
setPreferencesOpen(false);
|
|
}, []);
|
|
|
|
const rejectAll = useCallback(() => {
|
|
setState({
|
|
version: CONSENT_VERSION,
|
|
preferences: { functional: true, analytics: false },
|
|
decisionMade: true,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
setPreferencesOpen(false);
|
|
}, []);
|
|
|
|
const savePreferences = useCallback((preferences: Partial<ConsentPreferences>) => {
|
|
setState((prev) => ({
|
|
version: CONSENT_VERSION,
|
|
preferences: {
|
|
...defaultPreferences,
|
|
...prev.preferences,
|
|
...preferences,
|
|
functional: true,
|
|
},
|
|
decisionMade: true,
|
|
updatedAt: new Date().toISOString(),
|
|
}));
|
|
setPreferencesOpen(false);
|
|
}, []);
|
|
|
|
const hasConsent = useCallback(
|
|
(category: ConsentCategory) => {
|
|
return Boolean(state.preferences?.[category]);
|
|
},
|
|
[state.preferences],
|
|
);
|
|
|
|
const openPreferences = useCallback(() => {
|
|
setPreferencesOpen(true);
|
|
}, []);
|
|
|
|
const closePreferences = useCallback(() => {
|
|
setPreferencesOpen(false);
|
|
}, []);
|
|
|
|
const value = useMemo<ConsentContextValue>(
|
|
() => ({
|
|
preferences: state.preferences,
|
|
decisionMade: state.decisionMade,
|
|
showBanner: !state.decisionMade,
|
|
acceptAll,
|
|
rejectAll,
|
|
savePreferences,
|
|
hasConsent,
|
|
openPreferences,
|
|
closePreferences,
|
|
isPreferencesOpen,
|
|
}),
|
|
[
|
|
state.preferences,
|
|
state.decisionMade,
|
|
acceptAll,
|
|
rejectAll,
|
|
savePreferences,
|
|
hasConsent,
|
|
openPreferences,
|
|
closePreferences,
|
|
isPreferencesOpen,
|
|
],
|
|
);
|
|
|
|
return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>;
|
|
};
|
|
|
|
export const useConsent = (): ConsentContextValue => {
|
|
const context = useContext(ConsentContext);
|
|
|
|
if (!context) {
|
|
throw new Error('useConsent must be used within a ConsentProvider');
|
|
}
|
|
|
|
return context;
|
|
};
|