Fikirtepe Urban Renewal
One of Istanbul's largest urban renewal projects — 15,000 housing units modernised across the Fikirtepe district.
- Istanbul, Kadıköy
- 2023
Project and case-study card with editorial status states and grid or feature layouts — overlay links and soft-failure item handling.
Use when listing portfolio / case-study / completed-projects cards on a public page or embedded business-profile widget. The grid variant fits magazine grids (compose with magazine-layout + filter-bar + page-hero for the full page assembly with zero new code); the feature variant fits embedded mosaic widgets (designed for the future bento-grid-01 layout). Status is editorial — set on the data object by an editor, NOT derived from a clock; differs from event-card's time-window kernel by design. The public PROJECT_STATUS_CONFIG export lets consumers build status legends, count summaries, and filter rows that share the same color / label vocabulary as the card. Migration origin: kasder kas-social-front-v0 ProjectCard.tsx + BusinessProjectsSection.tsx.
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/project-cardAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/project-card-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Grid variant — vertical image-on-top, hover-reveal "View details" CTA, lift-on-hover. Compose with magazine-layout for the public projects-page assembly (see usage notes).
One of Istanbul's largest urban renewal projects — 15,000 housing units modernised across the Fikirtepe district.
Earthquake-resistant modern housing complex built in the aftermath of the 2020 İzmir earthquake.
Net-zero carbon-footprint living district powered by renewable energy and integrated mobility.
Restoration of the UNESCO World Heritage historical centre of Bursa, balancing daily life with preservation.
Modern, accessible public-space design along the Black Sea coastline, integrating recreation and ecology.
Conversion of the historic port district into a tourism and culture hub — currently in detailed-planning phase.
"use client"; import { useState } from "react";import { Bookmark, BookmarkCheck, Share2 } from "lucide-react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { ProjectCard } from "./project-card";import { dummyCategoryStyles, dummyProjects, dummyTrCategoryStyles, dummyTrLabels, dummyTrProjects,} from "./dummy-data";import type { ProjectCardItem } from "./types"; const HREF_BASE = "/projects"; function makeHref(project: ProjectCardItem) { return `${HREF_BASE}/${project.id}`;} function ActionsCluster({ projectId, saved, onToggle,}: { projectId: string; saved: boolean; onToggle: (id: string) => void;}) { return ( <div className="flex gap-1.5"> <Button variant="secondary" size="sm" onClick={(e) => { e.preventDefault(); e.stopPropagation(); onToggle(projectId); }} aria-label={saved ? "Remove from saved projects" : "Save project"} > {saved ? ( <BookmarkCheck aria-hidden="true" className="size-4" /> ) : ( <Bookmark aria-hidden="true" className="size-4" /> )} </Button> <Button variant="secondary" size="sm" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }} aria-label="Share project" > <Share2 aria-hidden="true" className="size-4" /> </Button> </div> );} const featuredProject = dummyProjects.find((p) => p.featured)!;const ongoingProject = dummyProjects.find( (p) => p.status === "ongoing" && !p.featured,)!; /** * Bento-pattern className per index-within-batch-of-5 (mirrors kasder * `getLgPattern`). Inline preview of what `bento-grid-01` will eventually own. */function bentoClassFor(index: number, batchSize: number): string { const indexInBatch = index % 5; switch (batchSize) { case 1: return "lg:col-span-3 lg:row-span-2"; case 2: return indexInBatch === 0 ? "lg:col-span-2 lg:row-span-1" : "lg:col-span-1 lg:row-span-1"; case 3: if (indexInBatch === 2) return "lg:col-span-2 lg:row-span-2"; return "lg:col-span-1 lg:row-span-1"; case 4: if (indexInBatch === 0) return "lg:col-span-1 lg:row-span-2"; if (indexInBatch < 3) return "lg:col-span-1 lg:row-span-1"; return "lg:col-span-2 lg:row-span-1"; case 5: default: if (indexInBatch === 0) return "lg:col-span-2 lg:row-span-1"; return "lg:col-span-1 lg:row-span-1"; }} export default function ProjectCardDemo() { const [saved, setSaved] = useState<Set<string>>(() => new Set()); const toggleSaved = (id: string) => setSaved((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); // Bento layout: process projects in batches of 5 const bentoBatches: { items: ProjectCardItem[]; batchSize: number }[] = []; for (let i = 0; i < dummyProjects.length; i += 5) { const batch = dummyProjects.slice(i, i + 5); bentoBatches.push({ items: batch, batchSize: batch.length }); } return ( <Tabs defaultValue="grid" className="w-full"> <SwipeTabsList> <TabsTrigger value="grid">Grid</TabsTrigger> <TabsTrigger value="feature">Feature (bento)</TabsTrigger> <TabsTrigger value="featured">Featured</TabsTrigger> <TabsTrigger value="localized">Localized (TR)</TabsTrigger> <TabsTrigger value="actions">Actions slot</TabsTrigger> </SwipeTabsList> {/* 1. Grid — vertical image-on-top, 1/2/3-col responsive */} <TabsContent value="grid" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> Grid variant — vertical image-on-top, hover-reveal "View details" CTA, lift-on-hover. Compose with{" "} <code className="text-xs px-1.5 py-0.5 rounded bg-muted"> magazine-layout </code>{" "} for the public projects-page assembly (see usage notes). </p> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> {dummyProjects.map((project) => ( <ProjectCard key={project.id} project={project} variant="grid" href={makeHref(project)} categoryStyles={dummyCategoryStyles} /> ))} </div> </TabsContent> {/* 2. Feature (bento) — full-bleed background, no hover-CTA */} <TabsContent value="feature" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> Feature variant — full-bleed image background, white-on-dark text, no hover-CTA. Designed to live inside{" "} <code className="text-xs px-1.5 py-0.5 rounded bg-muted"> bento-grid-01 </code>{" "} (deferred). Until that ships, the consumer drives sizing via inline <code className="text-xs px-1.5 py-0.5 rounded bg-muted"> lg:col-span-X lg:row-span-Y </code>{" "} on the card's <code className="text-xs px-1.5 py-0.5 rounded bg-muted"> className </code>. </p> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 auto-rows-[180px] gap-4"> {bentoBatches.flatMap(({ items, batchSize }) => items.map((project, indexInBatch) => ( <ProjectCard key={project.id} project={project} variant="feature" href={makeHref(project)} categoryStyles={dummyCategoryStyles} className={bentoClassFor(indexInBatch, batchSize)} /> )), )} </div> </TabsContent> {/* 3. Featured — grid + feature side-by-side comparison */} <TabsContent value="featured" className="mt-6"> <div className="space-y-8"> <div> <p className="mb-3 text-sm text-muted-foreground"> Grid variant — top accent border + star prefix on title. </p> <div className="grid md:grid-cols-2 gap-6"> <ProjectCard project={featuredProject} variant="grid" href={makeHref(featuredProject)} categoryStyles={dummyCategoryStyles} /> <ProjectCard project={ongoingProject} variant="grid" href={makeHref(ongoingProject)} categoryStyles={dummyCategoryStyles} /> </div> </div> <div> <p className="mb-3 text-sm text-muted-foreground"> Feature variant — inset ring + star prefix on title. </p> <div className="grid md:grid-cols-2 gap-4 auto-rows-[220px]"> <ProjectCard project={featuredProject} variant="feature" href={makeHref(featuredProject)} categoryStyles={dummyCategoryStyles} /> <ProjectCard project={ongoingProject} variant="feature" href={makeHref(ongoingProject)} categoryStyles={dummyCategoryStyles} /> </div> </div> </div> </TabsContent> {/* 4. Localized (TR) */} <TabsContent value="localized" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> Turkish project data + labels + category-style map. Mirrors kasder defaults end-to-end; proves the full i18n surface. </p> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> {dummyTrProjects.map((project) => ( <ProjectCard key={project.id} project={project} variant="grid" href={makeHref(project)} categoryStyles={dummyTrCategoryStyles} labels={dummyTrLabels} /> ))} </div> </TabsContent> {/* 5. Actions slot + custom href */} <TabsContent value="actions" className="mt-6"> <div className="space-y-2"> <p className="text-sm text-muted-foreground"> Action buttons stop propagation — clicks on Save / Share don't navigate. The rest of the card surface still does. Custom{" "} <code className="text-xs px-1.5 py-0.5 rounded bg-muted"> getHref </code>{" "} routes via{" "} <code className="text-xs px-1.5 py-0.5 rounded bg-muted"> /portfolio/{"{id}"} </code>{" "} instead of the default{" "} <code className="text-xs px-1.5 py-0.5 rounded bg-muted"> /projects/{"{id}"} </code> . Currently saved:{" "} <span className="font-mono text-xs"> {saved.size > 0 ? Array.from(saved).join(", ") : "(none)"} </span> </p> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> {dummyProjects.slice(0, 3).map((project) => ( <ProjectCard key={project.id} project={project} variant="grid" getHref={(p) => `/portfolio/${p.id}`} categoryStyles={dummyCategoryStyles} actions={ <ActionsCluster projectId={project.id} saved={saved.has(project.id)} onToggle={toggleSaved} /> } /> ))} </div> </div> </TabsContent> </Tabs> );} Reach for ProjectCard when you need a project / case-study / portfolio preview with a 3-state editorial status (completed / in-progress / planned). Two layouts: variant="grid"(vertical image-on-top with a hover-reveal "View details" CTA) and variant="feature" (full-bleed image background, white-on-dark text, designed for embedded mosaic widgets).
import { ProjectCard } from "@/components/project-card";
<ProjectCard
project={{
id: "fikirtepe-renewal",
title: "Fikirtepe Urban Renewal",
category: "Urban Renewal",
location: "Istanbul, Kadıköy",
year: "2023",
image: "/cover.jpg",
description: "15,000 housing units modernised across the district.",
status: "ongoing",
}}
variant="grid"
href="/projects/fikirtepe-renewal"
/>;grid— vertical image-on-top with status pill (top-left), category pill (top-right), primary-tinted gradient overlay, hover-reveal "View details" CTA, lift-on-hover. Renders well in 1/2/3-column responsive grids — pair with magazine-layout.feature — full-bleed image background, white-on-dark content overlaid at the bottom (title + 2-line description). Status pill top-right, category top-left. NO meta row, NO hover-CTA — denser and quieter than grid. Designed for embedded widgets inside bento-grid-01 (deferred) or any consumer-driven sized parent.Status is set on the data object by an editor. The card does NOT derive status from a date or completion-percentage — projects don't have a time-window kernel. The status drives the pill color + label via PROJECT_STATUS_CONFIG:
completed — primary lime pill. Project is done; reads celebratory.ongoing — chart-3 teal pill. Project is currently active. (Originally proposed bg-accent but pro-ui's --accent is a near-white surface token, not a brand color — teal is the readable middle ground.)planned — muted-grey pill with subtle border. Project is scheduled but not yet started; reads quiet, not attention-grabbing.PROJECT_STATUS_CONFIG + ProjectStatus are exported alongside the card. Consumers can read the same color / label map for status legends, filter rows, count summaries — without rendering a card. Pure data, server-component-importable, tree-shakeable.
import {
PROJECT_STATUS_CONFIG,
type ProjectStatus,
} from "@/components/project-card";
function StatusSummary({ projects }: { projects: { status: ProjectStatus }[] }) {
const counts = projects.reduce<Record<ProjectStatus, number>>(
(acc, p) => ({ ...acc, [p.status]: (acc[p.status] ?? 0) + 1 }),
{ completed: 0, ongoing: 0, planned: 0 },
);
return (
<div className="flex gap-3">
{(Object.keys(counts) as ProjectStatus[]).map((s) => (
<span key={s} className={`px-2 py-0.5 rounded-full text-xs ${PROJECT_STATUS_CONFIG[s].className}`}>
{PROJECT_STATUS_CONFIG[s].label}: {counts[s]}
</span>
))}
</div>
);
}Pass linkComponent to swap the underlying anchor:
import NextLink from "next/link";
import { ProjectCard } from "@/components/project-card";
<ProjectCard
project={project}
variant="grid"
href={`/projects/${project.id}`}
linkComponent={NextLink}
/>;href precedence: getHref(project) wins over href wins over project.href wins over "#" fallback.
Default behavior renders a universal Building2 icon + a white-translucent chip on top of the image. Override per-category via the categoryStyles map (default: empty). Each entry can provide a className, an icon, or both:
import { Trees, Shield } from "lucide-react";
<ProjectCard
project={project}
variant="grid"
href={`/projects/${project.id}`}
// Solid backgrounds (90% opacity) for over-image legibility — light tints
// (bg-X/15 text-X) read poorly over photographic content. Use solid fills with
// text-white or text-{token}-foreground.
categoryStyles={{
"Sustainable Development": {
className: "bg-chart-3/90 text-white",
icon: Trees,
},
"Disaster Management": {
className: "bg-warning/90 text-warning-foreground",
icon: Shield,
},
}}
/>;Pass a partial labels object — only the keys you want to override:
<ProjectCard
project={project}
variant="grid"
href={`/projects/${project.id}`}
labels={{
completed: "Tamamlandı",
ongoing: "Devam Ediyor",
planned: "Planlanan",
viewDetails: "Detayları Gör",
featuredAriaLabel: "Öne çıkan proje",
}}
/>;Drop interactive children into actions — they sit at z-10 over the link overlay. When supplied, the category pill yields its top-right slot to actions and moves to bottom-right (grid) OR the status pill yields and stacks under the category pill at top-left (feature). Each nested button MUST call e.stopPropagation()or the card's link will fire too.
<ProjectCard
project={project}
variant="grid"
href={`/projects/${project.id}`}
actions={
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleBookmark(project.id);
}}
aria-label="Bookmark"
>
<Bookmark className="size-4" />
</button>
}
/>;The card is a leaf. Compose the full page from already-shipped pro-comps — zero new code beyond the data layer:
import { useState } from "react";
import NextLink from "next/link";
import { Building2 } from "lucide-react";
import { PageHero } from "@/components/page-hero";
import { FilterBar } from "@/components/filter-bar";
import {
MagazineLayout,
useMagazineFilter,
} from "@/components/magazine-layout";
import { ProjectCard } from "@/components/project-card";
export default function ProjectsPage({ allProjects }) {
const [category, setCategory] = useState<string | null>(null);
const filtered = useMagazineFilter({
items: allProjects,
pageSize: 6,
filterPredicate: (p) => !category || p.category === category,
simulatedLoadingMs: 500,
});
return (
<MagazineLayout
hero={
<PageHero
badge="Projects"
badgeIcon={Building2}
title="Transformations We've Delivered"
titleHighlight="Across Türkiye"
description="Urban renewal, disaster management, and sustainable-development projects."
/>
}
filterBar={
<FilterBar
categories={["Urban Renewal", "Disaster Management", "Sustainable Development"]}
category={category}
onCategoryChange={setCategory}
hideSearch
hideDateRange
/>
}
displayedItems={filtered.displayedItems}
hasMore={filtered.hasMore}
isLoading={filtered.isLoading}
onLoadMore={filtered.loadMore}
renderItem={(project) => (
<ProjectCard
key={project.id}
project={project}
variant="grid"
href={`/projects/${project.id}`}
linkComponent={NextLink}
/>
)}
/>
);
}feature variant sizing contractThe feature variant uses absolute inset-0 for its image and requires a sized parent — the card does NOT impose a default aspect ratio. Without a sized container, the card collapses to zero height. The future bento-grid-01 (layout) will absorb this responsibility; until then, drive sizing with auto-rows-[180px] on the grid container or pass className="lg:col-span-2 lg:row-span-1" per card.
image empty string ⇒ bg-muted placeholder with centered Building2 icon. No broken-image icon.imageAlt undefined ⇒ falls back to title.location / year undefined ⇒ that meta cell omitted (grid). Both undefined ⇒ entire <ul> not rendered.feature variant never renders the meta row regardless (matches source DNA).href / getHref / project.href ⇒ link falls to "#".aria-labelledby pointing to the useId()-generated <h3>id. The link's accessible name is the title — not a flattened blob.ariaLabel when needed (e.g. translated titles).motion-safe:. Reduced-motion users see static cards.<Star> icon prefix on the title (aria-hidden) plus an sr-only announcement (labels.featuredAriaLabel).ArrowRight on the grid hover-CTA is mirrored via rtl:rotate-180 for right-to-left locales.React.memo at the export. Pass stable project references from your data layer to keep memo effective.<img> uses loading="lazy" by default — override via the loading prop for above-fold cards.PROJECT_STATUS_CONFIG) is pure data with zero React imports — safe to import in Server Components.