Skip to content

Commit 457e994

Browse files
committed
feat(ui): Replace the Hero image with a 3d interactive model
1 parent 460f154 commit 457e994

7 files changed

Lines changed: 254 additions & 17 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020
"lucide-react": "^0.534.0",
2121
"react": "^19.1.0",
2222
"react-dom": "^19.1.0",
23-
"react-router-dom": "^7.7.0"
23+
"react-router-dom": "^7.7.0",
24+
"three": "^0.179.1"
2425
},
2526
"devDependencies": {
2627
"@eslint/js": "^9.33.0",
2728
"@types/react": "^19.1.8",
2829
"@types/react-dom": "^19.1.6",
30+
"@types/three": "^0.179.0",
2931
"@vitejs/plugin-react": "^4.6.0",
3032
"autoprefixer": "^10.4.21",
3133
"eslint": "^9.33.0",

pnpm-lock.yaml

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/robot.glb

128 KB
Binary file not shown.

src/components/landing/HeroSection.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
import { Link } from 'react-router-dom';
22
import { Search, Users, Star } from 'lucide-react';
33

4-
export function HeroSection() {
4+
import RobotModel from './RobotModel';
5+
6+
export default function HeroSection() {
57
return (
68
<section className="py-20 px-4 pt-32">
79
<div className="container mx-auto max-w-6xl">
810
<div className="grid lg:grid-cols-2 gap-12 items-center">
911
<div className="space-y-8">
1012
<div className="space-y-4">
1113
<h1 className="text-4xl lg:text-6xl font-poppins font-bold text-slate-800 leading-tight">
12-
Aprende sin <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-green-500">límites</span>
14+
Aprende sin{' '}
15+
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-green-500">
16+
límites
17+
</span>
1318
</h1>
1419
<p className="text-xl text-slate-600 leading-relaxed">
15-
Descubre miles de cursos online con certificaciones oficiales. Aprende a tu ritmo desde cualquier dispositivo y transforma tu futuro profesional.
20+
Descubre miles de cursos online con certificaciones oficiales.
21+
Aprende a tu ritmo desde cualquier dispositivo y transforma tu
22+
futuro profesional.
1623
</p>
1724
</div>
1825
<div className="flex flex-col sm:flex-row gap-4">
19-
<Link to="/courses" className="inline-flex items-center justify-center text-lg bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-lg font-medium">
26+
<Link
27+
to="/courses"
28+
className="inline-flex items-center justify-center text-lg bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-lg font-medium"
29+
>
2030
<Search className="w-5 h-5 mr-2" />
2131
Explorar Cursos
2232
</Link>
@@ -35,14 +45,11 @@ export function HeroSection() {
3545
</div>
3646
</div>
3747
</div>
38-
<div className="relative">
39-
<div className="relative z-10">
40-
<img src="https://images.unsplash.com/photo-1542744095-291d1f67b221?q=80&w=2070&auto=format&fit=crop" alt="Estudiantes aprendiendo online" className="w-full h-auto rounded-2xl shadow-2xl" />
41-
</div>
42-
<div className="absolute -top-4 -right-4 w-full h-full bg-gradient-to-br from-blue-200 to-green-200 rounded-2xl -z-10 transform -rotate-3"></div>
48+
<div className="relative w-full h-[450px] lg:h-[500px]">
49+
<RobotModel />
4350
</div>
4451
</div>
4552
</div>
4653
</section>
4754
);
48-
}
55+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { useRef, useEffect, useState } from 'react';
2+
import * as THREE from 'three';
3+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
4+
5+
const RobotModel = () => {
6+
const mountRef = useRef<HTMLDivElement>(null);
7+
const [isLoading, setIsLoading] = useState(true);
8+
9+
useEffect(() => {
10+
const currentMount = mountRef.current;
11+
if (!currentMount) return;
12+
13+
const clock = new THREE.Clock();
14+
let model: THREE.Group | null = null;
15+
let mixer: THREE.AnimationMixer | null = null;
16+
17+
const scene = new THREE.Scene();
18+
const camera = new THREE.PerspectiveCamera(
19+
25,
20+
currentMount.clientWidth / currentMount.clientHeight,
21+
0.1,
22+
1000
23+
);
24+
camera.position.z = 10;
25+
26+
const renderer = new THREE.WebGLRenderer({
27+
antialias: true,
28+
alpha: true,
29+
});
30+
renderer.setSize(currentMount.clientWidth, currentMount.clientHeight);
31+
renderer.setPixelRatio(window.devicePixelRatio);
32+
renderer.outputColorSpace = THREE.SRGBColorSpace;
33+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
34+
renderer.toneMappingExposure = 1.0;
35+
currentMount.appendChild(renderer.domElement);
36+
37+
// --- Luces ---
38+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
39+
scene.add(ambientLight);
40+
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
41+
directionalLight.position.set(5, 10, 7.5);
42+
scene.add(directionalLight);
43+
44+
const loader = new GLTFLoader();
45+
loader.load(
46+
'/robot.glb',
47+
(gltf) => {
48+
model = gltf.scene;
49+
model.scale.set(1.3, 1.3, 1.3);
50+
model.position.y = -1.5;
51+
model.rotation.y = -Math.PI / 2;
52+
scene.add(model);
53+
54+
if (gltf.animations.length > 0) {
55+
mixer = new THREE.AnimationMixer(model);
56+
const action = mixer.clipAction(gltf.animations[0]);
57+
action.timeScale = 0.4;
58+
action.play();
59+
}
60+
61+
model.traverse((child) => {
62+
if ((child as THREE.Mesh).isMesh) {
63+
const meshChild = child as THREE.Mesh;
64+
if (
65+
meshChild.material &&
66+
'emissiveIntensity' in meshChild.material
67+
) {
68+
(
69+
meshChild.material as THREE.MeshStandardMaterial
70+
).emissiveIntensity = 0;
71+
}
72+
}
73+
});
74+
75+
setIsLoading(false);
76+
},
77+
undefined,
78+
(error) => {
79+
console.error('An error happened while loading the model:', error);
80+
setIsLoading(false);
81+
}
82+
);
83+
84+
let mouseX = 0;
85+
let mouseY = 0;
86+
const handleMouseMove = (event: MouseEvent) => {
87+
mouseX = (event.clientX / window.innerWidth) * 2 - 1;
88+
mouseY = -(event.clientY / window.innerHeight) * 2 + 1;
89+
};
90+
window.addEventListener('mousemove', handleMouseMove);
91+
92+
const animate = () => {
93+
requestAnimationFrame(animate);
94+
const deltaTime = clock.getDelta();
95+
96+
if (mixer) mixer.update(deltaTime);
97+
98+
if (model) {
99+
model.rotation.y = -Math.PI / 2 + mouseX * 0.5;
100+
model.rotation.x += (-mouseY * 0.5 - model.rotation.x) * 0.05;
101+
}
102+
renderer.render(scene, camera);
103+
};
104+
animate();
105+
106+
const handleResize = () => {
107+
if (!currentMount) return;
108+
camera.aspect = currentMount.clientWidth / currentMount.clientHeight;
109+
camera.updateProjectionMatrix();
110+
renderer.setSize(currentMount.clientWidth, currentMount.clientHeight);
111+
};
112+
window.addEventListener('resize', handleResize);
113+
114+
return () => {
115+
window.removeEventListener('resize', handleResize);
116+
window.removeEventListener('mousemove', handleMouseMove);
117+
if (renderer.domElement && currentMount.contains(renderer.domElement)) {
118+
currentMount.removeChild(renderer.domElement);
119+
}
120+
};
121+
}, []);
122+
123+
return (
124+
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
125+
{isLoading && (
126+
<div className="loading-spinner-container">
127+
<div className="loading-spinner"></div>
128+
</div>
129+
)}
130+
<div
131+
ref={mountRef}
132+
style={{
133+
width: '100%',
134+
height: '100%',
135+
opacity: isLoading ? 0 : 1,
136+
transition: 'opacity 0.5s ease-in-out',
137+
}}
138+
/>
139+
</div>
140+
);
141+
};
142+
143+
export default RobotModel;

0 commit comments

Comments
 (0)