diff --git a/index.html b/index.html index cf72387..5996085 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,16 @@ DevAlissu | Portfolio + diff --git a/package-lock.json b/package-lock.json index bb88151..a27dfef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router": "7.13.0", + "react-type-animation": "^3.2.0", "tailwind-merge": "3.2.0", "zustand": "^5.0.12" }, @@ -2478,6 +2479,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2528,6 +2538,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -2561,6 +2583,12 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2593,6 +2621,17 @@ } } }, + "node_modules/react-type-animation": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-type-animation/-/react-type-animation-3.2.0.tgz", + "integrity": "sha512-WXTe0i3rRNKjmggPvT5ntye1QBt0ATGbijeW6V3cQe2W0jaMABXXlPPEdtofnS9tM7wSRHchEvI9SUw+0kUohw==", + "license": "MIT", + "peerDependencies": { + "prop-types": "^15.5.4", + "react": ">= 15.0.0", + "react-dom": ">= 15.0.0" + } + }, "node_modules/rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", diff --git a/package.json b/package.json index 58c31de..2783162 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router": "7.13.0", + "react-type-animation": "^3.2.0", "tailwind-merge": "3.2.0", "zustand": "^5.0.12" }, diff --git a/src/features/contact/components/ContactSidebar.tsx b/src/features/contact/components/ContactSidebar.tsx index 537041a..e200572 100644 --- a/src/features/contact/components/ContactSidebar.tsx +++ b/src/features/contact/components/ContactSidebar.tsx @@ -1,10 +1,7 @@ import { useState } from 'react'; +import { Mail, Phone } from 'lucide-react'; import { CONTACT_EMAIL, CONTACT_PHONE, CONTACT_SOCIALS } from '../constants'; -const EMAIL_ICON = 'M13.3333 2.66667H2.66667C1.93333 2.66667 1.33333 3.26667 1.33333 4V12C1.33333 12.7333 1.93333 13.3333 2.66667 13.3333H13.3333C14.0667 13.3333 14.6667 12.7333 14.6667 12V4C14.6667 3.26667 14.0667 2.66667 13.3333 2.66667ZM13.3333 12H2.66667V5.33333L8 8.66667L13.3333 5.33333V12ZM8 7.33333L2.66667 4H13.3333L8 7.33333Z'; -const PHONE_ICON = 'M12.4 14C10.88 14 9.36 13.54 7.96 12.62C6.56 11.7 5.3 10.44 4.38 9.04C3.46 7.64 3 6.12 3 4.6C3 4.24 3.12 3.94 3.36 3.7C3.6 3.46 3.9 3.34 4.26 3.34H6.42C6.7 3.34 6.94 3.42 7.14 3.58C7.34 3.74 7.48 3.94 7.56 4.18L8.16 6.16C8.22 6.4 8.22 6.62 8.16 6.82C8.1 7.02 7.98 7.2 7.8 7.36L6.36 8.82C6.8 9.6 7.36 10.3 8.04 10.92C8.72 11.54 9.46 12.08 10.26 12.54L11.66 11.14C11.84 10.96 12.06 10.84 12.32 10.78C12.58 10.72 12.82 10.74 13.04 10.84L14.92 11.52C15.16 11.62 15.36 11.78 15.52 12C15.68 12.22 15.76 12.46 15.76 12.72V14.74C15.76 15.1 15.64 15.4 15.4 15.64C15.16 15.88 14.86 16 14.5 16C14.14 16 13.78 15.98 13.42 15.94C13.06 15.9 12.72 15.84 12.4 15.76V14Z'; -const SOCIAL_ICON = 'M11.3333 3.33333C11.3333 3.33333 11.2 2.4 10.8 2C10.2667 1.46667 9.66667 1.46667 9.4 1.43333C7.86667 1.33333 6 1.33333 6 1.33333C6 1.33333 4.13333 1.33333 2.6 1.43333C2.33333 1.46667 1.73333 1.46667 1.2 2C0.8 2.4 0.666667 3.33333 0.666667 3.33333C0.666667 3.33333 0.533333 4.4 0.533333 5.46667V6.53333C0.533333 7.6 0.666667 8.66667 0.666667 8.66667C0.666667 8.66667 0.8 9.6 1.2 10C1.73333 10.5333 2.4 10.5333 2.66667 10.6C3.73333 10.6667 6 10.6667 6 10.6667C6 10.6667 7.86667 10.6667 9.4 10.5667C9.66667 10.5333 10.2667 10.5333 10.8 10C11.2 9.6 11.3333 8.66667 11.3333 8.66667C11.3333 8.66667 11.4667 7.6 11.4667 6.53333V5.46667C11.4667 4.4 11.3333 3.33333 11.3333 3.33333ZM4.8 7.6V4.13333L7.73333 5.86667L4.8 7.6Z'; - export function ContactSidebar() { const [expandedContacts, setExpandedContacts] = useState(false); const [expandedSocial, setExpandedSocial] = useState(false); @@ -26,12 +23,12 @@ export function ContactSidebar() { {expandedContacts && (
-
- +
+ {CONTACT_EMAIL}
-
- +
+ {CONTACT_PHONE}
@@ -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 ( +