diff --git a/k8s/beacon/k8s-aks/airqo-beacon/values-stage.yaml b/k8s/beacon/k8s-aks/airqo-beacon/values-stage.yaml
index 67491e474c..679db9102f 100644
--- a/k8s/beacon/k8s-aks/airqo-beacon/values-stage.yaml
+++ b/k8s/beacon/k8s-aks/airqo-beacon/values-stage.yaml
@@ -1,7 +1,7 @@
replicaCount: 2
image:
repository: airqoacr.azurecr.io/airqo-stage-beacon
- tag: stage-28ed4fb6-1783946212
+ tag: stage-5417f56a-1783974410
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
diff --git a/src/beacon/components/providers/auth-provider.tsx b/src/beacon/components/providers/auth-provider.tsx
index d2e8b1387a..ccddcc7005 100644
--- a/src/beacon/components/providers/auth-provider.tsx
+++ b/src/beacon/components/providers/auth-provider.tsx
@@ -8,6 +8,12 @@ import authService from '@/services/api-service';
function AuthEffect({ children }: { children: ReactNode }) {
const { data: session, status } = useSession();
const [handoffProcessed, setHandoffProcessed] = useState(false);
+ const [isHandlingHandoff, setIsHandlingHandoff] = useState(() => {
+ if (typeof window !== 'undefined') {
+ return window.location.hash.includes('token=');
+ }
+ return false;
+ });
useEffect(() => {
if (status === 'authenticated' && session) {
@@ -29,11 +35,15 @@ function AuthEffect({ children }: { children: ReactNode }) {
role: (rawUser as any).privilege || 'user',
});
}
+
+ if (isHandlingHandoff) {
+ setIsHandlingHandoff(false);
+ }
} else if (status === 'unauthenticated') {
authService.setToken(null);
authService.setUserData(null);
}
- }, [session, status]);
+ }, [session, status, isHandlingHandoff]);
useEffect(() => {
if (handoffProcessed) return;
@@ -50,9 +60,22 @@ function AuthEffect({ children }: { children: ReactNode }) {
redirect: true,
callbackUrl: callbackUrl,
});
+ } else {
+ setIsHandlingHandoff(false);
}
}, [handoffProcessed]);
+ if (isHandlingHandoff || (status === 'unauthenticated' && typeof window !== 'undefined' && window.location.hash.includes('token='))) {
+ return (
+
-
+
-
+
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 (
+
+
+
+ );
+}
+
+export function SkeletonCard({
+ index,
+ reduceMotion,
+}: {
+ index: number;
+ reduceMotion: boolean | null;
+}) {
+ return (
+
+
+
+ {!reduceMotion && (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+export function SelfieEmptyState({
+ reduceMotion,
+}: {
+ reduceMotion: boolean | null;
+}) {
+ return (
+
+
+ {!reduceMotion && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+ No faces yet
+
+
+ Be the first to share an air quality selfie from the Africa Clean Air
+ Forum.
+
+
+ );
+}
+
+export function SelfieErrorState({
+ onRetry,
+ reduceMotion,
+}: {
+ onRetry: () => void;
+ reduceMotion: boolean | null;
+}) {
+ return (
+
+
+ Could not load selfies
+
+
+
+ Please check the connection and try again.
+
+
+
+ Try again
+
+
+ );
+}
+
+export function SelfieDesktopPagination({
+ page,
+ totalPages,
+ isPaused,
+ reduceMotion,
+ onPageChange,
+ layoutId = 'active-carousel-indicator',
+}: {
+ page: number;
+ totalPages: number;
+ isPaused: boolean;
+ reduceMotion: boolean | null;
+ onPageChange: (page: number) => void;
+ layoutId?: string;
+}) {
+ return (
+
+ );
+}
+
+export function SelfieMobilePagination({
+ page,
+ totalPages,
+ reduceMotion,
+}: {
+ page: number;
+ totalPages: number;
+ reduceMotion: boolean | null;
+}) {
+ return (
+
+
+
+
+
+
+ {page + 1}/{totalPages}
+
+
+ );
+}
+
+export function useSelfieCarouselKeyboard(
+ totalSlides: number,
+ goToRelativePage: (offset: number) => void,
+) {
+ return useCallback(
+ (event: KeyboardEvent) => {
+ if (totalSlides <= 1) return;
+ if (event.key === 'ArrowRight') {
+ event.preventDefault();
+ goToRelativePage(1);
+ }
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault();
+ goToRelativePage(-1);
+ }
+ },
+ [goToRelativePage, totalSlides],
+ );
+}
+
+export function useSelfieDragEnd(
+ totalSlides: number,
+ goToRelativePage: (offset: number) => void,
+) {
+ return useCallback(
+ (
+ _event: MouseEvent | TouchEvent | globalThis.PointerEvent,
+ info: PanInfo,
+ ) => {
+ if (totalSlides <= 1) return;
+
+ const movedLeft =
+ info.offset.x < -SWIPE_DISTANCE_THRESHOLD ||
+ info.velocity.x < -SWIPE_VELOCITY_THRESHOLD;
+ const movedRight =
+ info.offset.x > SWIPE_DISTANCE_THRESHOLD ||
+ info.velocity.x > SWIPE_VELOCITY_THRESHOLD;
+
+ if (movedLeft) goToRelativePage(1);
+ else if (movedRight) goToRelativePage(-1);
+ },
+ [goToRelativePage, totalSlides],
+ );
+}
diff --git a/src/website/src/components/layout/Footer.tsx b/src/website/src/components/layout/Footer.tsx
index 14841fcd19..f89b01a375 100644
--- a/src/website/src/components/layout/Footer.tsx
+++ b/src/website/src/components/layout/Footer.tsx
@@ -43,6 +43,7 @@ const footerLinks = {
label: 'Faces of Clean Air',
href: '/africa-clean-air-forum-2026/faces-of-clean-air',
},
+ { label: 'Clean Air Faces', href: '/africa-clean-air-forum-2026/selfies' },
{ label: 'Air Quality Quiz', href: '/africa-clean-air-forum-2026/quiz' },
{
label: 'Quiz Leaderboard',
diff --git a/src/website/src/features/clean-air-forum-2026/screens/LandingScreen.tsx b/src/website/src/features/clean-air-forum-2026/screens/LandingScreen.tsx
index 1ad1c39297..d428062f07 100644
--- a/src/website/src/features/clean-air-forum-2026/screens/LandingScreen.tsx
+++ b/src/website/src/features/clean-air-forum-2026/screens/LandingScreen.tsx
@@ -7,7 +7,6 @@ import { useRouter } from 'next/navigation';
import { FormEvent, useState } from 'react';
import AmbientBackground from '@/components/clean-air-forum-2026/AmbientBackground';
-import QrCodeButton from '@/components/clean-air-forum-2026/QrCodeButton';
import Screen from '@/components/clean-air-forum-2026/Screen';
import { Button } from '@/components/ui/button';
import {
@@ -220,8 +219,6 @@ export default function LandingScreen() {
-
-