Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f5c6f0d
Add verified StarLinker portfolio evidence metadata
yotamon Sep 20, 2026
6b67ef8
Clarify RightFlow evidence and Ensemblis development status
yotamon Sep 20, 2026
49d53b1
Strengthen WakeMyWay proof and CartShift ownership
yotamon Sep 20, 2026
45dbaf6
Show every personal project and fix client-work navigation
yotamon Sep 20, 2026
fb1eef9
Add consistent project proof snapshots and direct portfolio navigation
yotamon Sep 20, 2026
2135a56
Expose verifiable architecture and workflow evidence in project case …
yotamon Sep 20, 2026
5983ad4
Fix project lightbox focus management and portfolio navigation
yotamon Sep 20, 2026
c00b040
Replace brittle accent-based portfolio CSS selectors
yotamon Sep 20, 2026
f440124
Use real project update dates in portfolio sitemap
yotamon Sep 20, 2026
a6180b2
Add dedicated social cards for personal project case studies
yotamon Sep 20, 2026
fd99cfd
Improve project metadata and add structured software evidence
yotamon Sep 20, 2026
4ebd015
Tighten project evidence language and avoid overclaiming
yotamon Sep 20, 2026
e03738f
Protect meaningful SSR-safe counter values with regression coverage
yotamon Sep 20, 2026
dfa2c59
Finish Hebrew localization for project proof metadata
yotamon Sep 20, 2026
bab99ae
Add regression coverage for personal portfolio project metadata
yotamon Sep 20, 2026
5f086cd
Align CartShift portfolio narrative with co-founder ownership
yotamon Sep 20, 2026
c3c9cd5
Keep CartShift case-study ownership language precise
yotamon Sep 20, 2026
fe48813
Use one canonical order for all personal portfolio surfaces
yotamon Sep 20, 2026
2605cee
Derive /yotam project order from the portfolio source of truth
yotamon Sep 20, 2026
79c77b9
Update portfolio order regression test
yotamon Sep 20, 2026
34afad9
Clean canonical portfolio ordering implementation
yotamon Sep 20, 2026
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
227 changes: 217 additions & 10 deletions app/[locale]/(standalone)/portfolio/[slug]/ProjectShowcase.tsx

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions app/[locale]/(standalone)/portfolio/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ImageResponse } from 'next/og';
import { getPortfolioShowcase } from '@/lib/portfolio-showcase';

export const alt = 'Yotam Faraggi project case study';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

type Props = {
params: Promise<{ locale: string; slug: string }>;
};

export default async function OpenGraphImage({ params }: Props) {
const { locale, slug } = await params;
const project = getPortfolioShowcase(slug, locale);
const isHebrew = locale === 'he';

const title = project?.title ?? 'Project case study';
const descriptor = project?.descriptor ?? 'Product engineering';
const summary = project?.summary ?? '';
const accent = project?.accent ?? '#7367f0';
const status = project?.status ?? (isHebrew ? 'פרויקט' : 'Project');

return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: '#111113',
color: '#ffffff',
padding: '58px 68px',
fontFamily: 'Arial, Helvetica, sans-serif',
direction: isHebrew ? 'rtl' : 'ltr',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 22, color: 'rgba(255,255,255,.64)', letterSpacing: '.08em' }}>
Yotam Faraggi
</span>
<span
style={{
fontSize: 18,
color: '#111113',
background: accent,
borderRadius: 999,
padding: '12px 18px',
fontWeight: 700,
}}
>
{status}
</span>
</div>

<div style={{ display: 'flex', flexDirection: 'column', gap: 24, maxWidth: 1040 }}>
<span style={{ fontSize: 24, color: accent, letterSpacing: '.08em', textTransform: 'uppercase' }}>
{descriptor}
</span>
<div
style={{
display: 'flex',
fontSize: 92,
lineHeight: 0.92,
letterSpacing: '-.06em',
fontWeight: 700,
}}
>
{title}
</div>
<div
style={{
display: 'flex',
maxWidth: 930,
fontSize: 28,
lineHeight: 1.28,
color: 'rgba(255,255,255,.72)',
}}
>
{summary}
</div>
</div>

<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderTop: '1px solid rgba(255,255,255,.18)',
paddingTop: 22,
fontSize: 19,
color: 'rgba(255,255,255,.5)',
}}
>
<span>{project?.year ?? '2026'}</span>
<span>cart-shift.com/{locale}/portfolio/{slug}</span>
</div>
</div>
),
size
);
}
32 changes: 27 additions & 5 deletions app/[locale]/(standalone)/portfolio/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import Script from 'next/script';
import { notFound } from 'next/navigation';
import { setRequestLocale } from 'next-intl/server';
import ProjectShowcase from './ProjectShowcase';
Expand Down Expand Up @@ -29,8 +30,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const isHebrew = locale === 'he';
const title = `${project.title} - ${project.descriptor} | Yotam Faraggi`;
const description = project.summary;
const image = project.hero?.src.startsWith('/') ? project.hero.src : '/images/portfolio-v2/hero-art.webp';

