5 Commits

6 changed files with 386 additions and 47 deletions

View File

@@ -0,0 +1,67 @@
import { createServerFn } from '@tanstack/react-start'
import type { TExperience, TEducation, TProfile } from '#/types/strapi'
const STRAPI_BASE = process.env['STRAPI_URL'] ?? 'https://strapi.moshariri.com'
const buildHeaders = (): Record<string, string> => {
const token = process.env['STRAPI_API_TOKEN']
return token ? { Authorization: `Bearer ${token}` } : {}
}
export const fetchExperiences = createServerFn({ method: 'GET' }).handler(
async (): Promise<TExperience[]> => {
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)
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.map((item) => ({
...item,
objectives: item.objectives ?? [],
technologies: item.technologies ?? [],
}))
},
)
export const fetchExperience = createServerFn({ method: 'GET' })
.inputValidator((slug: string) => slug)
.handler(async ({ data: slug }): Promise<TExperience> => {
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<TEducation[]> => {
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 fetchProfile = createServerFn({ method: 'GET' }).handler(
async (): Promise<TProfile> => {
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
},
)

View File

@@ -1,6 +1,6 @@
import { strapi } from '@strapi/client' import { strapi } from '@strapi/client'
// Strapi base URL (without /api) // Strapi base URL (without /api) — client-safe env var only
const STRAPI_BASE = import.meta.env.VITE_STRAPI_URL ?? 'http://localhost:1337' const STRAPI_BASE = import.meta.env.VITE_STRAPI_URL ?? 'http://localhost:1337'
// Initialize the Strapi SDK with /api endpoint // Initialize the Strapi SDK with /api endpoint

View File

@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as AboutRouteImport } from './routes/about' import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as ExperienceSlugRouteImport } from './routes/experience/$slug'
const AboutRoute = AboutRouteImport.update({ const AboutRoute = AboutRouteImport.update({
id: '/about', id: '/about',
@@ -22,31 +23,40 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ExperienceSlugRoute = ExperienceSlugRouteImport.update({
id: '/experience/$slug',
path: '/experience/$slug',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/experience/$slug': typeof ExperienceSlugRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/experience/$slug': typeof ExperienceSlugRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/experience/$slug': typeof ExperienceSlugRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' fullPaths: '/' | '/about' | '/experience/$slug'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' to: '/' | '/about' | '/experience/$slug'
id: '__root__' | '/' | '/about' id: '__root__' | '/' | '/about' | '/experience/$slug'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute AboutRoute: typeof AboutRoute
ExperienceSlugRoute: typeof ExperienceSlugRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -65,12 +75,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/experience/$slug': {
id: '/experience/$slug'
path: '/experience/$slug'
fullPath: '/experience/$slug'
preLoaderRoute: typeof ExperienceSlugRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AboutRoute: AboutRoute, AboutRoute: AboutRoute,
ExperienceSlugRoute: ExperienceSlugRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -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 (
<div className="page-wrap flex min-h-[60vh] flex-col items-center justify-center gap-4 text-center">
<p className="island-kicker">Not Found</p>
<h1 className="display-title text-4xl">Experience not found</h1>
<Link to="/" className="nav-link">
Back to portfolio
</Link>
</div>
)
}
// ── 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 (
<span className="inline-block rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--lagoon-deep)]">
{label}
</span>
)
}
// ── 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 (
<main className="page-wrap px-4 pb-16 pt-10">
{/* ── Back link ─────────────────────────────────────── */}
<div className="mb-6">
<Link
to="/"
className="nav-link inline-flex items-center gap-1.5 text-sm"
>
<span aria-hidden="true"></span> Back
</Link>
</div>
{/* ── Header ────────────────────────────────────────── */}
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-12">
<div className="pointer-events-none absolute -left-20 -top-20 h-64 w-64 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.25),transparent_65%)]" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-64 w-64 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.15),transparent_65%)]" />
<p className="island-kicker mb-2">{exp.company}</p>
<h1 className="display-title mb-4 text-4xl font-bold leading-tight text-[var(--sea-ink)]">
{exp.role}
</h1>
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{exp.type != null && (
<span className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--sea-ink-soft)]">
{EMPLOYMENT_TYPE_LABEL[exp.type]}
</span>
)}
<span className="text-sm text-[var(--sea-ink-soft)]">
{startLabel} {endLabel}
</span>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span className="text-sm text-[var(--sea-ink-soft)]">{duration}</span>
</div>
{/* Technologies */}
{(exp.technologies ?? []).length > 0 && (
<div className="mt-5 flex flex-wrap gap-1.5">
{(exp.technologies ?? []).map((tech) => (
<TechChip key={tech.id} label={tech.name} />
))}
</div>
)}
</section>
{/* ── Objectives ────────────────────────────────────── */}
{(exp.objectives ?? []).length > 0 && (
<section className="mt-10">
<h2 className="island-kicker mb-4">Responsibilities</h2>
<div className="island-shell feature-card rounded-2xl p-6">
<ul className="ml-0 list-none space-y-3 p-0">
{(exp.objectives ?? []).map((obj) => (
<li
key={obj.id}
className="flex gap-3 text-sm leading-relaxed text-[var(--sea-ink-soft)]"
>
<span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[var(--lagoon)]" />
{obj.description}
</li>
))}
</ul>
</div>
</section>
)}
{/* ── Body (markdown) ───────────────────────────────── */}
{exp.body != null && exp.body.trim().length > 0 && (
<section className="mt-10">
<div className="island-shell feature-card rounded-2xl p-6 sm:p-8">
<article className="prose prose-sm max-w-none prose-headings:font-bold prose-headings:text-[var(--sea-ink)] prose-p:text-[var(--sea-ink-soft)] prose-li:text-[var(--sea-ink-soft)] prose-strong:text-[var(--sea-ink)] prose-a:text-[var(--lagoon-deep)]">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{exp.body}
</ReactMarkdown>
</article>
</div>
</section>
)}
</main>
)
}

