Skip to content

Commit bdda4ce

Browse files
treodenclaude
andcommitted
feat: landing pages + URL redirects + CMS root-level routing
Three interlocking bodies of work. URL-redirect subsystem - New url_redirect table (base) with write-time chain collapse; a notFound-gated middleware 302s an old path to the new one. - Inline capture in the product/category update services: url_key renames, category reassignment/unassignment (incl. whole variant groups), and category rename AND reparent. - Unified, unit-tested path algebra (pathRemap: buildEntityPath / remapPath / planSubtreeRedirects); boundary-anchored subscriber cascade (fixes the /shoe-sale sweep and /cat/cat-toy mangle); set-based recordRedirectsBatch. - Delete-cascade cleanup: purge a category subtree's aliases and capture the nested->root move of every product it uncategorises. CMS root-level routing - CMS pages are served at root-level /<url_key> via url_rewrite; the page-builder for CMS pages reverts to route-level. Landing pages (promotion module) - New first-class entity: root-level URL, page-builder-only body via entity_urn, admin grid + CRUD + duplicate, status + publish schedule; wins URL collisions. - Page-builder entity-scope registry; draft/scheduled pages preview in the editor via a valid changeset token (possession-based trust). SEO / UI - LinkPicker "Landing page" tab + a promotion:landing_page link loader. - Live SearchSnippetPreview (SERP snippet) on the landing-page and CMS-page SEO forms; dropped meta_keywords from landing pages; two-column landing edit layout with status + schedule in the sidebar. Verified: tsc 0 errors; jest 82 suites / 790 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9e84347 commit bdda4ce

107 files changed

Lines changed: 4825 additions & 103 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/evershop/src/bin/lib/addDefaultMiddlewareFuncs.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,28 @@ export function addDefaultMiddlewareFuncs(app) {
238238
return next();
239239
}
240240

241-
// Find the matched rewrite rule base on the request path
242-
const rewriteRule = await select()
243-
.from('url_rewrite')
244-
.where('request_path', '=', `/${path}`)
245-
.load(pool);
241+
// Find the matched rewrite rule based on the request path. url_rewrite only
242+
// enforces UNIQUE(entity_uuid), so two entities (e.g. a landing page and a
243+
// CMS page) can share a request_path. Resolve deterministically by
244+
// entity_type PRECEDENCE — landing pages win a genuine collision, then
245+
// cms_page, product, category, then oldest id (see wiki/landing-pages.md).
246+
// The query-builder can't express a CASE order (its OrderBy holds a single
247+
// field and mangles a CASE string), so this drops to raw SQL.
248+
const rewriteResult = await pool.query(
249+
`SELECT * FROM url_rewrite
250+
WHERE request_path = $1
251+
ORDER BY CASE entity_type
252+
WHEN 'landing_page' THEN 0
253+
WHEN 'cms_page' THEN 1
254+
WHEN 'product' THEN 2
255+
WHEN 'category' THEN 3
256+
ELSE 4
257+
END,
258+
url_rewrite_id ASC
259+
LIMIT 1`,
260+
[`/${path}`]
261+
);
262+
const rewriteRule = rewriteResult.rows[0] ?? null;
246263

