diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 76440ac..d334dc1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -28,7 +28,28 @@ "Bash(git add:*)", "Bash(GIT_TRACE=1 git commit:*)", "Bash(cmd /c \"del /f \"\"d:\\\\Usuario\\\\Desktop\\\\trdtyyyy\\\\Portfolio\\\\app\\\\projects\\\\[id]\\\\page.tsx\"\" && del /f \"\"d:\\\\Usuario\\\\Desktop\\\\trdtyyyy\\\\Portfolio\\\\app\\\\projects\\\\[id]\\\\not-found.tsx\"\"\")", - "Bash(cmd /c \"del /f \"\"d:\\\\Usuario\\\\Desktop\\\\trdtyyyy\\\\Portfolio\\\\app\\\\projects\\\\[id]\\\\page.tsx\"\"\")" + "Bash(cmd /c \"del /f \"\"d:\\\\Usuario\\\\Desktop\\\\trdtyyyy\\\\Portfolio\\\\app\\\\projects\\\\[id]\\\\page.tsx\"\"\")", + "Bash(pnpm build:*)", + "Bash(gh pr create --title 'Second-pass redesign: B&W palette, typography polish, image carousel, bug fixes' --body ':*)", + "Bash(pip install:*)", + "Bash(pip3 install:*)", + "Bash(where pip:*)", + "Bash(winget install:*)", + "Bash(export PATH=\"$PATH:/c/Program Files/GitHub CLI\")", + "Bash('/c/Program Files/GitHub CLI/gh.exe' pr create --title 'Second-pass redesign: B&W palette, typography polish, image carousel, bug fixes' --body ':*)", + "Bash(\"/c/Program Files/GitHub CLI/gh.exe\" auth login --web)", + "Bash(\"/c/Program Files/GitHub CLI/gh.exe\" pr checks 6 --repo Repetto-A/Portfolio)", + "Bash(\"/c/Program Files/GitHub CLI/gh.exe\" run view 24492383559 --repo Repetto-A/Portfolio --log-failed)", + "Bash(pnpm install:*)", + "Bash(gh pr:*)", + "Bash(gh.exe pr:*)", + "Bash(where gh.exe)", + "Bash(cmd /c \"gh pr checks\")", + "Bash(cmd /c \"where gh\")", + "Bash(cmd /c \"where /R C:\\\\Users\\\\conta gh.exe\")", + "Bash(\"/c/Program Files/GitHub CLI/gh.exe\" pr checks)", + "Bash(\"/c/Program Files/GitHub CLI/gh.exe\" run view 24496201059 --log-failed)", + "Bash(pnpm format:*)" ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 981d626..f678ea4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v3 with: - version: 8 + version: 10 - name: Get pnpm store directory id: pnpm-cache @@ -62,7 +62,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v3 with: - version: 8 + version: 10 - name: Get pnpm store directory id: pnpm-cache @@ -99,7 +99,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v3 with: - version: 8 + version: 10 - name: Get pnpm store directory id: pnpm-cache diff --git a/.prettierignore b/.prettierignore index f555b70..234cb7c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,6 @@ yarn.lock .gitignore public *.md +.claude +test-results +e2e diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index fc5c9a8..a70b3a9 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,39 +1,39 @@ -import { NextResponse } from "next/server"; -import nodemailer from "nodemailer"; +import { NextResponse } from "next/server" +import nodemailer from "nodemailer" export async function POST(request: Request) { try { - const body = await request.json(); - + const body = await request.json() + // Validaciones básicas if (!body.name || !body.email || !body.subject || !body.message) { return NextResponse.json( { success: false, error: "Todos los campos son obligatorios" }, { status: 400 } - ); + ) } // Validar formato de email - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!emailRegex.test(body.email)) { return NextResponse.json( { success: false, error: "Formato de correo electrónico inválido" }, { status: 400 } - ); + ) } // Verificar variables de entorno - const requiredEnvVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASSWORD', 'SMTP_FROM']; - const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); - + const requiredEnvVars = ["SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_FROM"] + const missingVars = requiredEnvVars.filter((varName) => !process.env[varName]) + if (missingVars.length > 0) { return NextResponse.json( - { - success: false, - error: `Error de configuración del servidor. Por favor, contacta al administrador.` + { + success: false, + error: `Error de configuración del servidor. Por favor, contacta al administrador.`, }, { status: 500 } - ); + ) } // Configuración del transporte SMTP @@ -46,9 +46,9 @@ export async function POST(request: Request) { pass: process.env.SMTP_PASSWORD, }, tls: { - rejectUnauthorized: false - } - }); + rejectUnauthorized: false, + }, + }) // Opciones del correo const mailOptions = { @@ -78,26 +78,26 @@ export async function POST(request: Request) { `, - }; + } - const info = await transporter.sendMail(mailOptions); + await transporter.sendMail(mailOptions) return NextResponse.json( - { + { success: true, message: "¡Mensaje enviado con éxito! Me pondré en contacto contigo pronto.", }, { status: 200 } - ); - } catch (error: any) { - console.error('Error sending email:', error); + ) + } catch (error: unknown) { + console.error("Error sending email:", error) return NextResponse.json( - { - success: false, - error: "Error al procesar la solicitud. Por favor, inténtalo de nuevo más tarde." + { + success: false, + error: "Error al procesar la solicitud. Por favor, inténtalo de nuevo más tarde.", }, { status: 500 } - ); + ) } } @@ -106,9 +106,9 @@ export async function OPTIONS() { return new Response(null, { status: 200, headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", }, - }); + }) } diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 2ec0d47..aeeb958 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -99,7 +99,7 @@ export async function GET(req: NextRequest) { { width: 1200, height: 630, - }, + } ) } catch (error) { console.error("Error generating OG image:", error) diff --git a/app/globals.css b/app/globals.css index 0e9e370..1d8bc15 100644 --- a/app/globals.css +++ b/app/globals.css @@ -10,7 +10,7 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); + --primary: oklch(0.145 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); @@ -22,7 +22,7 @@ --destructive-foreground: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --ring: oklch(0.145 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); @@ -42,12 +42,12 @@ .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); - --card: oklch(0.145 0 0); + --card: oklch(0.16 0 0); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.145 0 0); + --popover: oklch(0.16 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 0 0); - --primary-foreground: oklch(0.205 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.145 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); @@ -58,7 +58,7 @@ --destructive-foreground: oklch(0.637 0.237 25.331); --border: oklch(0.269 0 0); --input: oklch(0.269 0 0); - --ring: oklch(0.439 0 0); + --ring: oklch(0.922 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); @@ -111,6 +111,8 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-jetbrains-mono), ui-monospace, monospace; } html { @@ -136,16 +138,16 @@ body { } ::-webkit-scrollbar-track { - background: rgb(var(--background)); + background: var(--background); } ::-webkit-scrollbar-thumb { - background: rgb(var(--muted)); + background: var(--muted); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: rgb(var(--muted-foreground)); + background: var(--muted-foreground); } @layer base { @@ -155,6 +157,8 @@ body { body { @apply bg-background text-foreground; + letter-spacing: -0.011em; + line-height: 1.6; } /* Ensure tap targets are at least 44x44px on touch devices */ @@ -199,8 +203,8 @@ body { left: -9999px; z-index: 999; padding: 1rem 1.5rem; - background-color: rgb(var(--background)); - color: rgb(var(--foreground)); + background-color: var(--background); + color: var(--foreground); text-decoration: none; border-radius: 0.5rem; } @@ -222,6 +226,84 @@ body { } } +/* ── Custom animations ─────────────────────────────── */ +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(18px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Card entrance on scroll — progressive enhancement */ +@supports (animation-timeline: view()) { + @media (prefers-reduced-motion: no-preference) { + .scroll-reveal { + animation: fade-in-up 0.55s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-timeline: view(); + animation-range: entry 0% entry 25%; + } + } +} + +/* Staggered scroll-reveal variants for grid cards */ +@supports (animation-timeline: view()) { + @media (prefers-reduced-motion: no-preference) { + .scroll-reveal-stagger-1 { + animation: fade-in-up 0.55s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-timeline: view(); + animation-range: entry 0% entry 25%; + } + .scroll-reveal-stagger-2 { + animation: fade-in-up 0.55s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-timeline: view(); + animation-range: entry 3% entry 28%; + } + .scroll-reveal-stagger-3 { + animation: fade-in-up 0.55s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-timeline: view(); + animation-range: entry 6% entry 31%; + } + .scroll-reveal-stagger-4 { + animation: fade-in-up 0.55s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-timeline: view(); + animation-range: entry 9% entry 34%; + } + } +} + +/* Utility: gradient text (primary → violet) */ +.gradient-text { + background: linear-gradient( + 135deg, + oklch(0.52 0.22 263) 0%, + oklch(0.55 0.25 295) 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.dark .gradient-text { + background: linear-gradient( + 135deg, + oklch(0.68 0.18 263) 0%, + oklch(0.72 0.22 295) 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ── Print styles ──────────────────────────────────── */ /* Print styles */ @media print { nav, diff --git a/app/layout.tsx b/app/layout.tsx index b797597..e97faf5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,15 @@ -import type React from "react" +import { type ReactNode } from "react" import { Inter, JetBrains_Mono } from "next/font/google" import "./globals.css" import type { Metadata, Viewport } from "next" import { ThemeProvider } from "@/components/theme-provider" import { I18nProvider } from "@/lib/i18n-context" -import { generateMetadata as genMetadata, generatePersonSchema, generateWebSiteSchema, siteConfig } from "@/lib/seo" +import { + generateMetadata as genMetadata, + generatePersonSchema, + generateWebSiteSchema, + siteConfig, +} from "@/lib/seo" import { Analytics } from "@vercel/analytics/react" import { SpeedInsights } from "@vercel/speed-insights/next" import { ScrollProvider } from "@/lib/scroll-context" @@ -45,15 +50,25 @@ export const viewport: Viewport = { ], } -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ children }: { children: ReactNode }) { const personSchema = generatePersonSchema() const webSiteSchema = generateWebSiteSchema() return ( - + -