From ef9f16c2cc224459e11248785fd937a0a6256ac1 Mon Sep 17 00:00:00 2001 From: Mohammad Hariri Date: Sun, 19 Apr 2026 12:43:32 +0200 Subject: [PATCH 1/3] feat: integrate Strapi CMS for dynamic portfolio data --- src/data/loaders/portfolio.ts | 125 ++++++++-------------------------- src/routes/index.tsx | 110 +++++++++++++++++++++--------- src/types/strapi.ts | 75 +++++++++++++------- 3 files changed, 154 insertions(+), 156 deletions(-) diff --git a/src/data/loaders/portfolio.ts b/src/data/loaders/portfolio.ts index 54bb47d..0b327ae 100644 --- a/src/data/loaders/portfolio.ts +++ b/src/data/loaders/portfolio.ts @@ -1,10 +1,5 @@ import { createServerFn } from '@tanstack/react-start' -import type { TExperience, TEducation, TSkillGroup } from '#/types/strapi' -import { - experiences as fallbackExperiences, - educations as fallbackEducations, - skillGroups as fallbackSkillGroups, -} from '#/data/portfolio' +import type { TExperience, TEducation, TProfile } from '#/types/strapi' const STRAPI_BASE = process.env['STRAPI_URL'] ?? 'https://strapi.moshariri.com' @@ -13,107 +8,41 @@ const buildHeaders = (): Record => { return token ? { Authorization: `Bearer ${token}` } : {} } -const fetchFromStrapi = async (path: string): Promise<{ data: T[] }> => { - const url = `${STRAPI_BASE}/api${path}` - console.log(`[Strapi] → GET ${url}`) - const response = await fetch(url, { headers: buildHeaders() }) - if (!response.ok) { - throw new Error(`Strapi request failed: ${response.status} ${url}`) - } - const json = (await response.json()) as { data: T[] } - console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`) - return json -} - export const fetchExperiences = createServerFn({ method: 'GET' }).handler( async (): Promise => { - try { - const { data } = await fetchFromStrapi( - '/experiences?sort=order:asc&populate=*', - ) - return data.map((item) => ({ - id: item.id, - company: item.company, - role: item.role, - employmentType: item.employmentType, - startDate: item.startDate, - endDate: item.endDate, - location: item.location, - bullets: item.bullets ?? [], - tech: item.tech ?? [], - order: item.order, - })) - } catch (err) { - console.error('[Strapi] fetchExperiences failed, using fallback:', err) - return fallbackExperiences.map((e, i) => ({ - id: i, - company: e.company, - role: e.role, - employmentType: - e.type === 'Full-time' ? 'full-time' : ('part-time' as const), - startDate: e.startDate, - endDate: e.endDate ?? undefined, - location: e.location, - bullets: [...e.bullets], - tech: [...e.tech], - order: i, - })) - } + const url = `${STRAPI_BASE}/api/experiences?populate[objectives]=*&populate[technologies]=*` + console.log(`[Strapi] → GET ${url}`) + const res = await fetch(url, { headers: buildHeaders() }) + if (!res.ok) + throw new Error(`Strapi request failed: ${res.status} ${res.url}`) + const json = (await res.json()) as { data: TExperience[] } + console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`) + return json.data }, ) export const fetchEducations = createServerFn({ method: 'GET' }).handler( async (): Promise => { - try { - const { data } = await fetchFromStrapi( - '/educations?sort=order:asc&populate=*', - ) - return data.map((item) => ({ - id: item.id, - institution: item.institution, - degree: item.degree, - fieldOfStudy: item.fieldOfStudy, - startYear: item.startYear, - endYear: item.endYear, - grade: item.grade, - order: item.order, - })) - } catch (err) { - console.error('[Strapi] fetchEducations failed, using fallback:', err) - return fallbackEducations.map((e, i) => ({ - id: i, - institution: e.institution, - degree: e.degree, - fieldOfStudy: undefined, - startYear: e.startYear, - endYear: e.endYear, - grade: e.grade ?? undefined, - order: i, - })) - } + const url = `${STRAPI_BASE}/api/educations?populate=*` + console.log(`[Strapi] → GET ${url}`) + const res = await fetch(url, { headers: buildHeaders() }) + if (!res.ok) + throw new Error(`Strapi request failed: ${res.status} ${res.url}`) + const json = (await res.json()) as { data: TEducation[] } + console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`) + return json.data }, ) -export const fetchSkillGroups = createServerFn({ method: 'GET' }).handler( - async (): Promise => { - try { - const { data } = await fetchFromStrapi( - '/skill-groups?sort=order:asc&populate=*', - ) - return data.map((item) => ({ - id: item.id, - category: item.category, - skills: item.skills ?? [], - order: item.order, - })) - } catch (err) { - console.error('[Strapi] fetchSkillGroups failed, using fallback:', err) - return fallbackSkillGroups.map((g, i) => ({ - id: i, - category: g.category, - skills: [...g.skills], - order: i, - })) - } +export const fetchProfile = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const url = `${STRAPI_BASE}/api/profile?populate[skills][populate]=*` + console.log(`[Strapi] → GET ${url}`) + const res = await fetch(url, { headers: buildHeaders() }) + if (!res.ok) + throw new Error(`Strapi request failed: ${res.status} ${res.url}`) + const json = (await res.json()) as { data: TProfile } + console.log(`[Strapi] ← GET ${url} OK`) + return json.data }, ) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8987f65..2e46c37 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,24 +1,43 @@ import { createFileRoute } from '@tanstack/react-router' import { computeDuration, formatDate, CONTACT_EMAIL } from '#/data/portfolio' -import type { TExperience, TEducation, TSkillGroup } from '#/types/strapi' +import type { TExperience, TEducation, TSkill } from '#/types/strapi' import { fetchExperiences, fetchEducations, - fetchSkillGroups, + fetchProfile, } from '#/data/loaders/portfolio' export const Route = createFileRoute('/')({ loader: async () => { - const [experiences, educations, skillGroups] = await Promise.all([ + const [experiences, educations, profile] = await Promise.all([ fetchExperiences(), fetchEducations(), - fetchSkillGroups(), + fetchProfile(), ]) - return { experiences, educations, skillGroups } + return { experiences, educations, profile } }, + errorComponent: StrapiErrorPage, component: HomePage, }) +// ── Error page ──────────────────────────────────────────────────────────── + +function StrapiErrorPage() { + return ( +
+

Service Unavailable

+

We'll be right back

+

+ The portfolio data is temporarily unavailable. Please check back in a + few minutes. +

+
+ ) +} + // ── Shared UI primitives ────────────────────────────────────────────────── type ButtonVariant = 'primary' | 'ghost' @@ -77,7 +96,7 @@ function SectionHeading({ // ── Experience card ─────────────────────────────────────────────────────── -const EMPLOYMENT_TYPE_LABEL: Record = { +const EMPLOYMENT_TYPE_LABEL: Record<'full-time' | 'part-time', string> = { 'full-time': 'Full-time', 'part-time': 'Part-time', } @@ -89,9 +108,11 @@ function ExperienceCard({ readonly exp: TExperience readonly index: number }) { - const duration = computeDuration(exp.startDate, exp.endDate ?? undefined) - const startLabel = formatDate(exp.startDate) - const endLabel = exp.endDate ? formatDate(exp.endDate) : 'Present' + const startIso = exp.startDate.slice(0, 7) + const endIso = exp.endDate?.slice(0, 7) + const duration = computeDuration(startIso, endIso) + const startLabel = formatDate(startIso) + const endLabel = endIso ? formatDate(endIso) : 'Present' return (
- - {EMPLOYMENT_TYPE_LABEL[exp.employmentType]} - + {exp.type != null && ( + + {EMPLOYMENT_TYPE_LABEL[exp.type]} + + )}
@@ -124,28 +147,26 @@ function ExperienceCard({ {duration} - - {exp.location}
- {exp.bullets.length > 0 && ( + {exp.objectives.length > 0 && (
    - {exp.bullets.map((bullet) => ( + {exp.objectives.map((obj) => (
  • - {bullet} + {obj.description}
  • ))}
)} - {exp.tech.length > 0 && ( + {exp.technologies.length > 0 && (
- {exp.tech.map((t) => ( - + {exp.technologies.map((tech) => ( + ))}
)} @@ -157,15 +178,19 @@ function ExperienceCard({ // ── Education card ──────────────────────────────────────────────────────── function EducationCard({ edu }: { readonly edu: TEducation }) { + const startYear = new Date(edu.startDate).getFullYear() + const endYear = edu.endDate ? new Date(edu.endDate).getFullYear() : null + return (

{edu.institution}

{edu.degree} + {edu.fieldOfStudy ? ` ${edu.fieldOfStudy}` : ''}

- {edu.startYear} – {edu.endYear} + {startYear} – {endYear ?? 'Present'} {edu.grade != null && ( <> @@ -182,13 +207,19 @@ function EducationCard({ edu }: { readonly edu: TEducation }) { // ── Skill group card ────────────────────────────────────────────────────── -function SkillGroupCard({ group }: { readonly group: TSkillGroup }) { +function SkillGroupCard({ + category, + skills, +}: { + readonly category: string + readonly skills: readonly TSkill[] +}) { return (
-

{group.category}

+

{category}

- {group.skills.map((skill) => ( - + {skills.map((skill) => ( + ))}
@@ -198,7 +229,17 @@ function SkillGroupCard({ group }: { readonly group: TSkillGroup }) { // ── Page ────────────────────────────────────────────────────────────────── function HomePage() { - const { experiences, educations, skillGroups } = Route.useLoaderData() + const { experiences, educations, profile } = Route.useLoaderData() + + const skillsByGroup = profile.skills.reduce>( + (acc, skill) => { + const key = skill.group + acc[key] = [...(acc[key] ?? []), skill] + return acc + }, + {}, + ) + return (
{/* ── Hero ──────────────────────────────────────────── */} @@ -211,12 +252,11 @@ function HomePage() { Software Engineer · Copenhagen, Denmark

- Hi, I'm Mohammad. + Hi, I'm {profile.firstname}.

- Crafting robust full-stack solutions with a passion for clean - architecture, great developer experience, and shipping products people - love. + {profile.about ?? + 'Crafting robust full-stack solutions with a passion for clean architecture, great developer experience, and shipping products people love.'}

@@ -261,8 +301,12 @@ function HomePage() { id="skills" />
- {skillGroups.map((group) => ( - + {Object.entries(skillsByGroup).map(([groupKey, skills]) => ( + ))}
diff --git a/src/types/strapi.ts b/src/types/strapi.ts index 03488b6..a97ceb3 100644 --- a/src/types/strapi.ts +++ b/src/types/strapi.ts @@ -89,33 +89,58 @@ export type TStrapiResponse = { } } +export type TSkill = { + readonly id: number + readonly name: string + readonly group: string + readonly link: string | null +} + +export type TProfile = { + readonly id: number + readonly documentId: string + readonly firstname: string + readonly lastname: string + readonly fullname: string + readonly phone?: string + readonly workStatus: string + readonly dateOfBirth?: string + readonly about?: string + readonly skills: readonly TSkill[] +} + +export type TObjective = { + readonly id: number + readonly description: string +} + +export type TTechnology = { + readonly id: number + readonly name: string + readonly group: 'frontend' | 'backend' | 'database' | 'infrastructure' + readonly link: string | null +} + export type TExperience = { - id: number - company: string - role: string - employmentType: 'full-time' | 'part-time' - startDate: string - endDate?: string - location?: string - bullets: string[] - tech: string[] - order: number + readonly id: number + readonly documentId: string + readonly company: string + readonly role: string + readonly startDate: string + readonly endDate: string | null + readonly type: 'full-time' | 'part-time' | null + readonly locale?: string + readonly objectives: readonly TObjective[] + readonly technologies: readonly TTechnology[] } export type TEducation = { - id: number - institution: string - degree: string - fieldOfStudy?: string - startYear: number - endYear?: number - grade?: string - order: number -} - -export type TSkillGroup = { - id: number - category: string - skills: string[] - order: number + readonly id: number + readonly documentId: string + readonly institution: string + readonly degree: string + readonly fieldOfStudy?: string + readonly startDate: string + readonly endDate: string | null + readonly grade: string | null } From 7285dfd74dd95b0aabc917e7f1d045279e603029 Mon Sep 17 00:00:00 2001 From: Mohammad Hariri Date: Sun, 19 Apr 2026 12:49:52 +0200 Subject: [PATCH 2/3] feat: add experience detail page with slug routing --- src/data/loaders/portfolio.ts | 17 +++- src/routeTree.gen.ts | 24 +++++- src/routes/experience/$slug.tsx | 134 ++++++++++++++++++++++++++++++++ src/routes/index.tsx | 12 ++- src/types/strapi.ts | 2 + 5 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 src/routes/experience/$slug.tsx diff --git a/src/data/loaders/portfolio.ts b/src/data/loaders/portfolio.ts index 0b327ae..0cf8ef3 100644 --- a/src/data/loaders/portfolio.ts +++ b/src/data/loaders/portfolio.ts @@ -10,7 +10,7 @@ const buildHeaders = (): Record => { export const fetchExperiences = createServerFn({ method: 'GET' }).handler( async (): Promise => { - const url = `${STRAPI_BASE}/api/experiences?populate[objectives]=*&populate[technologies]=*` + const url = `${STRAPI_BASE}/api/experiences?populate=*&populate[objectives]=*&populate[technologies]=*` console.log(`[Strapi] → GET ${url}`) const res = await fetch(url, { headers: buildHeaders() }) if (!res.ok) @@ -21,6 +21,21 @@ export const fetchExperiences = createServerFn({ method: 'GET' }).handler( }, ) +export const fetchExperience = createServerFn({ method: 'GET' }) + .inputValidator((slug: string) => slug) + .handler(async ({ data: slug }): Promise => { + const url = `${STRAPI_BASE}/api/experiences?filters[slug][$eq]=${slug}&populate=*&populate[objectives]=*&populate[technologies]=*` + console.log(`[Strapi] → GET ${url}`) + const res = await fetch(url, { headers: buildHeaders() }) + if (!res.ok) + throw new Error(`Strapi request failed: ${res.status} ${res.url}`) + const json = (await res.json()) as { data: TExperience[] } + const item = json.data[0] + if (!item) throw new Error(`Experience not found: ${slug}`) + console.log(`[Strapi] ← GET ${url} OK (${item.company})`) + return item + }) + export const fetchEducations = createServerFn({ method: 'GET' }).handler( async (): Promise => { const url = `${STRAPI_BASE}/api/educations?populate=*` diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 421daf2..98063f1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +import { Route as ExperienceSlugRouteImport } from './routes/experience/$slug' const AboutRoute = AboutRouteImport.update({ id: '/about', @@ -22,31 +23,40 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ExperienceSlugRoute = ExperienceSlugRouteImport.update({ + id: '/experience/$slug', + path: '/experience/$slug', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute + '/experience/$slug': typeof ExperienceSlugRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute + '/experience/$slug': typeof ExperienceSlugRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/about': typeof AboutRoute + '/experience/$slug': typeof ExperienceSlugRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' + fullPaths: '/' | '/about' | '/experience/$slug' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' - id: '__root__' | '/' | '/about' + to: '/' | '/about' | '/experience/$slug' + id: '__root__' | '/' | '/about' | '/experience/$slug' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute + ExperienceSlugRoute: typeof ExperienceSlugRoute } declare module '@tanstack/react-router' { @@ -65,12 +75,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/experience/$slug': { + id: '/experience/$slug' + path: '/experience/$slug' + fullPath: '/experience/$slug' + preLoaderRoute: typeof ExperienceSlugRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, + ExperienceSlugRoute: ExperienceSlugRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/experience/$slug.tsx b/src/routes/experience/$slug.tsx new file mode 100644 index 0000000..88da3a4 --- /dev/null +++ b/src/routes/experience/$slug.tsx @@ -0,0 +1,134 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { fetchExperience } from '#/data/loaders/portfolio' +import { computeDuration, formatDate } from '#/data/portfolio' +import type { TExperience } from '#/types/strapi' + +export const Route = createFileRoute('/experience/$slug')({ + loader: ({ params }) => fetchExperience({ data: params.slug }), + errorComponent: ExperienceErrorPage, + component: ExperienceDetailPage, +}) + +// ── Error page ──────────────────────────────────────────────────────────── + +function ExperienceErrorPage() { + return ( +
+

Not Found

+

Experience not found

+ + ← Back to portfolio + +
+ ) +} + +// ── Shared primitives ───────────────────────────────────────────────────── + +const EMPLOYMENT_TYPE_LABEL: Record<'full-time' | 'part-time', string> = { + 'full-time': 'Full-time', + 'part-time': 'Part-time', +} + +function TechChip({ label }: { readonly label: string }) { + return ( + + {label} + + ) +} + +// ── Detail page ─────────────────────────────────────────────────────────── + +function ExperienceDetailPage() { + const exp: TExperience = Route.useLoaderData() + + const startIso = exp.startDate.slice(0, 7) + const endIso = exp.endDate?.slice(0, 7) + const duration = computeDuration(startIso, endIso) + const startLabel = formatDate(startIso) + const endLabel = endIso ? formatDate(endIso) : 'Present' + + return ( +
+ {/* ── Back link ─────────────────────────────────────── */} +
+ + Back + +
+ + {/* ── Header ────────────────────────────────────────── */} +
+
+
+ +

{exp.company}

+

+ {exp.role} +

+ + {/* Meta row */} +
+ {exp.type != null && ( + + {EMPLOYMENT_TYPE_LABEL[exp.type]} + + )} + + {startLabel} — {endLabel} + + + {duration} +
+ + {/* Technologies */} + {exp.technologies.length > 0 && ( +
+ {exp.technologies.map((tech) => ( + + ))} +
+ )} +
+ + {/* ── Objectives ────────────────────────────────────── */} + {exp.objectives.length > 0 && ( +
+

Responsibilities

+
+
    + {exp.objectives.map((obj) => ( +
  • + + {obj.description} +
  • + ))} +
+
+
+ )} + + {/* ── Body (markdown) ───────────────────────────────── */} + {exp.body != null && exp.body.trim().length > 0 && ( +
+
+
+ + {exp.body} + +
+
+
+ )} +
+ ) +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 2e46c37..f3773d7 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' import { computeDuration, formatDate, CONTACT_EMAIL } from '#/data/portfolio' import type { TExperience, TEducation, TSkill } from '#/types/strapi' import { @@ -128,12 +128,16 @@ function ExperienceCard({ {/* Card */}
-
+

{exp.company}

-

+

{exp.role}

-
+ {exp.type != null && ( {EMPLOYMENT_TYPE_LABEL[exp.type]} diff --git a/src/types/strapi.ts b/src/types/strapi.ts index a97ceb3..2e4d5df 100644 --- a/src/types/strapi.ts +++ b/src/types/strapi.ts @@ -126,6 +126,8 @@ export type TExperience = { readonly documentId: string readonly company: string readonly role: string + readonly slug: string + readonly body: string | null readonly startDate: string readonly endDate: string | null readonly type: 'full-time' | 'part-time' | null From efadaa691845e39f0bb1229eb6990cbc7a98e251 Mon Sep 17 00:00:00 2001 From: Mohammad Hariri Date: Sun, 19 Apr 2026 12:57:03 +0200 Subject: [PATCH 3/3] fix: guard optional objectives/technologies fields at type and render layer --- src/data/loaders/portfolio.ts | 6 +++++- src/routes/experience/$slug.tsx | 8 ++++---- src/routes/index.tsx | 8 ++++---- src/types/strapi.ts | 4 ++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/data/loaders/portfolio.ts b/src/data/loaders/portfolio.ts index 0cf8ef3..7c0f8cc 100644 --- a/src/data/loaders/portfolio.ts +++ b/src/data/loaders/portfolio.ts @@ -17,7 +17,11 @@ export const fetchExperiences = createServerFn({ method: 'GET' }).handler( throw new Error(`Strapi request failed: ${res.status} ${res.url}`) const json = (await res.json()) as { data: TExperience[] } console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`) - return json.data + return json.data.map((item) => ({ + ...item, + objectives: item.objectives ?? [], + technologies: item.technologies ?? [], + })) }, ) diff --git a/src/routes/experience/$slug.tsx b/src/routes/experience/$slug.tsx index 88da3a4..2a92cd3 100644 --- a/src/routes/experience/$slug.tsx +++ b/src/routes/experience/$slug.tsx @@ -88,9 +88,9 @@ function ExperienceDetailPage() {
{/* Technologies */} - {exp.technologies.length > 0 && ( + {(exp.technologies ?? []).length > 0 && (
- {exp.technologies.map((tech) => ( + {(exp.technologies ?? []).map((tech) => ( ))}
@@ -98,12 +98,12 @@ function ExperienceDetailPage() { {/* ── Objectives ────────────────────────────────────── */} - {exp.objectives.length > 0 && ( + {(exp.objectives ?? []).length > 0 && (

Responsibilities

    - {exp.objectives.map((obj) => ( + {(exp.objectives ?? []).map((obj) => (
  • {duration}
- {exp.objectives.length > 0 && ( + {(exp.objectives ?? []).length > 0 && (
    - {exp.objectives.map((obj) => ( + {(exp.objectives ?? []).map((obj) => (
  • )} - {exp.technologies.length > 0 && ( + {(exp.technologies ?? []).length > 0 && (
    - {exp.technologies.map((tech) => ( + {(exp.technologies ?? []).map((tech) => ( ))}
    diff --git a/src/types/strapi.ts b/src/types/strapi.ts index 2e4d5df..55d3ccf 100644 --- a/src/types/strapi.ts +++ b/src/types/strapi.ts @@ -132,8 +132,8 @@ export type TExperience = { readonly endDate: string | null readonly type: 'full-time' | 'part-time' | null readonly locale?: string - readonly objectives: readonly TObjective[] - readonly technologies: readonly TTechnology[] + readonly objectives?: readonly TObjective[] + readonly technologies?: readonly TTechnology[] } export type TEducation = {