Skip to content

Commit ca0ba0d

Browse files
committed
feat: enable custom pages
1 parent 3ea62da commit ca0ba0d

10 files changed

Lines changed: 313 additions & 43 deletions

File tree

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
1+
import { PageBlocks } from "@/components/PageBlockRenderer"
2+
import { LandingContainer } from "@/components/ui/LandingContainer"
3+
import { getCustomLandingPage } from "@/lib/cms"
4+
import { isSupportedLocale } from "@/lib/i18n"
5+
import { getCustomLandingPageMetadata } from "@/lib/pageMetadata"
6+
import type { Metadata } from "next"
17
import { notFound } from "next/navigation"
28

3-
export default function UnknownLocalizedPage() {
4-
notFound()
9+
type CatchAllPageParams = Promise<{ locale: string; rest: string[] }>
10+
11+
async function getCatchAllParams(params: CatchAllPageParams) {
12+
const { locale, rest } = await params
13+
if (!isSupportedLocale(locale) || rest.length !== 1 || !rest[0]?.trim()) notFound()
14+
15+
return { locale, slug: rest[0] }
16+
}
17+
18+
export default async function CustomLandingPage({ params }: { params: CatchAllPageParams }) {
19+
const { locale, slug } = await getCatchAllParams(params)
20+
const page = await getCustomLandingPage(slug, locale)
21+
if (!page) notFound()
22+
23+
return (
24+
<LandingContainer>
25+
<div className="h-12 lg:h-16" aria-hidden="true" />
26+
<PageBlocks blocks={page.layout} locale={locale} />
27+
</LandingContainer>
28+
)
29+
}
30+
31+
export async function generateMetadata({ params }: { params: CatchAllPageParams }): Promise<Metadata> {
32+
const { locale, slug } = await getCatchAllParams(params)
33+
return getCustomLandingPageMetadata(slug, locale)
534
}

src/app/(landing)/server-sitemap.xml/route.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getActionSlugs, getBlogSlugs, getJobSlugs } from "@/lib/cms"
1+
import { getActionSlugs, getBlogSlugs, getCustomLandingPageSlugs, getJobSlugs } from "@/lib/cms"
22
import { SUPPORTED_LOCALES } from "@/lib/i18n"
33
import { resolveSiteUrl } from "@/lib/siteConfig"
44

