-
+
+
{CONTACT_EMAIL}
-
@@ -53,25 +50,26 @@ export function ContactSidebar() {
{expandedSocial && (
- {CONTACT_SOCIALS.map((social) =>
- social.active ? (
+ {CONTACT_SOCIALS.map((social) => {
+ const Icon = social.icon;
+ return social.active ? (
-
- {social.name}
+
+ {social.name}
) : (
-
-
+
+
{social.name}
- ),
- )}
+ );
+ })}
)}
diff --git a/src/features/contact/constants/index.ts b/src/features/contact/constants/index.ts
deleted file mode 100644
index 6f2227c..0000000
--- a/src/features/contact/constants/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export const CONTACT_EMAIL = 'alissu.dev@gmail.com';
-export const CONTACT_PHONE = '+55 14 9 9970-46645';
-
-export interface SocialLink {
- name: string;
- url: string;
- active: boolean;
-}
-
-export const CONTACT_SOCIALS: SocialLink[] = [
- { name: 'Instagram', url: 'https://instagram.com/alissu_sz_', active: true },
- { name: 'WhatsApp', url: 'https://wa.me/5514999704645', active: true },
-];
diff --git a/src/features/contact/constants/index.tsx b/src/features/contact/constants/index.tsx
new file mode 100644
index 0000000..463767f
--- /dev/null
+++ b/src/features/contact/constants/index.tsx
@@ -0,0 +1,36 @@
+import type { ComponentType, SVGProps } from 'react';
+import { Instagram } from 'lucide-react';
+
+export const CONTACT_EMAIL = 'alissu.dev@gmail.com';
+export const CONTACT_PHONE = '+55 14 9 9970-46645';
+
+type IconComponent = ComponentType
>;
+
+function WhatsApp(props: SVGProps) {
+ return (
+
+ );
+}
+
+export interface SocialLink {
+ name: string;
+ url: string;
+ active: boolean;
+ icon: IconComponent;
+}
+
+export const CONTACT_SOCIALS: SocialLink[] = [
+ { name: 'Instagram', url: 'https://instagram.com/alissu_sz_', active: true, icon: Instagram },
+ { name: 'WhatsApp', url: 'https://wa.me/5514999704645', active: true, icon: WhatsApp },
+];
diff --git a/src/features/contact/hooks/useContactForm.ts b/src/features/contact/hooks/useContactForm.ts
index 5292973..7746820 100644
--- a/src/features/contact/hooks/useContactForm.ts
+++ b/src/features/contact/hooks/useContactForm.ts
@@ -1,5 +1,6 @@
import { useState } from 'react';
import type { ContactFormData, ContactFormErrors, ContactFormStatus } from '../types';
+import { trackEvent } from '../../../shared/services/analytics';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -42,6 +43,7 @@ export function useContactForm() {
return;
}
+ trackEvent('contact-submit');
setFormStatus('success');
setFormErrors({});
};
diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx
index dd544ba..1d2c6ee 100644
--- a/src/features/home/HomePage.tsx
+++ b/src/features/home/HomePage.tsx
@@ -1,9 +1,23 @@
import { lazy, Suspense } from 'react';
+import { TypeAnimation } from 'react-type-animation';
const SnakeGame = lazy(() =>
import('../snake-game').then((m) => ({ default: m.SnakeGame })),
);
+const ROLES = [
+ 'Front-end developer',
+ 2000,
+ 'Back-end developer',
+ 2000,
+ 'Full-Stack developer',
+ 2000,
+ 'Mobile developer',
+ 2000,
+ 'IoT developer',
+ 2000,
+];
+
export function HomePage() {
return (
@@ -16,10 +30,17 @@ export function HomePage() {
Alissu
-
-
{`> Front-end developer `}
-
{`&&`}
-
{` Full-Stack developer`}
+
+ >
+
diff --git a/src/features/projects/ProjectsPage.tsx b/src/features/projects/ProjectsPage.tsx
index a538389..93e4927 100644
--- a/src/features/projects/ProjectsPage.tsx
+++ b/src/features/projects/ProjectsPage.tsx
@@ -1,14 +1,20 @@
-import { useState } from 'react';
+import { useState, useCallback } from 'react';
import { useProjectFilter } from './hooks/useProjectFilter';
import { TechFilter } from './components/TechFilter';
import { ProjectCard } from './components/ProjectCard';
import { ProjectModal } from './components/ProjectModal';
import type { Project } from './types';
+import { trackEvent } from '../../shared/services/analytics';
export function ProjectsPage() {
const { selectedTech, toggleTech, filteredProjects } = useProjectFilter();
const [selectedProject, setSelectedProject] = useState(null);
+ const handleSelect = useCallback((project: Project) => {
+ trackEvent('project-open', { id: project.id, title: project.title });
+ setSelectedProject(project);
+ }, []);
+
return (
@@ -27,7 +33,7 @@ export function ProjectsPage() {
key={project.id}
project={project}
index={i}
- onSelect={setSelectedProject}
+ onSelect={handleSelect}
/>
))}
diff --git a/src/features/snake-game/SnakeGame.tsx b/src/features/snake-game/SnakeGame.tsx
index 28ee0ac..15663b2 100644
--- a/src/features/snake-game/SnakeGame.tsx
+++ b/src/features/snake-game/SnakeGame.tsx
@@ -83,7 +83,7 @@ export function SnakeGame({ className = '' }: SnakeGameProps) {
{mode === 'competitive' && highScore > 0 && }
-
+
s + t.weight, 0);
@@ -134,6 +135,8 @@ export const useGameStore = create((set, get) => ({
},
beginCountdown: () => {
+ const { difficulty, mode } = get();
+ trackEvent('snake-start', { difficulty, mode });
set({
status: 'countdown',
score: 0,
@@ -166,9 +169,10 @@ export const useGameStore = create((set, get) => ({
},
gameOver: () => {
- const { score, highScore, shakeKey } = get();
+ const { score, highScore, shakeKey, difficulty, mode } = get();
const newHighScore = Math.max(score, highScore);
if (newHighScore > highScore) saveHighScore(newHighScore);
+ trackEvent('snake-game-over', { score, difficulty, mode });
set({ status: 'game-over', highScore: newHighScore, combo: 0, shakeKey: shakeKey + 1 });
},
@@ -228,7 +232,7 @@ export const useGameStore = create((set, get) => ({
},
moveSnake: () => {
- const { snake, direction, directionQueue, food, status, mode, combo, lastEatTime, particles, shakeKey } = get();
+ const { snake, direction, directionQueue, food, status, mode, difficulty, combo, lastEatTime, particles, shakeKey } = get();
if (status !== 'playing') return;
let currentDirection = direction;
@@ -293,6 +297,7 @@ export const useGameStore = create((set, get) => ({
if (!newFood || (mode === 'casual' && newScore >= FOOD_TO_WIN_CASUAL)) {
const newHighScore = Math.max(newScore, highScore);
if (newHighScore > highScore) saveHighScore(newHighScore);
+ trackEvent('snake-victory', { score: newScore, difficulty });
set({
...dirUpdate,
status: 'victory',
diff --git a/src/shared/components/decorative/CodeRain.tsx b/src/shared/components/decorative/CodeRain.tsx
new file mode 100644
index 0000000..4af06f5
--- /dev/null
+++ b/src/shared/components/decorative/CodeRain.tsx
@@ -0,0 +1,120 @@
+import { useEffect, useRef } from 'react';
+
+const CHARS = ['0', '1'];
+const FONT_SIZE = 14;
+const COLUMN_WIDTH = 20;
+const TRAIL_LENGTH = 6;
+const COLOR_RGB = '70, 236, 213';
+const SPEED_PX_PER_SEC = 220;
+
+interface Column {
+ headY: number; // continuous pixel position
+ lastSlot: number;
+ chars: string[];
+}
+
+interface CodeRainProps {
+ className?: string;
+}
+
+export function CodeRain({ className = '' }: CodeRainProps) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (reducedMotion) return;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ let rafId = 0;
+ let lastFrame = 0;
+ let cols: Column[] = [];
+ let columnCount = 0;
+ let dpr = 1;
+
+ const randChar = () => CHARS[Math.floor(Math.random() * CHARS.length)]!;
+
+ const resize = () => {
+ dpr = window.devicePixelRatio || 1;
+ const rect = canvas.getBoundingClientRect();
+ canvas.width = Math.floor(rect.width * dpr);
+ canvas.height = Math.floor(rect.height * dpr);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ columnCount = Math.floor(rect.width / COLUMN_WIDTH);
+ cols = Array.from({ length: columnCount }, () => {
+ const startY = Math.random() * -rect.height;
+ return {
+ headY: startY,
+ lastSlot: Math.floor(startY / FONT_SIZE),
+ chars: Array.from({ length: TRAIL_LENGTH }, randChar),
+ };
+ });
+ ctx.font = `${FONT_SIZE}px 'Fira Code', monospace`;
+ ctx.textBaseline = 'top';
+ };
+
+ const animate = (now: number) => {
+ const rect = canvas.getBoundingClientRect();
+ const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000;
+ lastFrame = now;
+
+ ctx.clearRect(0, 0, rect.width, rect.height);
+
+ for (let i = 0; i < columnCount; i++) {
+ const col = cols[i]!;
+ col.headY += SPEED_PX_PER_SEC * dt;
+
+ // advance char stack when crossing a slot boundary
+ const currentSlot = Math.floor(col.headY / FONT_SIZE);
+ const slotsCrossed = currentSlot - col.lastSlot;
+ if (slotsCrossed > 0) {
+ for (let s = 0; s < slotsCrossed; s++) {
+ col.chars.pop();
+ col.chars.unshift(randChar());
+ }
+ col.lastSlot = currentSlot;
+ }
+
+ const x = i * COLUMN_WIDTH;
+ for (let t = 0; t < TRAIL_LENGTH; t++) {
+ const y = col.headY - t * FONT_SIZE;
+ if (y < -FONT_SIZE || y > rect.height) continue;
+ const alpha = ((TRAIL_LENGTH - t) / TRAIL_LENGTH) * 0.9;
+ ctx.fillStyle = `rgba(${COLOR_RGB}, ${alpha})`;
+ ctx.fillText(col.chars[t]!, x, y);
+ }
+
+ // respawn above the viewport once the trail has fully passed
+ if (col.headY - TRAIL_LENGTH * FONT_SIZE > rect.height && Math.random() > 0.985) {
+ col.headY = -FONT_SIZE * TRAIL_LENGTH;
+ col.lastSlot = Math.floor(col.headY / FONT_SIZE);
+ }
+ }
+
+ rafId = requestAnimationFrame(animate);
+ };
+
+ resize();
+ rafId = requestAnimationFrame(animate);
+
+ const ro = new ResizeObserver(resize);
+ ro.observe(canvas);
+
+ return () => {
+ cancelAnimationFrame(rafId);
+ ro.disconnect();
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/src/shared/components/layout/Layout.tsx b/src/shared/components/layout/Layout.tsx
index e9bc877..1f87881 100644
--- a/src/shared/components/layout/Layout.tsx
+++ b/src/shared/components/layout/Layout.tsx
@@ -2,12 +2,17 @@ import { Suspense } from 'react';
import { Outlet, useLocation } from 'react-router';
import { Header } from './Header';
import { Footer } from './Footer';
+import { CodeRain } from '../decorative/CodeRain';
export function Layout() {
const location = useLocation();
return (
+
+
+
+
diff --git a/src/shared/services/analytics.ts b/src/shared/services/analytics.ts
new file mode 100644
index 0000000..2408a51
--- /dev/null
+++ b/src/shared/services/analytics.ts
@@ -0,0 +1,16 @@
+declare global {
+ interface Window {
+ umami?: {
+ track: (eventName: string, eventData?: Record
) => void;
+ };
+ }
+}
+
+export function trackEvent(name: string, data?: Record) {
+ if (typeof window === 'undefined') return;
+ try {
+ window.umami?.track(name, data);
+ } catch {
+ // silently ignore if umami hasn't loaded or fails
+ }
+}