diff --git a/components/floating-title.tsx b/components/floating-title.tsx
index 461ebab..2878841 100644
--- a/components/floating-title.tsx
+++ b/components/floating-title.tsx
@@ -1,126 +1,126 @@
-"use client"
-
-import { useRef, useEffect, useState } from "react"
-import { useScrollContext } from "@/lib/scroll-context"
-import { useTranslations, getTranslation } from "@/lib/i18n-context"
-
-/**
- * Fixed-position element that interpolates between the hero h1 and the navbar
- * brand text during scroll, creating a continuous "dock into navbar" effect.
- *
- * Uses translate3d + scale for GPU-composited animation.
- * Purely decorative — the real heading and nav link remain in the DOM.
- *
- * Opacity-gated: the clone mounts with opacity 0, applies exact computed styles
- * from the hero h1 (read via getComputedStyle), and only becomes visible after
- * the first rAF tick to guarantee no unstyled flash frame.
- */
-export function FloatingTitle() {
- const {
- scrollY,
- scrollProgress,
- heroTitleRect,
- navTitleRect,
- heroComputedStyles,
- navFontSize,
- isFloatingTitleActive,
- } = useScrollContext()
- const [translations] = useTranslations()
- const [revealed, setRevealed] = useState(false)
- const revealRafRef = useRef(0)
-
- // Determine if the clone should be in the DOM at all.
- // Uses isFloatingTitleActive from context which includes: !prefersReducedMotion,
- // fontsReady, heroWasVisible, 0 < scrollProgress < 1
- const shouldMount =
- isFloatingTitleActive &&
- heroTitleRect != null &&
- navTitleRect != null &&
- heroComputedStyles != null &&
- navFontSize != null
-
- // Opacity-gated reveal: mount with opacity 0, then reveal after one rAF
- // so the browser has time to apply computed styles before painting.
- useEffect(() => {
- if (shouldMount && !revealed) {
- revealRafRef.current = requestAnimationFrame(() => {
- setRevealed(true)
- })
- }
- if (!shouldMount && revealed) {
- setRevealed(false)
- }
- return () => {
- if (revealRafRef.current) cancelAnimationFrame(revealRafRef.current)
- }
- }, [shouldMount, revealed])
-
- if (!shouldMount || !heroTitleRect || !navTitleRect || !heroComputedStyles || !navFontSize) {
- return null
- }
-
- const t = easeInOutCubic(scrollProgress)
-
- // Hero rect is in document coords — convert to viewport
- const heroViewportTop = heroTitleRect.top - scrollY
- const heroViewportLeft = heroTitleRect.left
-
- // Nav rect is already in viewport coords (fixed navbar)
- const navViewportTop = navTitleRect.top
- const navViewportLeft = navTitleRect.left
-
- // Position the element at the hero's initial viewport position,
- // then translate toward the nav position
- const deltaX = (navViewportLeft - heroViewportLeft) * t
- const deltaY = (navViewportTop - heroViewportTop) * t
-
- // Scale based on font-size ratio (not element height) so line-wrapping
- // on mobile doesn't cause the title to shrink more than it should.
- const heroFontPx = parseFloat(heroComputedStyles.fontSize)
- const navFontPx = parseFloat(navFontSize)
- const targetScale = navFontPx / heroFontPx
- const scale = lerp(1, targetScale, t)
-
- const name = getTranslation(translations, "hero.name", "Alejandro Repetto")
-
- return (
-
-
- {name}
-
-
- )
-}
-
-function lerp(a: number, b: number, t: number): number {
- return a + (b - a) * t
-}
-
-function easeInOutCubic(t: number): number {
- return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
-}
+"use client"
+
+import { useRef, useEffect, useState } from "react"
+import { useScrollContext } from "@/lib/scroll-context"
+import { useTranslations, getTranslation } from "@/lib/i18n-context"
+
+/**
+ * Fixed-position element that interpolates between the hero h1 and the navbar
+ * brand text during scroll, creating a continuous "dock into navbar" effect.
+ *
+ * Uses translate3d + scale for GPU-composited animation.
+ * Purely decorative — the real heading and nav link remain in the DOM.
+ *
+ * Opacity-gated: the clone mounts with opacity 0, applies exact computed styles
+ * from the hero h1 (read via getComputedStyle), and only becomes visible after
+ * the first rAF tick to guarantee no unstyled flash frame.
+ */
+export function FloatingTitle() {
+ const {
+ scrollY,
+ scrollProgress,
+ heroTitleRect,
+ navTitleRect,
+ heroComputedStyles,
+ navFontSize,
+ isFloatingTitleActive,
+ } = useScrollContext()
+ const [translations] = useTranslations()
+ const [revealed, setRevealed] = useState(false)
+ const revealRafRef = useRef(0)
+
+ // Determine if the clone should be in the DOM at all.
+ // Uses isFloatingTitleActive from context which includes: !prefersReducedMotion,
+ // fontsReady, heroWasVisible, 0 < scrollProgress < 1
+ const shouldMount =
+ isFloatingTitleActive &&
+ heroTitleRect != null &&
+ navTitleRect != null &&
+ heroComputedStyles != null &&
+ navFontSize != null
+
+ // Opacity-gated reveal: mount with opacity 0, then reveal after one rAF
+ // so the browser has time to apply computed styles before painting.
+ useEffect(() => {
+ if (shouldMount && !revealed) {
+ revealRafRef.current = requestAnimationFrame(() => {
+ setRevealed(true)
+ })
+ }
+ if (!shouldMount && revealed) {
+ setRevealed(false)
+ }
+ return () => {
+ if (revealRafRef.current) cancelAnimationFrame(revealRafRef.current)
+ }
+ }, [shouldMount, revealed])
+
+ if (!shouldMount || !heroTitleRect || !navTitleRect || !heroComputedStyles || !navFontSize) {
+ return null
+ }
+
+ const t = easeInOutCubic(scrollProgress)
+
+ // Hero rect is in document coords — convert to viewport
+ const heroViewportTop = heroTitleRect.top - scrollY
+ const heroViewportLeft = heroTitleRect.left
+
+ // Nav rect is already in viewport coords (fixed navbar)
+ const navViewportTop = navTitleRect.top
+ const navViewportLeft = navTitleRect.left
+
+ // Position the element at the hero's initial viewport position,
+ // then translate toward the nav position
+ const deltaX = (navViewportLeft - heroViewportLeft) * t
+ const deltaY = (navViewportTop - heroViewportTop) * t
+
+ // Scale based on font-size ratio (not element height) so line-wrapping
+ // on mobile doesn't cause the title to shrink more than it should.
+ const heroFontPx = parseFloat(heroComputedStyles.fontSize)
+ const navFontPx = parseFloat(navFontSize)
+ const targetScale = navFontPx / heroFontPx
+ const scale = lerp(1, targetScale, t)
+
+ const name = getTranslation(translations, "hero.name")
+
+ return (
+
+
+ {name}
+
+
+ )
+}
+
+function lerp(a: number, b: number, t: number): number {
+ return a + (b - a) * t
+}
+
+function easeInOutCubic(t: number): number {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
+}
diff --git a/components/navigation.tsx b/components/navigation.tsx
index 9117670..549d46c 100644
--- a/components/navigation.tsx
+++ b/components/navigation.tsx
@@ -1,90 +1,90 @@
-"use client"
-
-import { useState } from "react"
-import Link from "next/link"
-import { Button } from "@/components/ui/button"
-import { Menu, X } from "lucide-react"
-import { LanguageSwitcher } from "@/components/language-switcher"
-import { ThemeToggle } from "@/components/theme-toggle"
-import { useTranslations, getTranslation } from "@/lib/i18n-context"
-import { useScrollContext } from "@/lib/scroll-context"
-import { cn } from "@/lib/utils"
-
-export function Navigation() {
- const [isOpen, setIsOpen] = useState(false)
- const [translations, locale, loading] = useTranslations()
- const { registerNavTitleRef, isFloatingTitleActive, scrollProgress, fontsReady } = useScrollContext()
-
- const navItems = [
- { href: "#about", label: getTranslation(translations, "navigation.about", "About") },
- { href: "#projects", label: getTranslation(translations, "navigation.projects", "Projects") },
- { href: "#contact", label: getTranslation(translations, "navigation.contact", "Contact") },
- ]
-
- return (
-
-
-
-
= 1) && "opacity-0",
- )}
- >
-
Alejandro Repetto
-
-
- {/* Desktop Navigation */}
-
- {navItems.map((item) => (
-
- {item.label}
-
- ))}
-
-
-
-
-
-
-
- {/* Mobile Navigation Button */}
-
-
-
-
- setIsOpen(!isOpen)} className="w-9 h-9 p-0">
- {isOpen ? : }
- Toggle menu
-
-
-
-
- {/* Mobile Navigation Menu */}
- {isOpen && (
-
-
- {navItems.map((item) => (
- setIsOpen(false)}
- >
- {item.label}
-
- ))}
-
-
- )}
-
-
- )
-}
+"use client"
+
+import { useState } from "react"
+import Link from "next/link"
+import { Button } from "@/components/ui/button"
+import { Menu, X } from "lucide-react"
+import { LanguageSwitcher } from "@/components/language-switcher"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { useTranslations, getTranslation } from "@/lib/i18n-context"
+import { useScrollContext } from "@/lib/scroll-context"
+import { cn } from "@/lib/utils"
+
+export function Navigation() {
+ const [isOpen, setIsOpen] = useState(false)
+ const [translations, locale] = useTranslations()
+ const { registerNavTitleRef, isFloatingTitleActive, scrollProgress, fontsReady } = useScrollContext()
+
+ const navItems = [
+ { href: "#about", label: getTranslation(translations, "navigation.about") },
+ { href: "#projects", label: getTranslation(translations, "navigation.projects") },
+ { href: "#contact", label: getTranslation(translations, "navigation.contact") },
+ ]
+
+ return (
+
+
+
+
= 1) && "opacity-0",
+ )}
+ >
+
Alejandro Repetto
+
+
+ {/* Desktop Navigation */}
+
+ {navItems.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+ {/* Mobile Navigation Button */}
+
+
+
+
+ setIsOpen(!isOpen)} className="w-9 h-9 p-0">
+ {isOpen ? : }
+ Toggle menu
+
+
+
+
+ {/* Mobile Navigation Menu */}
+ {isOpen && (
+
+
+ {navItems.map((item) => (
+ setIsOpen(false)}
+ >
+ {item.label}
+
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/components/project-detail-modal.tsx b/components/project-detail-modal.tsx
index c9c1ced..1558ddb 100644
--- a/components/project-detail-modal.tsx
+++ b/components/project-detail-modal.tsx
@@ -1,154 +1,154 @@
-"use client"
-
-import { useState } from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/ui/dialog"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { ImageLightbox } from "@/components/ui/image-lightbox"
-import { Github, ExternalLink } from "lucide-react"
-import Link from "next/link"
-import Image from "next/image"
-import { useTranslations, getTranslation } from "@/lib/i18n-context"
-import type { Project } from "@/types/project"
-
-interface ProjectDetailModalProps {
- project: Project | null
- isOpen: boolean
- onClose: () => void
-}
-
-export function ProjectDetailModal({ project, isOpen, onClose }: ProjectDetailModalProps) {
- const [translations, locale] = useTranslations()
- const [lightboxOpen, setLightboxOpen] = useState(false)
- const [lightboxIndex, setLightboxIndex] = useState(0)
-
- if (!project) return null
-
- const content = project.locales[locale] || project.locales["en"]
-
- const handleOpenChange = (open: boolean) => {
- if (!open) onClose()
- }
-
- return (
- <>
-
-
-
-
-
- {getTranslation(translations, `projects.categories.${project.category}`, project.category)}
-
-
- {getTranslation(translations, `projects.status.${project.status}`, project.status)}
-
-
- {content.title}
- {content.short}
-
-
-
-
- {/* Image thumbnails */}
- {project.images?.length > 0 && (
-
- {project.images.map((img, i) => (
- {
- setLightboxIndex(i)
- setLightboxOpen(true)
- }}
- className="relative aspect-video rounded-md overflow-hidden border border-border hover:ring-2 ring-primary transition-all focus:outline-none focus:ring-2 focus:ring-primary"
- aria-label={`${content.title} — ${getTranslation(translations, "projects.image", "image")} ${i + 1}`}
- >
-
-
- ))}
-
- )}
-
- {/* Description */}
-
- {content.description}
-
-
- {/* Features */}
- {content.features?.length > 0 && (
-
-
- {getTranslation(translations, "projects.features", "Key Features")}
-
-
- {content.features.map((f, i) => (
-
-
- {f}
-
- ))}
-
-
- )}
-
- {/* Tech stack */}
-
-
- {getTranslation(translations, "projects.techStack", "Technology Stack")}
-
-
- {project.techStack.map((t) => (
-
- {t}
-
- ))}
-
-
-
- {/* Links */}
-
- {project.githubUrl && (
-
-
-
- {getTranslation(translations, "projects.cta.code", "Code")}
-
-
- )}
- {project.demoUrl && (
-
-
-
- Demo
-
-
- )}
-
-
-
-
-
-
- {/* Lightbox as sibling to Dialog to avoid z-index/focus conflicts */}
- setLightboxOpen(false)}
- projectTitle={content.title}
- />
- >
- )
-}
+"use client"
+
+import { useState } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { ImageLightbox } from "@/components/ui/image-lightbox"
+import { Github, ExternalLink } from "lucide-react"
+import Link from "next/link"
+import Image from "next/image"
+import { useTranslations, getTranslation } from "@/lib/i18n-context"
+import type { Project } from "@/types/project"
+
+interface ProjectDetailModalProps {
+ project: Project | null
+ isOpen: boolean
+ onClose: () => void
+}
+
+export function ProjectDetailModal({ project, isOpen, onClose }: ProjectDetailModalProps) {
+ const [translations, locale] = useTranslations()
+ const [lightboxOpen, setLightboxOpen] = useState(false)
+ const [lightboxIndex, setLightboxIndex] = useState(0)
+
+ if (!project) return null
+
+ const content = project.locales[locale] || project.locales["en"]
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) onClose()
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {getTranslation(translations, `projects.categories.${project.category}`, project.category)}
+
+
+ {getTranslation(translations, `projects.status.${project.status}`, project.status)}
+
+
+ {content.title}
+ {content.short}
+
+
+
+
+ {/* Image thumbnails */}
+ {project.images?.length > 0 && (
+
+ {project.images.map((img, i) => (
+ {
+ setLightboxIndex(i)
+ setLightboxOpen(true)
+ }}
+ className="relative aspect-video rounded-md overflow-hidden border border-border hover:ring-2 ring-primary transition-all focus:outline-none focus:ring-2 focus:ring-primary"
+ aria-label={`${content.title} — ${getTranslation(translations, "projects.image")} ${i + 1}`}
+ >
+
+
+ ))}
+
+ )}
+
+ {/* Description */}
+
+ {content.description}
+
+
+ {/* Features */}
+ {content.features?.length > 0 && (
+
+
+ {getTranslation(translations, "projects.features")}
+
+
+ {content.features.map((f, i) => (
+
+
+ {f}
+
+ ))}
+
+
+ )}
+
+ {/* Tech stack */}
+
+
+ {getTranslation(translations, "projects.techStack")}
+
+
+ {project.techStack.map((t) => (
+
+ {t}
+
+ ))}
+
+
+
+ {/* Links */}
+
+ {project.githubUrl && (
+
+
+
+ {getTranslation(translations, "projects.cta.code")}
+
+
+ )}
+ {project.demoUrl && (
+
+
+
+ Demo
+
+
+ )}
+
+
+
+
+
+
+ {/* Lightbox as sibling to Dialog to avoid z-index/focus conflicts */}
+ setLightboxOpen(false)}
+ projectTitle={content.title}
+ />
+ >
+ )
+}
diff --git a/components/project-timeline.tsx b/components/project-timeline.tsx
index 0da85b1..68f4ff6 100644
--- a/components/project-timeline.tsx
+++ b/components/project-timeline.tsx
@@ -1,116 +1,116 @@
-"use client"
-
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Calendar, CheckCircle2 } from "lucide-react"
-import { cn } from "@/lib/utils"
-import { useTranslations, getTranslation } from "@/lib/i18n-context"
-
-interface TimelineEvent {
- date: string
- title: string | { en: string; es: string }
- description: string | { en: string; es: string }
-}
-
-interface ProjectTimelineProps {
- timeline: TimelineEvent[]
- className?: string
-}
-
-export function ProjectTimeline({ timeline, className }: ProjectTimelineProps) {
- const [translations, locale] = useTranslations()
-
- // Parse dates and find current/future events
- const now = new Date()
-
- // Helper to parse date as local date (not UTC)
- const parseLocalDate = (dateString: string) => {
- const [year, month, day] = dateString.split('-').map(Number)
- return new Date(year, month - 1, day) // month is 0-indexed in JS
- }
-
- // Helper to get localized content
- const getLocalizedContent = (content: string | { en: string; es: string }) => {
- if (typeof content === 'string') return content
- return content[locale as 'en' | 'es'] || content.en
- }
-
- return (
-
-
-
-
- {getTranslation(translations, "projects.timeline.title", "Project Timeline")}
-
-
-
-
- {/* Vertical line */}
-
-
- {timeline.map((event, index) => {
- const eventDate = parseLocalDate(event.date)
- const isPast = eventDate <= now
- const isToday = eventDate.toDateString() === now.toDateString()
-
- return (
-
- {/* Timeline dot */}
-
- {isPast && !isToday && (
-
- )}
-
-
-
-
-
- {eventDate.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
- month: "short",
- day: "numeric",
- year: "numeric",
- })}
-
- {isToday && (
-
- {getTranslation(translations, "projects.timeline.today", "Today")}
-
- )}
-
-
- {getLocalizedContent(event.title)}
-
-
- {getLocalizedContent(event.description)}
-
-
-
- )
- })}
-
-
-
- )
-}
+"use client"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Calendar, CheckCircle2 } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { useTranslations, getTranslation } from "@/lib/i18n-context"
+
+interface TimelineEvent {
+ date: string
+ title: string | { en: string; es: string }
+ description: string | { en: string; es: string }
+}
+
+interface ProjectTimelineProps {
+ timeline: TimelineEvent[]
+ className?: string
+}
+
+export function ProjectTimeline({ timeline, className }: ProjectTimelineProps) {
+ const [translations, locale] = useTranslations()
+
+ // Parse dates and find current/future events
+ const now = new Date()
+
+ // Helper to parse date as local date (not UTC)
+ const parseLocalDate = (dateString: string) => {
+ const [year, month, day] = dateString.split('-').map(Number)
+ return new Date(year, month - 1, day) // month is 0-indexed in JS
+ }
+
+ // Helper to get localized content
+ const getLocalizedContent = (content: string | { en: string; es: string }) => {
+ if (typeof content === 'string') return content
+ return content[locale as 'en' | 'es'] || content.en
+ }
+
+ return (
+
+
+
+
+ {getTranslation(translations, "projects.timeline.title")}
+
+
+
+
+ {/* Vertical line */}
+
+
+ {timeline.map((event, index) => {
+ const eventDate = parseLocalDate(event.date)
+ const isPast = eventDate <= now
+ const isToday = eventDate.toDateString() === now.toDateString()
+
+ return (
+
+ {/* Timeline dot */}
+
+ {isPast && !isToday && (
+
+ )}
+
+
+
+
+
+ {eventDate.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
+
+ {isToday && (
+
+ {getTranslation(translations, "projects.timeline.today")}
+
+ )}
+
+
+ {getLocalizedContent(event.title)}
+
+
+ {getLocalizedContent(event.description)}
+
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/components/sections/about-section.tsx b/components/sections/about-section.tsx
index a6d8a0e..df495d5 100644
--- a/components/sections/about-section.tsx
+++ b/components/sections/about-section.tsx
@@ -1,65 +1,65 @@
-"use client"
-
-import { Container } from "@/components/layout/container"
-import { Section } from "@/components/layout/section"
-import { SectionHeader } from "@/components/layout/section-header"
-import { useTranslations, getTranslation } from "@/lib/i18n-context"
-
-export function AboutSection() {
- const [translations] = useTranslations()
-
- return (
-
-
-
-
-
-
- {/* Background */}
-
-
- {getTranslation(translations, "about.bio.background.title", "Background")}
-
-
- {getTranslation(
- translations,
- "about.bio.background.text",
- "I've been programming seriously since 2022, when I placed 6th in my first ICPC tournament. A 2024 winter training camp was a turning point that transformed how I approach technical challenges.\n\nI'm learning distributed systems and design patterns through hands-on implementation: building load balancers, message brokers, and experimenting with database architectures.\n\nMy projects range from NASA Space Apps prediction algorithms to production systems with RAG architectures. I don't just use technologies. I dig into how they work and when they actually make sense architecturally.",
- )
- .split("\n\n")
- .map((paragraph, index) => (
-
{paragraph}
- ))}
-
-
-
- {/* Approach */}
-
-
- {getTranslation(translations, "about.bio.approach.title", "Approach")}
-
-
- {getTranslation(
- translations,
- "about.bio.approach.text",
- "I think about efficiency first. Competitive programming trained me to analyze complexity before coding. But I'm pragmatic about execution: MVPs ship fast, production systems are built to last.\n\nMy process: research what exists, understand the problem deeply, consult when needed, then iterate rapidly. I focus on writing code that's maintainable and scalable without over-engineering early stages.\n\nI work well independently and in teams, and I adapt to what the project needs. Local hackathon wins and real-world deployments both prove the same thing: I deliver solutions that work now and scale later.",
- )
- .split("\n\n")
- .map((paragraph, index) => (
-
{paragraph}
- ))}
-
-
-
-
-
-
- )
-}
+"use client"
+
+import { Container } from "@/components/layout/container"
+import { Section } from "@/components/layout/section"
+import { SectionHeader } from "@/components/layout/section-header"
+import { useTranslations, getTranslation } from "@/lib/i18n-context"
+
+export function AboutSection() {
+ const [translations] = useTranslations()
+
+ return (
+
+
+
+
+
+
+ {/* Background */}
+
+
+ {getTranslation(translations, "about.bio.background.title")}
+
+
+ {getTranslation(
+ translations,
+ "about.bio.background.text",
+ "I've been programming seriously since 2022, when I placed 6th in my first ICPC tournament. A 2024 winter training camp was a turning point that transformed how I approach technical challenges.\n\nI'm learning distributed systems and design patterns through hands-on implementation: building load balancers, message brokers, and experimenting with database architectures.\n\nMy projects range from NASA Space Apps prediction algorithms to production systems with RAG architectures. I don't just use technologies. I dig into how they work and when they actually make sense architecturally.",
+ )
+ .split("\n\n")
+ .map((paragraph, index) => (
+
{paragraph}
+ ))}
+
+
+
+ {/* Approach */}
+
+
+ {getTranslation(translations, "about.bio.approach.title")}
+
+
+ {getTranslation(
+ translations,
+ "about.bio.approach.text",
+ "I think about efficiency first. Competitive programming trained me to analyze complexity before coding. But I'm pragmatic about execution: MVPs ship fast, production systems are built to last.\n\nMy process: research what exists, understand the problem deeply, consult when needed, then iterate rapidly. I focus on writing code that's maintainable and scalable without over-engineering early stages.\n\nI work well independently and in teams, and I adapt to what the project needs. Local hackathon wins and real-world deployments both prove the same thing: I deliver solutions that work now and scale later.",
+ )
+ .split("\n\n")
+ .map((paragraph, index) => (
+
{paragraph}
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/components/sections/awards-section.tsx b/components/sections/awards-section.tsx
index ca03b24..4b8fa2a 100644
--- a/components/sections/awards-section.tsx
+++ b/components/sections/awards-section.tsx
@@ -1,170 +1,170 @@
-"use client"
-
-import { Card, CardContent, CardHeader } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Container } from "@/components/layout/container"
-import { Section } from "@/components/layout/section"
-import { SectionHeader } from "@/components/layout/section-header"
-import { PressModal } from "@/components/press-modal"
-import {
- Trophy,
- Award,
- ExternalLink,
- Users,
- ArrowRight,
- Lightbulb,
- Globe,
- Eye,
-} from "lucide-react"
-import Link from "next/link"
-import { getVisibleAwards, localize } from "@/data/awards"
-import type { Award as AwardType } from "@/data/awards"
-import { useTranslations } from "@/lib/i18n-context"
-import { hasMatchingProject } from "@/lib/project-utils"
-
-const ICON_MAP = {
- award: Award,
- trophy: Trophy,
- lightbulb: Lightbulb,
- globe: Globe,
-} as const
-
-function AwardCard({
- award,
- locale,
- onSeeMore,
-}: {
- award: AwardType
- locale: string
- onSeeMore: (projectId: string) => void
-}) {
- const IconComponent = ICON_MAP[award.icon] ?? Trophy
- const showSeeMore = hasMatchingProject(award.projectId)
-
- return (
-
-
-
-
-
-
-
-
-
- {award.year}
-
-
-
-
-
- {localize(award.title, locale)}
-
-
-
-
-
-
- {localize(award.description, locale)
- .split("\n")
- .map((part, i) => (
-
{part}
- ))}
-
-
-
-
-
- {locale === "es" ? "Tecnologias Clave" : "Key Technologies"}
-
-
- {award.tags.map((tech) => (
-
- {tech}
-
- ))}
-
-
-
-
- {/* Press CTA -- only if press is enabled and has links */}
- {award.press?.enabled && award.press.links.length > 0 && (
-
- )}
-
- {award.proofUrl && (
-
-
-
-
- {locale === "es" ? "Ver Detalles del Premio" : "View Award Details"}
-
-
-
- )}
-
- {/* See Project CTA -- scrolls to project and opens its modal */}
- {showSeeMore && (
-
-
onSeeMore(award.projectId)}
- aria-label={
- locale === "es"
- ? `Ver proyecto ${localize(award.title, locale)}`
- : `See project ${localize(award.title, locale)}`
- }
- >
-
- {locale === "es" ? "Ver Proyecto" : "See Project"}
-
-
-
- )}
-
-
-
- )
-}
-
-export function AwardsSection() {
- const [, locale] = useTranslations()
- const visibleAwards = getVisibleAwards()
-
- const handleSeeMore = (projectId: string) => {
- window.dispatchEvent(new CustomEvent("open-project", { detail: projectId }))
- }
-
- return (
-
-
-
-
-
- {visibleAwards.map((award) => (
-
- ))}
-
-
-
- )
-}
+"use client"
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Container } from "@/components/layout/container"
+import { Section } from "@/components/layout/section"
+import { SectionHeader } from "@/components/layout/section-header"
+import { PressModal } from "@/components/press-modal"
+import {
+ Trophy,
+ Award,
+ ExternalLink,
+ Users,
+ ArrowRight,
+ Lightbulb,
+ Globe,
+ Eye,
+} from "lucide-react"
+import Link from "next/link"
+import { getVisibleAwards, localize } from "@/data/awards"
+import type { Award as AwardType } from "@/data/awards"
+import { useTranslations } from "@/lib/i18n-context"
+import { hasMatchingProject } from "@/lib/project-utils"
+
+const ICON_MAP = {
+ award: Award,
+ trophy: Trophy,
+ lightbulb: Lightbulb,
+ globe: Globe,
+} as const
+
+function AwardCard({
+ award,
+ locale,
+ onSeeMore,
+}: {
+ award: AwardType
+ locale: string
+ onSeeMore: (projectId: string) => void
+}) {
+ const IconComponent = ICON_MAP[award.icon] ?? Trophy
+ const showSeeMore = hasMatchingProject(award.projectId)
+
+ return (
+
+
+
+
+
+
+
+
+
+ {award.year}
+
+
+
+
+
+ {localize(award.title, locale)}
+
+
+
+
+
+
+ {localize(award.description, locale)
+ .split("\n")
+ .map((part, i) => (
+
{part}
+ ))}
+
+
+
+
+
+ {locale === "es" ? "Tecnologias Clave" : "Key Technologies"}
+
+
+ {award.tags.map((tech) => (
+
+ {tech}
+
+ ))}
+
+
+
+
+ {/* Press CTA -- only if press is enabled and has links */}
+ {award.press?.enabled && award.press.links.length > 0 && (
+
+ )}
+
+ {award.proofUrl && (
+
+
+
+
+ {locale === "es" ? "Ver Detalles del Premio" : "View Award Details"}
+
+
+
+ )}
+
+ {/* See Project CTA -- scrolls to project and opens its modal */}
+ {showSeeMore && (
+
+
onSeeMore(award.projectId)}
+ aria-label={
+ locale === "es"
+ ? `Ver proyecto ${localize(award.title, locale)}`
+ : `See project ${localize(award.title, locale)}`
+ }
+ >
+
+ {locale === "es" ? "Ver Proyecto" : "See Project"}
+
+
+
+ )}
+
+
+
+ )
+}
+
+export function AwardsSection() {
+ const [, locale] = useTranslations()
+ const visibleAwards = getVisibleAwards()
+
+ const handleSeeMore = (projectId: string) => {
+ window.dispatchEvent(new CustomEvent("open-project", { detail: projectId }))
+ }
+
+ return (
+
+
+
+
+
+ {visibleAwards.map((award) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/components/sections/contact-section.tsx b/components/sections/contact-section.tsx
index 663f7d4..77d3e3b 100644
--- a/components/sections/contact-section.tsx
+++ b/components/sections/contact-section.tsx
@@ -1,397 +1,397 @@
-"use client"
-
-import React from "react"
-
-import { useState } from "react"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Label } from "@/components/ui/label"
-import {
- Mail,
- Linkedin,
- Github,
- Send,
- MapPin,
- Clock,
- CheckCircle,
- AlertCircle,
- Download,
-} from "lucide-react"
-import Link from "next/link"
-import { useTranslations, getTranslation, getResumeUrl } from "@/lib/i18n-context"
-
-interface FormData {
- name: string
- email: string
- subject: string
- message: string
-}
-
-export function ContactSection() {
- const [translations, locale] = useTranslations()
- const resumeUrl = getResumeUrl(locale)
-
- const [formData, setFormData] = useState({
- name: "",
- email: "",
- subject: "",
- message: "",
- })
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle")
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
- setIsSubmitting(true)
- setSubmitStatus("idle")
-
- try {
- const response = await fetch("/api/contact", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(formData),
- })
-
- await response.json()
-
- if (response.ok) {
- setSubmitStatus("success")
- setFormData({
- name: "",
- email: "",
- subject: "",
- message: "",
- })
- } else {
- setSubmitStatus("error")
- }
- } catch (error) {
- setSubmitStatus("error")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- const handleChange = (e: React.ChangeEvent) => {
- const { name, value } = e.target
- setFormData((prev) => ({
- ...prev,
- [name]: value,
- }))
- }
-
- const isFormValid = formData.name && formData.email && formData.subject && formData.message
-
- return (
-
- )
-}
+"use client"
+
+import React from "react"
+
+import { useState } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import {
+ Mail,
+ Linkedin,
+ Github,
+ Send,
+ MapPin,
+ Clock,
+ CheckCircle,
+ AlertCircle,
+ Download,
+} from "lucide-react"
+import Link from "next/link"
+import { useTranslations, getTranslation, getResumeUrl } from "@/lib/i18n-context"
+
+interface FormData {
+ name: string
+ email: string
+ subject: string
+ message: string
+}
+
+export function ContactSection() {
+ const [translations, locale] = useTranslations()
+ const resumeUrl = getResumeUrl(locale)
+
+ const [formData, setFormData] = useState({
+ name: "",
+ email: "",
+ subject: "",
+ message: "",
+ })
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle")
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setIsSubmitting(true)
+ setSubmitStatus("idle")
+
+ try {
+ const response = await fetch("/api/contact", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(formData),
+ })
+
+ await response.json()
+
+ if (response.ok) {
+ setSubmitStatus("success")
+ setFormData({
+ name: "",
+ email: "",
+ subject: "",
+ message: "",
+ })
+ } else {
+ setSubmitStatus("error")
+ }
+ } catch (error) {
+ setSubmitStatus("error")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target
+ setFormData((prev) => ({
+ ...prev,
+ [name]: value,
+ }))
+ }
+
+ const isFormValid = formData.name && formData.email && formData.subject && formData.message
+
+ return (
+
+ )
+}
diff --git a/components/sections/hero-section.tsx b/components/sections/hero-section.tsx
index 2a8c8da..50723a9 100644
--- a/components/sections/hero-section.tsx
+++ b/components/sections/hero-section.tsx
@@ -1,134 +1,134 @@
-"use client"
-
-import { Button } from "@/components/ui/button"
-import { ArrowDown, ExternalLink } from "lucide-react"
-import Link from "next/link"
-import Image from "next/image"
-import { useTranslations, getTranslation } from "@/lib/i18n-context"
-import { useScrollContext } from "@/lib/scroll-context"
-import { cn } from "@/lib/utils"
-
-export function HeroSection() {
- const [translations] = useTranslations()
- const { registerHeroTitleRef, isFloatingTitleActive, fontsReady } = useScrollContext()
-
- return (
-
-
-
- {/* Left Column - Text Content */}
-
-
-
- {getTranslation(translations, "hero.name", "Alejandro Repetto")}
-
-
- {getTranslation(translations, "hero.title", "Award-Winning Systems Engineer")}
-
-
- {(() => {
- const desc = getTranslation(
- translations,
- "hero.description",
- "I'm obsessed with reducing complexity. I build fast, ship faster, and refine until it's state of the art.\nAwarded at NASA Space Apps, ETH Argentina, and ETH Global."
- )
- const [main, awards] = desc.split("\n")
- return (
- <>
- {main}
- {awards && (
- <>
-
-
- {awards}
-
- >
- )}
- >
- )
- })()}
-
-
-
- {/* CTA */}
-
-
-
- {getTranslation(translations, "hero.cta.projects", "Explore Projects")}
-
-
-
-
-
-
- {/* Right Column - Profile Image */}
-
-
-
-
- {/* Floating Elements */}
-
-
- {getTranslation(translations, "hero.status.currently", "Currently")}
-
-
- {getTranslation(translations, "hero.status.building", "Building AI Solutions")}
-
-
-
-
-
- {getTranslation(translations, "hero.status.focus", "Focus")}
-
-
- {getTranslation(translations, "hero.status.automation", "Automation & ML")}
-
-
-
-
-
-
- {/* Scroll Indicator */}
-
-
-
-
- {getTranslation(translations, "hero.scroll", "Learn more")}
-
-
-
-
-
-
-
- )
-}
+"use client"
+
+import { Button } from "@/components/ui/button"
+import { ArrowDown, ExternalLink } from "lucide-react"
+import Link from "next/link"
+import Image from "next/image"
+import { useTranslations, getTranslation } from "@/lib/i18n-context"
+import { useScrollContext } from "@/lib/scroll-context"
+import { cn } from "@/lib/utils"
+
+export function HeroSection() {
+ const [translations] = useTranslations()
+ const { registerHeroTitleRef, isFloatingTitleActive, fontsReady } = useScrollContext()
+
+ return (
+
+
+
+ {/* Left Column - Text Content */}
+
+
+
+ {getTranslation(translations, "hero.name")}
+
+
+ {getTranslation(translations, "hero.title")}
+
+
+ {(() => {
+ const desc = getTranslation(
+ translations,
+ "hero.description",
+ ""
+ )
+ const [main, awards] = desc.split("\n")
+ return (
+ <>
+ {main}
+ {awards && (
+ <>
+
+
+ {awards}
+
+ >
+ )}
+ >
+ )
+ })()}
+
+
+
+ {/* CTA */}
+
+
+
+ {getTranslation(translations, "hero.cta.projects")}
+
+
+
+
+
+
+ {/* Right Column - Profile Image */}
+
+
+
+
+ {/* Floating Elements */}
+
+
+ {getTranslation(translations, "hero.status.currently")}
+
+
+ {getTranslation(translations, "hero.status.building")}
+
+
+
+
+
+ {getTranslation(translations, "hero.status.focus")}
+
+
+ {getTranslation(translations, "hero.status.automation")}
+
+
+
+
+
+
+ {/* Scroll Indicator */}
+
+
+
+
+ {getTranslation(translations, "hero.scroll")}
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/sections/projects-section.tsx b/components/sections/projects-section.tsx
index 1768264..d85b560 100644
--- a/components/sections/projects-section.tsx
+++ b/components/sections/projects-section.tsx
@@ -1,255 +1,255 @@
-"use client"
-
-import { useState, useEffect, useCallback, useRef } from "react"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { ProjectDetailModal } from "@/components/project-detail-modal"
-import { Container } from "@/components/layout/container"
-import { Section } from "@/components/layout/section"
-import { SectionHeader } from "@/components/layout/section-header"
-import { ExternalLink, Github, Code, Zap, Calendar, Eye } from "lucide-react"
-import Link from "next/link"
-import projectsData from "@/content/projects.json"
-import { useTranslations, getTranslation } from "@/lib/i18n-context"
-
-const categoryIcons = {
- "Web Application": Code,
- "Business Automation": Zap,
- "Management System": Calendar,
- "AI/ML Research": Eye,
- "Blockchain/DeFi": ExternalLink,
- "Blockchain/AI": ExternalLink,
- "Healthcare System": Calendar,
- "Game / AgTech": Eye,
-}
-
-interface Project {
- id: string
- locales: {
- [key: string]: {
- title: string
- short: string
- description: string
- features: string[]
- }
- }
- status: string
- techStack: string[]
- githubUrl: string
- demoUrl: string | null
- images: string[]
- category: keyof typeof categoryIcons
-}
-
-export function ProjectsSection() {
- const [translations, locale] = useTranslations()
- const [selectedProject, setSelectedProject] = useState(null)
- const [modalOpen, setModalOpen] = useState(false)
- const projectsRef = useRef>(new Map())
-
- const statusConfig = getStatusConfig(translations)
-
- // Build project map for event-based lookup
- const projects = projectsData.projects.map((projectData): Project => ({
- ...projectData,
- status: projectData.status in statusConfig ? projectData.status : "in-progress",
- category: (projectData.category in categoryIcons
- ? projectData.category
- : "Web Application") as keyof typeof categoryIcons,
- }))
-
- // Keep map in sync
- projectsRef.current.clear()
- for (const p of projects) projectsRef.current.set(p.id, p)
-
- const handleProjectModalOpen = useCallback((project: Project) => {
- setSelectedProject(project)
- setModalOpen(true)
- }, [])
-
- const handleModalClose = useCallback(() => {
- setModalOpen(false)
- setSelectedProject(null)
- }, [])
-
- // Listen for "open-project" events dispatched by awards section
- useEffect(() => {
- const handler = (e: Event) => {
- const projectId = (e as CustomEvent).detail
- const project = projectsRef.current.get(projectId)
- if (project) {
- // Scroll the card into view, then open modal
- const card = document.getElementById(`project-${projectId}`)
- if (card) {
- card.scrollIntoView({ behavior: "smooth", block: "center" })
- }
- // Small delay so the scroll is visible before modal opens
- setTimeout(() => {
- setSelectedProject(project)
- setModalOpen(true)
- }, 400)
- }
- }
- window.addEventListener("open-project", handler)
- return () => window.removeEventListener("open-project", handler)
- }, [])
-
- return (
-
-
-
-
-
- {projects.map((project) => {
- const IconComponent = categoryIcons[project.category] || Code
- const statusStyle = statusConfig[project.status as keyof typeof statusConfig] || statusConfig["in-progress"]
- const projectContent = project.locales[locale] || project.locales["en"]
-
- return (
-
-
-
-
-
-
- {statusStyle.label}
-
-
-
- {getTranslation(translations, `projects.categories.${project.category}`, project.category)}
-
-
-
-
- handleProjectModalOpen(project)}
- className="hover:underline text-left w-full"
- >
- {projectContent.title}
-
-
-
-
-
-
- {projectContent.short}
-
-
-
- {project.techStack.slice(0, 3).map((tech) => (
-
- {tech}
-
- ))}
- {project.techStack.length > 3 && (
-
- +{project.techStack.length - 3}
-
- )}
-
-
-
-
- {project.githubUrl && (
-
-
- GitHub
-
- )}
- {project.demoUrl && (
-
-
- Demo
-
- )}
-
-
-
handleProjectModalOpen(project)}
- >
- {getTranslation(translations, "projects.cta.learnMore", "Learn More")}
-
-
-
-
- )
- })}
-
-
-
-
-
- {getTranslation(translations, "projects.collaboration.title", "Interested in collaborating?")}
-
-
- {getTranslation(
- translations,
- "projects.collaboration.description",
- "I'm always open to discussing new projects and opportunities in systems engineering and AI/ML.",
- )}
-
-
- {getTranslation(translations, "projects.cta.getInTouch", "Get In Touch")}
-
-
-
-
-
-
-
- )
-}
-
-function getStatusConfig(translations: any) {
- return {
- "in-progress": {
- label: getTranslation(translations, "projects.status.in-progress", "In Progress"),
- color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
- },
- production: {
- label: getTranslation(translations, "projects.status.production", "Production"),
- color: "bg-green-500/10 text-green-500 border-green-500/20",
- },
- prototype: {
- label: getTranslation(translations, "projects.status.prototype", "Prototype"),
- color: "bg-purple-500/10 text-purple-500 border-purple-500/20",
- },
- "regional-winner-global-nominee": {
- label: getTranslation(
- translations,
- "projects.status.regional-winner-global-nominee",
- "Regional Winner · Global Nominee",
- ),
- color: "bg-blue-500/10 text-blue-500 border-blue-500/20",
- },
- }
-}
+"use client"
+
+import { useState, useEffect, useCallback, useRef } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { ProjectDetailModal } from "@/components/project-detail-modal"
+import { Container } from "@/components/layout/container"
+import { Section } from "@/components/layout/section"
+import { SectionHeader } from "@/components/layout/section-header"
+import { ExternalLink, Github, Code, Zap, Calendar, Eye } from "lucide-react"
+import Link from "next/link"
+import projectsData from "@/content/projects.json"
+import { useTranslations, getTranslation } from "@/lib/i18n-context"
+
+const categoryIcons = {
+ "Web Application": Code,
+ "Business Automation": Zap,
+ "Management System": Calendar,
+ "AI/ML Research": Eye,
+ "Blockchain/DeFi": ExternalLink,
+ "Blockchain/AI": ExternalLink,
+ "Healthcare System": Calendar,
+ "Game / AgTech": Eye,
+}
+
+interface Project {
+ id: string
+ locales: {
+ [key: string]: {
+ title: string
+ short: string
+ description: string
+ features: string[]
+ }
+ }
+ status: string
+ techStack: string[]
+ githubUrl: string
+ demoUrl: string | null
+ images: string[]
+ category: keyof typeof categoryIcons
+}
+
+export function ProjectsSection() {
+ const [translations, locale] = useTranslations()
+ const [selectedProject, setSelectedProject] = useState(null)
+ const [modalOpen, setModalOpen] = useState(false)
+ const projectsRef = useRef>(new Map())
+
+ const statusConfig = getStatusConfig(translations)
+
+ // Build project map for event-based lookup
+ const projects = projectsData.projects.map((projectData): Project => ({
+ ...projectData,
+ status: projectData.status in statusConfig ? projectData.status : "in-progress",
+ category: (projectData.category in categoryIcons
+ ? projectData.category
+ : "Web Application") as keyof typeof categoryIcons,
+ }))
+
+ // Keep map in sync
+ projectsRef.current.clear()
+ for (const p of projects) projectsRef.current.set(p.id, p)
+
+ const handleProjectModalOpen = useCallback((project: Project) => {
+ setSelectedProject(project)
+ setModalOpen(true)
+ }, [])
+
+ const handleModalClose = useCallback(() => {
+ setModalOpen(false)
+ setSelectedProject(null)
+ }, [])
+
+ // Listen for "open-project" events dispatched by awards section
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const projectId = (e as CustomEvent).detail
+ const project = projectsRef.current.get(projectId)
+ if (project) {
+ // Scroll the card into view, then open modal
+ const card = document.getElementById(`project-${projectId}`)
+ if (card) {
+ card.scrollIntoView({ behavior: "smooth", block: "center" })
+ }
+ // Small delay so the scroll is visible before modal opens
+ setTimeout(() => {
+ setSelectedProject(project)
+ setModalOpen(true)
+ }, 400)
+ }
+ }
+ window.addEventListener("open-project", handler)
+ return () => window.removeEventListener("open-project", handler)
+ }, [])
+
+ return (
+
+
+
+
+
+ {projects.map((project) => {
+ const IconComponent = categoryIcons[project.category] || Code
+ const statusStyle = statusConfig[project.status as keyof typeof statusConfig] || statusConfig["in-progress"]
+ const projectContent = project.locales[locale] || project.locales["en"]
+
+ return (
+
+
+
+
+
+
+ {statusStyle.label}
+
+
+
+ {getTranslation(translations, `projects.categories.${project.category}`, project.category)}
+
+
+
+
+ handleProjectModalOpen(project)}
+ className="hover:underline text-left w-full"
+ >
+ {projectContent.title}
+
+
+
+
+
+
+ {projectContent.short}
+
+
+
+ {project.techStack.slice(0, 3).map((tech) => (
+
+ {tech}
+
+ ))}
+ {project.techStack.length > 3 && (
+
+ +{project.techStack.length - 3}
+
+ )}
+
+
+
+
+ {project.githubUrl && (
+
+
+ GitHub
+
+ )}
+ {project.demoUrl && (
+
+
+ Demo
+
+ )}
+
+
+
handleProjectModalOpen(project)}
+ >
+ {getTranslation(translations, "projects.cta.learnMore")}
+
+
+
+
+ )
+ })}
+
+
+
+
+
+ {getTranslation(translations, "projects.collaboration.title")}
+
+
+ {getTranslation(
+ translations,
+ "projects.collaboration.description",
+ "I'm always open to discussing new projects and opportunities in systems engineering and AI/ML.",
+ )}
+
+
+ {getTranslation(translations, "projects.cta.getInTouch")}
+
+
+
+
+
+
+
+ )
+}
+
+function getStatusConfig(translations: any) {
+ return {
+ "in-progress": {
+ label: getTranslation(translations, "projects.status.in-progress"),
+ color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
+ },
+ production: {
+ label: getTranslation(translations, "projects.status.production"),
+ color: "bg-green-500/10 text-green-500 border-green-500/20",
+ },
+ prototype: {
+ label: getTranslation(translations, "projects.status.prototype"),
+ color: "bg-purple-500/10 text-purple-500 border-purple-500/20",
+ },
+ "regional-winner-global-nominee": {
+ label: getTranslation(
+ translations,
+ "projects.status.regional-winner-global-nominee",
+ "Regional Winner · Global Nominee",
+ ),
+ color: "bg-blue-500/10 text-blue-500 border-blue-500/20",
+ },
+ }
+}
diff --git a/components/sections/skills-section.tsx b/components/sections/skills-section.tsx
index acddaa2..95b6495 100644
--- a/components/sections/skills-section.tsx
+++ b/components/sections/skills-section.tsx
@@ -1,132 +1,132 @@
-"use client"
-
-import { Badge } from "@/components/ui/badge"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Container } from "@/components/layout/container"
-import { Section } from "@/components/layout/section"
-import { SectionHeader } from "@/components/layout/section-header"
-import { Grid } from "@/components/layout/grid"
-import { useTranslations, getTranslation } from "@/lib/i18n-context"
-
-export function SkillsSection() {
- const [translations] = useTranslations()
-
- const skillCategories = [
- {
- titleKey: "skills.categories.languages",
- titleDefault: "Languages & Frameworks",
- skills: [
- "Python",
- "C",
- "C++",
- "Java",
- "TypeScript",
- "JavaScript",
- "Django",
- "Django Rest Framework",
- "FastAPI",
- "Flask",
- "Express",
- "React",
- "Next.js",
- "HTML5",
- "CSS3",
- "TailwindCSS",
- ],
- },
- {
- titleKey: "skills.categories.frameworks",
- titleDefault: "AI/ML & Data Science",
- skills: [
- "TensorFlow",
- "PyTorch",
- "Scikit-learn",
- "Langchain",
- "Langgraph",
- "NumPy",
- "Pandas",
- "Matplotlib",
- "OpenCV",
- "Natural Language Processing",
- "Computer Vision",
- "Data Analysis & Visualization",
- ],
- },
- {
- titleKey: "skills.categories.databases",
- titleDefault: "Databases & Infrastructure",
- skills: [
- "MySQL",
- "PostgreSQL",
- "SQLite",
- "MongoDB",
- "Redis",
- "Docker",
- "Terraform",
- "AWS",
- "Vercel",
- "Nginx",
- "Linux",
- "Git",
- ],
- },
- {
- titleKey: "skills.categories.tools",
- titleDefault: "Automation & Integration",
- skills: [
- "REST APIs",
- "GraphQL",
- "Webhook Integration",
- "Selenium",
- "Social Media Automation",
- "Telegram and Whatsapp Bots",
- "Email Automation",
- "AFIP Connection",
- "Document Processing",
- "AI Chatbots",
- "AI Agents",
- "Workflow Automations",
- ],
- },
- ]
-
- return (
-
-
-
-
-
- {skillCategories.map((category, index) => (
-
-
-
- {getTranslation(translations, category.titleKey, category.titleDefault)}
-
-
-
-
- {category.skills.map((skill, skillIndex) => (
-
- {skill}
-
- ))}
-
-
-
- ))}
-
-
-
- )
-}
+"use client"
+
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Container } from "@/components/layout/container"
+import { Section } from "@/components/layout/section"
+import { SectionHeader } from "@/components/layout/section-header"
+import { Grid } from "@/components/layout/grid"
+import { useTranslations, getTranslation } from "@/lib/i18n-context"
+
+export function SkillsSection() {
+ const [translations] = useTranslations()
+
+ const skillCategories = [
+ {
+ titleKey: "skills.categories.languages",
+ titleDefault: "Languages & Frameworks",
+ skills: [
+ "Python",
+ "C",
+ "C++",
+ "Java",
+ "TypeScript",
+ "JavaScript",
+ "Django",
+ "Django Rest Framework",
+ "FastAPI",
+ "Flask",
+ "Express",
+ "React",
+ "Next.js",
+ "HTML5",
+ "CSS3",
+ "TailwindCSS",
+ ],
+ },
+ {
+ titleKey: "skills.categories.frameworks",
+ titleDefault: "AI/ML & Data Science",
+ skills: [
+ "TensorFlow",
+ "PyTorch",
+ "Scikit-learn",
+ "Langchain",
+ "Langgraph",
+ "NumPy",
+ "Pandas",
+ "Matplotlib",
+ "OpenCV",
+ "Natural Language Processing",
+ "Computer Vision",
+ "Data Analysis & Visualization",
+ ],
+ },
+ {
+ titleKey: "skills.categories.databases",
+ titleDefault: "Databases & Infrastructure",
+ skills: [
+ "MySQL",
+ "PostgreSQL",
+ "SQLite",
+ "MongoDB",
+ "Redis",
+ "Docker",
+ "Terraform",
+ "AWS",
+ "Vercel",
+ "Nginx",
+ "Linux",
+ "Git",
+ ],
+ },
+ {
+ titleKey: "skills.categories.tools",
+ titleDefault: "Automation & Integration",
+ skills: [
+ "REST APIs",
+ "GraphQL",
+ "Webhook Integration",
+ "Selenium",
+ "Social Media Automation",
+ "Telegram and Whatsapp Bots",
+ "Email Automation",
+ "AFIP Connection",
+ "Document Processing",
+ "AI Chatbots",
+ "AI Agents",
+ "Workflow Automations",
+ ],
+ },
+ ]
+
+ return (
+
+
+
+
+
+ {skillCategories.map((category, index) => (
+
+
+
+ {getTranslation(translations, category.titleKey, category.titleDefault)}
+
+
+
+
+ {category.skills.map((skill, skillIndex) => (
+
+ {skill}
+
+ ))}
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/lib/i18n-context.tsx b/lib/i18n-context.tsx
index 1d6edba..882a84b 100644
--- a/lib/i18n-context.tsx
+++ b/lib/i18n-context.tsx
@@ -1,108 +1,84 @@
-"use client"
-
-import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"
-
-export interface Translations {
- [key: string]: any
-}
-
-interface I18nContextType {
- translations: Translations
- locale: string
- loading: boolean
- setLocale: (locale: string) => void
-}
-
-const I18nContext = createContext(undefined)
-
-export function I18nProvider({ children }: { children: ReactNode }) {
- const [locale, setLocaleState] = useState("en")
- const [translations, setTranslations] = useState({})
- const [loading, setLoading] = useState(true)
-
- // Load translations when locale changes
- useEffect(() => {
- const loadTranslations = async () => {
- try {
- setLoading(true)
- const response = await fetch(`/locales/${locale}/common.json`)
- const data = await response.json()
- setTranslations(data)
- } catch (error) {
- console.error("Error loading translations:", error)
- // Fallback to English if translation fails
- if (locale !== "en") {
- try {
- const fallbackResponse = await fetch("/locales/en/common.json")
- const fallbackData = await fallbackResponse.json()
- setTranslations(fallbackData)
- } catch (fallbackError) {
- console.error("Error loading fallback translations:", fallbackError)
- }
- }
- } finally {
- setLoading(false)
- }
- }
-
- loadTranslations()
- }, [locale])
-
- // Load stored locale preference on mount
- useEffect(() => {
- const storedLocale = localStorage.getItem("preferred-locale")
- if (storedLocale && (storedLocale === "en" || storedLocale === "es")) {
- setLocaleState(storedLocale)
- }
- }, [])
-
- const setLocale = (newLocale: string) => {
- setLocaleState(newLocale)
- localStorage.setItem("preferred-locale", newLocale)
- }
-
- return (
-
- {children}
-
- )
-}
-
-// Hook to use translations
-export function useTranslations(): [Translations, string, boolean] {
- const context = useContext(I18nContext)
- if (!context) {
- throw new Error("useTranslations must be used within I18nProvider")
- }
- return [context.translations, context.locale, context.loading]
-}
-
-// Hook to get locale setter
-export function useLocale() {
- const context = useContext(I18nContext)
- if (!context) {
- throw new Error("useLocale must be used within I18nProvider")
- }
- return { locale: context.locale, setLocale: context.setLocale }
-}
-
-// Helper function to get nested translation values
-export function getTranslation(translations: Translations, key: string, fallback?: string): string {
- const keys = key.split(".")
- let value = translations
-
- for (const k of keys) {
- if (value && typeof value === "object" && k in value) {
- value = value[k]
- } else {
- return fallback || key
- }
- }
-
- return typeof value === "string" ? value : fallback || key
-}
-
-// Helper function to get locale-specific resume URL
-export function getResumeUrl(locale: string): string {
- return locale === "es" ? "/resume-es.pdf" : "/resume-en.pdf"
-}
+"use client"
+
+import React, { createContext, useContext, useState, useEffect, useLayoutEffect, ReactNode } from "react"
+import en from "@/public/locales/en/common.json"
+import es from "@/public/locales/es/common.json"
+
+export type Translations = typeof en
+
+const TRANSLATIONS: Record = { en, es }
+
+interface I18nContextType {
+ translations: Translations
+ locale: string
+ setLocale: (locale: string) => void
+}
+
+const I18nContext = createContext(undefined)
+
+// Runs synchronously before paint on client, falls back to useEffect on server
+const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
+
+export function I18nProvider({ children }: { children: ReactNode }) {
+ const [locale, setLocaleState] = useState("en")
+
+ // Read stored locale before first paint (never runs on server)
+ useIsomorphicLayoutEffect(() => {
+ const stored = localStorage.getItem("preferred-locale")
+ if (stored && stored in TRANSLATIONS) {
+ setLocaleState(stored)
+ }
+ }, [])
+
+ const translations = TRANSLATIONS[locale] ?? en
+
+ const setLocale = (newLocale: string) => {
+ setLocaleState(newLocale)
+ localStorage.setItem("preferred-locale", newLocale)
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Hook to use translations
+export function useTranslations(): [Translations, string] {
+ const context = useContext(I18nContext)
+ if (!context) {
+ throw new Error("useTranslations must be used within I18nProvider")
+ }
+ return [context.translations, context.locale]
+}
+
+// Hook to get locale setter
+export function useLocale() {
+ const context = useContext(I18nContext)
+ if (!context) {
+ throw new Error("useLocale must be used within I18nProvider")
+ }
+ return { locale: context.locale, setLocale: context.setLocale }
+}
+
+// Helper function to get nested translation values
+export function getTranslation(translations: Translations, key: string): string {
+ const keys = key.split(".")
+ let value: unknown = translations
+
+ for (const k of keys) {
+ if (value && typeof value === "object" && k in value) {
+ value = (value as Record)[k]
+ } else {
+ return ""
+ }
+ }
+
+ return typeof value === "string" ? value : ""
+}
+
+// Helper function to get locale-specific resume URL
+export function getResumeUrl(locale: string): string {
+ return locale === "es" ? "/resume-es.pdf" : "/resume-en.pdf"
+}
diff --git a/lib/scroll-context.tsx b/lib/scroll-context.tsx
index ab476ca..ab831f0 100644
--- a/lib/scroll-context.tsx
+++ b/lib/scroll-context.tsx
@@ -1,260 +1,260 @@
-"use client"
-
-import React, {
- createContext,
- useContext,
- useState,
- useCallback,
- useRef,
- useEffect,
- useMemo,
- type ReactNode,
-} from "react"
-import { useScrollProgress } from "@/hooks/use-scroll-progress"
-import { useLocale } from "@/lib/i18n-context"
-import { useTheme } from "next-themes"
-
-/** Bounding rect stored in a coordinate system noted per field */
-interface Rect {
- top: number
- left: number
- width: number
- height: number
-}
-
-/**
- * Computed font styles read from the hero element via getComputedStyle.
- * Applied as inline styles on the floating clone for pixel-perfect match.
- */
-export interface ComputedTitleStyles {
- fontFamily: string
- fontWeight: string
- fontSize: string
- lineHeight: string
- letterSpacing: string
- color: string
- webkitFontSmoothing: string
-}
-
-interface ScrollContextType {
- scrollY: number
- /** 0 at page top, 1 when hero title has scrolled to navbar position */
- scrollProgress: number
- prefersReducedMotion: boolean
- /** Hero title rect in document coords (top includes scrollY offset) */
- heroTitleRect: Rect | null
- /** Nav title rect in viewport coords (fixed element, does not move with scroll) */
- navTitleRect: Rect | null
- /** Computed styles read from the hero h1 — used by the floating clone */
- heroComputedStyles: ComputedTitleStyles | null
- /** Computed font-size of the nav brand (px string, e.g. "18px") */
- navFontSize: string | null
- /** True only after document.fonts.ready has resolved */
- fontsReady: boolean
- registerHeroTitleRef: (el: HTMLElement | null) => void
- registerNavTitleRef: (el: HTMLElement | null) => void
- /** True when the floating title should be visible (0 < progress < 1, no reduced motion, fonts ready) */
- isFloatingTitleActive: boolean
-}
-
-const ScrollContext = createContext(undefined)
-
-const FALLBACK_THRESHOLD = 700
-/** Multiplier applied to the computed hero-to-nav distance. >1 = slower animation */
-const THRESHOLD_SCALE = 1.4
-/** Minimum scroll distance (px) so the animation never feels too abrupt on mobile */
-const MIN_THRESHOLD = 250
-
-export function ScrollProvider({ children }: { children: ReactNode }) {
- const heroRef = useRef(null)
- const navRef = useRef(null)
- // Reactive state for IntersectionObserver to track
- const [heroEl, setHeroEl] = useState(null)
- const [heroTitleRect, setHeroTitleRect] = useState(null)
- const [navTitleRect, setNavTitleRect] = useState(null)
- const [heroComputedStyles, setHeroComputedStyles] = useState(null)
- const [navFontSize, setNavFontSize] = useState(null)
- const [fontsReady, setFontsReady] = useState(false)
-
- // Becomes true the first time the hero element enters the viewport.
- // Using IntersectionObserver instead of a one-time check in measure()
- // so it works even when the page loads mid-scroll or via deep link.
- const [heroWasVisible, setHeroWasVisible] = useState(false)
-
- // Dynamic threshold: the document-space distance the title needs to travel
- const threshold = useMemo(() => {
- if (!heroTitleRect || !navTitleRect) return FALLBACK_THRESHOLD
- // heroTitleRect.top is in document coords, navTitleRect.top is in viewport coords
- // The animation should complete when scrollY causes the hero title's viewport-top
- // to reach the nav title's viewport-top.
- // hero viewport top = heroTitleRect.top - scrollY
- // We want: heroTitleRect.top - scrollY = navTitleRect.top
- // => scrollY = heroTitleRect.top - navTitleRect.top
- const computed = (heroTitleRect.top - navTitleRect.top) * THRESHOLD_SCALE
- return Math.max(computed > 0 ? computed : FALLBACK_THRESHOLD, MIN_THRESHOLD)
- }, [heroTitleRect, navTitleRect])
-
- const { scrollY, scrollProgress, prefersReducedMotion } = useScrollProgress({ threshold })
-
- // Used to trigger remeasurement on locale/theme changes
- const { locale } = useLocale()
- const { theme } = useTheme()
-
- /**
- * Measure both bounding rects and read the hero's computed styles.
- * Called only after fonts are ready to guarantee correct metrics.
- */
- const measure = useCallback(() => {
- if (heroRef.current) {
- const rect = heroRef.current.getBoundingClientRect()
- setHeroTitleRect({
- top: rect.top + window.scrollY,
- left: rect.left,
- width: rect.width,
- height: rect.height,
- })
-
- // Read exact computed styles from the hero element
- const cs = window.getComputedStyle(heroRef.current)
- setHeroComputedStyles({
- fontFamily: cs.fontFamily,
- fontWeight: cs.fontWeight,
- fontSize: cs.fontSize,
- lineHeight: cs.lineHeight,
- letterSpacing: cs.letterSpacing,
- color: cs.color,
- webkitFontSmoothing:
- (cs as unknown as Record).webkitFontSmoothing ?? "antialiased",
- })
- }
- if (navRef.current) {
- const rect = navRef.current.getBoundingClientRect()
- // Nav is position:fixed — getBoundingClientRect already gives viewport coords
- setNavTitleRect({
- top: rect.top,
- left: rect.left,
- width: rect.width,
- height: rect.height,
- })
- setNavFontSize(window.getComputedStyle(navRef.current).fontSize)
- }
- }, [])
-
- const registerHeroTitleRef = useCallback((el: HTMLElement | null) => {
- heroRef.current = el
- setHeroEl(el)
- // Don't measure immediately — wait for fonts.ready in the effect
- }, [])
-
- // Watch for the hero entering the viewport at any point.
- // Once seen, heroWasVisible stays true and the observer disconnects.
- useEffect(() => {
- if (!heroEl || heroWasVisible) return
-
- const observer = new IntersectionObserver(
- (entries) => {
- if (entries[0]?.isIntersecting) {
- setHeroWasVisible(true)
- observer.disconnect()
- }
- },
- { threshold: 0.1 }
- )
-
- observer.observe(heroEl)
- return () => observer.disconnect()
- }, [heroEl, heroWasVisible])
-
- const registerNavTitleRef = useCallback((el: HTMLElement | null) => {
- navRef.current = el
- }, [])
-
- // Wait for fonts.ready before first measurement, then remeasure on triggers
- useEffect(() => {
- let cancelled = false
- let resizeTimer: ReturnType
-
- const doMeasure = () => {
- if (!cancelled) {
- measure()
- if (!fontsReady) setFontsReady(true)
- }
- }
-
- // Debounce resize/viewport changes to avoid measuring mid-transition
- const debouncedMeasure = () => {
- clearTimeout(resizeTimer)
- resizeTimer = setTimeout(doMeasure, 100)
- }
-
- // Always await fonts.ready before measuring — this is the critical gate
- // that prevents measuring with fallback font metrics.
- if (document.fonts?.ready) {
- document.fonts.ready.then(doMeasure)
- } else {
- // Fallback for environments without FontFaceSet API
- doMeasure()
- }
-
- window.addEventListener("resize", debouncedMeasure)
-
- // Handle mobile viewport changes (URL bar hide/show)
- const vv = window.visualViewport
- if (vv) {
- vv.addEventListener("resize", debouncedMeasure)
- }
-
- return () => {
- cancelled = true
- clearTimeout(resizeTimer)
- window.removeEventListener("resize", debouncedMeasure)
- if (vv) {
- vv.removeEventListener("resize", debouncedMeasure)
- }
- }
- }, [measure, fontsReady, locale, theme])
-
- const isFloatingTitleActive =
- !prefersReducedMotion &&
- fontsReady &&
- heroWasVisible &&
- scrollProgress > 0 &&
- scrollProgress < 1
-
- const value = useMemo(
- () => ({
- scrollY,
- scrollProgress,
- prefersReducedMotion,
- heroTitleRect,
- navTitleRect,
- heroComputedStyles,
- navFontSize,
- fontsReady,
- registerHeroTitleRef,
- registerNavTitleRef,
- isFloatingTitleActive,
- }),
- [
- scrollY,
- scrollProgress,
- prefersReducedMotion,
- heroTitleRect,
- navTitleRect,
- heroComputedStyles,
- navFontSize,
- fontsReady,
- registerHeroTitleRef,
- registerNavTitleRef,
- isFloatingTitleActive,
- ]
- )
-
- return {children}
-}
-
-export function useScrollContext() {
- const ctx = useContext(ScrollContext)
- if (!ctx) throw new Error("useScrollContext must be used within ScrollProvider")
- return ctx
-}
+"use client"
+
+import React, {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ useRef,
+ useEffect,
+ useMemo,
+ type ReactNode,
+} from "react"
+import { useScrollProgress } from "@/hooks/use-scroll-progress"
+import { useLocale } from "@/lib/i18n-context"
+import { useTheme } from "next-themes"
+
+/** Bounding rect stored in a coordinate system noted per field */
+interface Rect {
+ top: number
+ left: number
+ width: number
+ height: number
+}
+
+/**
+ * Computed font styles read from the hero element via getComputedStyle.
+ * Applied as inline styles on the floating clone for pixel-perfect match.
+ */
+export interface ComputedTitleStyles {
+ fontFamily: string
+ fontWeight: string
+ fontSize: string
+ lineHeight: string
+ letterSpacing: string
+ color: string
+ webkitFontSmoothing: string
+}
+
+interface ScrollContextType {
+ scrollY: number
+ /** 0 at page top, 1 when hero title has scrolled to navbar position */
+ scrollProgress: number
+ prefersReducedMotion: boolean
+ /** Hero title rect in document coords (top includes scrollY offset) */
+ heroTitleRect: Rect | null
+ /** Nav title rect in viewport coords (fixed element, does not move with scroll) */
+ navTitleRect: Rect | null
+ /** Computed styles read from the hero h1 — used by the floating clone */
+ heroComputedStyles: ComputedTitleStyles | null
+ /** Computed font-size of the nav brand (px string, e.g. "18px") */
+ navFontSize: string | null
+ /** True only after document.fonts.ready has resolved */
+ fontsReady: boolean
+ registerHeroTitleRef: (el: HTMLElement | null) => void
+ registerNavTitleRef: (el: HTMLElement | null) => void
+ /** True when the floating title should be visible (0 < progress < 1, no reduced motion, fonts ready) */
+ isFloatingTitleActive: boolean
+}
+
+const ScrollContext = createContext(undefined)
+
+const FALLBACK_THRESHOLD = 700
+/** Multiplier applied to the computed hero-to-nav distance. >1 = slower animation */
+const THRESHOLD_SCALE = 1.4
+/** Minimum scroll distance (px) so the animation never feels too abrupt on mobile */
+const MIN_THRESHOLD = 250
+
+export function ScrollProvider({ children }: { children: ReactNode }) {
+ const heroRef = useRef(null)
+ const navRef = useRef(null)
+ // Reactive state for IntersectionObserver to track
+ const [heroEl, setHeroEl] = useState(null)
+ const [heroTitleRect, setHeroTitleRect] = useState(null)
+ const [navTitleRect, setNavTitleRect] = useState(null)
+ const [heroComputedStyles, setHeroComputedStyles] = useState(null)
+ const [navFontSize, setNavFontSize] = useState(null)
+ const [fontsReady, setFontsReady] = useState(false)
+
+ // Becomes true the first time the hero element enters the viewport.
+ // Using IntersectionObserver instead of a one-time check in measure()
+ // so it works even when the page loads mid-scroll or via deep link.
+ const [heroWasVisible, setHeroWasVisible] = useState(false)
+
+ // Dynamic threshold: the document-space distance the title needs to travel
+ const threshold = useMemo(() => {
+ if (!heroTitleRect || !navTitleRect) return FALLBACK_THRESHOLD
+ // heroTitleRect.top is in document coords, navTitleRect.top is in viewport coords
+ // The animation should complete when scrollY causes the hero title's viewport-top
+ // to reach the nav title's viewport-top.
+ // hero viewport top = heroTitleRect.top - scrollY
+ // We want: heroTitleRect.top - scrollY = navTitleRect.top
+ // => scrollY = heroTitleRect.top - navTitleRect.top
+ const computed = (heroTitleRect.top - navTitleRect.top) * THRESHOLD_SCALE
+ return Math.max(computed > 0 ? computed : FALLBACK_THRESHOLD, MIN_THRESHOLD)
+ }, [heroTitleRect, navTitleRect])
+
+ const { scrollY, scrollProgress, prefersReducedMotion } = useScrollProgress({ threshold })
+
+ // Used to trigger remeasurement on locale/theme changes
+ const { locale } = useLocale()
+ const { theme } = useTheme()
+
+ /**
+ * Measure both bounding rects and read the hero's computed styles.
+ * Called only after fonts are ready to guarantee correct metrics.
+ */
+ const measure = useCallback(() => {
+ if (heroRef.current) {
+ const rect = heroRef.current.getBoundingClientRect()
+ setHeroTitleRect({
+ top: rect.top + window.scrollY,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ })
+
+ // Read exact computed styles from the hero element
+ const cs = window.getComputedStyle(heroRef.current)
+ setHeroComputedStyles({
+ fontFamily: cs.fontFamily,
+ fontWeight: cs.fontWeight,
+ fontSize: cs.fontSize,
+ lineHeight: cs.lineHeight,
+ letterSpacing: cs.letterSpacing,
+ color: cs.color,
+ webkitFontSmoothing:
+ (cs as unknown as Record).webkitFontSmoothing ?? "antialiased",
+ })
+ }
+ if (navRef.current) {
+ const rect = navRef.current.getBoundingClientRect()
+ // Nav is position:fixed — getBoundingClientRect already gives viewport coords
+ setNavTitleRect({
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ })
+ setNavFontSize(window.getComputedStyle(navRef.current).fontSize)
+ }
+ }, [])
+
+ const registerHeroTitleRef = useCallback((el: HTMLElement | null) => {
+ heroRef.current = el
+ setHeroEl(el)
+ // Don't measure immediately — wait for fonts.ready in the effect
+ }, [])
+
+ // Watch for the hero entering the viewport at any point.
+ // Once seen, heroWasVisible stays true and the observer disconnects.
+ useEffect(() => {
+ if (!heroEl || heroWasVisible) return
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ setHeroWasVisible(true)
+ observer.disconnect()
+ }
+ },
+ { threshold: 0.1 }
+ )
+
+ observer.observe(heroEl)
+ return () => observer.disconnect()
+ }, [heroEl, heroWasVisible])
+
+ const registerNavTitleRef = useCallback((el: HTMLElement | null) => {
+ navRef.current = el
+ }, [])
+
+ // Wait for fonts.ready before first measurement, then remeasure on triggers
+ useEffect(() => {
+ let cancelled = false
+ let resizeTimer: ReturnType
+
+ const doMeasure = () => {
+ if (!cancelled) {
+ measure()
+ if (!fontsReady) setFontsReady(true)
+ }
+ }
+
+ // Debounce resize/viewport changes to avoid measuring mid-transition
+ const debouncedMeasure = () => {
+ clearTimeout(resizeTimer)
+ resizeTimer = setTimeout(doMeasure, 100)
+ }
+
+ // Always await fonts.ready before measuring — this is the critical gate
+ // that prevents measuring with fallback font metrics.
+ if (document.fonts?.ready) {
+ document.fonts.ready.then(doMeasure)
+ } else {
+ // Fallback for environments without FontFaceSet API
+ doMeasure()
+ }
+
+ window.addEventListener("resize", debouncedMeasure)
+
+ // Handle mobile viewport changes (URL bar hide/show)
+ const vv = window.visualViewport
+ if (vv) {
+ vv.addEventListener("resize", debouncedMeasure)
+ }
+
+ return () => {
+ cancelled = true
+ clearTimeout(resizeTimer)
+ window.removeEventListener("resize", debouncedMeasure)
+ if (vv) {
+ vv.removeEventListener("resize", debouncedMeasure)
+ }
+ }
+ }, [measure, fontsReady, locale, theme])
+
+ const isFloatingTitleActive =
+ !prefersReducedMotion &&
+ fontsReady &&
+ heroWasVisible &&
+ scrollProgress > 0 &&
+ scrollProgress < 1
+
+ const value = useMemo(
+ () => ({
+ scrollY,
+ scrollProgress,
+ prefersReducedMotion,
+ heroTitleRect,
+ navTitleRect,
+ heroComputedStyles,
+ navFontSize,
+ fontsReady,
+ registerHeroTitleRef,
+ registerNavTitleRef,
+ isFloatingTitleActive,
+ }),
+ [
+ scrollY,
+ scrollProgress,
+ prefersReducedMotion,
+ heroTitleRect,
+ navTitleRect,
+ heroComputedStyles,
+ navFontSize,
+ fontsReady,
+ registerHeroTitleRef,
+ registerNavTitleRef,
+ isFloatingTitleActive,
+ ]
+ )
+
+ return {children}
+}
+
+export function useScrollContext() {
+ const ctx = useContext(ScrollContext)
+ if (!ctx) throw new Error("useScrollContext must be used within ScrollProvider")
+ return ctx
+}
diff --git a/lib/seo.ts b/lib/seo.ts
index dbeb0a3..9d58536 100644
--- a/lib/seo.ts
+++ b/lib/seo.ts
@@ -1,261 +1,261 @@
-import type { Metadata } from "next"
-
-export interface SEOProps {
- title: string
- description: string
- canonical?: string
- ogImage?: string
- ogType?: "website" | "article" | "profile"
- keywords?: string[]
- publishedTime?: string
- modifiedTime?: string
- authors?: string[]
- noindex?: boolean
-}
-
-const siteConfig = {
- name: "Alejandro Repetto",
- title: "Alejandro Repetto: Systems Engineer & Full-Stack Developer | Portfolio",
- description:
- "Award-winning systems engineer specializing in automation and full-stack development. NASA Space Apps winner, ETH Global finalist. View projects, case studies, and get in touch.",
- url: "https://www.repetto-a.com",
- ogImage: "https://www.repetto-a.com/og-image.png",
- keywords: [
- "systems engineering",
- "AI/ML",
- "machine learning",
- "automation",
- "web development",
- "full-stack developer",
- "NASA Space Apps",
- "ETH Global",
- "blockchain",
- "portfolio",
- ],
- author: {
- name: "Alejandro Repetto",
- url: "https://www.repetto-a.com",
- email: "repettoalejandroing@gmail.com",
- github: "https://github.com/Repetto-A",
- linkedin: "https://www.linkedin.com/in/alejandro-repetto",
- },
-}
-
-export function generateMetadata({
- title,
- description,
- canonical,
- ogImage,
- ogType = "website",
- keywords = [],
- publishedTime,
- modifiedTime,
- authors,
- noindex = false,
-}: SEOProps): Metadata {
- const finalTitle = title === siteConfig.name || title === siteConfig.title ? title : `${title} | ${siteConfig.name}`
- const finalCanonical = canonical || siteConfig.url
- const finalOgImage = ogImage || siteConfig.ogImage
- const finalKeywords = [...siteConfig.keywords, ...keywords]
-
- return {
- title: finalTitle,
- description,
- keywords: finalKeywords,
- authors: authors ? authors.map((name) => ({ name })) : [{ name: siteConfig.author.name }],
- creator: siteConfig.author.name,
- publisher: siteConfig.author.name,
- robots: noindex ? { index: false, follow: false } : { index: true, follow: true },
- openGraph: {
- type: ogType,
- locale: "en_US",
- url: finalCanonical,
- title: finalTitle,
- description,
- siteName: siteConfig.name,
- images: [
- {
- url: finalOgImage,
- width: 1200,
- height: 630,
- alt: title,
- },
- ],
- ...(publishedTime && { publishedTime }),
- ...(modifiedTime && { modifiedTime }),
- },
- twitter: {
- card: "summary_large_image",
- title: finalTitle,
- description,
- images: [finalOgImage],
- creator: "@repetto_a",
- },
- alternates: {
- canonical: finalCanonical,
- },
- metadataBase: new URL(siteConfig.url),
- }
-}
-
-export function generatePersonSchema() {
- return {
- "@context": "https://schema.org",
- "@type": "Person",
- name: "Alejandro Repetto",
- alternateName: "Repetto-A",
- url: "https://www.repetto-a.com",
- image: "https://www.repetto-a.com/og-image.png",
- jobTitle: "Systems Engineer & AI/ML Developer",
- worksFor: {
- "@type": "EducationalOrganization",
- name: "UTN - Universidad Tecnológica Nacional",
- url: "https://www.frro.utn.edu.ar/",
- },
- alumniOf: {
- "@type": "EducationalOrganization",
- name: "Universidad Tecnológica Nacional",
- },
- knowsAbout: [
- "Systems Engineering",
- "Artificial Intelligence",
- "Machine Learning",
- "Web Development",
- "Automation",
- "Blockchain",
- "Full-Stack Development",
- ],
- sameAs: ["https://github.com/Repetto-A", "https://www.linkedin.com/in/alejandro-repetto"],
- email: "repettoalejandroing@gmail.com",
- award: [
- "ETH Argentina 2025 - Roxium DAO Ops, Best Powerhouse App (1st Prize)",
- "NASA Space Apps Challenge 2025 - FarmHero, Local Winner & Global Nominee",
- "ETH Global 2025 - Zorrito Finance, 2nd Best dApp using Filecoin",
- ],
- }
-}
-
-export function generateProjectSchema(project: {
- id: string
- title: string
- description: string
- status: string
- techStack: string[]
- githubUrl?: string | null
- demoUrl?: string | null
- category: string
-}) {
- return {
- "@context": "https://schema.org",
- "@type": "SoftwareApplication",
- name: project.title,
- description: project.description,
- applicationCategory: project.category,
- creator: {
- "@type": "Person",
- name: "Alejandro Repetto",
- url: "https://www.repetto-a.com",
- },
- ...(project.githubUrl && {
- codeRepository: project.githubUrl,
- }),
- ...(project.demoUrl && {
- url: project.demoUrl,
- }),
- programmingLanguage: project.techStack,
- operatingSystem: "Web Browser",
- offers: {
- "@type": "Offer",
- price: "0",
- priceCurrency: "USD",
- },
- }
-}
-
-export function generateArticleSchema({
- title,
- description,
- publishedTime,
- modifiedTime,
- author = "Alejandro Repetto",
- url,
- image,
-}: {
- title: string
- description: string
- publishedTime?: string
- modifiedTime?: string
- author?: string
- url: string
- image?: string
-}) {
- return {
- "@context": "https://schema.org",
- "@type": "Article",
- headline: title,
- description,
- author: {
- "@type": "Person",
- name: author,
- url: "https://www.repetto-a.com",
- },
- publisher: {
- "@type": "Person",
- name: "Alejandro Repetto",
- url: "https://www.repetto-a.com",
- },
- url,
- ...(image && { image }),
- ...(publishedTime && { datePublished: publishedTime }),
- ...(modifiedTime && { dateModified: modifiedTime }),
- mainEntityOfPage: {
- "@type": "WebPage",
- "@id": url,
- },
- }
-}
-
-export function generateWebSiteSchema() {
- return {
- "@context": "https://schema.org",
- "@type": "WebSite",
- name: siteConfig.name,
- description: siteConfig.description,
- url: siteConfig.url,
- author: {
- "@type": "Person",
- name: siteConfig.author.name,
- },
- potentialAction: {
- "@type": "SearchAction",
- target: {
- "@type": "EntryPoint",
- urlTemplate: `${siteConfig.url}/projects?q={search_term_string}`,
- },
- "query-input": "required name=search_term_string",
- },
- }
-}
-
-export function generateBreadcrumbSchema(items: { name: string; url: string }[]) {
- return {
- "@context": "https://schema.org",
- "@type": "BreadcrumbList",
- itemListElement: items.map((item, index) => ({
- "@type": "ListItem",
- position: index + 1,
- name: item.name,
- item: item.url,
- })),
- }
-}
-
-export function generateOGImageUrl(title: string, type: "project" | "article" | "default" = "default"): string {
- const params = new URLSearchParams({
- title: title.slice(0, 100),
- type,
- })
- return `${siteConfig.url}/api/og?${params.toString()}`
-}
-
-export { siteConfig }
+import type { Metadata } from "next"
+
+export interface SEOProps {
+ title: string
+ description: string
+ canonical?: string
+ ogImage?: string
+ ogType?: "website" | "article" | "profile"
+ keywords?: string[]
+ publishedTime?: string
+ modifiedTime?: string
+ authors?: string[]
+ noindex?: boolean
+}
+
+const siteConfig = {
+ name: "Alejandro Repetto",
+ title: "Alejandro Repetto: Systems Engineer & Full-Stack Developer | Portfolio",
+ description:
+ "Award-winning systems engineer specializing in automation and full-stack development. NASA Space Apps winner, ETH Global finalist. View projects, case studies, and get in touch.",
+ url: "https://www.repetto-a.com",
+ ogImage: "https://www.repetto-a.com/og-image.png",
+ keywords: [
+ "systems engineering",
+ "AI/ML",
+ "machine learning",
+ "automation",
+ "web development",
+ "full-stack developer",
+ "NASA Space Apps",
+ "ETH Global",
+ "blockchain",
+ "portfolio",
+ ],
+ author: {
+ name: "Alejandro Repetto",
+ url: "https://www.repetto-a.com",
+ email: "repettoalejandroing@gmail.com",
+ github: "https://github.com/Repetto-A",
+ linkedin: "https://www.linkedin.com/in/alejandro-repetto",
+ },
+}
+
+export function generateMetadata({
+ title,
+ description,
+ canonical,
+ ogImage,
+ ogType = "website",
+ keywords = [],
+ publishedTime,
+ modifiedTime,
+ authors,
+ noindex = false,
+}: SEOProps): Metadata {
+ const finalTitle = title === siteConfig.name || title === siteConfig.title ? title : `${title} | ${siteConfig.name}`
+ const finalCanonical = canonical || siteConfig.url
+ const finalOgImage = ogImage || siteConfig.ogImage
+ const finalKeywords = [...siteConfig.keywords, ...keywords]
+
+ return {
+ title: finalTitle,
+ description,
+ keywords: finalKeywords,
+ authors: authors ? authors.map((name) => ({ name })) : [{ name: siteConfig.author.name }],
+ creator: siteConfig.author.name,
+ publisher: siteConfig.author.name,
+ robots: noindex ? { index: false, follow: false } : { index: true, follow: true },
+ openGraph: {
+ type: ogType,
+ locale: "en_US",
+ url: finalCanonical,
+ title: finalTitle,
+ description,
+ siteName: siteConfig.name,
+ images: [
+ {
+ url: finalOgImage,
+ width: 1200,
+ height: 630,
+ alt: title,
+ },
+ ],
+ ...(publishedTime && { publishedTime }),
+ ...(modifiedTime && { modifiedTime }),
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: finalTitle,
+ description,
+ images: [finalOgImage],
+ creator: "@repetto_a",
+ },
+ alternates: {
+ canonical: finalCanonical,
+ },
+ metadataBase: new URL(siteConfig.url),
+ }
+}
+
+export function generatePersonSchema() {
+ return {
+ "@context": "https://schema.org",
+ "@type": "Person",
+ name: "Alejandro Repetto",
+ alternateName: "Repetto-A",
+ url: "https://www.repetto-a.com",
+ image: "https://www.repetto-a.com/og-image.png",
+ jobTitle: "Systems Engineer & AI/ML Developer",
+ worksFor: {
+ "@type": "EducationalOrganization",
+ name: "UTN - Universidad Tecnológica Nacional",
+ url: "https://www.frro.utn.edu.ar/",
+ },
+ alumniOf: {
+ "@type": "EducationalOrganization",
+ name: "Universidad Tecnológica Nacional",
+ },
+ knowsAbout: [
+ "Systems Engineering",
+ "Artificial Intelligence",
+ "Machine Learning",
+ "Web Development",
+ "Automation",
+ "Blockchain",
+ "Full-Stack Development",
+ ],
+ sameAs: ["https://github.com/Repetto-A", "https://www.linkedin.com/in/alejandro-repetto"],
+ email: "repettoalejandroing@gmail.com",
+ award: [
+ "ETH Argentina 2025 - Roxium DAO Ops, Best Powerhouse App (1st Prize)",
+ "NASA Space Apps Challenge 2025 - FarmHero, Local Winner & Global Nominee",
+ "ETH Global 2025 - Zorrito Finance, 2nd Best dApp using Filecoin",
+ ],
+ }
+}
+
+export function generateProjectSchema(project: {
+ id: string
+ title: string
+ description: string
+ status: string
+ techStack: string[]
+ githubUrl?: string | null
+ demoUrl?: string | null
+ category: string
+}) {
+ return {
+ "@context": "https://schema.org",
+ "@type": "SoftwareApplication",
+ name: project.title,
+ description: project.description,
+ applicationCategory: project.category,
+ creator: {
+ "@type": "Person",
+ name: "Alejandro Repetto",
+ url: "https://www.repetto-a.com",
+ },
+ ...(project.githubUrl && {
+ codeRepository: project.githubUrl,
+ }),
+ ...(project.demoUrl && {
+ url: project.demoUrl,
+ }),
+ programmingLanguage: project.techStack,
+ operatingSystem: "Web Browser",
+ offers: {
+ "@type": "Offer",
+ price: "0",
+ priceCurrency: "USD",
+ },
+ }
+}
+
+export function generateArticleSchema({
+ title,
+ description,
+ publishedTime,
+ modifiedTime,
+ author = "Alejandro Repetto",
+ url,
+ image,
+}: {
+ title: string
+ description: string
+ publishedTime?: string
+ modifiedTime?: string
+ author?: string
+ url: string
+ image?: string
+}) {
+ return {
+ "@context": "https://schema.org",
+ "@type": "Article",
+ headline: title,
+ description,
+ author: {
+ "@type": "Person",
+ name: author,
+ url: "https://www.repetto-a.com",
+ },
+ publisher: {
+ "@type": "Person",
+ name: "Alejandro Repetto",
+ url: "https://www.repetto-a.com",
+ },
+ url,
+ ...(image && { image }),
+ ...(publishedTime && { datePublished: publishedTime }),
+ ...(modifiedTime && { dateModified: modifiedTime }),
+ mainEntityOfPage: {
+ "@type": "WebPage",
+ "@id": url,
+ },
+ }
+}
+
+export function generateWebSiteSchema() {
+ return {
+ "@context": "https://schema.org",
+ "@type": "WebSite",
+ name: siteConfig.name,
+ description: siteConfig.description,
+ url: siteConfig.url,
+ author: {
+ "@type": "Person",
+ name: siteConfig.author.name,
+ },
+ potentialAction: {
+ "@type": "SearchAction",
+ target: {
+ "@type": "EntryPoint",
+ urlTemplate: `${siteConfig.url}/projects?q={search_term_string}`,
+ },
+ "query-input": "required name=search_term_string",
+ },
+ }
+}
+
+export function generateBreadcrumbSchema(items: { name: string; url: string }[]) {
+ return {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: items.map((item, index) => ({
+ "@type": "ListItem",
+ position: index + 1,
+ name: item.name,
+ item: item.url,
+ })),
+ }
+}
+
+export function generateOGImageUrl(title: string, type: "project" | "article" | "default" = "default"): string {
+ const params = new URLSearchParams({
+ title: title.slice(0, 100),
+ type,
+ })
+ return `${siteConfig.url}/api/og?${params.toString()}`
+}
+
+export { siteConfig }