@@ -44,12 +44,20 @@ export async function GET() {
4444

4545
const localizedUrls = await Promise.all(
4646
SUPPORTED_LOCALES.map(async (locale) => {
47-
const [blogSlugs, jobSlugs, actionSlugs] = await Promise.all([
47+
const [blogSlugs, jobSlugs, actionSlugs, customPageSlugs] = await Promise.all([
4848
getBlogSlugs(locale),
4949
getJobSlugs(locale),
50-
getActionSlugs(locale)
50+
getActionSlugs(locale),
51+
getCustomLandingPageSlugs(locale),
5152
])
5253

54+
const customPageUrls = customPageSlugs.map((slug): SitemapUrl => ({
55+
loc: new URL(`/${locale}/${slug}`, siteUrl).toString(),
56+
lastmod,
57+
changefreq: "weekly",
58+
priority: 0.7,
59+
}))
60+
5361
const blogUrls = blogSlugs.map((slug): SitemapUrl => ({
5462
loc: new URL(`/${locale}/blog/${slug}`, siteUrl).toString(),
5563
lastmod,
@@ -71,7 +79,7 @@ export async function GET() {
7179
priority: 0.6
7280
}))
7381

74-
return [...blogUrls, ...jobUrls, ...actionUrls]
82+
return [...customPageUrls, ...blogUrls, ...jobUrls, ...actionUrls]
7583
})
7684
)
7785

src/collections/pages.ts

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,47 +29,124 @@ import { WideHeroBlock } from "@/blocks/WideHeroBlock"
2929
import { StatsBlock } from "@/blocks/StatsBlock"
3030
import { FlowExampleBlock } from "@/blocks/FlowExampleBlock"
3131

32+
const RESERVED_PAGE_SLUGS = [
33+
"main",
34+
"jobs",
35+
"blog",
36+
"features",
37+
"about-us",
38+
"legal-notice",
39+
"privacy",
40+
"terms",
41+
"contact",
42+
"actions",
43+
"action-details",
44+
"community-edition",
45+
"enterprise-edition",
46+
"subscription",
47+
] as const
48+
49+
function formatSlug(value: string) {
50+
return value
51+
.normalize("NFKD")
52+
.replace(/[\u0300-\u036f]/g, "")
53+
.toLowerCase()
54+
.trim()
55+
.replace(/[^a-z0-9]+/g, "-")
56+
.replace(/^-+|-+$/g, "")
57+
}
58+
59+
function isCustomPage(siblingData: unknown) {
60+
return Boolean(siblingData && typeof siblingData === "object" && "customPage" in siblingData && siblingData.customPage)
61+
}
62+
3263
export const Pages: CollectionConfig = {
3364
slug: "pages",
3465
admin: {
3566
useAsTitle: "title",
36-
defaultColumns: ["title", "slug", "updatedAt"],
67+
defaultColumns: ["title", "slug", "customSlug", "updatedAt"],
3768
},
3869
access: {
3970
read: () => true,
4071
create: ({ req }) => Boolean(req.user),
4172
update: ({ req }) => Boolean(req.user),
4273
delete: ({ req }) => Boolean(req.user),
4374
},
75+
hooks: {
76+
beforeValidate: [
77+
({ data, originalDoc }) => {
78+
if (!data) return data
79+
80+
const customPage = Boolean(data.customPage ?? originalDoc?.customPage)
81+
if (!customPage) {
82+
data.customSlug = null
83+
return data
84+
}
85+
86+
const submittedSlug = typeof data.customSlug === "string" ? data.customSlug : typeof originalDoc?.customSlug === "string" ? originalDoc.customSlug : ""
87+
const title = typeof data.title === "string" ? data.title : typeof originalDoc?.title === "string" ? originalDoc.title : ""
88+
89+
data.slug = null
90+
data.customSlug = formatSlug(submittedSlug.trim() || title)
91+
92+
return data
93+
},
94+
],
95+
},
4496
fields: [
4597
{
4698
name: "title",
4799
type: "text",
48100
required: true,
49101
localized: true,
50102
},
103+
{
104+
name: "customPage",
105+
label: "Custom Page",
106+
type: "checkbox",
107+
defaultValue: false,
108+
admin: {
109+
description: "Enable this to use a custom URL slug instead of one of the predefined pages.",
110+
},
111+
},
51112
{
52113
name: "slug",
114+
label: "Slug",
53115
type: "select",
54-
required: true,
116+
required: false,
55117
unique: true,
56118
index: true,
57-
options: [
58-
{ label: "main", value: "main" },
59-
{ label: "jobs", value: "jobs" },
60-
{ label: "blog", value: "blog" },
61-
{ label: "features", value: "features" },
62-
{ label: "about-us", value: "about-us" },
63-
{ label: "legal-notice", value: "legal-notice" },
64-
{ label: "privacy", value: "privacy" },
65-
{ label: "terms", value: "terms" },
66-
{ label: "contact", value: "contact" },
67-
{ label: "actions", value: "actions" },
68-
{ label: "action-details", value: "action-details" },
69-
{ label: "community-edition", value: "community-edition" },
70-
{ label: "enterprise-edition", value: "enterprise-edition" },
71-
{ label: "subscription", value: "subscription" },
72-
],
119+
options: RESERVED_PAGE_SLUGS.map((slug) => ({ label: slug, value: slug })),
120+
admin: {
121+
condition: (_, siblingData) => !isCustomPage(siblingData),
122+
},
123+
validate: (value: string | null | undefined, { siblingData }: { siblingData: unknown }) => {
124+
if (isCustomPage(siblingData)) return true
125+
return value ? true : "Select a predefined slug or enable Custom Page."
126+
},
127+
},
128+
{
129+
name: "customSlug",
130+
label: "Slug",
131+
type: "text",
132+
required: false,
133+
unique: true,
134+
index: true,
135+
admin: {
136+
condition: (_, siblingData) => isCustomPage(siblingData),
137+
description: "Generated from the title when left empty. Predefined page slugs cannot be used.",
138+
},
139+
validate: (value: string | null | undefined, { siblingData }: { siblingData: unknown }) => {
140+
if (!isCustomPage(siblingData)) return true
141+
142+
const slug = formatSlug(typeof value === "string" ? value : "")
143+
if (!slug) return true
144+
if ((RESERVED_PAGE_SLUGS as readonly string[]).includes(slug)) {
145+
return `"${slug}" is reserved for a predefined page.`
146+
}
147+
148+
return true
149+
},
73150
},
74151
{
75152
name: "layout",

src/lib/cms.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,35 @@ const getLandingPageCached = cache(async (cachedSlug: string, cachedLocale: AppL
327327
})
328328
})
329329

330+
const getCustomLandingPageCached = cache(async (customSlug: string, locale: AppLocale): Promise<Page | null> => {
331+
return cmsFindOne(`getCustomLandingPage(${customSlug}, ${locale})`, null, {
332+
collection: "pages",
333+
locale,
334+
fallbackLocale: DEFAULT_LOCALE,
335+
where: {
336+
and: [{ customPage: { equals: true } }, { customSlug: { equals: customSlug } }],
337+
},
338+
limit: 1,
339+
depth: 2,
340+
pagination: false,
341+
})
342+
})
343+
344+
const getCustomLandingPageSlugsCached = cache(async (locale: AppLocale): Promise<string[]> => {
345+
const pages = await cmsFindMany<Pick<Page, "customSlug">>(`getCustomLandingPageSlugs(${locale})`, [], {
346+
collection: "pages",
347+
locale,
348+
fallbackLocale: DEFAULT_LOCALE,
349+
where: { customPage: { equals: true } },
350+
pagination: false,
351+
limit: 1000,
352+
depth: 0,
353+
select: { customSlug: true },
354+
})
355+
356+
return pages.flatMap((page) => (page.customSlug ? [page.customSlug] : []))
357+
})
358+
330359
const getNavigationCached = cache(async (locale: AppLocale): Promise<NavigationData | null> => {
331360
return cmsFindGlobal<Navigation>(`getNavigation(${locale})`, null, {
332361
slug: "navigation",
@@ -580,6 +609,14 @@ export async function getLandingPage(slug = "main", locale: AppLocale = DEFAULT_
580609
return getLandingPageCached(slug, locale)
581610
}
582611

612+
export async function getCustomLandingPage(customSlug: string, locale: AppLocale = DEFAULT_LOCALE): Promise<Page | null> {
613+
return getCustomLandingPageCached(customSlug, locale)
614+
}
615+
616+
export async function getCustomLandingPageSlugs(locale: AppLocale = DEFAULT_LOCALE): Promise<string[]> {
617+
return getCustomLandingPageSlugsCached(locale)
618+
}
619+
583620
export async function getNavigation(locale: AppLocale = DEFAULT_LOCALE) {
584621
return getNavigationCached(locale)
585622
}

src/lib/pageMetadata.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type Media, type Page } from "@/payload-types"
2-
import { getLandingPage } from "@/lib/cms"
2+
import { getCustomLandingPage, getLandingPage } from "@/lib/cms"
33
import { isSupportedLocale, type AppLocale } from "@/lib/i18n"
44
import { createMetadata, resolveSiteUrl } from "@/lib/siteConfig"
55
import type { Metadata } from "next"
@@ -29,10 +29,24 @@ export async function getLandingPageMetadata(slug: string, locale: string): Prom
2929
return mapLandingPageToMetadata(page, locale)
3030
}
3131

32+
export async function getCustomLandingPageMetadata(slug: string, locale: string): Promise<Metadata> {
33+
if (!isSupportedLocale(locale)) {
34+
return createMetadata()
35+
}
36+
37+
const page = await getCustomLandingPage(slug, locale)
38+
if (!page) {
39+
return createMetadata()
40+
}
41+
42+
return mapLandingPageToMetadata(page, locale)
43+
}
44+
3245
function mapLandingPageToMetadata(page: Page, locale: AppLocale): Metadata {
3346
const title = page.meta?.title ?? page.title
3447
const description = page.meta?.description ?? undefined
35-
const canonicalPath = getPagePath(locale, page.slug)
48+
const slug = page.customPage ? page.customSlug : page.slug
49+
const canonicalPath = getPagePath(locale, slug ?? "")
3650
const canonicalUrl = new URL(canonicalPath, resolveSiteUrl()).toString()
3751
const image = getMediaUrl(page.meta?.image)
3852

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"version": "7",
3+
"dialect": "postgresql",
4+
"tables": {
5+
"public.pages": {
6+
"name": "pages",
7+
"schema": "public",
8+
"columns": {
9+
"custom_page": {
10+
"name": "custom_page",
11+
"type": "boolean",
12+
"primaryKey": false,
13+
"notNull": false,
14+
"default": false
15+
},
16+
"slug": {
17+
"name": "slug",
18+
"type": "enum_pages_slug",
19+
"typeSchema": "public",
20+
"primaryKey": false,
21+
"notNull": false
22+
},
23+
"custom_slug": {
24+
"name": "custom_slug",
25+
"type": "varchar",
26+
"primaryKey": false,
27+
"notNull": false
28+
}
29+
},
30+
"indexes": {
31+
"pages_slug_idx": {
32+
"name": "pages_slug_idx",
33+
"columns": [{ "expression": "slug", "isExpression": false, "asc": true, "nulls": "last" }],
34+
"isUnique": true,
35+
"concurrently": false,
36+
"method": "btree",
37+
"with": {}
38+
},
39+
"pages_custom_slug_idx": {
40+
"name": "pages_custom_slug_idx",
41+
"columns": [{ "expression": "custom_slug", "isExpression": false, "asc": true, "nulls": "last" }],
42+
"isUnique": true,
43+
"concurrently": false,
44+
"method": "btree",
45+
"with": {}
46+
}
47+
},
48+
"foreignKeys": {},
49+
"compositePrimaryKeys": {}
50+
}
51+
},
52+
"enums": {},
53+
"schemas": {},
54+
"_meta": {},
55+
"id": "20260727_145500_custom_pages",
56+
"prevId": "20260726_220500_cookie_banner_optional_fields"
57+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres"
2+
3+
export async function up({ db }: MigrateUpArgs): Promise<void> {
4+
await db.execute(sql`
5+
ALTER TABLE "pages" ALTER COLUMN "slug" DROP NOT NULL;
6+
ALTER TABLE "pages" ADD COLUMN "custom_page" boolean DEFAULT false;
7+
ALTER TABLE "pages" ADD COLUMN "custom_slug" varchar;
8+
CREATE UNIQUE INDEX "pages_custom_slug_idx" ON "pages" USING btree ("custom_slug");
9+
`)
10+
}
11+
12+
export async function down({ db }: MigrateDownArgs): Promise<void> {
13+
await db.execute(sql`
14+
DO $$
15+
BEGIN
16+
IF EXISTS (SELECT 1 FROM "pages" WHERE "custom_page" = true) THEN
17+
RAISE EXCEPTION 'Cannot roll back custom pages while custom page records exist.';
18+
END IF;
19+
END
20+
$$;
21+
22+
DROP INDEX "public"."pages_custom_slug_idx";
23+
ALTER TABLE "pages" DROP COLUMN "custom_slug";
24+
ALTER TABLE "pages" DROP COLUMN "custom_page";
25+
ALTER TABLE "pages" ALTER COLUMN "slug" SET NOT NULL;
26+
`)
27+
}

0 commit comments

Comments
 (0)