@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.
Files changed (50) hide show
  1. package/package.json +14 -2
  2. package/vtex/client.ts +1 -1
  3. package/vtex/commerceLoaders.ts +1 -1
  4. package/vtex/inline-loaders/productDetailsPage.ts +1 -0
  5. package/vtex/manifest.gen.ts +2 -0
  6. package/vtex/utils/transform.ts +71 -1
  7. package/website/client.ts +20 -0
  8. package/website/components/Analytics.tsx +146 -0
  9. package/website/components/Seo.tsx +139 -0
  10. package/website/components/Theme.tsx +47 -0
  11. package/website/components/Video.tsx +44 -0
  12. package/website/flags/audience.ts +56 -0
  13. package/website/flags/everyone.ts +19 -0
  14. package/website/flags/flag.ts +15 -0
  15. package/website/flags/multivariate/image.ts +11 -0
  16. package/website/flags/multivariate/message.ts +11 -0
  17. package/website/flags/multivariate/page.ts +16 -0
  18. package/website/flags/multivariate/section.ts +16 -0
  19. package/website/flags/multivariate.ts +1 -0
  20. package/website/index.ts +22 -0
  21. package/website/loaders/environment.ts +45 -0
  22. package/website/loaders/fonts/googleFonts.ts +119 -0
  23. package/website/loaders/fonts/local.ts +85 -0
  24. package/website/loaders/secret.ts +60 -0
  25. package/website/loaders/secretString.ts +18 -0
  26. package/website/manifest.gen.ts +31 -0
  27. package/website/matchers/always.ts +12 -0
  28. package/website/matchers/cookie.ts +33 -0
  29. package/website/matchers/cron.ts +109 -0
  30. package/website/matchers/date.ts +29 -0
  31. package/website/matchers/device.ts +40 -0
  32. package/website/matchers/environment.ts +21 -0
  33. package/website/matchers/host.ts +25 -0
  34. package/website/matchers/location.ts +113 -0
  35. package/website/matchers/multi.ts +24 -0
  36. package/website/matchers/negate.ts +21 -0
  37. package/website/matchers/never.ts +12 -0
  38. package/website/matchers/pathname.ts +69 -0
  39. package/website/matchers/queryString.ts +98 -0
  40. package/website/matchers/random.ts +24 -0
  41. package/website/matchers/site.ts +21 -0
  42. package/website/matchers/userAgent.ts +23 -0
  43. package/website/mod.ts +48 -0
  44. package/website/sections/Analytics/Analytics.tsx +7 -0
  45. package/website/sections/Seo/Seo.tsx +14 -0
  46. package/website/sections/Seo/SeoV2.tsx +45 -0
  47. package/website/types.ts +125 -0
  48. package/website/utils/html.ts +1 -0
  49. package/website/utils/location.ts +20 -0
  50. 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,12 @@
1
+ /**
2
+ * @title Never
3
+ * @description Hide from all users
4
+ * @icon eye-off
5
+ */
6
+ const MatchNever = () => {
7
+ return false;
8
+ };
9
+
10
+ export default MatchNever;
11
+
12
+ export const cacheable = true;
@@ -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,7 @@
1
+ import AnalyticsComponent, { type Props } from "../../components/Analytics";
2
+
3
+ function Section(props: Props) {
4
+ return <AnalyticsComponent {...props} />;
5
+ }
6
+
7
+ export default Section;
@@ -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;
@@ -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
+ };