View File

@@ -1,15 +1,42 @@
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 { import {
experiences, fetchExperiences,
educations, fetchEducations,
skillGroups, fetchProfile,
computeDuration, } from '#/data/loaders/portfolio'
formatDate,
CONTACT_EMAIL,
} from '#/data/portfolio'
import type { Experience, Education, SkillGroup } from '#/data/portfolio'
export const Route = createFileRoute('/')({ component: HomePage }) export const Route = createFileRoute('/')({
loader: async () => {
const [experiences, educations, profile] = await Promise.all([
fetchExperiences(),
fetchEducations(),
fetchProfile(),
])
return { experiences, educations, profile }
},
errorComponent: StrapiErrorPage,
component: HomePage,
})
// ── Error page ────────────────────────────────────────────────────────────
function StrapiErrorPage() {
return (
<div className="page-wrap flex min-h-[60vh] flex-col items-center justify-center gap-4 text-center">
<p className="island-kicker">Service Unavailable</p>
<h1 className="display-title text-4xl">We'll be right back</h1>
<p
style={{ color: 'var(--sea-ink-soft)' }}
className="max-w-md text-base"
>
The portfolio data is temporarily unavailable. Please check back in a
few minutes.
</p>
</div>
)
}
// ── Shared UI primitives ────────────────────────────────────────────────── // ── Shared UI primitives ──────────────────────────────────────────────────
@@ -69,16 +96,23 @@ function SectionHeading({
// ── Experience card ─────────────────────────────────────────────────────── // ── Experience card ───────────────────────────────────────────────────────
const EMPLOYMENT_TYPE_LABEL: Record<'full-time' | 'part-time', string> = {
'full-time': 'Full-time',
'part-time': 'Part-time',
}
function ExperienceCard({ function ExperienceCard({
exp, exp,
index, index,
}: { }: {
readonly exp: Experience readonly exp: TExperience
readonly index: number readonly index: number
}) { }) {
const duration = computeDuration(exp.startDate, exp.endDate ?? undefined) const startIso = exp.startDate.slice(0, 7)
const startLabel = formatDate(exp.startDate) const endIso = exp.endDate?.slice(0, 7)
const endLabel = exp.endDate ? formatDate(exp.endDate) : 'Present' const duration = computeDuration(startIso, endIso)
const startLabel = formatDate(startIso)
const endLabel = endIso ? formatDate(endIso) : 'Present'
return ( return (
<div <div
@@ -94,15 +128,21 @@ function ExperienceCard({
{/* Card */} {/* Card */}
<div className="island-shell feature-card mb-6 flex-1 rounded-2xl p-5 pb-6"> <div className="island-shell feature-card mb-6 flex-1 rounded-2xl p-5 pb-6">
<div className="mb-2 flex flex-wrap items-start justify-between gap-2"> <div className="mb-2 flex flex-wrap items-start justify-between gap-2">
<div> <Link
to="/experience/$slug"
params={{ slug: exp.slug }}
className="group min-w-0"
>
<p className="island-kicker mb-0.5">{exp.company}</p> <p className="island-kicker mb-0.5">{exp.company}</p>
<h3 className="m-0 text-base font-bold text-[var(--sea-ink)]"> <h3 className="m-0 text-base font-bold text-[var(--sea-ink)] transition group-hover:text-[var(--lagoon-deep)]">
{exp.role} {exp.role}
</h3> </h3>
</div> </Link>
<span className="mt-0.5 flex-shrink-0 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--sea-ink-soft)]"> {exp.type != null && (
{exp.type} <span className="mt-0.5 flex-shrink-0 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--sea-ink-soft)]">
</span> {EMPLOYMENT_TYPE_LABEL[exp.type]}
</span>
)}
</div> </div>
<div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]"> <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
@@ -111,28 +151,26 @@ function ExperienceCard({
</span> </span>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" /> <span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span>{duration}</span> <span>{duration}</span>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span>{exp.location}</span>
</div> </div>
{exp.bullets.length > 0 && ( {(exp.objectives ?? []).length > 0 && (
<ul className="mb-3 ml-0 list-none space-y-1.5 p-0"> <ul className="mb-3 ml-0 list-none space-y-1.5 p-0">
{exp.bullets.map((bullet) => ( {(exp.objectives ?? []).map((obj) => (
<li <li
key={bullet} key={obj.id}
className="flex gap-2 text-sm text-[var(--sea-ink-soft)]" className="flex gap-2 text-sm text-[var(--sea-ink-soft)]"
> >
<span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[var(--lagoon)]" /> <span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[var(--lagoon)]" />
{bullet} {obj.description}
</li> </li>
))} ))}
</ul> </ul>
)} )}
{exp.tech.length > 0 && ( {(exp.technologies ?? []).length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{exp.tech.map((t) => ( {(exp.technologies ?? []).map((tech) => (
<TechChip key={t} label={t} /> <TechChip key={tech.id} label={tech.name} />
))} ))}
</div> </div>
)} )}
@@ -143,18 +181,22 @@ function ExperienceCard({
// ── Education card ──────────────────────────────────────────────────────── // ── Education card ────────────────────────────────────────────────────────
function EducationCard({ edu }: { readonly edu: Education }) { function EducationCard({ edu }: { readonly edu: TEducation }) {
const startYear = new Date(edu.startDate).getFullYear()
const endYear = edu.endDate ? new Date(edu.endDate).getFullYear() : null
return ( return (
<div className="island-shell feature-card rise-in rounded-2xl p-6"> <div className="island-shell feature-card rise-in rounded-2xl p-6">
<p className="island-kicker mb-2">{edu.institution}</p> <p className="island-kicker mb-2">{edu.institution}</p>
<h3 className="mb-2 text-base font-bold leading-snug text-[var(--sea-ink)]"> <h3 className="mb-2 text-base font-bold leading-snug text-[var(--sea-ink)]">
{edu.degree} {edu.degree}
{edu.fieldOfStudy ? ` ${edu.fieldOfStudy}` : ''}
</h3> </h3>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
<span> <span>
{edu.startYear} {edu.endYear} {startYear} {endYear ?? 'Present'}
</span> </span>
{edu.grade !== null && ( {edu.grade != null && (
<> <>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" /> <span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span className="font-semibold text-[var(--lagoon-deep)]"> <span className="font-semibold text-[var(--lagoon-deep)]">
@@ -169,13 +211,19 @@ function EducationCard({ edu }: { readonly edu: Education }) {
// ── Skill group card ────────────────────────────────────────────────────── // ── Skill group card ──────────────────────────────────────────────────────
function SkillGroupCard({ group }: { readonly group: SkillGroup }) { function SkillGroupCard({
category,
skills,
}: {
readonly category: string
readonly skills: readonly TSkill[]
}) {
return ( return (
<div className="island-shell feature-card rise-in rounded-2xl p-5"> <div className="island-shell feature-card rise-in rounded-2xl p-5">
<p className="island-kicker mb-3">{group.category}</p> <p className="island-kicker mb-3">{category}</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{group.skills.map((skill) => ( {skills.map((skill) => (
<TechChip key={skill} label={skill} /> <TechChip key={skill.id} label={skill.name} />
))} ))}
</div> </div>
</div> </div>
@@ -185,6 +233,17 @@ function SkillGroupCard({ group }: { readonly group: SkillGroup }) {
// ── Page ────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────
function HomePage() { function HomePage() {
const { experiences, educations, profile } = Route.useLoaderData()
const skillsByGroup = profile.skills.reduce<Record<string, TSkill[]>>(
(acc, skill) => {
const key = skill.group
acc[key] = [...(acc[key] ?? []), skill]
return acc
},
{},
)
return ( return (
<main className="page-wrap px-4 pb-16 pt-10"> <main className="page-wrap px-4 pb-16 pt-10">
{/* ── Hero ──────────────────────────────────────────── */} {/* ── Hero ──────────────────────────────────────────── */}
@@ -197,12 +256,11 @@ function HomePage() {
Software Engineer · Copenhagen, Denmark Software Engineer · Copenhagen, Denmark
</p> </p>
<h1 className="display-title mb-4 max-w-2xl text-5xl font-bold leading-[1.02] tracking-tight text-[var(--sea-ink)] sm:text-7xl"> <h1 className="display-title mb-4 max-w-2xl text-5xl font-bold leading-[1.02] tracking-tight text-[var(--sea-ink)] sm:text-7xl">
Hi, I'm Mohammad. Hi, I'm {profile.firstname}.
</h1> </h1>
<p className="mb-8 max-w-xl text-base leading-relaxed text-[var(--sea-ink-soft)] sm:text-lg"> <p className="mb-8 max-w-xl text-base leading-relaxed text-[var(--sea-ink-soft)] sm:text-lg">
Crafting robust full-stack solutions with a passion for clean {profile.about ??
architecture, great developer experience, and shipping products people 'Crafting robust full-stack solutions with a passion for clean architecture, great developer experience, and shipping products people love.'}
love.
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -247,8 +305,12 @@ function HomePage() {
id="skills" id="skills"
/> />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{skillGroups.map((group) => ( {Object.entries(skillsByGroup).map(([groupKey, skills]) => (
<SkillGroupCard key={group.category} group={group} /> <SkillGroupCard
key={groupKey}
category={groupKey}
skills={skills}
/>
))} ))}
</div> </div>
</section> </section>

View File

@@ -3,7 +3,7 @@
* These types match the Strapi Cloud Template Blog schema * These types match the Strapi Cloud Template Blog schema
*/ */
import type { Block } from '@/components/blocks' import type { Block } from '#/components/blocks'
// Base image type from Strapi media library // Base image type from Strapi media library
export type TImage = { export type TImage = {
@@ -88,3 +88,61 @@ export type TStrapiResponse<T = null> = {
pagination?: TStrapiPagination pagination?: TStrapiPagination
} }
} }
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 = {
readonly id: number
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
readonly locale?: string
readonly objectives?: readonly TObjective[]
readonly technologies?: readonly TTechnology[]
}
export type TEducation = {
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
}