Skip to content

Commit f72f876

Browse files
ugurkocdeclaude
andcommitted
feat: add 3D rotating package animation to sign-in verification
Replace the spinner loading state on the sign-in page with a 3D verification scene featuring a rotating package box with gentle emissive pulse and float animation. Uses react-three-fiber with bloom post-processing. Reduced motion users see the static fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c750eae commit f72f876

File tree

8 files changed

+1010
-22
lines changed

8 files changed

+1010
-22
lines changed

app/auth/signin/page.tsx

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useEffect, useState, Suspense, useCallback } from 'react';
55
import Link from 'next/link';
66
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
77
import { Shield, Loader2, Package, ChevronDown, Copy, Check, AlertTriangle, Users, Boxes, ArrowLeft } from 'lucide-react';
8+
import dynamic from 'next/dynamic';
9+
import { VerificationSceneFallback } from '@/components/auth/verification-scene/VerificationSceneFallback';
810
import { Button } from '@/components/ui/button';
911
import { useMicrosoftAuth } from '@/hooks/useMicrosoftAuth';
1012
import { getAdminConsentUrl } from '@/lib/msal-config';
@@ -16,6 +18,11 @@ import { FadeIn } from '@/components/landing/animations/FadeIn';
1618
import { CountUp } from '@/components/landing/animations/CountUp';
1719
import { springPresets } from '@/lib/animations/variants';
1820

