Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Insert Google Analytics Tag ID here
NEXT_PUBLIC_GA_ID=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example

# vercel
.vercel
Expand Down
24 changes: 24 additions & 0 deletions src/app/certificates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,32 @@ 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 type { Metadata } from 'next';
import Link from 'next/link';

const metaDescription =
'Professional certifications and credentials earned by Elmar Chavez as a full stack developer.';
export const metadata: Metadata = {
title: 'Elmar Chavez | Certificates',
description: metaDescription,
keywords: [
'Elmar Chavez Certificates',
'Elmar Chavez Certifications',
'Web Development Certifications',
'Full Stack Developer Certifications',
'Software Development Certifications',
],
openGraph: {
title: 'Certificates | Elmar Chavez',
description: metaDescription,
},
twitter: {
card: 'summary_large_image',
title: 'Certificates | Elmar Chavez',
description: metaDescription,
},
};

export default function ExperiencePage() {
return (
<main
Expand Down
26 changes: 25 additions & 1 deletion src/app/experience/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,32 @@ 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';
import type { Metadata } from 'next';
import Link from 'next/link';

const metaDescription =
'Professional experience and roles of Elmar Chavez as a full stack developer.';
export const metadata: Metadata = {
title: 'Elmar Chavez | Experience',
description: metaDescription,
keywords: [
'Elmar Chavez Experience',
'Elmar Chavez Developer',
'Full Stack Developer Experience',
'Web Developer Experience',
'Software Developer Experience',
],
openGraph: {
title: 'Experience | Elmar Chavez',
description: metaDescription,
},
twitter: {
card: 'summary_large_image',
title: 'Experience | Elmar Chavez',
description: metaDescription,
},
};

export default function ExperiencePage() {
return (
Expand Down
4 changes: 3 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Metadata, Viewport } from 'next';
import { Sora } from 'next/font/google';
import { cn } from '@/lib/utils';
import './globals.css';
import ThemeProvider from '@/components/providers/theme-provider';
import { cn } from '@/lib/utils';
import SkipLink from '@/components/accessibility/skip-link';
import GoogleAnalytics from '@/components/analytics/google-analytics';

const sora = Sora({ subsets: ['latin'], display: 'swap' });
const metaDescription =
Expand Down Expand Up @@ -96,6 +97,7 @@ export default function RootLayout({
>
<body className="min-h-full transition-colors duration-150 ease-in-out">
<SkipLink />
<GoogleAnalytics />
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
Expand Down
8 changes: 6 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import About from '@/components/sections/about';
import Blog from '@/components/sections/blog';
import Certificates from '@/components/sections/certificates';
import Experience from '@/components/sections/experience';
import Footer from '@/components/sections/footer';
import Hero from '@/components/sections/hero';
Expand All @@ -14,8 +15,11 @@ export default function Home() {
<About />
<TechStack />
</div>
<div className="gap 4 mb-4 flex flex-col gap-4 md:flex-row">
<Experience />
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-stretch">
<div className="flex flex-col gap-4 min-[420px]:flex-row md:w-2/5 md:flex-col md:justify-between">
<Experience />
<Certificates />
</div>
<Projects />
</div>
<Blog />
Expand Down
22 changes: 22 additions & 0 deletions src/components/analytics/google-analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Script from 'next/script';

export default function GoogleAnalytics() {
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;

return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}');
`}
</Script>
</>
);
}
21 changes: 21 additions & 0 deletions src/components/sections/certificates.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import { certificates } from '@/data/experiences';
import Certificates from './certificates';

describe('Certificates section', () => {
test('renders Certificates heading', () => {
render(<Certificates />);
const title = screen.getByRole('heading', { name: /certificates/i });
expect(title).toBeInTheDocument();
});
test('renders every certificate', () => {
render(<Certificates />);
const certificates = screen.getAllByRole('listitem');
expect(certificates).toHaveLength(certificates.length);
});
test('renders clickable certificate links', () => {
render(<Certificates />);
const links = screen.getAllByRole('link', { name: /certificate/i });
expect(links).toHaveLength(certificates.length);
});
});
44 changes: 44 additions & 0 deletions src/components/sections/certificates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { certificates } from '@/data/experiences';
import { FiExternalLink } from 'react-icons/fi';
import ViewAllLink from '../ui/view-all-link';
import Link from 'next/link';

export default function Certificates() {
return (
<section className="border-border/40 bg-card rounded-lg border p-4 transition-colors duration-150 ease-in-out">
<header className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold">Certificates</h2>
<ViewAllLink href="/certificates" />
</header>

<ul className="mx-auto flex w-fit flex-col gap-4 md:w-auto">
{certificates.map(
({ id, title, institution, dateReceived, urlLink }) => {
return (
<li
className="before:border-border before:bg-background first:before:bg-foreground after:bg-border/50 hover:before:bg-foreground relative pl-4.5 before:absolute before:top-1 before:left-0 before:h-3 before:w-3 before:rounded-full before:border-2 before:transition-colors before:duration-150 before:ease-in-out after:absolute after:top-4 after:-bottom-6 after:left-1.5 after:w-px after:transition-colors after:duration-150 after:ease-in-out last:after:hidden md:pl-6"
key={id}
>
<Link
className="group decoration-muted-foreground mb-1 flex items-center gap-1 text-[0.85rem] font-semibold decoration-1 underline-offset-4 outline-0 hover:underline focus-visible:underline"
href={urlLink}
target="_blank"
aria-label={`Open ${title} certificate`}
>
{title}
<FiExternalLink className="group-focus-visible:text-foreground text-muted-foreground group-hover:text-foreground relative bottom-px" />
</Link>
<p className="flex flex-col text-[0.75rem]">
<span>{institution}</span>
<span className="text-muted-foreground text-[0.70rem]">
{`Issued ${dateReceived}`}
</span>
</p>
</li>
);
},
)}
</ul>
</section>
);
}
29 changes: 4 additions & 25 deletions src/components/sections/experience.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen, within } from '@testing-library/react';
import { workExperiences, certificates } from '@/data/experiences';
import { render, screen } from '@testing-library/react';
import { workExperiences } from '@/data/experiences';
import Experience from './experience';

describe('Experience section', () => {
Expand All @@ -8,30 +8,9 @@ describe('Experience section', () => {
const title = screen.getByRole('heading', { name: /experience/i });
expect(title).toBeInTheDocument();
});
test('renders Certificates heading', () => {
render(<Experience />);
const title = screen.getByRole('heading', { name: /certificates/i });
expect(title).toBeInTheDocument();
});
test('renders every work experience', () => {
render(<Experience />);
const title = screen.getByRole('heading', { name: /experience/i });
const article = title.closest('article')!;
const experienceSection = within(article);
const experiencesRendered = experienceSection.getAllByRole('listitem');
expect(experiencesRendered).toHaveLength(workExperiences.length);
});
test('renders every certificate', () => {
render(<Experience />);
const title = screen.getByRole('heading', { name: /certificate/i });
const article = title.closest('article')!;
const certificateSection = within(article);
const certificatesRendered = certificateSection.getAllByRole('listitem');
expect(certificatesRendered).toHaveLength(certificates.length);
});
test('renders clickable certificate links', () => {
render(<Experience />);
const links = screen.getAllByRole('link', { name: /certificate/i });
expect(links).toHaveLength(certificates.length);
const experiences = screen.getAllByRole('listitem');
expect(experiences).toHaveLength(workExperiences.length);
});
});
84 changes: 22 additions & 62 deletions src/components/sections/experience.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,30 @@
import { FiExternalLink } from 'react-icons/fi';
import { workExperiences, certificates } from '@/data/experiences';
import { workExperiences } from '@/data/experiences';
import ViewAllLink from '../ui/view-all-link';
import Link from 'next/link';

const Experience = () => {
return (
<section className="border-border/40 bg-card flex shrink-0 flex-col gap-4 rounded-lg border p-4 transition-colors duration-150 ease-in-out min-[488px]:flex-row md:max-w-[20rem] md:flex-col md:gap-8">
<article className="min-w-0 flex-1">
<header className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold">Experience</h2>
<ViewAllLink href="/experience" />
</header>
<section className="border-border/40 bg-card rounded-lg border p-4 transition-colors duration-150 ease-in-out">
<header className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold">Experience</h2>
<ViewAllLink href="/experience" />
</header>

<ul className="flex flex-col gap-4 md:gap-8">
{workExperiences.map(
({ id, startDate, endDate, position, companyName, location }) => {
return (
<li
className="before:border-border before:bg-background first:before:bg-foreground after:bg-border/50 hover:before:bg-foreground relative pl-4.5 before:absolute before:top-1 before:left-0 before:h-3 before:w-3 before:rounded-full before:border-2 before:transition-colors before:duration-150 before:ease-in-out after:absolute after:top-4 after:-bottom-6 after:left-1.5 after:w-px last:after:hidden md:pl-6 md:after:-bottom-10"
key={id}
>
<p className="mb-1 text-base font-semibold">{position}</p>
<p className="text-[0.75rem]">{`${companyName} · ${location}`}</p>
<p className="text-muted-foreground text-[0.75rem]">{`${startDate} - ${endDate}`}</p>
</li>
);
},
)}
</ul>
</article>

<article className="min-w-0 flex-1">
<header className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold">Certificates</h2>
<ViewAllLink href="/certificates" />
</header>

<ul className="flex flex-col gap-4">
{certificates.map(
({ id, title, institution, dateReceived, urlLink }) => {
return (
<li
className="before:border-border before:bg-background first:before:bg-foreground after:bg-border/50 hover:before:bg-foreground relative pl-4.5 before:absolute before:top-1 before:left-0 before:h-3 before:w-3 before:rounded-full before:border-2 before:transition-colors before:duration-150 before:ease-in-out after:absolute after:top-4 after:-bottom-6 after:left-1.5 after:w-px after:transition-colors after:duration-150 after:ease-in-out last:after:hidden md:pl-6"
key={id}
>
<Link
className="group decoration-muted-foreground mb-1 flex items-center gap-1 text-[0.85rem] font-semibold decoration-1 underline-offset-4 outline-0 hover:underline focus-visible:underline"
href={urlLink}
target="_blank"
aria-label={`Open ${title} certificate`}
>
{title}
<FiExternalLink className="group-focus-visible:text-foreground text-muted-foreground group-hover:text-foreground relative bottom-px" />
</Link>
<p className="flex flex-col text-[0.75rem]">
<span>{institution}</span>
<span className="text-muted-foreground text-[0.70rem]">
{`Issued ${dateReceived}`}
</span>
</p>
</li>
);
},
)}
</ul>
</article>
<ul className="mx-auto flex w-fit flex-col gap-4 md:w-auto md:gap-6">
{workExperiences.map(
({ id, startDate, endDate, position, companyName, location }) => {
return (
<li
className="before:border-border before:bg-background first:before:bg-foreground after:bg-border/50 hover:before:bg-foreground relative pl-4.5 before:absolute before:top-1 before:left-0 before:h-3 before:w-3 before:rounded-full before:border-2 before:transition-colors before:duration-150 before:ease-in-out after:absolute after:top-4 after:-bottom-6 after:left-1.5 after:w-px last:after:hidden md:pl-6 md:after:-bottom-8"
key={id}
>
<p className="mb-1 text-base font-semibold">{position}</p>
<p className="text-[0.75rem]">{`${companyName} · ${location}`}</p>
<p className="text-muted-foreground text-[0.75rem]">{`${startDate} - ${endDate}`}</p>
</li>
);
},
)}
</ul>
</section>
);
};
Expand Down
Loading