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.
115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string };
|
|
|
|
export function usePollGalleryDelta(token: string) {
|
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [newCount, setNewCount] = useState(0);
|
|
const latestAt = useRef<string | null>(null);
|
|
const timer = useRef<number | null>(null);
|
|
const [visible, setVisible] = useState(
|
|
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
|
|
);
|
|
|
|
async function fetchDelta() {
|
|
if (!token) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
|
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(token)}/photos${qs}`, {
|
|
headers: { 'Cache-Control': 'no-store' },
|
|
});
|
|
|
|
if (res.status === 304) return; // No new content
|
|
|
|
if (!res.ok) {
|
|
console.warn(`Gallery API error: ${res.status} ${res.statusText}`);
|
|
return; // Don't update state on error
|
|
}
|
|
|
|
const json = await res.json();
|
|
|
|
// Handle different response formats
|
|
const newPhotos = Array.isArray(json.data) ? json.data :
|
|
Array.isArray(json) ? json :
|
|
json.photos || [];
|
|
|
|
if (newPhotos.length > 0) {
|
|
const added = newPhotos.length;
|
|
|
|
if (latestAt.current) {
|
|
// Delta mode: Add new photos to existing list
|
|
const merged = [...newPhotos, ...photos];
|
|
// Remove duplicates by ID
|
|
const uniquePhotos = merged.filter((photo, index, self) =>
|
|
index === self.findIndex(p => p.id === photo.id)
|
|
);
|
|
setPhotos(uniquePhotos);
|
|
if (added > 0) setNewCount((c) => c + added);
|
|
} else {
|
|
// Initial load: Set all photos
|
|
setPhotos(newPhotos);
|
|
}
|
|
|
|
// Update latest timestamp
|
|
if (json.latest_photo_at) {
|
|
latestAt.current = json.latest_photo_at;
|
|
} else if (newPhotos.length > 0) {
|
|
// Fallback: use newest photo timestamp
|
|
const newest = newPhotos.reduce((latest: number, photo: any) => {
|
|
const photoTime = new Date(photo.created_at || photo.created_at_timestamp || 0).getTime();
|
|
return photoTime > latest ? photoTime : latest;
|
|
}, 0);
|
|
latestAt.current = new Date(newest).toISOString();
|
|
}
|
|
} else if (latestAt.current) {
|
|
// Delta mode but no new photos: keep existing photos
|
|
console.log('No new photos, keeping existing gallery state');
|
|
// Don't update photos state
|
|
} else {
|
|
// Initial load with no photos
|
|
setPhotos([]);
|
|
}
|
|
|
|
setLoading(false);
|
|
} catch (error) {
|
|
console.error('Gallery polling error:', error);
|
|
setLoading(false);
|
|
// Don't update state on error - keep previous photos
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const onVis = () => setVisible(document.visibilityState === 'visible');
|
|
document.addEventListener('visibilitychange', onVis);
|
|
return () => document.removeEventListener('visibilitychange', onVis);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setPhotos([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
latestAt.current = null;
|
|
setPhotos([]);
|
|
fetchDelta();
|
|
if (timer.current) window.clearInterval(timer.current);
|
|
// Poll less aggressively when hidden
|
|
const interval = visible ? 30_000 : 90_000;
|
|
timer.current = window.setInterval(fetchDelta, interval);
|
|
return () => {
|
|
if (timer.current) window.clearInterval(timer.current);
|
|
};
|
|
}, [token, visible]);
|
|
|
|
function acknowledgeNew() { setNewCount(0); }
|
|
return { loading, photos, newCount, acknowledgeNew };
|
|
}
|