Skip to content

Commit e69e4e0

Browse files
Merge pull request #461 from CodeForAfrica/ft/entity-page-blocks
feat(cms): refactor home page to use block-based tenant and entity se…
2 parents 555affe + a54f6f3 commit e69e4e0

File tree

13 files changed

+459
-155
lines changed

13 files changed

+459
-155
lines changed

public/cms/entity-selection.png

59.3 KB
Loading

public/cms/tenant-selector.png

53.9 KB
Loading

src/app/(frontend)/[...slugs]/page.tsx

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import React, { Suspense } from "react";
22

3-
import { queryPageBySlug } from "@/lib/payload";
3+
import { getGlobalPayload, queryPageBySlug } from "@/lib/payload";
44
import { notFound, redirect } from "next/navigation";
55
import { getDomain } from "@/lib/domain";
66
import { CommonHomePage } from "@/components/CommonHomePage";
77
import { BlockRenderer } from "@/components/BlockRenderer";
88
import Navigation from "@/components/Navigation";
99
import Footer from "@/components/Footer";
10-
import { PoliticalEntityList } from "@/components/PoliticalEntityList";
1110
import { getTenantBySubDomain, getTenantNavigation } from "@/lib/data/tenants";
12-
import {
13-
getPoliticalEntitiesByTenant,
14-
getPromiseCountsForEntities,
15-
} from "@/lib/data/politicalEntities";
11+
import { getPoliticalEntitiesByTenant } from "@/lib/data/politicalEntities";
1612

