Skip to content

Commit 9203280

Browse files
authored
Merge pull request #268 from gitcoinco/feat/add-dates-and-read-time
feat: show dates & read time on cards & details page
2 parents 334f681 + 8762231 commit 9203280

File tree

14 files changed

+147
-61
lines changed

14 files changed

+147
-61
lines changed

src/app/apps/[slug]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export default async function AppDetailPage({ params }: PageProps) {
8383
item={app}
8484
breadcrumbHref="/apps"
8585
breadcrumbLabel="Back to Apps"
86+
showDate={false}
8687
relatedSections={[
8788
{
8889
title: 'Related Apps',

src/app/campaigns/[slug]/page.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,35 @@ export default async function CampaignDetailPage({ params }: PageProps) {
6969
},
7070
}
7171

72+
const formatDate = (d: string) =>
73+
new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
74+
75+
const campaignStats = (campaign.matchingPoolUsd || campaign.projectsCount || campaign.startDate) ? (
76+
<dl className="flex flex-wrap gap-x-10 gap-y-4 py-5 border-b border-gray-700">
77+
{campaign.matchingPoolUsd && (
78+
<div>
79+
<dt className="text-xs text-gray-400 uppercase tracking-wider">Matching Pool</dt>
80+
<dd className="mt-1 text-xl font-semibold text-gray-25">{campaign.matchingPoolUsd}</dd>
81+
</div>
82+
)}
83+
{campaign.projectsCount && (
84+
<div>
85+
<dt className="text-xs text-gray-400 uppercase tracking-wider">Projects</dt>
86+
<dd className="mt-1 text-xl font-semibold text-gray-25">{campaign.projectsCount}</dd>
87+
</div>
88+
)}
89+
{campaign.startDate && (
90+
<div>
91+
<dt className="text-xs text-gray-400 uppercase tracking-wider">Period</dt>
92+
<dd className="mt-1 text-xl font-semibold text-gray-25">
93+
{formatDate(campaign.startDate)}
94+
{campaign.endDate ? ` – ${formatDate(campaign.endDate)}` : " – Ongoing"}
95+
</dd>
96+
</div>
97+
)}
98+
</dl>
99+
) : undefined;
100+
72101
return (
73102
<>
74103
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumb) }} />
@@ -79,6 +108,7 @@ export default async function CampaignDetailPage({ params }: PageProps) {
79108
breadcrumbLabel="Back to Campaigns"
80109
ctaUrl={campaign.ctaUrl}
81110
ctaLabel="Visit Campaign"
111+
contentBefore={campaignStats}
82112
relatedSections={[
83113
{
84114
title: 'Related Apps',

src/components/cards/CampaignCard.tsx

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,14 @@ interface CampaignCardProps {
1111
ctaLabel?: string;
1212
}
1313

14-
function getTimelineLabel(startDate?: string, endDate?: string): string | null {
15-
if (!endDate) return null;
16-
const now = new Date();
17-
const end = new Date(endDate);
18-
const start = startDate ? new Date(startDate) : null;
19-
20-
if (end < now) return "Ended";
21-
if (start && start > now) {
22-
const days = Math.ceil(
23-
(start.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
24-
);
25-
return days === 1 ? "Starts in 1 day" : `Starts in ${days} days`;
26-
}
14+
function fmtMonth(dateStr: string) {
15+
return new Date(dateStr).toLocaleDateString("en-US", { month: "short", year: "numeric" });
16+
}
2717

28-
const days = Math.ceil(
29-
(end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
30-
);
31-
if (days < 1) return "Ends today";
32-
return days === 1 ? "1 day left" : `${days} days left`;
18+
function getDateRange(startDate?: string, endDate?: string): string | null {
19+
if (!startDate) return null;
20+
const end = endDate ? fmtMonth(endDate) : "Ongoing";
21+
return `${fmtMonth(startDate)}${end}`;
3322
}
3423

3524
function getStatusBadge(
@@ -76,7 +65,7 @@ export default function CampaignCard({
7665
ctaLabel = "Visit campaign",
7766
}: CampaignCardProps) {
7867
const campaignUrl = `/campaigns/${campaign.slug}`;
79-
const timelineLabel = getTimelineLabel(campaign.startDate, campaign.endDate);
68+
const dateRange = getDateRange(campaign.startDate, campaign.endDate);
8069
const statusBadge = getStatusBadge(campaign.startDate, campaign.endDate);
8170

8271
const metrics = [
@@ -90,10 +79,10 @@ export default function CampaignCard({
9079
label: "Projects",
9180
value: campaign.projectsCount,
9281
},
93-
timelineLabel && {
82+
dateRange && {
9483
icon: Calendar,
95-
label: "Timeline",
96-
value: timelineLabel,
84+
label: "Period",
85+
value: dateRange,
9786
},
9887
].filter(Boolean) as { icon: LucideIcon; label: string; value: string }[];
9988

@@ -139,6 +128,7 @@ export default function CampaignCard({
139128
layout="banner"
140129
banner={campaign.banner}
141130
bannerHeight="h-48"
131+
date={campaign.startDate}
142132
/>
143133
);
144134
}

src/components/cards/CaseStudyCard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ContentCard from './ContentCard'
22
import type { CaseStudy } from '@/lib/types'
3+
import { calcReadTime } from '@/lib/utils'
34

45
interface CaseStudyCardProps {
56
caseStudy: CaseStudy
@@ -14,6 +15,8 @@ export default function CaseStudyCard({ caseStudy }: CaseStudyCardProps) {
1415
tags={caseStudy.tags}
1516
layout="banner"
1617
banner={caseStudy.banner}
18+
readTime={calcReadTime(caseStudy.description)}
19+
date={caseStudy.lastUpdated}
1720
/>
1821
)
1922
}

src/components/cards/ContentCard.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Link from "next/link";
22
import Image from "next/image";
33
import TagsList from "../ui/TagsList";
4+
import ReadTimeBadge from "../ui/ReadTimeBadge";
5+
import { formatRelativeDate } from "@/lib/utils";
46

57
interface ContentCardProps {
68
href: string;
@@ -12,6 +14,8 @@ interface ContentCardProps {
1214
logo?: string;
1315
banner?: string;
1416
bannerHeight?: string;
17+
readTime?: number;
18+
date?: string;
1519
}
1620

1721
export default function ContentCard({
@@ -24,6 +28,8 @@ export default function ContentCard({
2428
logo,
2529
banner = "/content-images/placeholder.png",
2630
bannerHeight,
31+
readTime,
32+
date,
2733
}: ContentCardProps) {
2834
const isBanner = layout === "banner";
2935

@@ -51,6 +57,7 @@ export default function ContentCard({
5157
className="object-cover group-hover:scale-105 transition-transform duration-300"
5258
/>
5359
<div className="absolute inset-0 bg-linear-to-b from-transparent to-gray-950 group-hover:opacity-0 transition-all duration-500" />
60+
{readTime !== undefined && <ReadTimeBadge minutes={readTime} />}
5461
</div>
5562
)}
5663

@@ -77,13 +84,14 @@ export default function ContentCard({
7784
</h3>
7885
</div>
7986

80-
<p className="text-gray-300 font-serif text-sm mb-4 line-clamp-3 flex-grow">
87+
<p className="text-gray-300 font-serif text-sm line-clamp-3 flex-grow">
8188
{shortDescription}
8289
</p>
90+
{date && <p className="text-xs text-gray-500 text-right mt-2">{formatRelativeDate(date)}</p>}
8391
</>
8492

8593
{/* Tags */}
86-
<div className="pt-5 border-t border-gray-500/60 h-[3.7rem]">
94+
<div className="pt-4 border-t border-gray-500/60 h-[3.7rem] mt-3">
8795
<TagsList tags={tags} />
8896
</div>
8997
</div>

src/components/cards/MechanismCard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ContentCard from './ContentCard'
22
import type { Mechanism } from '@/lib/types'
3+
import { calcReadTime } from '@/lib/utils'
34

45
interface MechanismCardProps {
56
mechanism: Mechanism
@@ -14,6 +15,8 @@ export default function MechanismCard({ mechanism }: MechanismCardProps) {
1415
tags={mechanism.tags}
1516
layout="banner"
1617
banner={mechanism.banner}
18+
readTime={calcReadTime(mechanism.description)}
19+
date={mechanism.lastUpdated}
1720
/>
1821
)
1922
}

src/components/cards/ResearchCard.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import Link from "next/link";
22
import Image from "next/image";
33
import ContentCard from "./ContentCard";
44
import type { Research } from "@/lib/types";
5-
import { Badge, Button } from "../ui";
5+
import { Badge, Button, ReadTimeBadge } from "../ui";
6+
import { calcReadTime, formatRelativeDate } from "@/lib/utils";
67

78
interface ResearchCardProps {
89
research: Research;
@@ -13,6 +14,8 @@ export default function ResearchCard({
1314
research,
1415
variant = "default",
1516
}: ResearchCardProps) {
17+
const readTime = calcReadTime(research.description);
18+
1619
if (variant === "sensemaking") {
1720
return (
1821
<Link href={`/research/${research.slug}`}>
@@ -29,6 +32,7 @@ export default function ResearchCard({
2932
className="object-cover"
3033
/>
3134
<div className="absolute inset-0 bg-linear-to-b from-transparent to-gray-900" />
35+
<ReadTimeBadge minutes={readTime} />
3236
</div>
3337
<div className="flex flex-1 flex-col py-6 px-10">
3438
<Badge
@@ -45,6 +49,7 @@ export default function ResearchCard({
4549
<p className="sm:max-w-[60%] mt-2 text-sm text-gray-300 font-serif">
4650
{research.shortDescription}
4751
</p>
52+
<p className="mt-2 text-xs text-gray-500 text-right">{formatRelativeDate(research.lastUpdated)}</p>
4853
<div className="mt-4 flex justify-end">
4954
<Button
5055
variant="ghost"
@@ -72,6 +77,7 @@ export default function ResearchCard({
7277
className="object-cover group-hover:scale-105 transition-transform duration-300"
7378
/>
7479
<div className="absolute inset-0 bg-linear-to-b from-transparent to-gray-900 group-hover:opacity-0 transition-all duration-500" />
80+
<ReadTimeBadge minutes={readTime} />
7581
</div>
7682
<div className="flex flex-1 flex-col px-4 pb-4">
7783
<Badge
@@ -85,9 +91,10 @@ export default function ResearchCard({
8591
<h3 className="-translate-y-1/2 text-md sm:text-xl md:text-2xl text-center font-bold h-18 flex items-center overflow-visible">
8692
<span className="line-clamp-3">{research.name}</span>
8793
</h3>
88-
<p className="text-xs text-gray-400 font-serif line-clamp-4 mb-2">
94+
<p className="text-xs text-gray-400 font-serif line-clamp-4 mb-1">
8995
{research.shortDescription}
9096
</p>
97+
<p className="text-xs text-gray-500 text-right mb-2">{formatRelativeDate(research.lastUpdated)}</p>
9198
<Button
9299
variant="ghost"
93100
className="mt-auto pt-4 w-full flex items-center justify-center gap-3"

src/components/layouts/CategoryContent.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
} from "@/lib/types";
1616
import TagsList from "@/components/ui/TagsList";
1717
import InitialAvatar from "@/components/ui/InitialAvatar";
18+
import { formatRelativeDate } from "@/lib/utils";
1819
import AppCard from "@/components/cards/AppCard";
1920
import MechanismCard from "@/components/cards/MechanismCard";
2021
import ResearchCard from "@/components/cards/ResearchCard";
@@ -68,22 +69,31 @@ const BASE_HREFS: Record<ContentType, string> = {
6869

6970
// ─── Helpers ──────────────────────────────────────────────────────────────────
7071

72+
function sortKey(item: BaseContent): string {
73+
return (item as Campaign).startDate ?? item.lastUpdated;
74+
}
75+
76+
// Active = campaign that hasn't ended yet (no endDate or endDate in the future)
77+
function isActive(item: BaseContent): boolean {
78+
const endDate = (item as Campaign).endDate;
79+
if (endDate === undefined) return false; // not a campaign
80+
return !endDate || new Date(endDate) > new Date();
81+
}
82+
7183
function sortItems(items: BaseContent[], sort: SortOption): BaseContent[] {
7284
const sorted = [...items];
7385
if (sort === "newest")
74-
return sorted.sort((a, b) => b.lastUpdated.localeCompare(a.lastUpdated));
86+
return sorted.sort((a, b) => {
87+
const aActive = isActive(a);
88+
const bActive = isActive(b);
89+
if (aActive !== bActive) return aActive ? -1 : 1;
90+
return sortKey(b).localeCompare(sortKey(a));
91+
});
7592
if (sort === "oldest")
76-
return sorted.sort((a, b) => a.lastUpdated.localeCompare(b.lastUpdated));
93+
return sorted.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
7794
return sorted.sort((a, b) => a.name.localeCompare(b.name));
7895
}
7996

80-
function formatDate(dateStr: string) {
81-
return new Date(dateStr).toLocaleDateString("en-US", {
82-
year: "numeric",
83-
month: "short",
84-
});
85-
}
86-
8797
// ─── Sub-components ───────────────────────────────────────────────────────────
8898

8999
function GridCard({ item, type }: { item: BaseContent; type: ContentType }) {
@@ -147,10 +157,12 @@ function ListRow({
147157
item,
148158
href,
149159
preferLogo,
160+
showDate,
150161
}: {
151162
item: BaseContent;
152163
href: string;
153164
preferLogo: boolean;
165+
showDate: boolean;
154166
}) {
155167
return (
156168
<Link href={href}>
@@ -163,9 +175,11 @@ function ListRow({
163175
<h3 className="font-semibold text-gray-25 line-clamp-3 text-base sm:text-2xl">
164176
{item.name}
165177
</h3>
166-
<span className="text-xs text-gray-500 shrink-0 tabular-nums">
167-
{formatDate(item.lastUpdated)}
168-
</span>
178+
{showDate && (
179+
<span className="text-xs text-gray-500 shrink-0 tabular-nums">
180+
{formatRelativeDate((item as Campaign).startDate ?? item.lastUpdated)}
181+
</span>
182+
)}
169183
</div>
170184
<p className="text-sm text-gray-300 font-serif mt-0.5 line-clamp-2 sm:line-clamp-4">
171185
{item.shortDescription}
@@ -304,6 +318,7 @@ export function CategoryContent({
304318
const sorted = useMemo(() => sortItems(filtered, sort), [filtered, sort]);
305319
const baseHref = BASE_HREFS[type];
306320
const preferLogo = type === "app";
321+
const showDate = type !== "app";
307322

308323
return (
309324
<div>
@@ -353,6 +368,7 @@ export function CategoryContent({
353368
item={item}
354369
href={`${baseHref}/${item.slug}`}
355370
preferLogo={preferLogo}
371+
showDate={showDate}
356372
/>
357373
))}
358374
</div>

src/components/layouts/DetailPageLayout.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,13 @@ export function Breadcrumb({ href, label }: BreadcrumbProps) {
3737
interface HeroImageProps {
3838
src: string;
3939
alt: string;
40-
readTime?: number;
4140
}
4241

43-
export function HeroImage({ src, alt, readTime }: HeroImageProps) {
42+
export function HeroImage({ src, alt }: HeroImageProps) {
4443
return (
4544
<div className="h-64 md:h-80 bg-gray-950 relative overflow-hidden">
4645
<Image src={src} alt={alt} fill sizes="100vw" className="object-cover" />
4746
<div className="absolute inset-0 bg-linear-to-t from-gray-950 to-transparent" />
48-
{readTime !== undefined && (
49-
<div className="absolute top-4 right-4 bg-gray-900 backdrop-blur-sm text-gray-200 px-3 py-1.5 rounded-md">
50-
{readTime} min read
51-
</div>
52-
)}
5347
</div>
5448
);
5549
}

0 commit comments

Comments
 (0)