diff --git a/package-lock.json b/package-lock.json index 4a6d15b..97e3422 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "elmarchavez", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "elmarchavez", - "version": "0.1.0", + "version": "1.0.0", + "license": "MIT", "dependencies": { "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", @@ -22,7 +23,8 @@ "react-icons": "^5.6.0", "shadcn": "^4.6.0", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "yet-another-react-lightbox": "^3.32.2" }, "devDependencies": { "@playwright/test": "^1.61.1", @@ -14716,6 +14718,32 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yet-another-react-lightbox": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.32.2.tgz", + "integrity": "sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/igordanchenko" + }, + "peerDependencies": { + "@types/react": "^16 || ^17 || ^18 || ^19", + "@types/react-dom": "^16 || ^17 || ^18 || ^19", + "react": "^16.8.0 || ^17 || ^18 || ^19", + "react-dom": "^16.8.0 || ^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 914c7c8..7b2b9a3 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "react-icons": "^5.6.0", "shadcn": "^4.6.0", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "yet-another-react-lightbox": "^3.32.2" }, "devDependencies": { "@playwright/test": "^1.61.1", @@ -58,4 +59,4 @@ "typescript": "^5", "vitest": "^4.1.5" } -} \ No newline at end of file +} diff --git a/public/certificates/frontend-developement-libraries-certificate.webp b/public/certificates/frontend-developement-libraries-certificate.webp new file mode 100644 index 0000000..7cd7c85 Binary files /dev/null and b/public/certificates/frontend-developement-libraries-certificate.webp differ diff --git a/public/certificates/javascript-certificate.webp b/public/certificates/javascript-certificate.webp new file mode 100644 index 0000000..aca7285 Binary files /dev/null and b/public/certificates/javascript-certificate.webp differ diff --git a/public/certificates/relational-database-certificate.webp b/public/certificates/relational-database-certificate.webp new file mode 100644 index 0000000..a84ea3a Binary files /dev/null and b/public/certificates/relational-database-certificate.webp differ diff --git a/public/certificates/responsive-web-design-certificate.webp b/public/certificates/responsive-web-design-certificate.webp new file mode 100644 index 0000000..396d505 Binary files /dev/null and b/public/certificates/responsive-web-design-certificate.webp differ diff --git a/src/app/certificates/page.tsx b/src/app/certificates/page.tsx new file mode 100644 index 0000000..55445f8 --- /dev/null +++ b/src/app/certificates/page.tsx @@ -0,0 +1,83 @@ +import Breadcrumb from '@/components/sections/breadcrumb'; +import ThemeToggle from '@/components/theme/theme-toggle'; +import Footer from '@/components/sections/footer'; +import CertificateImage from '@/components/ui/certificate-image'; +import { CERTIFICATES } from '@/data/experiences'; +import { FiExternalLink } from 'react-icons/fi'; +import Link from 'next/link'; + +export default function ExperiencePage() { + return ( +
+
+ + +
+ +
+

Certificates

+

+ List of all my professional certifications to date. +

+
+ + + +
+ ); +} diff --git a/src/app/experience/page.tsx b/src/app/experience/page.tsx new file mode 100644 index 0000000..dd7fdbd --- /dev/null +++ b/src/app/experience/page.tsx @@ -0,0 +1,90 @@ +import Breadcrumb from '@/components/sections/breadcrumb'; +import Footer from '@/components/sections/footer'; +import ThemeToggle from '@/components/theme/theme-toggle'; +import { WORK_EXPERIENCES } from '@/data/experiences'; +import Link from 'next/link'; +import { FiExternalLink } from 'react-icons/fi'; + +export default function ExperiencePage() { + return ( +
+
+ + +
+ +
+

Experience

+

+ My professional experience as a software engineer. +

+
+ + + +
+ ); +} diff --git a/src/components/sections/experience.tsx b/src/components/sections/experience.tsx index 8d585e1..3327071 100644 --- a/src/components/sections/experience.tsx +++ b/src/components/sections/experience.tsx @@ -1,12 +1,16 @@ import { FiExternalLink } from 'react-icons/fi'; import { workExperiences, certificates } from '@/data/experiences'; +import ViewAllLink from '../ui/view-all-link'; import Link from 'next/link'; const Experience = () => { return ( -
-
-

Experience

+
+
+
+

Experience

+ +
    {workExperiences.map( @@ -26,8 +30,11 @@ const Experience = () => {
-
-

Certificates

+
+
+

Certificates

+ +
    {certificates.map( @@ -38,13 +45,13 @@ const Experience = () => { key={id} > {title} - +

    {institution} diff --git a/src/components/ui/certificate-image.tsx b/src/components/ui/certificate-image.tsx new file mode 100644 index 0000000..1731b3a --- /dev/null +++ b/src/components/ui/certificate-image.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Image from 'next/image'; +import Lightbox from 'yet-another-react-lightbox'; +import 'yet-another-react-lightbox/styles.css'; + +interface CertificateImageProps { + src: string; + alt: string; + title: string; +} + +export default function CertificateImage({ + src, + alt, + title, +}: CertificateImageProps) { + const [open, setOpen] = useState(false); + const slides = useMemo( + () => [ + { + src, + alt, + width: 1250, + height: 830, + }, + ], + [src, alt], + ); + + const handleClick = () => setOpen(true); + const handleClose = () => setOpen(false); + + return ( + <> + + + null, + buttonNext: () => null, + }} + /> + + ); +} diff --git a/src/data/blog-list.ts b/src/data/blog-list.ts index f0b6fe8..50ac648 100644 --- a/src/data/blog-list.ts +++ b/src/data/blog-list.ts @@ -60,7 +60,7 @@ export const BLOGS: BlogType[] = [ description: 'My thoughts on AI dependency and why struggling through problems is still one of the best ways to combat cognitive atrophy and become a better engineer.', publishDate: 'September 2026', - readTime: '8 min read', + readTime: '9 min read', tags: ['#ai', '#programming', '#software'], blogUrl: `/blog/${getSlug('The Slow and Quiet Cognitive Atrophy of a Modern Software Engineer')}`, imgUrl: '/img/blog-5.webp', diff --git a/src/data/experiences.ts b/src/data/experiences.ts index d30c58a..aac8eb1 100644 --- a/src/data/experiences.ts +++ b/src/data/experiences.ts @@ -1,24 +1,41 @@ import { WorkExperience, Certificate } from '@/types/experience'; -export const workExperiences: WorkExperience[] = [ - { - id: 2, - startDate: 'June 2026', - endDate: 'Present', - position: 'Full Stack Developer', - companyName: 'CALEC', - location: 'New York, USA (Remote)', - }, +export const WORK_EXPERIENCES: WorkExperience[] = [ { id: 1, startDate: 'March 2025', endDate: 'June 2026', position: 'Full Stack Developer', companyName: 'Freelance', + companyUrl: 'https://github.com/CodingWithJiro', location: 'Philippines', + responsibilities: [ + 'Developed modern Next.js and React applications using JavaScript, TypeScript, and Tailwind CSS.', + 'Implemented client-side routing, state management, and API integration in React-based projects.', + 'Built testing workflows using Vitest, Playwright, React Testing Library, and Mock Service Worker (MSW).', + 'Collaborated on open-source projects using Git, GitHub, pull requests, and code review workflows.', + ], + }, + { + id: 2, + startDate: 'June 2026', + endDate: 'Present', + position: 'Full Stack Developer', + companyName: 'CALEC', + companyUrl: 'https://calec.org/', + location: 'New York, USA (Remote)', + responsibilities: [ + 'Managed and reviewed pull requests, approving and merging changes into main and staging branches.', + 'Resolved application issues in Laravel and Livewire by tracing generated markup and debugging.', + 'Improved WCAG accessibility across the Ri2L web application by auditing pages with Axe DevTools.', + 'Established a Laravel development environment with WSL2, Docker, PHP, Composer, Node.js, and Ubuntu.', + ], }, ]; +export const workExperiences: WorkExperience[] = + WORK_EXPERIENCES.slice(-2).reverse(); + export const CERTIFICATES: Certificate[] = [ { id: 1, @@ -27,6 +44,9 @@ export const CERTIFICATES: Certificate[] = [ dateReceived: 'Dec 2025', urlLink: 'https://www.freecodecamp.org/certification/codingwithjiro/responsive-web-design-v9', + imgUrl: '/certificates/responsive-web-design-certificate.webp', + description: + 'Covers HTML, CSS, accessibility, responsive design, and modern web layouts.', }, { id: 2, @@ -35,6 +55,9 @@ export const CERTIFICATES: Certificate[] = [ dateReceived: 'Dec 2025', urlLink: 'https://www.freecodecamp.org/certification/codingwithjiro/javascript-v9', + imgUrl: '/certificates/javascript-certificate.webp', + description: + 'Covers JavaScript fundamentals, data structures, algorithms, DOM, and programming concepts.', }, { id: 3, @@ -43,6 +66,9 @@ export const CERTIFICATES: Certificate[] = [ dateReceived: 'May 2026', urlLink: 'https://www.freecodecamp.org/certification/codingwithjiro/relational-databases-v9', + imgUrl: '/certificates/relational-database-certificate.webp', + description: + 'Covers Bash, SQL, PostgreSQL, relational databases, scripting, and Git workflows.', }, { id: 4, @@ -51,6 +77,9 @@ export const CERTIFICATES: Certificate[] = [ dateReceived: 'September 2026', urlLink: 'https://www.freecodecamp.org/certification/codingwithjiro/front-end-development-libraries-v9', + imgUrl: '/certificates/frontend-developement-libraries-certificate.webp', + description: + 'Covers React, state management, routing, testing, performance, CSS frameworks, and TypeScript.', }, ]; diff --git a/src/types/experience.ts b/src/types/experience.ts index aea23ce..f777d98 100644 --- a/src/types/experience.ts +++ b/src/types/experience.ts @@ -4,7 +4,9 @@ export type WorkExperience = { endDate: string; position: string; companyName: string; + companyUrl: string; location: string; + responsibilities: string[]; }; export type Certificate = { @@ -13,4 +15,6 @@ export type Certificate = { institution: string; dateReceived: string; urlLink: string; + imgUrl: string; + description: string; }; diff --git a/tests/certificates.spec.ts b/tests/certificates.spec.ts index 2e11261..7340bd0 100644 --- a/tests/certificates.spec.ts +++ b/tests/certificates.spec.ts @@ -1,16 +1,76 @@ import { test, expect } from '@playwright/test'; -import { certificates } from '@/data/experiences'; +import { certificates, CERTIFICATES } from '@/data/experiences'; -test('visitor can open certificate link in a new tab', async ({ page }) => { - await page.goto('/'); - const link = page.getByRole('link', { name: /relational databases/i }); - const certificate = certificates.find( - ({ title }) => title === 'Relational Databases', - )!; - expect(certificate).toBeDefined(); - const newPagePromise = page.waitForEvent('popup'); - await link.scrollIntoViewIfNeeded(); - await link.click(); - const newPage = await newPagePromise; - await expect(newPage).toHaveURL(certificate.urlLink); +test.describe('Certificates', () => { + test('visitor can see the Certificates section', async ({ page }) => { + await page.goto('/'); + const heading = page.getByRole('heading', { name: /^certificates$/i }); + await expect(heading).toBeVisible(); + }); + test('visitor can view the Certificates page', async ({ page }) => { + await page.goto('/'); + const viewAllLink = page.locator('a[href="/certificates"]'); + await viewAllLink.click(); + await expect(page).toHaveURL('/certificates'); + }); + test('visitor can open certificate link in a new tab', async ({ page }) => { + await page.goto('/'); + const certificate = certificates[0]; + const certificateLink = page.getByRole('link', { + name: `Open ${certificate.title} certificate`, + }); + const newPagePromise = page.waitForEvent('popup'); + await certificateLink.click(); + const newPage = await newPagePromise; + await expect(newPage).toHaveURL(certificate.urlLink); + }); + test('visitor can see the Certificates page contents', async ({ page }) => { + await page.goto('/certificates'); + const heading = page.getByRole('heading', { name: /^certificates$/i }); + await expect(heading).toBeVisible(); + const description = page.getByText( + /^list of all my professional certifications to date.$/i, + ); + await expect(description).toBeVisible(); + }); + test('visitor can see the latest certificate entry in Certificates page', async ({ + page, + }) => { + await page.goto('/certificates'); + const latestCertificate = CERTIFICATES.at(-1)!; + const { title, urlLink } = latestCertificate; + const heading = page.getByRole('heading', { name: title }); + await expect(heading).toBeVisible(); + const link = page.getByRole('link', { + name: `See Elmar Chavez's ${title} certificate in new tab`, + }); + await expect(link).toHaveAttribute('href', urlLink); + }); + test('visitor can open the latest certificate link in new tab', async ({ + page, + }) => { + await page.goto('/certificates'); + const latestCertificate = CERTIFICATES.at(-1)!; + const { title, urlLink } = latestCertificate; + const link = page.getByRole('link', { + name: `See Elmar Chavez's ${title} certificate in new tab`, + }); + const newPagePromise = page.waitForEvent('popup'); + await link.click(); + const newPage = await newPagePromise; + await expect(newPage).toHaveURL(urlLink); + }); + test('visitor can view enlarged image of the latest certificate entry', async ({ + page, + }) => { + await page.goto('/certificates'); + const latestCertificate = CERTIFICATES.at(-1)!; + const { title } = latestCertificate; + const imageButton = page.getByRole('button', { + name: `Enlarge and view ${title} certificate.`, + }); + await imageButton.click(); + const closeButton = page.getByRole('button', { name: /close/i }); + await expect(closeButton).toBeVisible(); + }); }); diff --git a/tests/experience.spec.ts b/tests/experience.spec.ts new file mode 100644 index 0000000..0ecde5d --- /dev/null +++ b/tests/experience.spec.ts @@ -0,0 +1,68 @@ +import { WORK_EXPERIENCES } from '@/data/experiences'; +import { WorkExperience } from '@/types/experience'; +import { test, expect } from '@playwright/test'; + +test.describe('Experience', () => { + test('visitor can see the Experience section', async ({ page }) => { + await page.goto('/'); + const heading = page.getByRole('heading', { name: /^experience$/i }); + await expect(heading).toBeVisible(); + }); + test('visitor can view the Experience page', async ({ page }) => { + await page.goto('/'); + const viewAllLink = page.locator('a[href="/experience"]'); + await viewAllLink.click(); + await expect(page).toHaveURL('/experience'); + }); + test('visitor can see the Experience page contents', async ({ page }) => { + await page.goto('/experience'); + const heading = page.getByRole('heading', { name: /^experience$/i }); + await expect(heading).toBeVisible(); + const description = page.getByText( + /^my professional experience as a software engineer.$/i, + ); + await expect(description).toBeVisible(); + }); + test('visitor can see the latest work experience in Experience page', async ({ + page, + }) => { + await page.goto('/experience'); + const latestWorkExperience: WorkExperience = WORK_EXPERIENCES.at(-1)!; + const { position, companyName, companyUrl, location } = + latestWorkExperience; + const heading = page.getByRole('heading', { name: position }).first(); + await expect(heading).toBeVisible(); + const companyLink = page.getByRole('link', { + name: `Open ${companyName}'s official website to new tab.`, + }); + await expect(companyLink).toBeVisible(); + await expect(companyLink).toHaveAttribute('href', companyUrl); + const companyLocation = page.getByText(location); + await expect(companyLocation).toBeVisible(); + }); + test('visitor can view a company website in new tab', async ({ page }) => { + await page.goto('/experience'); + const latestWorkExperience: WorkExperience = WORK_EXPERIENCES.at(-1)!; + const { companyUrl, companyName } = latestWorkExperience; + const companyLink = page.getByRole('link', { + name: `Open ${companyName}'s official website to new tab.`, + }); + const newPagePromise = page.waitForEvent('popup'); + await companyLink.click(); + const newPage = await newPagePromise; + await expect(newPage).toHaveURL(companyUrl); + }); + test('visitor can focus the company website link with keyboard', async ({ + page, + }) => { + await page.goto('/experience'); + const latestWorkExperience: WorkExperience = WORK_EXPERIENCES.at(-1)!; + const { companyUrl, companyName } = latestWorkExperience; + const companyLink = page.getByRole('link', { + name: `Open ${companyName}'s official website to new tab.`, + }); + await expect(companyLink).toHaveAttribute('href', companyUrl); + await companyLink.focus(); + await expect(companyLink).toBeFocused(); + }); +}); diff --git a/tests/keyboard.spec.ts b/tests/keyboard.spec.ts index 7ce088d..23152e4 100644 --- a/tests/keyboard.spec.ts +++ b/tests/keyboard.spec.ts @@ -155,4 +155,32 @@ test.describe('Keyboard Accessibility', () => { await page.keyboard.press('Enter'); await expect(page).toHaveURL('/blog'); }); + test('visitor can tab to the Experience view all link and open the Experience page', async ({ + page, + browserName, + }) => { + test.skip( + browserName === 'webkit', + 'WebKit on Windows does not traverse focus order correctly.', + ); + const viewAllLink = page.locator('a[href="/experience"]'); + await tabUntilFocused(page, viewAllLink, 'View all link'); + await expect(viewAllLink).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL('/experience'); + }); + test('visitor can tab to the Certificates view all link and open the Certificates page', async ({ + page, + browserName, + }) => { + test.skip( + browserName === 'webkit', + 'WebKit on Windows does not traverse focus order correctly.', + ); + const viewAllLink = page.locator('a[href="/certificates"]'); + await tabUntilFocused(page, viewAllLink, 'View all link'); + await expect(viewAllLink).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL('/certificates'); + }); });