Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { MigrateUpArgs } from "@payloadcms/db-mongodb";

/**
* Political-entity slugs must be unique per tenant (enforced going forward by
* the `ensureUniqueSlug` beforeValidate hook). This migration verifies no
* pre-existing duplicates violate that invariant; it fails loudly so an
* operator resolves the conflicting slugs before deploying, since duplicate
* slugs make public entity resolution ambiguous.
*/
export async function up({ payload }: MigrateUpArgs): Promise<void> {
const { docs } = await payload.find({
collection: "political-entities",
pagination: false,
depth: 0,
overrideAccess: true,
});

const entitiesByKey = new Map<string, string[]>();

for (const doc of docs) {
if (!doc.slug) {
continue;
}
const tenantId =
typeof doc.tenant === "string"
? doc.tenant
: (doc.tenant?.id ?? "no-tenant");
const key = `${tenantId}:${doc.slug}`;
const entries = entitiesByKey.get(key) ?? [];
entries.push(`${doc.name ?? "unnamed"} (${doc.id})`);
entitiesByKey.set(key, entries);
}

const duplicates = [...entitiesByKey.entries()].filter(
([, entries]) => entries.length > 1,
);

if (duplicates.length > 0) {
const details = duplicates
.map(
([key, entries]) =>
` ${key}: ${entries.join(", ")}`,
)
.join("\n");

throw new Error(
`Duplicate political-entity slugs detected (tenant:slug). ` +
`Resolve these before migrating:\n${details}`,
);
}

payload.logger.info(
"check_political_entity_slug_uniqueness:: no duplicate slugs found",
);
}

