@decocms/apps 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -2
- package/vtex/client.ts +1 -1
- package/vtex/commerceLoaders.ts +1 -1
- package/vtex/inline-loaders/productDetailsPage.ts +1 -0
- package/vtex/manifest.gen.ts +2 -0
- package/vtex/utils/transform.ts +71 -1
- package/website/client.ts +20 -0
- package/website/components/Analytics.tsx +146 -0
- package/website/components/Seo.tsx +139 -0
- package/website/components/Theme.tsx +47 -0
- package/website/components/Video.tsx +44 -0
- package/website/flags/audience.ts +56 -0
- package/website/flags/everyone.ts +19 -0
- package/website/flags/flag.ts +15 -0
- package/website/flags/multivariate/image.ts +11 -0
- package/website/flags/multivariate/message.ts +11 -0
- package/website/flags/multivariate/page.ts +16 -0
- package/website/flags/multivariate/section.ts +16 -0
- package/website/flags/multivariate.ts +1 -0
- package/website/index.ts +22 -0
- package/website/loaders/environment.ts +45 -0
- package/website/loaders/fonts/googleFonts.ts +119 -0
- package/website/loaders/fonts/local.ts +85 -0
- package/website/loaders/secret.ts +60 -0
- package/website/loaders/secretString.ts +18 -0
- package/website/manifest.gen.ts +31 -0
- package/website/matchers/always.ts +12 -0
- package/website/matchers/cookie.ts +33 -0
- package/website/matchers/cron.ts +109 -0
- package/website/matchers/date.ts +29 -0
- package/website/matchers/device.ts +40 -0
- package/website/matchers/environment.ts +21 -0
- package/website/matchers/host.ts +25 -0
- package/website/matchers/location.ts +113 -0
- package/website/matchers/multi.ts +24 -0
- package/website/matchers/negate.ts +21 -0
- package/website/matchers/never.ts +12 -0
- package/website/matchers/pathname.ts +69 -0
- package/website/matchers/queryString.ts +98 -0
- package/website/matchers/random.ts +24 -0
- package/website/matchers/site.ts +21 -0
- package/website/matchers/userAgent.ts +23 -0
- package/website/mod.ts +48 -0
- package/website/sections/Analytics/Analytics.tsx +7 -0
- package/website/sections/Seo/Seo.tsx +14 -0
- package/website/sections/Seo/SeoV2.tsx +45 -0
- package/website/types.ts +125 -0
- package/website/utils/html.ts +1 -0
- package/website/utils/location.ts +20 -0
- package/website/utils/multivariate.ts +20 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { MatchContext } from "../types";
|
|
2
|
+
import { haversine } from "../utils/location";
|
|
3
|
+
|
|
4
|
+
export interface Coordinate {
|
|
5
|
+
latitude: number;
|
|
6
|
+
longitude: number;
|
|
7
|
+
radius?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Map {
|
|
11
|
+
/**
|
|
12
|
+
* @title Area selection
|
|
13
|
+
* @example -7.27820,-35.97630,2000
|
|
14
|
+
*/
|
|
15
|
+
coordinates?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Location {
|
|
19
|
+
/**
|
|
20
|
+
* @title City
|
|
21
|
+
* @example São Paulo
|
|
22
|
+
*/
|
|
23
|
+
city?: string;
|
|
24
|
+
/**
|
|
25
|
+
* @title Region Code
|
|
26
|
+
* @example SP
|
|
27
|
+
*/
|
|
28
|
+
regionCode?: string;
|
|
29
|
+
/**
|
|
30
|
+
* @title Country
|
|
31
|
+
* @example BR
|
|
32
|
+
*/
|
|
33
|
+
country?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Props {
|
|
37
|
+
/** @title Include Locations */
|
|
38
|
+
includeLocations?: (Location | Map)[];
|
|
39
|
+
/** @title Exclude Locations */
|
|
40
|
+
excludeLocations?: (Location | Map)[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MapLocation {
|
|
44
|
+
city?: string;
|
|
45
|
+
regionCode?: string;
|
|
46
|
+
country?: string;
|
|
47
|
+
coordinates?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const matchLocation =
|
|
51
|
+
(defaultNotMatched = true, source: MapLocation) =>
|
|
52
|
+
(target: MapLocation) => {
|
|
53
|
+
if (!target.regionCode && !target.city && !target.country && !target.coordinates) {
|
|
54
|
+
return defaultNotMatched;
|
|
55
|
+
}
|
|
56
|
+
let result = !target.regionCode || target.regionCode === source.regionCode;
|
|
57
|
+
result &&=
|
|
58
|
+
!source.coordinates ||
|
|
59
|
+
!target.coordinates ||
|
|
60
|
+
haversine(source.coordinates, target.coordinates) <= Number(target.coordinates.split(",")[2]);
|
|
61
|
+
result &&= !target.city || target.city === source.city;
|
|
62
|
+
result &&= !target.country || target.country === source.country;
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function fixEncoding(input: string): string {
|
|
67
|
+
try {
|
|
68
|
+
const utf8bytes = [...input].map((char) => char.charCodeAt(0));
|
|
69
|
+
return new TextDecoder("utf-8").decode(Uint8Array.from(utf8bytes));
|
|
70
|
+
} catch {
|
|
71
|
+
return input;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const escaped = ({ city, country, regionCode, coordinates }: MapLocation): MapLocation => {
|
|
76
|
+
return {
|
|
77
|
+
coordinates,
|
|
78
|
+
regionCode,
|
|
79
|
+
city: city ? fixEncoding(city) : city,
|
|
80
|
+
country: country ? fixEncoding(country) : country,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @title Location
|
|
86
|
+
* @description Target users based on their geographical location, such as country, city, or region
|
|
87
|
+
* @icon map-2
|
|
88
|
+
*/
|
|
89
|
+
export default function MatchLocation(
|
|
90
|
+
{ includeLocations, excludeLocations }: Props,
|
|
91
|
+
{ request }: MatchContext,
|
|
92
|
+
) {
|
|
93
|
+
const city = request.headers.get("cf-ipcity") ?? undefined;
|
|
94
|
+
const country = request.headers.get("cf-ipcountry") ?? undefined;
|
|
95
|
+
const regionCode = request.headers.get("cf-region-code") ?? undefined;
|
|
96
|
+
const latitude = request.headers.get("cf-iplatitude") ?? undefined;
|
|
97
|
+
const longitude = request.headers.get("cf-iplongitude") ?? undefined;
|
|
98
|
+
const coordinates = latitude ? `${latitude},${longitude}` : undefined;
|
|
99
|
+
|
|
100
|
+
const userLocation = { city, country, regionCode, coordinates };
|
|
101
|
+
const isLocationExcluded =
|
|
102
|
+
excludeLocations?.some(matchLocation(false, escaped(userLocation))) ?? false;
|
|
103
|
+
|
|
104
|
+
if (isLocationExcluded) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (includeLocations?.length === 0) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return includeLocations?.some(matchLocation(true, escaped(userLocation))) ?? true;
|
|
113
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MatchContext, Matcher } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @title Combined options with {{{op}}}
|
|
5
|
+
*/
|
|
6
|
+
export interface Props {
|
|
7
|
+
op: "or" | "and";
|
|
8
|
+
matchers: Matcher[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @title Multi
|
|
13
|
+
* @description Create more complex conditions by combining multiple matchers
|
|
14
|
+
* @icon plus
|
|
15
|
+
*/
|
|
16
|
+
const MatchMulti =
|
|
17
|
+
({ op, matchers }: Props) =>
|
|
18
|
+
(ctx: MatchContext) => {
|
|
19
|
+
return op === "or"
|
|
20
|
+
? matchers.some((matcher) => matcher(ctx))
|
|
21
|
+
: matchers.every((matcher) => matcher(ctx));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default MatchMulti;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MatchContext, Matcher } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface Props {
|
|
4
|
+
/**
|
|
5
|
+
* @description Matcher to be negated.
|
|
6
|
+
*/
|
|
7
|
+
matcher: Matcher;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @title Negates
|
|
12
|
+
* @description Create conditions that target users who do not meet certain criteria
|
|
13
|
+
* @icon minus
|
|
14
|
+
*/
|
|
15
|
+
const NegateMatcher =
|
|
16
|
+
({ matcher }: Props) =>
|
|
17
|
+
(ctx: MatchContext) => {
|
|
18
|
+
return !matcher(ctx);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default NegateMatcher;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { MatchContext } from "../types";
|
|
2
|
+
|
|
3
|
+
interface BaseCase {
|
|
4
|
+
/**
|
|
5
|
+
* @title Pathname
|
|
6
|
+
* @description Must start with "/"
|
|
7
|
+
*/
|
|
8
|
+
pathname?: string;
|
|
9
|
+
/**
|
|
10
|
+
* @title Negate
|
|
11
|
+
* @description Inverts the match result (NOT)
|
|
12
|
+
* @default false
|
|
13
|
+
*/
|
|
14
|
+
negate?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @title Equals */
|
|
18
|
+
interface Equals extends BaseCase {
|
|
19
|
+
/** @readonly @hide true */
|
|
20
|
+
type: "Equals";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Includes extends BaseCase {
|
|
24
|
+
/** @readonly @hide true */
|
|
25
|
+
type: "Includes";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Template extends BaseCase {
|
|
29
|
+
/** @readonly @hide true */
|
|
30
|
+
type: "Template";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Props {
|
|
34
|
+
/** @title Operation */
|
|
35
|
+
case: Equals | Includes | Template;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const operations: Record<Props["case"]["type"], (pathname: string, condition: string) => boolean> =
|
|
39
|
+
Object.freeze({
|
|
40
|
+
Equals: (pathname, value) => pathname === value,
|
|
41
|
+
Includes: (pathname, value) => pathname.includes(value),
|
|
42
|
+
Template: (pathname, template) => {
|
|
43
|
+
const escaped = template
|
|
44
|
+
.replace(/:[^/]+/g, "\0")
|
|
45
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
46
|
+
.replace(/\0/g, "([^/]+)");
|
|
47
|
+
const regex = new RegExp(`^${escaped}$`);
|
|
48
|
+
return regex.test(pathname);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @title Pathname
|
|
54
|
+
* @description Target users based on the pathname
|
|
55
|
+
* @icon world-www
|
|
56
|
+
*/
|
|
57
|
+
const MatchPathname = (props: Props, { request }: MatchContext) => {
|
|
58
|
+
const url = new URL(request.url);
|
|
59
|
+
const pathname = url.pathname;
|
|
60
|
+
if (!props.case.pathname) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const result = operations[props.case.type](pathname, props.case.pathname);
|
|
64
|
+
return props.case.negate ? !result : result;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default MatchPathname;
|
|
68
|
+
|
|
69
|
+
export const cacheable = true;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { MatchContext } from "../types";
|
|
2
|
+
|
|
3
|
+
interface BaseCase {
|
|
4
|
+
value: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** @title Equals */
|
|
8
|
+
interface Equals extends BaseCase {
|
|
9
|
+
/** @readonly @hide true */
|
|
10
|
+
type: "Equals";
|
|
11
|
+
}
|
|
12
|
+
interface Greater extends BaseCase {
|
|
13
|
+
/** @readonly @hide true */
|
|
14
|
+
type: "Greater";
|
|
15
|
+
}
|
|
16
|
+
interface Lesser extends BaseCase {
|
|
17
|
+
/** @readonly @hide true */
|
|
18
|
+
type: "Lesser";
|
|
19
|
+
}
|
|
20
|
+
interface GreaterOrEquals extends BaseCase {
|
|
21
|
+
/** @readonly @hide true */
|
|
22
|
+
type: "GreaterOrEquals";
|
|
23
|
+
}
|
|
24
|
+
interface LesserOrEquals extends BaseCase {
|
|
25
|
+
/** @readonly @hide true */
|
|
26
|
+
type: "LesserOrEquals";
|
|
27
|
+
}
|
|
28
|
+
interface Includes extends BaseCase {
|
|
29
|
+
/** @readonly @hide true */
|
|
30
|
+
type: "Includes";
|
|
31
|
+
}
|
|
32
|
+
interface Exists {
|
|
33
|
+
/** @readonly @hide true */
|
|
34
|
+
type: "Exists";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @title {{{param}}} {{{case.type}}} {{{case.value}}}
|
|
39
|
+
*/
|
|
40
|
+
interface Condition {
|
|
41
|
+
param: string;
|
|
42
|
+
case: Equals | Greater | Lesser | GreaterOrEquals | LesserOrEquals | Includes | Exists;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @title Query String Matcher
|
|
47
|
+
*/
|
|
48
|
+
export interface Props {
|
|
49
|
+
conditions: Condition[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const matchesAtLeastOne = (
|
|
53
|
+
params: string[],
|
|
54
|
+
condition: Condition,
|
|
55
|
+
compare: (a: string, b: string) => boolean,
|
|
56
|
+
) => {
|
|
57
|
+
if (condition.case.type === "Exists") {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const value = (condition.case as BaseCase).value;
|
|
61
|
+
return params.filter((param) => compare(param, value)).length > 0;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const operations: Record<
|
|
65
|
+
Condition["case"]["type"],
|
|
66
|
+
(param: string[], condition: Condition) => boolean
|
|
67
|
+
> = Object.freeze({
|
|
68
|
+
Equals: (params, condition) => matchesAtLeastOne(params, condition, (a, b) => a === b),
|
|
69
|
+
Greater: (params, condition) => matchesAtLeastOne(params, condition, (a, b) => a > b),
|
|
70
|
+
GreaterOrEquals: (params, condition) => matchesAtLeastOne(params, condition, (a, b) => a >= b),
|
|
71
|
+
Includes: (params, condition) => matchesAtLeastOne(params, condition, (a, b) => a.includes(b)),
|
|
72
|
+
Lesser: (params, condition) => matchesAtLeastOne(params, condition, (a, b) => a < b),
|
|
73
|
+
LesserOrEquals: (params, condition) => matchesAtLeastOne(params, condition, (a, b) => a <= b),
|
|
74
|
+
Exists: (_params, _condition) => true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @title Query String
|
|
79
|
+
* @description Match with a specific querystring
|
|
80
|
+
* @icon question-mark
|
|
81
|
+
*/
|
|
82
|
+
const MatchQueryString = (props: Props, { request }: MatchContext) => {
|
|
83
|
+
let matches = true;
|
|
84
|
+
const url = new URL(request.url);
|
|
85
|
+
for (const condition of props.conditions) {
|
|
86
|
+
const params = url.searchParams.getAll(condition.param);
|
|
87
|
+
if (!params.length) {
|
|
88
|
+
matches = false;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
matches = matches && operations[condition.case.type](params, condition);
|
|
92
|
+
}
|
|
93
|
+
return matches;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export default MatchQueryString;
|
|
97
|
+
|
|
98
|
+
export const cacheable = true;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title ABTest {{{percentage traffic}}}
|
|
3
|
+
*/
|
|
4
|
+
export interface Props {
|
|
5
|
+
traffic: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// once selected the session will reuse the same value
|
|
9
|
+
export const sticky = "session";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @title Random
|
|
13
|
+
* @description Target a percentage of the total traffic to do an A/B test
|
|
14
|
+
* @icon arrow-split
|
|
15
|
+
*/
|
|
16
|
+
const MatchRandom = ({ traffic }: Props) => {
|
|
17
|
+
return Math.random() < traffic;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default MatchRandom;
|
|
21
|
+
|
|
22
|
+
export const sessionKey = (props: Props) => {
|
|
23
|
+
return `${props.traffic}`;
|
|
24
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MatchContext } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @title {{{siteId}}}
|
|
5
|
+
*/
|
|
6
|
+
export interface Props {
|
|
7
|
+
siteId: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @title Site
|
|
12
|
+
* @description Target users based on the deco website ID they are on
|
|
13
|
+
* @icon hand-click
|
|
14
|
+
*/
|
|
15
|
+
const MatchSite = ({ siteId }: Props, { siteId: currSiteId }: MatchContext) => {
|
|
16
|
+
return siteId === currSiteId;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default MatchSite;
|
|
20
|
+
|
|
21
|
+
export const cacheable = true;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MatchContext } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @title {{{includes}}} {{{match}}}
|
|
5
|
+
*/
|
|
6
|
+
export interface Props {
|
|
7
|
+
includes?: string;
|
|
8
|
+
match?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @title User Agent
|
|
13
|
+
* @description Target users based on their web browser or operational system
|
|
14
|
+
* @icon world
|
|
15
|
+
*/
|
|
16
|
+
const MatchUserAgent = ({ includes, match }: Props, { request }: MatchContext) => {
|
|
17
|
+
const ua = request.headers.get("user-agent") || "";
|
|
18
|
+
const regexMatch = match ? new RegExp(match).test(ua) : true;
|
|
19
|
+
const includesFound = includes ? ua.includes(includes) : true;
|
|
20
|
+
return regexMatch && includesFound;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default MatchUserAgent;
|
package/website/mod.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Website app module — standard autoconfig contract.
|
|
3
|
+
*
|
|
4
|
+
* Exports `configure` following the AppModContract pattern.
|
|
5
|
+
* Provides SEO defaults, theme, matchers, and flags for the site.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AppDefinition, ResolveSecretFn } from "../commerce/app-types";
|
|
9
|
+
import { configureWebsite } from "./client";
|
|
10
|
+
import manifest from "./manifest.gen";
|
|
11
|
+
import type { WebsiteConfig } from "./types";
|
|
12
|
+
|
|
13
|
+
// -------------------------------------------------------------------------
|
|
14
|
+
// State
|
|
15
|
+
// -------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface WebsiteState {
|
|
18
|
+
config: WebsiteConfig;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// -------------------------------------------------------------------------
|
|
22
|
+
// Configure
|
|
23
|
+
// -------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configure the Website app from CMS block data.
|
|
27
|
+
* Always returns an AppDefinition (no required fields).
|
|
28
|
+
*/
|
|
29
|
+
export async function configure(
|
|
30
|
+
// biome-ignore lint/suspicious/noExplicitAny: block data comes from CMS with no fixed schema
|
|
31
|
+
block: any,
|
|
32
|
+
_resolveSecret: ResolveSecretFn,
|
|
33
|
+
): Promise<AppDefinition<WebsiteState>> {
|
|
34
|
+
const config: WebsiteConfig = {
|
|
35
|
+
seo: block?.seo,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
configureWebsite(config);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name: "website",
|
|
42
|
+
manifest,
|
|
43
|
+
state: { config },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Placeholder preview for CMS editor. */
|
|
48
|
+
export const preview = undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import SeoComponent, { type Props as SeoProps } from "../../components/Seo";
|
|
2
|
+
|
|
3
|
+
type Props = Omit<SeoProps, "jsonLDs">;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @deprecated true
|
|
7
|
+
* @migrate website/sections/Seo/SeoV2.tsx
|
|
8
|
+
* @title SEO deprecated
|
|
9
|
+
*/
|
|
10
|
+
function Section(props: Props) {
|
|
11
|
+
return <SeoComponent {...props} />;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default Section;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import SeoComponent, {
|
|
2
|
+
renderTemplateString,
|
|
3
|
+
type SEOSection,
|
|
4
|
+
type Props as SeoProps,
|
|
5
|
+
} from "../../components/Seo";
|
|
6
|
+
import type { WebsiteConfig } from "../../types";
|
|
7
|
+
|
|
8
|
+
type Props = Pick<
|
|
9
|
+
SeoProps,
|
|
10
|
+
"title" | "description" | "type" | "favicon" | "image" | "themeColor" | "noIndexing"
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Loader that merges page-level SEO props with app-level defaults.
|
|
15
|
+
* The framework calls this with the WebsiteConfig from the app state.
|
|
16
|
+
*/
|
|
17
|
+
export function loader(props: Props, seo?: WebsiteConfig["seo"]) {
|
|
18
|
+
const {
|
|
19
|
+
titleTemplate = "",
|
|
20
|
+
descriptionTemplate = "",
|
|
21
|
+
title: appTitle = "",
|
|
22
|
+
description: appDescription = "",
|
|
23
|
+
...seoSiteProps
|
|
24
|
+
} = seo ?? {};
|
|
25
|
+
|
|
26
|
+
const { title: _title, description: _description, ...seoProps } = props;
|
|
27
|
+
|
|
28
|
+
const title = renderTemplateString(
|
|
29
|
+
(titleTemplate ?? "").trim().length === 0 ? "%s" : titleTemplate,
|
|
30
|
+
_title ?? appTitle,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const description = renderTemplateString(
|
|
34
|
+
(descriptionTemplate ?? "").trim().length === 0 ? "%s" : descriptionTemplate,
|
|
35
|
+
_description ?? appDescription,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return { ...seoSiteProps, ...seoProps, title, description };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Section(props: Props): SEOSection {
|
|
42
|
+
return <SeoComponent {...props} />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default Section;
|
package/website/types.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the website app.
|
|
3
|
+
*
|
|
4
|
+
* Defines the matcher/flag system types locally so we don't depend on
|
|
5
|
+
* @deco/deco/blocks — everything is self-contained in @decocms/apps.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// -------------------------------------------------------------------------
|
|
9
|
+
// Script
|
|
10
|
+
// -------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export type Script = { src: string | ((req: Request) => string) };
|
|
13
|
+
|
|
14
|
+
// -------------------------------------------------------------------------
|
|
15
|
+
// Matcher system
|
|
16
|
+
// -------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Context passed to matchers at request time.
|
|
20
|
+
* The framework populates this from the incoming request.
|
|
21
|
+
*/
|
|
22
|
+
export interface MatchContext {
|
|
23
|
+
request: Request;
|
|
24
|
+
device: "mobile" | "tablet" | "desktop";
|
|
25
|
+
siteId: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A matcher is a function that evaluates request context and returns a boolean.
|
|
30
|
+
*/
|
|
31
|
+
export type Matcher = (ctx: MatchContext) => boolean;
|
|
32
|
+
|
|
33
|
+
// -------------------------------------------------------------------------
|
|
34
|
+
// Flag system
|
|
35
|
+
// -------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A feature flag with a matcher and two branches.
|
|
39
|
+
* The framework evaluates the matcher at request time and selects the
|
|
40
|
+
* appropriate branch value.
|
|
41
|
+
*/
|
|
42
|
+
export interface FlagObj<T> {
|
|
43
|
+
matcher: Matcher;
|
|
44
|
+
true: T;
|
|
45
|
+
false: T;
|
|
46
|
+
name: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A multivariate flag with multiple variants, each with its own matcher.
|
|
51
|
+
*/
|
|
52
|
+
export interface MultivariateFlag<T> {
|
|
53
|
+
variants: Variant<T>[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A single variant in a multivariate flag.
|
|
58
|
+
*/
|
|
59
|
+
export interface Variant<T> {
|
|
60
|
+
matcher?: Matcher;
|
|
61
|
+
value: T;
|
|
62
|
+
weight?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
// Theme / Font types
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export interface Variable {
|
|
70
|
+
name: string;
|
|
71
|
+
value: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Font = {
|
|
75
|
+
family: string;
|
|
76
|
+
styleSheet: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// -------------------------------------------------------------------------
|
|
80
|
+
// SEO types
|
|
81
|
+
// -------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** @description Recommended: 1200 x 630 px (up to 5MB) */
|
|
84
|
+
export type ImageWidget = string;
|
|
85
|
+
|
|
86
|
+
export type OGType = "website" | "article";
|
|
87
|
+
|
|
88
|
+
export interface SeoConfig {
|
|
89
|
+
title?: string;
|
|
90
|
+
/**
|
|
91
|
+
* @title Title template
|
|
92
|
+
* @description add a %s whenever you want it to be replaced with the product name, category name or search term
|
|
93
|
+
* @default %s
|
|
94
|
+
*/
|
|
95
|
+
titleTemplate?: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
/**
|
|
98
|
+
* @title Description template
|
|
99
|
+
* @description add a %s whenever you want it to be replaced with the product name, category name or search term
|
|
100
|
+
* @default %s
|
|
101
|
+
*/
|
|
102
|
+
descriptionTemplate?: string;
|
|
103
|
+
/** @default website */
|
|
104
|
+
type?: OGType;
|
|
105
|
+
/** @description Recommended: 1200 x 630 px (up to 5MB) */
|
|
106
|
+
image?: ImageWidget;
|
|
107
|
+
/** @description Recommended: 16 x 16 px */
|
|
108
|
+
favicon?: ImageWidget;
|
|
109
|
+
/** @description Suggested color that browsers should use to customize the display */
|
|
110
|
+
themeColor?: string;
|
|
111
|
+
/**
|
|
112
|
+
* @title Disable indexing
|
|
113
|
+
* @description In testing, you can use this to prevent search engines from indexing your site
|
|
114
|
+
*/
|
|
115
|
+
noIndexing?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
// Website app config
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export interface WebsiteConfig {
|
|
123
|
+
/** @title Seo */
|
|
124
|
+
seo?: SeoConfig;
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const stripHTML = (str: string) => str.replace(/(<([^>]+)>)/gi, "");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const toRadians = (degrees: number): number => {
|
|
2
|
+
return degrees * (Math.PI / 180);
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export const haversine = (coordinate1: string, coordinate2: string): number => {
|
|
6
|
+
const [lat1, lng1] = coordinate1.split(",").map((value) => Number(value));
|
|
7
|
+
const [lat2, lng2] = coordinate2.split(",").map((value) => Number(value));
|
|
8
|
+
|
|
9
|
+
const earthRadius = 6371000; // Earth radius in meters
|
|
10
|
+
const dLat = toRadians(lat1 - lat2);
|
|
11
|
+
const dLon = toRadians(lng1 - lng2);
|
|
12
|
+
|
|
13
|
+
const halfChordLengthSquared =
|
|
14
|
+
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
15
|
+
Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2));
|
|
16
|
+
|
|
17
|
+
const angularDistance =
|
|
18
|
+
2 * Math.atan2(Math.sqrt(halfChordLengthSquared), Math.sqrt(1 - halfChordLengthSquared));
|
|
19
|
+
return earthRadius * angularDistance;
|
|
20
|
+
};
|