1713
type Args = {
1814
params: Promise<{
@@ -39,7 +35,7 @@ export default async function Page(params: Args) {
3935
const politicalEntities = await getPoliticalEntitiesByTenant(tenant);
4036

4137
const politicalEntity = politicalEntities.find(
42-
(entity) => entity.slug === maybePoliticalEntitySlug,
38+
(entity) => entity.slug === maybePoliticalEntitySlug
4339
);
4440

4541
if (!politicalEntity) {
@@ -48,18 +44,25 @@ export default async function Page(params: Args) {
4844
if (onlyEntity) {
4945
const fallbackPageSlugs = slugs.length > 0 ? slugs : ["index"];
5046
const sanitizedPageSlugs = fallbackPageSlugs.filter(
51-
(slug) => slug && slug !== "index",
47+
(slug) => slug && slug !== "index"
5248
);
5349
const segments = [onlyEntity.slug, ...sanitizedPageSlugs].filter(
54-
Boolean,
50+
Boolean
5551
);
5652
const targetPath = segments.length > 0 ? `/${segments.join("/")}` : "/";
5753
redirect(targetPath);
5854
}
5955
}
6056
const fallbackPageSlugs = slugs.length > 0 ? slugs : ["index"];
61-
const promiseCounts = await getPromiseCountsForEntities(
62-
politicalEntities.map((entity) => entity.id),
57+
const payload = await getGlobalPayload();
58+
const homePage = await payload.findGlobal({
59+
slug: "home-page",
60+
});
61+
const entityBlocks = (homePage?.entitySelector?.blocks ?? []).map(
62+
(block) =>
63+
block.blockType === "entity-selection"
64+
? { ...block, pageSlugs: fallbackPageSlugs }
65+
: block
6366
);
6467

6568
return (
@@ -69,12 +72,7 @@ export default async function Page(params: Args) {
6972
{...navigation}
7073
tenantSelectionHref={tenantSelectionHref}
7174
/>
72-
<PoliticalEntityList
73-
tenant={tenant}
74-
politicalEntities={politicalEntities}
75-
pageSlugs={fallbackPageSlugs}
76-
promiseCounts={promiseCounts}
77-
/>
75+
<BlockRenderer blocks={entityBlocks} />
7876
<Footer title={title} description={description} {...footer} />
7977
</>
8078
);

src/app/api/meedan-sync/route.ts

Lines changed: 130 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
11
import { NextRequest, NextResponse } from "next/server";
2+
import { unlink } from "node:fs/promises";
23

3-
import {
4-
mapPublishedReports,
5-
type PublishedReportsResponse,
6-
} from "@/lib/meedan";
7-
import { syncMeedanReports } from "@/lib/syncMeedanReports";
8-
import { writeFileSync } from "node:fs";
4+
import { getGlobalPayload } from "@/lib/payload";
5+
import type { Media, Promise as PromiseDoc } from "@/payload-types";
6+
import { downloadFile } from "@/utils/files";
97

108
const WEBHOOK_SECRET_ENV_KEY = "WEBHOOK_SECRET_KEY";
119

10+
type MeedanFile = {
11+
url?: unknown;
12+
};
13+
14+
type MeedanField = {
15+
value?: unknown;
16+
};
17+
18+
type MeedanWebhookPayload = {
19+
data?: {
20+
id?: unknown;
21+
};
22+
object?: {
23+
file?: MeedanFile[];
24+
data?: {
25+
fields?: MeedanField[];
26+
};
27+
};
28+
};
29+
30+
const normaliseString = (value: unknown): string | null => {
31+
if (typeof value === "string") {
32+
const trimmed = value.trim();
33+
return trimmed.length > 0 ? trimmed : null;
34+
}
35+
36+
if (typeof value === "number" && Number.isFinite(value)) {
37+
return String(value);
38+
}
39+
40+
return null;
41+
};
42+
1243
export const POST = async (request: NextRequest) => {
1344
const configuredSecret = process.env[WEBHOOK_SECRET_ENV_KEY];
1445

@@ -17,8 +48,8 @@ export const POST = async (request: NextRequest) => {
1748
"meedan-sync:: Missing WEBHOOK_SECRET_KEY environment variable"
1849
);
1950
return NextResponse.json(
20-
{ error: "Service misconfigured" },
21-
{ status: 500 }
51+
{ ok: false, updated: false, error: "Service misconfigured" },
52+
{ status: 200 }
2253
);
2354
}
2455

@@ -28,50 +59,118 @@ export const POST = async (request: NextRequest) => {
2859
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
2960
}
3061

31-
let parsed: unknown;
62+
let parsed: MeedanWebhookPayload;
3263

3364
try {
34-
parsed = await request.json();
65+
parsed = (await request.json()) as MeedanWebhookPayload;
3566
} catch (_error) {
67+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
68+
}
69+
const meedanId = normaliseString(parsed?.data?.id);
70+
71+
if (!meedanId) {
3672
return NextResponse.json(
3773
{
38-
error: "Invalid JSON body",
74+
error: "Missing Meedan ID",
3975
},
4076
{ status: 400 }
4177
);
4278
}
43-
// TODO: @kelvinkipruto remove this; only for testing
44-
writeFileSync(
45-
`Payload-${Date.now().toString()}.json`,
46-
JSON.stringify(parsed)
47-
);
4879

49-
const reports = mapPublishedReports(
50-
(parsed ?? {}) as PublishedReportsResponse
51-
);
80+
const imageUrl = normaliseString(parsed?.object?.file?.[0]?.url ?? null);
5281

53-
if (reports.length === 0) {
82+
if (!imageUrl) {
5483
return NextResponse.json(
55-
{
56-
error: "No valid reports provided",
57-
},
84+
{ error: "No image URL provided" },
5885
{ status: 400 }
5986
);
6087
}
6188

6289
try {
63-
await syncMeedanReports({
64-
reports,
90+
const payload = await getGlobalPayload();
91+
92+
const { docs } = await payload.find({
93+
collection: "promises",
94+
limit: 1,
95+
where: {
96+
meedanId: {
97+
equals: meedanId,
98+
},
99+
},
65100
});
66101

67-
return NextResponse.json({ ok: true });
102+
const promise = (docs[0] ?? null) as PromiseDoc | null;
103+
104+
if (!promise) {
105+
return NextResponse.json({ error: "Promise not found" }, { status: 404 });
106+
}
107+
108+
if (promise.imageUrl?.trim() === imageUrl) {
109+
return NextResponse.json({ ok: true, updated: false }, { status: 200 });
110+
}
111+
112+
const fallbackAlt =
113+
(
114+
parsed?.object?.data?.fields?.[0]?.value as string | undefined
115+
)?.trim() ??
116+
promise.title?.trim() ??
117+
promise.headline?.trim() ??
118+
"Meedan promise image";
119+
120+
let filePath: string | null = null;
121+
122+
try {
123+
filePath = await downloadFile(imageUrl);
124+
125+
const media = (await payload.create({
126+
collection: "media",
127+
data: {
128+
alt: fallbackAlt,
129+
},
130+
filePath,
131+
})) as Media;
132+
133+
const mediaId =
134+
typeof media?.id === "string"
135+
? media.id
136+
: typeof media?.id === "number"
137+
? String(media.id)
138+
: null;
139+
140+
if (!mediaId) {
141+
return NextResponse.json(
142+
{ error: "Failed to cache image" },
143+
{ status: 500 }
144+
);
145+
}
146+
147+
await payload.update({
148+
collection: "promises",
149+
id: promise.id,
150+
data: {
151+
image: mediaId,
152+
imageUrl,
153+
},
154+
});
155+
156+
return NextResponse.json({ ok: true, updated: true }, { status: 200 });
157+
} finally {
158+
if (filePath) {
159+
try {
160+
await unlink(filePath);
161+
} catch (cleanupError) {
162+
console.warn(
163+
"meedan-sync:: Failed to clean up temp image",
164+
cleanupError
165+
);
166+
}
167+
}
168+
}
68169
} catch (error) {
69-
console.error("meedan-sync:: Failed to process reports", error);
170+
console.error("meedan-sync:: Failed to process webhook", error);
70171
return NextResponse.json(
71-
{
72-
error: "Failed to process reports",
73-
},
74-
{ status: 500 }
172+
{ ok: false, updated: false, error: "Failed to process webhook" },
173+
{ status: 200 }
75174
);
76175
}
77176
};

src/blocks/EntitySelection.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Block } from "payload";
2+
3+
export const EntitySelection: Block = {
4+
slug: "entity-selection",
5+
imageURL: "/cms/entity-selection.png",
6+
imageAltText: "Entity Selection",
7+
labels: {
8+
singular: {
9+
en: "Entity Selection",
10+
fr: "Sélection d'entité",
11+
},
12+
plural: {
13+
en: "Entity Selections",
14+
fr: "Sélections d'entités",
15+
},
16+
},
17+
fields: [
18+
{
19+
name: "emptyTitle",
20+
type: "text",
21+
required: true,
22+
label: {
23+
en: "Empty List Title",
24+
fr: "Titre de la liste vide",
25+
},
26+
defaultValue:
27+
"No political entities have been published yet for this tenant",
28+
},
29+
{
30+
name: "EmptySubtitle",
31+
type: "textarea",
32+
required: true,
33+
label: {
34+
en: "Empty List Subtitle",
35+
fr: "Sous-titre de la liste vide",
36+
},
37+
defaultValue:
38+
"Check back soon for newly tracked leaders and their promises.",
39+
},
40+
],
41+
};

src/blocks/TenantSelector.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Block } from "payload";
2+
3+
export const TenantSelection: Block = {
4+
slug: "tenant-selection",
5+
imageURL: "/cms/tenant-selector.png",
6+
imageAltText: "Tenant Selector",
7+
fields: [
8+
{
9+
name: "title",
10+
type: "text",
11+
required: true,
12+
label: {
13+
en: "Section title",
14+
fr: "Titre de la section",
15+
},
16+
defaultValue: "Select Tenant",
17+
},
18+
{
19+
name: "subtitle",
20+
type: "textarea",
21+
required: true,
22+
label: {
23+
en: "Section description",
24+
fr: "Description de la section",
25+
},
26+
defaultValue: "Choose a country to view their tracked promises.",
27+
},
28+
{
29+
name: "ctaLabel",
30+
required: true,
31+
type: "text",
32+
label: {
33+
en: "CTA label",
34+
fr: "Libellé du CTA",
35+
},
36+
defaultValue: "Open tenant site",
37+
},
38+
{
39+
name: "emptyListLabel",
40+
type: "text",
41+
required: true,
42+
label: {
43+
en: "Empty Tenant List Label",
44+
fr: "Étiquette de liste de locataires vide",
45+
},
46+
defaultValue: "No tenants created yet",
47+
},
48+
],
49+
};

src/collections/SiteSettings/tabs/NavigationTab.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ export const NavigationTab: Tab = {
4747
type: "array",
4848
required: true,
4949
minRows: 1,
50-
maxRows: 2,
5150
fields: [
5251
{
5352
name: "secondaryNavigation",

0 commit comments

Comments
 (0)