diff --git a/src/website/src/app/(programs)/africa-clean-air-forum-2026/selfies/page.tsx b/src/website/src/app/(programs)/africa-clean-air-forum-2026/selfies/page.tsx new file mode 100644 index 0000000000..21dd65188b --- /dev/null +++ b/src/website/src/app/(programs)/africa-clean-air-forum-2026/selfies/page.tsx @@ -0,0 +1,15 @@ +import SelfiesScreen from '@/features/clean-air-forum-2026/screens/SelfiesScreen'; +import { generateMetadata } from '@/lib/metadata'; + +export const metadata = generateMetadata({ + title: 'Selfies | Faces of Clean Air', + description: + 'View selfies shared by attendees of the Africa Clean Air Forum showcasing air quality readings from their locations.', + keywords: + 'Faces of Clean Air selfies, Africa Clean Air Forum photos, air quality advocates, PM2.5 readings Africa, clean air community', + url: '/africa-clean-air-forum-2026/selfies', +}); + +export default function SelfiesRoute() { + return ; +} diff --git a/src/website/src/components/clean-air-forum-2026/QrCodeButton.tsx b/src/website/src/components/clean-air-forum-2026/QrCodeButton.tsx index fcabb70268..dc7cb35c73 100644 --- a/src/website/src/components/clean-air-forum-2026/QrCodeButton.tsx +++ b/src/website/src/components/clean-air-forum-2026/QrCodeButton.tsx @@ -19,16 +19,16 @@ export default function QrCodeButton({ src = QR_IMAGE_SRC }: { src?: string }) { aria-label="Open QR code in full view" >
-
+
Clean Air Forum QR Code
- + Scan to participate
diff --git a/src/website/src/components/clean-air-forum-2026/selfie-components.tsx b/src/website/src/components/clean-air-forum-2026/selfie-components.tsx new file mode 100644 index 0000000000..98eea43a14 --- /dev/null +++ b/src/website/src/components/clean-air-forum-2026/selfie-components.tsx @@ -0,0 +1,462 @@ +'use client'; + +import { + motion, + type PanInfo, + useMotionValue, + useSpring, + useTransform, + type Variants, +} from 'framer-motion'; +import Image from 'next/image'; +import { + type KeyboardEvent, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useState, +} from 'react'; +import { FiCamera } from 'react-icons/fi'; + +import type { CleanAirSubmissionWithId } from '@/services/external/faces-of-clean-air.service'; + +export const CAROUSEL_INTERVAL_MS = 7600; +export const SWIPE_DISTANCE_THRESHOLD = 70; +export const SWIPE_VELOCITY_THRESHOLD = 500; +export const MOBILE_MEDIA_QUERY = '(max-width: 639px)'; +export const DESKTOP_CARDS_PER_PAGE = 8; + +const SMOOTH_SPRING = { stiffness: 150, damping: 25, mass: 0.9 }; + +export const headerContainerVariants: Variants = { + hidden: {}, + visible: { + transition: { delayChildren: 0.1, staggerChildren: 0.16 }, + }, +}; + +export const headerItemVariants: Variants = { + hidden: { opacity: 0, y: -20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.78, ease: [0.22, 1, 0.36, 1] }, + }, +}; + +const cardVariants: Variants = { + enter: { opacity: 0, y: 28, scale: 0.94 }, + center: { opacity: 1, y: 0, scale: 1 }, + exit: { opacity: 0, y: -16, scale: 0.96 }, +}; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia(query); + const updateMatch = () => setMatches(mediaQuery.matches); + updateMatch(); + mediaQuery.addEventListener('change', updateMatch); + return () => mediaQuery.removeEventListener('change', updateMatch); + }, [query]); + + return matches; +} + +export function FaceCard({ + submission, + priority, + reduceMotion, +}: { + submission: CleanAirSubmissionWithId; + priority: boolean; + reduceMotion: boolean | null; +}) { + const pointerX = useMotionValue(0.5); + const pointerY = useMotionValue(0.5); + + const transformedRotateX = useTransform(pointerY, [0, 1], [5, -5]); + const transformedRotateY = useTransform(pointerX, [0, 1], [-5, 5]); + + const rotateX = useSpring(transformedRotateX, SMOOTH_SPRING); + const rotateY = useSpring(transformedRotateY, SMOOTH_SPRING); + + const displayName = submission.displayName?.trim() || 'Clean Air Champion'; + + const location = + submission.locationName?.trim() || 'the Africa Clean Air Forum'; + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + if (reduceMotion || event.pointerType !== 'mouse') return; + + const cardBounds = event.currentTarget.getBoundingClientRect(); + const relativeX = (event.clientX - cardBounds.left) / cardBounds.width; + const relativeY = (event.clientY - cardBounds.top) / cardBounds.height; + + pointerX.set(Math.min(Math.max(relativeX, 0), 1)); + pointerY.set(Math.min(Math.max(relativeY, 0), 1)); + }, + [pointerX, pointerY, reduceMotion], + ); + + const resetPointerPosition = useCallback(() => { + pointerX.set(0.5); + pointerY.set(0.5); + }, [pointerX, pointerY]); + + return ( + + {`${displayName} + + ); +} + +export function SkeletonCard({ + index, + reduceMotion, +}: { + index: number; + reduceMotion: boolean | null; +}) { + return ( +