247264
if (rewriteRule) {
248265
// Find the route

packages/evershop/src/bin/seed/seedBlog.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export async function seedBlog(): Promise<void> {
118118
short_description: c.short_description ?? null
119119
})
120120
.execute(connection);
121-
await insertOnUpdate('url_rewrite', ['entity_uuid', 'language'])
121+
await insertOnUpdate('url_rewrite', ['entity_uuid'])
122122
.given({
123123
entity_type: 'blog_category',
124124
entity_uuid: cat.uuid,
@@ -145,7 +145,7 @@ export async function seedBlog(): Promise<void> {
145145
const tag = await insert('blog_tag')
146146
.given({ name: t.name, url_key: t.url_key })
147147
.execute(connection);
148-
await insertOnUpdate('url_rewrite', ['entity_uuid', 'language'])
148+
await insertOnUpdate('url_rewrite', ['entity_uuid'])
149149
.given({
150150
entity_type: 'blog_tag',
151151
entity_uuid: tag.uuid,
@@ -199,7 +199,7 @@ export async function seedBlog(): Promise<void> {
199199
meta_description: p.meta_description ?? null
200200
})
201201
.execute(connection);
202-
await insertOnUpdate('url_rewrite', ['entity_uuid', 'language'])
202+
await insertOnUpdate('url_rewrite', ['entity_uuid'])
203203
.given({
204204
entity_type: 'blog_post',
205205
entity_uuid: post.uuid,

packages/evershop/src/components/common/page-builder/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,6 @@ export { CategoryPicker } from './pickers/CategoryPicker.js';
5555
export { ProductPicker } from './pickers/ProductPicker.js';
5656
export { CollectionPicker } from './pickers/CollectionPicker.js';
5757
export { PagePicker } from './pickers/PagePicker.js';
58+
export { LandingPagePicker } from './pickers/LandingPagePicker.js';
5859
export { LinkPicker } from './pickers/LinkPicker.js';
5960
export { EntitySearchList } from './pickers/EntitySearchList.js';
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { EntitySearchList } from '@components/common/page-builder/pickers/EntitySearchList.js';
2+
import React, { useEffect, useState } from 'react';
3+
import { useQuery } from 'urql';
4+
5+
/**
6+
* Search-and-pick a landing page. Mirrors PagePicker but queries the promotion
7+
* `landingPages` collection (admin schema — the page-builder editor runs in
8+
* admin context). Stores the page's `url` for display; the LinkPicker turns the
9+
* pick into a `promotion:landing_page` URN, resolved to the live URL at request
10+
* time by the loader registered in the promotion bootstrap.
11+
*/
12+
13+
const SEARCH_QUERY = `
14+
query LandingPagePickerSearch($filters: [FilterInput]) {
15+
landingPages(filters: $filters) {
16+
items {
17+
uuid
18+
name
19+
urlKey
20+
url
21+
status
22+
}
23+
total
24+
}
25+
}
26+
`;
27+
28+
export interface LandingPagePickResult {
29+
url: string;
30+
name: string;
31+
uuid: string;
32+
}
33+
34+
export interface LandingPagePickerProps {
35+
selectedUrl?: string | null;
36+
/** Highlight the item whose uuid matches this — preferred over selectedUrl. */
37+
selectedUuid?: string | null;
38+
onPick: (result: LandingPagePickResult) => void;
39+
limit?: number;
40+
}
41+
42+
export function LandingPagePicker({
43+
selectedUrl,
44+
selectedUuid,
45+
onPick,
46+
limit = 10
47+
}: LandingPagePickerProps) {
48+
const [search, setSearch] = useState('');
49+
const [debounced, setDebounced] = useState('');
50+
51+
useEffect(() => {
52+
const t = setTimeout(() => setDebounced(search), 300);
53+
return () => clearTimeout(t);
54+
}, [search]);
55+
56+
const filters = debounced
57+
? [
58+
{ key: 'name', operation: 'like', value: debounced },
59+
{ key: 'limit', operation: 'eq', value: String(limit) }
60+
]
61+
: [{ key: 'limit', operation: 'eq', value: String(limit) }];
62+
63+
const [result] = useQuery({ query: SEARCH_QUERY, variables: { filters } });
64+
const items = (result.data?.landingPages?.items ?? []).map(
65+
(p: {
66+
uuid: string;
67+
name: string;
68+
url?: string | null;
69+
urlKey?: string | null;
70+
status: boolean;
71+
}) => ({
72+
id: p.url || `/${p.urlKey ?? p.uuid}`,
73+
primary: p.name,
74+
// Drafts shouldn't link from a public storefront (they 404 until
75+
// published), so mark them — same convention as PagePicker.
76+
secondary: p.status ? p.url ?? null : 'Draft',
77+
_uuid: p.uuid
78+
})
79+
);
80+
81+
// When selecting by uuid (URN storage), look up the item.id whose backing
82+
// _uuid matches; otherwise fall back to selectedUrl.
83+
const selectedIdByUuid = selectedUuid
84+
? items.find(
85+
(it) => (it as unknown as { _uuid: string })._uuid === selectedUuid
86+
)?.id ?? null
87+
: null;
88+
89+
return (
90+
<EntitySearchList
91+
items={items}
92+
selectedId={selectedIdByUuid ?? selectedUrl ?? null}
93+
search={search}
94+
onSearchChange={setSearch}
95+
loading={result.fetching}
96+
onSelect={(id, item) =>
97+
onPick({
98+
url: id,
99+
name: item.primary,
100+
uuid: (item as unknown as { _uuid: string })._uuid
101+
})
102+
}
103+
caption="Pick a landing page to link to."
104+
emptyHint={
105+
debounced
106+
? `No landing pages match "${debounced}".`
107+
: 'No landing pages yet.'
108+
}
109+
/>
110+
);
111+
}

packages/evershop/src/components/common/page-builder/pickers/LinkPicker.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
import { drawerInputClass } from '@components/common/page-builder/drawer/index.js';
33
import { BlogPicker } from '@components/common/page-builder/pickers/BlogPicker.js';
44
import { CategoryPicker } from '@components/common/page-builder/pickers/CategoryPicker.js';
5+
import { LandingPagePicker } from '@components/common/page-builder/pickers/LandingPagePicker.js';
56
import { PagePicker } from '@components/common/page-builder/pickers/PagePicker.js';
67
import { ProductPicker } from '@components/common/page-builder/pickers/ProductPicker.js';
78
import { _ } from '@evershop/evershop/lib/locale/translate/_';
89
import {
910
BlogUrn,
1011
CatalogUrn,
1112
CmsUrn,
13+
PromotionUrn,
1214
UrnService
1315
} from '@evershop/evershop/lib/urn';
1416
import React, { useState } from 'react';
@@ -40,6 +42,8 @@ function parseUrn(value: string | undefined | null): ParsedLinkUrn | null {
4042
if (service === 'catalog' && type === 'product') return { kind: 'product', id: uuid };
4143
if (service === 'catalog' && type === 'category') return { kind: 'category', id: uuid };
4244
if (service === 'cms' && type === 'page') return { kind: 'page', id: uuid };
45+
if (service === 'promotion' && type === 'landing_page')
46+
return { kind: 'landingPage', id: uuid };
4347
if (service === 'blog' && type === 'post')
4448
return { kind: 'blogPost', id: uuid };
4549
if (service === 'blog' && type === 'category')
@@ -51,6 +55,7 @@ function parseUrn(value: string | undefined | null): ParsedLinkUrn | null {
5155

5256
export type LinkKind =
5357
| 'page'
58+
| 'landingPage'
5459
| 'category'
5560
| 'product'
5661
| 'blogPost'
@@ -60,6 +65,7 @@ export type LinkKind =
6065

6166
const TABS: { value: LinkKind; label: string }[] = [
6267
{ value: 'page', label: _('Page') },
68+
{ value: 'landingPage', label: _('Landing page') },
6369
{ value: 'category', label: _('Category') },
6470
{ value: 'product', label: _('Product') },
6571
{ value: 'blogPost', label: _('Blog post') },
@@ -127,6 +133,19 @@ export function LinkPicker({
127133
}
128134
/>
129135
)}
136+
{tab === 'landingPage' && (
137+
<LandingPagePicker
138+
selectedUuid={parsed?.kind === 'landingPage' ? parsed.id : null}
139+
selectedUrl={parsed ? null : value || null}
140+
onPick={(r) =>
141+
onChange({
142+
url: PromotionUrn.landingPage(r.uuid),
143+
kind: 'landingPage',
144+
label: r.name
145+
})
146+
}
147+
/>
148+
)}
130149
{tab === 'category' && (
131150
<CategoryPicker
132151
selectedUuid={parsed?.kind === 'category' ? parsed.id : null}

packages/evershop/src/lib/urn/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ import { UrnService } from './services/UrnService.js';
3333
{ service: 'customer', type: 'customer', description: 'Customer account' },
3434
{ service: 'blog', type: 'post', description: 'Blog post' },
3535
{ service: 'blog', type: 'category', description: 'Blog category' },
36-
{ service: 'blog', type: 'tag', description: 'Blog tag' }
36+
{ service: 'blog', type: 'tag', description: 'Blog tag' },
37+
{
38+
service: 'promotion',
39+
type: 'landing_page',
40+
description: 'Promotion landing page'
41+
}
3742
].forEach(registerUrnSchema);
3843

3944
export const CatalogUrn = {
@@ -63,6 +68,11 @@ export const CustomerUrn = {
6368
customer: (uuid: string) => UrnService.build('customer', 'customer', uuid)
6469
};
6570

71+
export const PromotionUrn = {
72+
landingPage: (uuid: string) =>
73+
UrnService.build('promotion', 'landing_page', uuid)
74+
};
75+
6676
export { UrnService };
6777
export { registerUrnSchema, getUrnSchema, hasUrnSchema, listUrnSchemas };
6878
export type { UrnSchema } from './services/UrnRegistry.js';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Page-builder entity-scope registry.
3+
*
4+
* A route registered here gains per-entity page-builder editing: the editor
5+
* surfaces an entity selector (populated by `list`) and scopes widget placements
6+
* to `entity_urn` (built from `resolve`). Modules register their entity-scoped
7+
* routes from their own `bootstrap` — the page-builder never hardcodes or imports
8+
* a domain module. See wiki/landing-pages.md.
9+
*
10+
* This is a plain module-level registry (not the hook/processor registry), so it
11+
* is not subject to the post-bootstrap lock; by convention, registration happens
12+
* at bootstrap time only.
13+
*/
14+
15+
export interface EntityScopeEntity {
16+
uuid: string;
17+
name: string;
18+
urlKey: string;
19+
urn: string;
20+
/**
21+
* Published flag, when the entity type has one. The editor's scope selector
22+
* badges `false` as a draft. Optional/nullable so entity types without a
23+
* publish concept simply omit it (no badge).
24+
*/
25+
status?: boolean | null;
26+
}
27+
28+
export interface EntityScopeConfig {
29+
/** The page-builder route id this scope applies to (e.g. `'landingPageView'`). */
30+
routeId: string;
31+
/** The entity type in URN vocabulary (e.g. `'landing_page'`). */
32+
entityType: string;
33+
/**
34+
* Resolve a single entity uuid → its `entity_urn` + the storefront preview path
35+
* the editor iframe should load. Returns null when the uuid doesn't resolve.
36+
*/
37+
resolve: (
38+
uuid: string,
39+
pool: any
40+
) => Promise<{ urn: string; previewPath: string } | null>;
41+
/** List all selectable entities for the editor's scope selector. */
42+
list: (pool: any) => Promise<EntityScopeEntity[]>;
43+
}
44+
45+
const registry = new Map<string, EntityScopeConfig>();
46+
47+
export function registerEntityScope(config: EntityScopeConfig): void {
48+
registry.set(config.routeId, config);
49+
}
50+
51+
export function getEntityScope(
52+
routeId: string | undefined | null
53+
): EntityScopeConfig | undefined {
54+
return routeId ? registry.get(routeId) : undefined;
55+
}
56+
57+
export function getAllEntityScopes(): EntityScopeConfig[] {
58+
return Array.from(registry.values());
59+
}

packages/evershop/src/lib/widget/linkResolver.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Pool } from 'pg';
2+
import { localizeUrl } from '../locale/localeContext.js';
23
import { select } from '../postgres/query.js';
34
import { buildUrl } from '../router/buildUrl.js';
45
import { UrnService } from '../urn/index.js';
@@ -143,10 +144,7 @@ const pageLoader: LinkLoaderFactory = linkLoaderFromBatch(
143144
.where('uuid', 'IN', [...uuids])
144145
.execute(pool);
145146
const m = new Map<string, string>(
146-
rows.map((r: any) => [
147-
r.uuid,
148-
buildUrl('cmsPageView', { url_key: r.url_key })
149-
])
147+
rows.map((r: any) => [r.uuid, localizeUrl(`/${r.url_key}`)])
150148
);
151149
return uuids.map((u) => m.get(u) ?? null);
152150
}

packages/evershop/src/modules/base/graphql/types/Route/Route.admin.graphql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ type Route {
2929
(product list, category page) where the URL serves many entities.
3030
"""
3131
assignedEntity: AssignedEntity
32+
"""
33+
Non-null when this route serves many entities of one type that the
34+
page builder can scope widget placements to (e.g. `cmsPageView` serves
35+
every CMS page). Drives the topbar "entity scope" selector so the client
36+
need not hardcode `cmsPageView`. Null for routes with no entity dimension.
37+
"""
38+
entityScope: EntityScope
3239
}
3340

3441
"""
@@ -41,6 +48,16 @@ type AssignedEntity {
4148
label: String
4249
}
4350

51+
"""
52+
Describes a route's entity dimension for the page builder: the entity
53+
`type` (matches the URN service/type vocabulary, e.g. `cms_page`) and the
54+
GraphQL `listQuery` the selector calls to enumerate selectable entities.
55+
"""
56+
type EntityScope {
57+
type: String!
58+
listQuery: String!
59+
}
60+
4461
extend type Query {
4562
routes: [Route!]!
4663
route(id: String!): Route

0 commit comments

Comments
 (0)