export async function down(): Promise<void> {
// Verification-only migration; nothing to revert.
}
190 changes: 74 additions & 116 deletions src/app/(frontend)/[...slugs]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
getPageSeo,
resolveTenantSeoContext,
} from "@/lib/seo";
import {
resolveGlobalRoute,
resolvePublicRoute,
} from "@/lib/routes/publicRoutes";
import { resolveTenantLocale } from "@/utils/locales";
import { resolveBrowserLocale } from "@/lib/locale";
import type { EntityPage } from "@/payload-types";
Expand Down Expand Up @@ -222,8 +226,16 @@ export default async function Page(params: Args) {
const slugs = paramsValue?.slugs ?? [];

if (!tenant) {
const pageSlug = slugs[0] ?? "index";
const globalPage = await queryGlobalPageBySlug({ slug: pageSlug, locale });
const globalRoute = resolveGlobalRoute(slugs);

if (globalRoute.type === "not-found") {
return notFound();
}

const globalPage = await queryGlobalPageBySlug({
slug: globalRoute.pageSlug,
locale,
});

if (!globalPage) {
return notFound();
Expand All @@ -245,77 +257,71 @@ export default async function Page(params: Args) {
);
}

const [maybePoliticalEntitySlug, pageSlugCandidate] = slugs;

const politicalEntities = await getPoliticalEntitiesByTenant(tenant, locale);
const resolution = resolvePublicRoute(slugs, politicalEntities);

if (resolution.type === "not-found") {
return notFound();
}

const politicalEntity = politicalEntities.find(
(entity) => entity.slug === maybePoliticalEntitySlug
);
const payload = await getGlobalPayload();

if (!politicalEntity) {
const hasExplicitPageSlug =
Boolean(pageSlugCandidate) || Boolean(maybePoliticalEntitySlug);
if (resolution.type === "entity-selector") {
const entityPageSettings = await payload.findGlobal({
slug: "entity-page",
locale,
});
const entityBlocks = entityPageSettings?.entitySelector?.blocks ?? [];

if (!hasExplicitPageSlug) {
const entityPageSettings = await payload.findGlobal({
slug: "entity-page",
locale,
});
const entityBlocks = entityPageSettings?.entitySelector?.blocks ?? [];

const entityNavMenus =
(entityPageSettings?.primaryNavigation?.menus || [])
.map((m) => toMenuLink(m?.link ?? m))
.filter((x: MenuLink | null): x is MenuLink => Boolean(x)) || [];

const entitySecondaryNavColumns =
(entityPageSettings?.secondaryNavigationList || [])
.map((entry) => {
const menus = entry?.secondaryNavigation?.menus || [];
const links = menus
.map((m) => toMenuLink(m?.link ?? m))
.filter((x: MenuLink | null): x is MenuLink => Boolean(x));
if (!entry?.secondaryNavigation?.titles && links.length === 0) {
return null;
}
return {
title: entry?.secondaryNavigation?.titles || null,
links,
};
})
.filter((col): col is { title: string | null; links: MenuLink[] } =>
Boolean(col)
) || [];

const navigationForEntity = { ...navigation, menus: entityNavMenus };

const footerForTenant =
entitySecondaryNavColumns.length > 0
? { ...footer, secondaryNavColumns: entitySecondaryNavColumns }
: footer;
const entityNavMenus =
(entityPageSettings?.primaryNavigation?.menus || [])
.map((m) => toMenuLink(m?.link ?? m))
.filter((x: MenuLink | null): x is MenuLink => Boolean(x)) || [];

return (
<>
<Navigation
title={title}
{...navigationForEntity}
tenantSelectionHref={tenantSelectionHref}
/>
<Suspense>
<BlockRenderer blocks={entityBlocks} />
</Suspense>
<Footer title={title} description={description} {...footerForTenant} />
</>
);
}
const entitySecondaryNavColumns =
(entityPageSettings?.secondaryNavigationList || [])
.map((entry) => {
const menus = entry?.secondaryNavigation?.menus || [];
const links = menus
.map((m) => toMenuLink(m?.link ?? m))
.filter((x: MenuLink | null): x is MenuLink => Boolean(x));
if (!entry?.secondaryNavigation?.titles && links.length === 0) {
return null;
}
return {
title: entry?.secondaryNavigation?.titles || null,
links,
};
})
.filter((col): col is { title: string | null; links: MenuLink[] } =>
Boolean(col)
) || [];

const fallbackPageSlug =
pageSlugCandidate ?? maybePoliticalEntitySlug ?? "index";
const navigationForEntity = { ...navigation, menus: entityNavMenus };

const footerForTenant =
entitySecondaryNavColumns.length > 0
? { ...footer, secondaryNavColumns: entitySecondaryNavColumns }
: footer;

return (
<>
<Navigation
title={title}
{...navigationForEntity}
tenantSelectionHref={tenantSelectionHref}
/>
<Suspense>
<BlockRenderer blocks={entityBlocks} />
</Suspense>
<Footer title={title} description={description} {...footerForTenant} />
</>
);
}

if (resolution.type === "tenant-page") {
const tenantPage = await queryPageBySlug({
slug: fallbackPageSlug,
slug: resolution.pageSlug,
tenant,
locale,
});
Expand All @@ -340,7 +346,7 @@ export default async function Page(params: Args) {
}

const globalPage = await queryGlobalPageBySlug({
slug: fallbackPageSlug,
slug: resolution.pageSlug,
locale,
});

Expand All @@ -363,59 +369,11 @@ export default async function Page(params: Args) {
);
}

const entityPageSettings = await payload.findGlobal({
slug: "entity-page",
locale,
});
const entityBlocks = entityPageSettings?.entitySelector?.blocks ?? [];

const entityNavMenus =
(entityPageSettings?.primaryNavigation?.menus || [])
.map((m) => toMenuLink(m?.link ?? m))
.filter((x: MenuLink | null): x is MenuLink => Boolean(x)) || [];

const entitySecondaryNavColumns =
(entityPageSettings?.secondaryNavigationList || [])
.map((entry) => {
const menus = entry?.secondaryNavigation?.menus || [];
const links = menus
.map((m) => toMenuLink(m?.link ?? m))
.filter((x: MenuLink | null): x is MenuLink => Boolean(x));
if (!entry?.secondaryNavigation?.titles && links.length === 0) {
return null;
}
return {
title: entry?.secondaryNavigation?.titles || null,
links,
};
})
.filter((col): col is { title: string | null; links: MenuLink[] } =>
Boolean(col)
) || [];

const navigationForEntity = { ...navigation, menus: entityNavMenus };

const footerForTenant =
entitySecondaryNavColumns.length > 0
? { ...footer, secondaryNavColumns: entitySecondaryNavColumns }
: footer;

return (
<>
<Navigation
title={title}
{...navigationForEntity}
tenantSelectionHref={tenantSelectionHref}
/>
<Suspense>
<BlockRenderer blocks={entityBlocks} />
</Suspense>
<Footer title={title} description={description} {...footerForTenant} />
</>
);
return notFound();
}

const tenantPageSlug = pageSlugCandidate ?? "index";
const politicalEntity = resolution.entity;
const tenantPageSlug = resolution.pageSlug;

const page = await queryPageBySlug({
slug: tenantPageSlug,
Expand Down
45 changes: 45 additions & 0 deletions src/app/(frontend)/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import React, { useEffect } from "react";
import * as Sentry from "@sentry/nextjs";
import { Box, Button, Container, Typography } from "@mui/material";

export default function FrontendError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);

return (
<Container maxWidth="md">
<Box
sx={{
minHeight: "70vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 2,
py: 8,
}}
>
<Typography variant="h4" component="h1">
Something went wrong
</Typography>
<Typography variant="body1" color="text.secondary">
An unexpected error occurred while loading this page. Please try
again.
</Typography>
<Button onClick={reset} variant="contained" sx={{ mt: 2 }}>
Try again
</Button>
</Box>
</Container>
);
}
40 changes: 40 additions & 0 deletions src/app/(frontend)/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { Box, Button, Container, Typography } from "@mui/material";

export const metadata: Metadata = {
title: "Page not found | PromiseTracker",
};

export default function NotFound() {
return (
<Container maxWidth="md">
<Box
sx={{
minHeight: "70vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 2,
py: 8,
}}
>
<Typography variant="h1" component="p" sx={{ fontWeight: 700 }}>
404
</Typography>
<Typography variant="h4" component="h1">
Page not found
</Typography>
<Typography variant="body1" color="text.secondary">
The page you are looking for does not exist or may have been moved.
</Typography>
<Button component={Link} href="/" variant="contained" sx={{ mt: 2 }}>
Go to the homepage
</Button>
</Box>
</Container>
);
}
2 changes: 1 addition & 1 deletion src/components/BlockRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const BlockRenderer = ({ blocks, entity }: BlockProps) => {
if (Block) {
return (
<div key={String(key)}>
<Block {...block} entitySlug={entity?.slug} />
<Block {...block} entitySlug={entity?.slug} entity={entity} />
</div>
);
}
Expand Down
Loading