return {
title,
description,
Expand All @@ -48,14 +47,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
description,
type: 'website',
url: `${siteUrl}/${locale}/portfolio/${slug}`,
images: [{ url: image, alt: project.hero?.alt ?? project.title }],
locale: isHebrew ? 'he_IL' : 'en_US',
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [image],
},
};
}
Expand All @@ -69,6 +66,31 @@ export default async function PortfolioProjectPage({ params }: Props) {
if (!project) notFound();

const nextProject = getNextPortfolioShowcase(slug, validLocale);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://cart-shift.com';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: project.title,
description: project.summary,
url: project.liveUrl ?? `${siteUrl}/${validLocale}/portfolio/${project.slug}`,
dateModified: project.updatedAt,
creator: {
'@type': 'Person',
name: 'Yotam Faraggi',
url: `${siteUrl}/${validLocale}/yotam`,
},
applicationCategory: project.descriptor,
sameAs: [project.liveUrl, project.repositoryUrl].filter(Boolean),
};

return <ProjectShowcase project={project} nextProject={nextProject} locale={validLocale} />;
return (
<>
<Script
id={`project-${project.slug}-jsonld`}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProjectShowcase project={project} nextProject={nextProject} locale={validLocale} />
</>
);
}
26 changes: 13 additions & 13 deletions app/[locale]/(standalone)/portfolio/[slug]/project-showcase.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,34 @@
The global site heading rule otherwise forces a dark heading color even inside
editorial dark sections. Keep this scoped to project showcase pages and preserve
predictable contrast and wrapping across narrow mobile widths. */
main[style*='--project-accent'] h1,
main[style*='--project-accent'] h2,
main[style*='--project-accent'] h3,
main[style*='--project-accent'] h4,
main[style*='--project-accent'] h5,
main[style*='--project-accent'] h6 {
main[data-project] h1,
main[data-project] h2,
main[data-project] h3,
main[data-project] h4,
main[data-project] h5,
main[data-project] h6 {
color: inherit;
}

main[style*='--project-accent'] h1,
main[style*='--project-accent'] h2 {
main[data-project] h1,
main[data-project] h2 {
text-wrap: balance;
}

/* These are single-word product names. The old manual break was purely
decorative and becomes awkward on narrow screens. */
main[style*='--project-accent:#7367f0'] h1 br,
main[style*='--project-accent:#ff8b5f'] h1 br {
main[data-project='starlinker'] h1 br,
main[data-project='wakemyway'] h1 br {
display: none;
}

@media (max-width: 639px) {
main[style*='--project-accent'] h1,
main[style*='--project-accent'] h2 {
main[data-project] h1,
main[data-project] h2 {
line-height: 0.86;
}

main[style*='--project-accent'] h1 {
main[data-project] h1 {
overflow-wrap: normal;
word-break: normal;
}
Expand Down
17 changes: 3 additions & 14 deletions app/[locale]/(standalone)/yotam/YotamProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,17 @@ import { getPortfolioShowcases } from '@/lib/portfolio-showcase';

const ease = [0.22, 1, 0.36, 1] as const;
const reveal = { hidden: { opacity: 0, y: 26 }, visible: { opacity: 1, y: 0 } };
const selectedProjectOrder = ['starlinker', 'rightflow', 'cartshift-studio', 'wakemyway'];

export default function YotamProfile() {
const locale = useLocale();
const messages = useMessages() as { cv: RawCVMessages };
const cv = useMemo(() => buildCVData(messages.cv), [messages]);
const projects = useMemo(() => getPortfolioShowcases(locale), [locale]);
const selectedProjects = useMemo(
() =>
projects
.filter(project => selectedProjectOrder.includes(project.slug))
.sort(
(a, b) =>
selectedProjectOrder.indexOf(a.slug) - selectedProjectOrder.indexOf(b.slug)
),
[projects]
);
const selectedProjects = projects;
const reduceMotion = useReducedMotion();
const isHebrew = locale === 'he';
const profileHref = `/${locale}/yotam`;
const pdfHref = `/${locale}/cv/render?variant=default`;
const portfolioHref = `/${locale}/portfolio`;
const clientWorkHref = `/${locale}/work`;

const copy = isHebrew
? {
Expand Down Expand Up @@ -510,7 +499,7 @@ export default function YotamProfile() {
</div>

<motion.a
href={portfolioHref}
href={clientWorkHref}
{...sectionReveal}
className="group mt-10 grid gap-6 border-y border-black/20 py-8 lg:ms-[10%] lg:grid-cols-[0.28fr_1fr_auto] lg:items-center lg:gap-10"
>
Expand Down
13 changes: 10 additions & 3 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/markdown';
import { getAllCaseStudies } from '@/lib/case-studies';
import { portfolioShowcaseSlugs } from '@/lib/portfolio-showcase';
import { getPortfolioShowcases } from '@/lib/portfolio-showcase';
import { ANALYZER_INTENTS } from '@/lib/analyzer/funnel';
import { adminDb } from '@/lib/firebase-admin';

Expand Down Expand Up @@ -41,6 +41,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {

const posts = await getAllPosts();
const caseStudies = getAllCaseStudies('en');
const portfolioProjects = getPortfolioShowcases('en');

const blogUrls = posts.flatMap(post =>
createLocalizedUrls(baseUrl, `/blog/${post.slug}`, new Date(post.date), 'monthly', 0.7)
Expand All @@ -50,8 +51,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
createLocalizedUrls(baseUrl, `/work/${study.slug}`, undefined, 'monthly', 0.7)
);

const portfolioProjectUrls = portfolioShowcaseSlugs.flatMap(slug =>
createLocalizedUrls(baseUrl, `/portfolio/${slug}`, now, 'monthly', 0.8)
const portfolioProjectUrls = portfolioProjects.flatMap(project =>
createLocalizedUrls(
baseUrl,
`/portfolio/${project.slug}`,
new Date(project.updatedAt),
'monthly',
0.8
)
);

const approvedInsights = adminDb
Expand Down
Loading
Loading