21+
const VerificationScene = dynamic(
22+
() => import('@/components/auth/verification-scene/VerificationScene').then(m => m.VerificationScene),
23+
{ ssr: false, loading: () => <VerificationSceneFallback /> }
24+
);
25+
1926
// Microsoft logo SVG component -- colors per official brand spec
2027
function MicrosoftLogo({ className }: { className?: string }) {
2128
return (
@@ -125,24 +132,14 @@ function SignInContent() {
125132

126133
// Show loading state while checking auth or verifying consent
127134
if (isAuthenticated || isVerifyingConsent) {
135+
const statusText = isVerifyingConsent ? 'Verifying permissions...' : 'Redirecting to your dashboard...';
128136
return (
129137
<div className="flex min-h-screen items-center justify-center bg-bg-deepest">
130-
<motion.div
131-
className="flex flex-col items-center gap-6"
132-
initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0.95 }}
133-
animate={{ opacity: 1, scale: 1 }}
134-
transition={{ duration: 0.4 }}
135-
>
136-
<div className="relative">
137-
<div className="absolute inset-0 bg-accent-cyan/10 rounded-full blur-2xl animate-pulse" />
138-
<div className="relative w-16 h-16 bg-gradient-to-br from-accent-cyan to-accent-violet rounded-2xl flex items-center justify-center shadow-glow-cyan">
139-
<Loader2 className="h-7 w-7 animate-spin text-white" />
140-
</div>
141-
</div>
142-
<p className="text-text-muted text-sm font-medium">
143-
{isVerifyingConsent ? 'Verifying permissions...' : 'Redirecting to your dashboard...'}
144-
</p>
145-
</motion.div>
138+
{shouldReduceMotion ? (
139+
<VerificationSceneFallback statusText={statusText} />
140+
) : (
141+
<VerificationScene statusText={statusText} />
142+
)}
146143
</div>
147144
);
148145
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
'use client';
2+
3+
import { useRef } from 'react';
4+
import { useFrame } from '@react-three/fiber';
5+
import { RoundedBox, Float } from '@react-three/drei';
6+
import * as THREE from 'three';
7+
8+
export function PackageBox() {
9+
const groupRef = useRef<THREE.Group>(null!);
10+
const materialRef = useRef<THREE.MeshStandardMaterial>(null!);
11+
const edgeMaterialRef = useRef<THREE.MeshBasicMaterial>(null!);
12+
const materialized = useRef(false);
13+
14+
useFrame(({ clock }) => {
15+
const elapsed = clock.elapsedTime;
16+
const group = groupRef.current;
17+
const mat = materialRef.current;
18+
19+
if (!group || !mat) return;
20+
21+
// Materialize: scale from 0 to 1 over first 0.8s
22+
if (!materialized.current) {
23+
if (elapsed < 0.8) {
24+
const progress = elapsed / 0.8;
25+
const eased = 1 - Math.pow(1 - progress, 3);
26+
group.scale.setScalar(eased);
27+
} else {
28+
group.scale.setScalar(1);
29+
materialized.current = true;
30+
}
31+
}
32+
33+
// Continuous slow Y-axis rotation (~0.3 rad/s)
34+
group.rotation.y = elapsed * 0.3;
35+
36+
// Gentle emissive pulse: sine-wave between 0.15 and 0.35
37+
mat.emissiveIntensity = 0.25 + Math.sin(elapsed * 1.5) * 0.1;
38+
39+
// Update edge opacity in sync
40+
if (edgeMaterialRef.current) {
41+
edgeMaterialRef.current.opacity = 0.2 + mat.emissiveIntensity * 0.3;
42+
}
43+
});
44+
45+
return (
46+
<Float
47+
speed={1.5}
48+
rotationIntensity={0.1}
49+
floatIntensity={0.3}
50+
floatingRange={[-0.05, 0.05]}
51+
>
52+
<group ref={groupRef} scale={0}>
53+
{/* Main box */}
54+
<RoundedBox args={[1.2, 1.2, 1.2]} radius={0.12} smoothness={4}>
55+
<meshStandardMaterial
56+
ref={materialRef}
57+
color="#0891b2"
58+
metalness={0.3}
59+
roughness={0.4}
60+
emissive="#06b6d4"
61+
emissiveIntensity={0}
62+
/>
63+
</RoundedBox>
64+
65+
{/* Wireframe overlay for tech look */}
66+
<RoundedBox args={[1.22, 1.22, 1.22]} radius={0.12} smoothness={4}>
67+
<meshBasicMaterial
68+
ref={edgeMaterialRef}
69+
wireframe
70+
color="#22d3ee"
71+
transparent
72+
opacity={0.2}
73+
/>
74+
</RoundedBox>
75+
76+
{/* Cross symbol on front face */}
77+
{/* Horizontal bar */}
78+
<mesh position={[0, 0, 0.62]}>
79+
<boxGeometry args={[0.5, 0.08, 0.02]} />
80+
<meshBasicMaterial color="#a5f3fc" transparent opacity={0.6} />
81+
</mesh>
82+
{/* Vertical bar */}
83+
<mesh position={[0, 0, 0.62]}>
84+
<boxGeometry args={[0.08, 0.5, 0.02]} />
85+
<meshBasicMaterial color="#a5f3fc" transparent opacity={0.6} />
86+
</mesh>
87+
</group>
88+
</Float>
89+
);
90+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use client';
2+
3+
export function SceneLights() {
4+
return (
5+
<>
6+
{/* Moody ambient base */}
7+
<ambientLight intensity={0.15} />
8+
9+
{/* Cool white key light -- upper right */}
10+
<directionalLight
11+
position={[3, 4, 2]}
12+
intensity={0.8}
13+
color="#e0f0ff"
14+
/>
15+
16+
{/* Cyan fill -- left side */}
17+
<pointLight
18+
position={[-3, 1, 2]}
19+
intensity={0.6}
20+
color="#0891b2"
21+
distance={10}
22+
/>
23+
24+
{/* Violet accent -- below right */}
25+
<pointLight
26+
position={[2, -2, 1]}
27+
intensity={0.4}
28+
color="#7c3aed"
29+
distance={8}
30+
/>
31+
32+
{/* Cyan rim light -- behind */}
33+
<pointLight
34+
position={[0, 1, -3]}
35+
intensity={0.3}
36+
color="#06b6d4"
37+
distance={8}
38+
/>
39+
</>
40+
);
41+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Canvas } from '@react-three/fiber';
5+
import { EffectComposer, Bloom } from '@react-three/postprocessing';
6+
import { motion } from 'framer-motion';
7+
import { SceneLights } from './SceneLights';
8+
import { PackageBox } from './PackageBox';
9+
10+
interface VerificationSceneProps {
11+
statusText?: string;
12+
}
13+
14+
export function VerificationScene({ statusText = 'Verifying permissions...' }: VerificationSceneProps) {
15+
const [ready, setReady] = useState(false);
16+
17+
return (
18+
<div className="flex flex-col items-center gap-4">
19+
<motion.div
20+
aria-hidden="true"
21+
className="w-[320px] h-[320px] sm:w-[400px] sm:h-[400px]"
22+
initial={{ opacity: 0 }}
23+
animate={{ opacity: ready ? 1 : 0 }}
24+
transition={{ duration: 0.6 }}
25+
>
26+
<Canvas
27+
camera={{ position: [0, 0, 5], fov: 40 }}
28+
dpr={[1, 1.5]}
29+
gl={{ alpha: true, antialias: true }}
30+
onCreated={() => setReady(true)}
31+
style={{ background: 'transparent' }}
32+
>
33+
<SceneLights />
34+
<PackageBox />
35+
36+
<EffectComposer>
37+
<Bloom
38+
mipmapBlur
39+
intensity={0.8}
40+
luminanceThreshold={0.8}
41+
luminanceSmoothing={0.3}
42+
/>
43+
</EffectComposer>
44+
</Canvas>
45+
</motion.div>
46+
47+
<motion.p
48+
aria-live="polite"
49+
className="text-text-muted text-sm font-medium"
50+
initial={{ opacity: 0 }}
51+
animate={{ opacity: 1 }}
52+
transition={{ duration: 0.4, delay: 0.2 }}
53+
>
54+
{statusText}
55+
</motion.p>
56+
</div>
57+
);
58+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use client';
2+
3+
import { Loader2 } from 'lucide-react';
4+
5+
interface VerificationSceneFallbackProps {
6+
statusText?: string;
7+
}
8+
9+
export function VerificationSceneFallback({ statusText = 'Verifying permissions...' }: VerificationSceneFallbackProps) {
10+
return (
11+
<div className="flex flex-col items-center gap-6">
12+
<div className="relative">
13+
<div className="absolute inset-0 bg-accent-cyan/10 rounded-full blur-2xl animate-pulse" />
14+
<div className="relative w-16 h-16 bg-gradient-to-br from-accent-cyan to-accent-violet rounded-2xl flex items-center justify-center shadow-glow-cyan">
15+
<Loader2 className="h-7 w-7 animate-spin text-white" />
16+
</div>
17+
</div>
18+
<p aria-live="polite" className="text-text-muted text-sm font-medium">
19+
{statusText}
20+
</p>
21+
</div>
22+
);
23+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// VerificationScene is intentionally not re-exported here to avoid pulling
2+
// Three.js into the SSR evaluation path. Import it directly via dynamic():
3+
// import('@/components/auth/verification-scene/VerificationScene')
4+
export { VerificationSceneFallback } from './VerificationSceneFallback';

0 commit comments

Comments
 (0)