orionplace-react 0.5.0 → 0.6.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/README.md +10 -1
- package/bin/orionplace-react.mjs +7 -0
- package/package.json +1 -1
- package/runtime/marketplace.d.mts +85 -0
- package/runtime/marketplace.mjs +490 -3
- package/src/project-readme.mjs +17 -2
- package/src/setup.mjs +1 -0
- package/src/sitemap.mjs +93 -0
- package/templates/app/robots.txt/route.ts +11 -0
- package/templates/app/sitemap.xml/route.ts +12 -0
- package/templates/components/orionplace/account/ProfileDetailsForm.README.md +4 -0
- package/templates/components/orionplace/account/ProfileDetailsForm.tsx +7 -2
- package/templates/components/orionplace/cart/CartCheckoutDetails.README.md +4 -0
- package/templates/components/orionplace/cart/CartCheckoutDetails.tsx +20 -9
- package/templates/interfaces/orionplace/accounts/IProfileData.ts +2 -0
- package/templates/interfaces/orionplace/appBase/IApploadUserProfile.ts +2 -0
- package/templates/lib/orionplace/cartLabels.ts +2 -0
- package/templates/lib/orionplace/profile.ts +12 -0
- package/templates/lib/orionplace/profileLabels.ts +2 -0
- package/templates/proxy.ts +3 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ account, journal and support, in English and Russian. The package includes a
|
|
|
8
8
|
setup CLI and the full storefront source, so every page and component in your
|
|
9
9
|
project is yours to change.
|
|
10
10
|
|
|
11
|
-
- **Setup CLI:** `init`, `set backend`, `add frontend`, `set mimic` and `update`.
|
|
11
|
+
- **Setup CLI:** `init`, `set backend`, `add frontend`, `set mimic`, `sitemap rebuild` and `update`.
|
|
12
12
|
- **Server install:** `install` builds the storefront on your Linux server and
|
|
13
13
|
runs it as a system service behind nginx with HTTPS.
|
|
14
14
|
- **API gateway:** the browser calls `/api/...` on your own domain; the server
|
|
@@ -318,6 +318,8 @@ npx orionplace-react add frontend shop.example.com
|
|
|
318
318
|
npx orionplace-react add frontend www.shop.example.com
|
|
319
319
|
npx orionplace-react remove frontend www.shop.example.com
|
|
320
320
|
npx orionplace-react set mimic shop.example.com
|
|
321
|
+
npx orionplace-react sitemap rebuild [shop.example.com]
|
|
322
|
+
npx orionplace-react sitemap status
|
|
321
323
|
sudo npx orionplace-react install
|
|
322
324
|
npx orionplace-react status
|
|
323
325
|
sudo npx orionplace-react restart
|
|
@@ -334,8 +336,15 @@ npm run orionplace:remove-frontend -- www.shop.example.com
|
|
|
334
336
|
npm run orionplace:mimic -- shop.example.com
|
|
335
337
|
npm run orionplace:help
|
|
336
338
|
npm run orionplace:update
|
|
339
|
+
npm run orionplace:sitemap
|
|
337
340
|
```
|
|
338
341
|
|
|
342
|
+
The storefront serves `/robots.txt` and `/sitemap.xml` (public pages, categories,
|
|
343
|
+
products and journal publications in every language). The sitemap is rebuilt
|
|
344
|
+
from the backend once an hour; `npm run orionplace:sitemap` rebuilds it at once,
|
|
345
|
+
for example after new publications, and the running storefront serves it without
|
|
346
|
+
a restart.
|
|
347
|
+
|
|
339
348
|
`npm set` belongs to npm's own configuration system; use the commands above.
|
|
340
349
|
The setup commands configure local integration files. They do not provision a
|
|
341
350
|
backend, change your account or create DNS records. `install` deploys the
|
package/bin/orionplace-react.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { setupProject } from '../src/setup.mjs';
|
|
|
3
3
|
import { updateProject } from '../src/update.mjs';
|
|
4
4
|
import { installService } from '../src/install.mjs';
|
|
5
5
|
import { SERVICE_ACTIONS, SERVICE_ALIASES, controlService, findApp, printService } from '../src/service.mjs';
|
|
6
|
+
import { sitemapCommand } from '../src/sitemap.mjs';
|
|
6
7
|
import { readFile } from 'node:fs/promises';
|
|
7
8
|
import { resolve } from 'node:path';
|
|
8
9
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -30,6 +31,9 @@ Commands for an existing project (run from its root):
|
|
|
30
31
|
npx orionplace-react remove frontend <shop.example.com>
|
|
31
32
|
npx orionplace-react set mimic <existing-frontend.example.com>
|
|
32
33
|
npx orionplace-react set frontend <shop.example.com>
|
|
34
|
+
npx orionplace-react sitemap rebuild [domain] rebuild /sitemap.xml now (every frontend, or one);
|
|
35
|
+
the running storefront serves it at once
|
|
36
|
+
npx orionplace-react sitemap status [domain] when each sitemap was built
|
|
33
37
|
|
|
34
38
|
On your Linux server (run from the project root):
|
|
35
39
|
sudo npx orionplace-react install
|
|
@@ -131,6 +135,9 @@ try {
|
|
|
131
135
|
}
|
|
132
136
|
const done = await controlService({ action: command, cwd, name: install.name, lines: positional[1] ?? 100, follow, dryRun });
|
|
133
137
|
process.exitCode = printService(done);
|
|
138
|
+
} else if (command === 'sitemap' && positional.length <= 3) {
|
|
139
|
+
if (dryRun) throw new Error('--dry-run is not a sitemap option; sitemap status shows the state without changes.');
|
|
140
|
+
process.exitCode = await sitemapCommand({ cwd, action: target, domain: positional[2] });
|
|
134
141
|
} else if (command === 'init' && positional.length <= 2) {
|
|
135
142
|
result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
|
|
136
143
|
} else if (command === 'update' && positional.length === 1) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Next.js 16 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,3 +25,88 @@ export declare function forwardMarketplace(
|
|
|
25
25
|
request: Request,
|
|
26
26
|
configuration?: MarketplaceConfiguration,
|
|
27
27
|
): Promise<Response>;
|
|
28
|
+
|
|
29
|
+
export interface MarketplaceSitemapPage {
|
|
30
|
+
path: string;
|
|
31
|
+
lastmod?: string | null;
|
|
32
|
+
locales?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export declare function marketplacePublicOrigin(
|
|
36
|
+
request: Request,
|
|
37
|
+
tenantDomain?: string,
|
|
38
|
+
): string;
|
|
39
|
+
|
|
40
|
+
export declare function marketplaceRobots(origin: string): string;
|
|
41
|
+
|
|
42
|
+
export declare function marketplaceRobotsResponse(
|
|
43
|
+
request: Request,
|
|
44
|
+
configuration?: MarketplaceConfiguration,
|
|
45
|
+
): Promise<Response>;
|
|
46
|
+
|
|
47
|
+
export declare function sitemapLocales(locales: unknown): string[];
|
|
48
|
+
|
|
49
|
+
export declare function sitemapLastmod(
|
|
50
|
+
value: unknown,
|
|
51
|
+
now?: number,
|
|
52
|
+
): string | null;
|
|
53
|
+
|
|
54
|
+
export declare function sitemapApiPath(
|
|
55
|
+
paths: unknown,
|
|
56
|
+
name: string,
|
|
57
|
+
): string | null;
|
|
58
|
+
|
|
59
|
+
export declare function sitemapCategoryIds(categories: unknown): number[];
|
|
60
|
+
|
|
61
|
+
export declare function marketplaceSitemapXml(
|
|
62
|
+
origin: string,
|
|
63
|
+
pages: MarketplaceSitemapPage[],
|
|
64
|
+
locales: string[],
|
|
65
|
+
): string;
|
|
66
|
+
|
|
67
|
+
export declare function marketplaceSitemapResponse(
|
|
68
|
+
request: Request,
|
|
69
|
+
configuration?: MarketplaceConfiguration,
|
|
70
|
+
): Promise<Response>;
|
|
71
|
+
|
|
72
|
+
export interface MarketplaceSitemap {
|
|
73
|
+
version: number;
|
|
74
|
+
domain: string;
|
|
75
|
+
built: string;
|
|
76
|
+
locales: string[];
|
|
77
|
+
pages: MarketplaceSitemapPage[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MarketplaceSitemapRebuild {
|
|
81
|
+
domain: string;
|
|
82
|
+
path: string;
|
|
83
|
+
built: string;
|
|
84
|
+
urls: number;
|
|
85
|
+
pages: number;
|
|
86
|
+
publications: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export declare const SITEMAP_LIFETIME: number;
|
|
90
|
+
|
|
91
|
+
export declare function readMarketplaceDomain(
|
|
92
|
+
domain: string,
|
|
93
|
+
options?: { root?: string },
|
|
94
|
+
): Promise<MarketplaceTenant>;
|
|
95
|
+
|
|
96
|
+
export declare function marketplaceConfigDirectory(root?: string): string;
|
|
97
|
+
|
|
98
|
+
export declare function marketplaceDomains(root?: string): Promise<string[]>;
|
|
99
|
+
|
|
100
|
+
export declare function sitemapCacheDirectory(root?: string): string;
|
|
101
|
+
|
|
102
|
+
export declare function readSitemapCache(
|
|
103
|
+
domain: string,
|
|
104
|
+
root?: string,
|
|
105
|
+
): Promise<MarketplaceSitemap | null>;
|
|
106
|
+
|
|
107
|
+
export declare function sitemapUrlCount(sitemap: MarketplaceSitemap): number;
|
|
108
|
+
|
|
109
|
+
export declare function rebuildMarketplaceSitemap(
|
|
110
|
+
domain: string,
|
|
111
|
+
options?: { root?: string },
|
|
112
|
+
): Promise<MarketplaceSitemapRebuild>;
|
package/runtime/marketplace.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
1
|
+
import { chown, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
getDefaultAutoSelectFamilyAttemptTimeout,
|
|
5
5
|
isIP,
|
|
@@ -81,8 +81,20 @@ export async function readMarketplace(request, configuration = {}) {
|
|
|
81
81
|
if (!domain) {
|
|
82
82
|
throw new Error('Invalid storefront domain');
|
|
83
83
|
}
|
|
84
|
+
return readMarketplaceDomain(domain);
|
|
85
|
+
}
|
|
84
86
|
|
|
85
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The marketplace of one storefront domain: its `sitemaps/<domain>.cfg` in
|
|
89
|
+
* the project (`root`, the working directory by default).
|
|
90
|
+
* @param {string} name @param {{root?: string}} options
|
|
91
|
+
*/
|
|
92
|
+
export async function readMarketplaceDomain(name, options = {}) {
|
|
93
|
+
const domain = normalizeFrontDomain(name);
|
|
94
|
+
if (!domain) {
|
|
95
|
+
throw new Error('Invalid storefront domain');
|
|
96
|
+
}
|
|
97
|
+
const directory = marketplaceConfigDirectory(options.root);
|
|
86
98
|
const source = await readFile(resolve(directory, `${domain}.cfg`), 'utf8');
|
|
87
99
|
const lines = source
|
|
88
100
|
.split(/\r?\n/)
|
|
@@ -111,6 +123,13 @@ export async function readMarketplace(request, configuration = {}) {
|
|
|
111
123
|
return { domain, api };
|
|
112
124
|
}
|
|
113
125
|
|
|
126
|
+
/** The folder of the domain files (`SITEMAPS_DIR`, or `sitemaps`). */
|
|
127
|
+
export function marketplaceConfigDirectory(root = process.cwd()) {
|
|
128
|
+
return environment.SITEMAPS_DIR
|
|
129
|
+
? resolve(root, environment.SITEMAPS_DIR)
|
|
130
|
+
: resolve(root, 'sitemaps');
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
/** @param {number} status @param {string} message */
|
|
115
134
|
function failure(status, message) {
|
|
116
135
|
return Response.json(
|
|
@@ -297,3 +316,471 @@ export async function forwardMarketplace(request, configuration = {}) {
|
|
|
297
316
|
headers: responseHeaders,
|
|
298
317
|
});
|
|
299
318
|
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// robots.txt and sitemap.xml
|
|
322
|
+
//
|
|
323
|
+
// The sitemap lists the storefront's public pages in every language the
|
|
324
|
+
// marketplace offers (`?locale=<code>` links, with hreflang alternates): the
|
|
325
|
+
// home, catalog and directory pages, the journal and its publications when
|
|
326
|
+
// the blog is on, the subscription plans when there are any, every public
|
|
327
|
+
// category and every product on sale. It is built from the backend's public
|
|
328
|
+
// API and kept for an hour per storefront domain in
|
|
329
|
+
// node_modules/.cache/orionplace/sitemaps/<domain>.json (ORIONPLACE_CACHE_DIR
|
|
330
|
+
// moves it). "orionplace-<framework> sitemap rebuild" rebuilds that file at
|
|
331
|
+
// once; the running storefront serves the new file without a restart.
|
|
332
|
+
|
|
333
|
+
export const SITEMAP_LIFETIME = 60 * 60 * 1000;
|
|
334
|
+
const sitemapPageSize = 100;
|
|
335
|
+
const sitemapMaxPages = 500;
|
|
336
|
+
const sitemapMaxUrls = 50_000;
|
|
337
|
+
const sitemapVersion = 1;
|
|
338
|
+
/** Built sitemaps of this process, for when the cache folder is read-only. */
|
|
339
|
+
const sitemapMemory = new Map();
|
|
340
|
+
/** @type {Map<string, Promise<object>>} */
|
|
341
|
+
const sitemapBuilds = new Map();
|
|
342
|
+
|
|
343
|
+
/** The public origin a crawler used: the storefront host, or a local one. */
|
|
344
|
+
export function marketplacePublicOrigin(request, tenantDomain = '') {
|
|
345
|
+
const url = new URL(request.url);
|
|
346
|
+
const host = normalizeFrontDomain(
|
|
347
|
+
request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? '',
|
|
348
|
+
);
|
|
349
|
+
if (host) {
|
|
350
|
+
return `https://${host}`;
|
|
351
|
+
}
|
|
352
|
+
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(url.hostname);
|
|
353
|
+
return local || !tenantDomain ? url.origin : `https://${tenantDomain}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** @param {string} origin */
|
|
357
|
+
export function marketplaceRobots(origin) {
|
|
358
|
+
return `User-agent: *\nDisallow: /api/\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
|
|
362
|
+
export async function marketplaceRobotsResponse(request, configuration = {}) {
|
|
363
|
+
let domain = '';
|
|
364
|
+
try {
|
|
365
|
+
domain = (await readMarketplace(request, configuration)).domain;
|
|
366
|
+
} catch {
|
|
367
|
+
domain = '';
|
|
368
|
+
}
|
|
369
|
+
return new Response(
|
|
370
|
+
marketplaceRobots(marketplacePublicOrigin(request, domain)),
|
|
371
|
+
{
|
|
372
|
+
headers: {
|
|
373
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
374
|
+
'Cache-Control': 'public, max-age=3600',
|
|
375
|
+
'Vary': 'Host, X-Forwarded-Host, X-Front-Domain',
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @param {string} value */
|
|
382
|
+
function escapeXml(value) {
|
|
383
|
+
return value.replace(/[<>&"']/g, (character) => ({
|
|
384
|
+
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''',
|
|
385
|
+
})[character]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** @param {unknown} value */
|
|
389
|
+
function publicId(value) {
|
|
390
|
+
const id = Number(value);
|
|
391
|
+
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The marketplace's languages from /appload `init.locale`. */
|
|
395
|
+
export function sitemapLocales(locales) {
|
|
396
|
+
const codes = Object.entries(locales && typeof locales === 'object' ? locales : {})
|
|
397
|
+
.map(([key, item]) => String(item?.code || key).trim().toLowerCase())
|
|
398
|
+
.filter((code) => /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/.test(code));
|
|
399
|
+
return [...new Set(codes)].sort();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A product edit time (ISO 8601) or a blog post day ({date: 'DD.MM.YYYY'}). */
|
|
403
|
+
export function sitemapLastmod(value, now = Date.now()) {
|
|
404
|
+
if (typeof value === 'string' && value) {
|
|
405
|
+
const time = Date.parse(value);
|
|
406
|
+
return Number.isFinite(time) && time <= now + 60_000
|
|
407
|
+
? new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
408
|
+
: null;
|
|
409
|
+
}
|
|
410
|
+
const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(value?.date ?? '');
|
|
411
|
+
if (!match) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
const [, day, month, year] = match;
|
|
415
|
+
const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
|
|
416
|
+
const date = new Date(time);
|
|
417
|
+
return date.getUTCDate() === Number(day) &&
|
|
418
|
+
date.getUTCMonth() === Number(month) - 1 &&
|
|
419
|
+
time <= now + 24 * 60 * 60 * 1000
|
|
420
|
+
? `${year}-${month}-${day}`
|
|
421
|
+
: null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The API path (under /api) of a route named in /appload `unauth.paths`. */
|
|
425
|
+
export function sitemapApiPath(paths, name) {
|
|
426
|
+
for (const group of Object.values(paths ?? {})) {
|
|
427
|
+
for (const route of Object.values(group ?? {})) {
|
|
428
|
+
if (route?.url_name === name) {
|
|
429
|
+
const target = String(route.target ?? '').split('/{')[0].replace(/^\/+/, '');
|
|
430
|
+
return target.startsWith('api/') ? `/${target}` : null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Public categories, parents before children. */
|
|
438
|
+
export function sitemapCategoryIds(categories) {
|
|
439
|
+
const ids = [];
|
|
440
|
+
const visit = (category) => {
|
|
441
|
+
const id = publicId(category?.id);
|
|
442
|
+
if (id && category?.is_public) {
|
|
443
|
+
ids.push(id);
|
|
444
|
+
}
|
|
445
|
+
(category?.childs ?? []).forEach(visit);
|
|
446
|
+
};
|
|
447
|
+
(Array.isArray(categories) ? categories : []).forEach(visit);
|
|
448
|
+
return [...new Set(ids)];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* One <url> per page and language, each with alternates for its languages.
|
|
453
|
+
* A page with its own `locales` (a publication written in some languages
|
|
454
|
+
* only) is listed in those of the marketplace's languages.
|
|
455
|
+
* @param {string} origin
|
|
456
|
+
* @param {{path: string, lastmod?: string | null, locales?: string[]}[]} pages
|
|
457
|
+
* @param {string[]} locales
|
|
458
|
+
*/
|
|
459
|
+
export function marketplaceSitemapXml(origin, pages, locales) {
|
|
460
|
+
const href = (path, code) => {
|
|
461
|
+
const url = new URL(path, `${origin}/`);
|
|
462
|
+
if (code) {
|
|
463
|
+
url.searchParams.set('locale', code);
|
|
464
|
+
}
|
|
465
|
+
return escapeXml(url.toString());
|
|
466
|
+
};
|
|
467
|
+
const urls = [];
|
|
468
|
+
for (const page of pages) {
|
|
469
|
+
const own = page.locales
|
|
470
|
+
? locales.filter((code) => page.locales.includes(code))
|
|
471
|
+
: locales;
|
|
472
|
+
if (page.locales && !own.length) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const codes = own.length ? own : [''];
|
|
476
|
+
const alternates = own.length > 1
|
|
477
|
+
? own.map((code) =>
|
|
478
|
+
` <xhtml:link rel="alternate" hreflang="${escapeXml(code)}" href="${href(page.path, code)}" />\n`).join('')
|
|
479
|
+
: '';
|
|
480
|
+
const lastmod = page.lastmod ? ` <lastmod>${escapeXml(page.lastmod)}</lastmod>\n` : '';
|
|
481
|
+
for (const code of codes) {
|
|
482
|
+
if (urls.length >= sitemapMaxUrls) {
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
urls.push(` <url>\n <loc>${href(page.path, code)}</loc>\n${lastmod}${alternates} </url>\n`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
489
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n' +
|
|
490
|
+
urls.join('') +
|
|
491
|
+
'</urlset>\n';
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** @param {{domain: string, api: URL}} tenant @param {string} path @param {unknown} body */
|
|
495
|
+
async function sitemapRequest(tenant, path, body) {
|
|
496
|
+
const response = await fetch(new URL(path, tenant.api.origin), {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
headers: {
|
|
499
|
+
'Content-Type': 'application/json',
|
|
500
|
+
'Accept': 'application/json',
|
|
501
|
+
'X-Front-Domain': tenant.domain,
|
|
502
|
+
},
|
|
503
|
+
body: JSON.stringify(body),
|
|
504
|
+
cache: 'no-store',
|
|
505
|
+
signal: AbortSignal.timeout(requestTimeout),
|
|
506
|
+
});
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
throw new Error(`${path} answered ${response.status}`);
|
|
509
|
+
}
|
|
510
|
+
return response.json();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Every record of a paginated list (`page_data[key].data` + `rpag`). */
|
|
514
|
+
async function sitemapList(tenant, path, key, filters = {}) {
|
|
515
|
+
const records = new Map();
|
|
516
|
+
let page = 1;
|
|
517
|
+
for (let count = 0; count < sitemapMaxPages; count++) {
|
|
518
|
+
const payload = await sitemapRequest(tenant, path, {
|
|
519
|
+
filters,
|
|
520
|
+
paginate: { page, pp_items: sitemapPageSize },
|
|
521
|
+
});
|
|
522
|
+
const list = payload?.page_data?.[key];
|
|
523
|
+
if (!Array.isArray(list?.data)) {
|
|
524
|
+
throw new Error(`${path} returned no ${key} list`);
|
|
525
|
+
}
|
|
526
|
+
for (const record of list.data) {
|
|
527
|
+
const id = publicId(record?.id);
|
|
528
|
+
if (id) {
|
|
529
|
+
records.set(id, record);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const next = Number(list.rpag?.next_page_number);
|
|
533
|
+
if (!list.rpag?.has_next || !Number.isSafeInteger(next) || next <= page) {
|
|
534
|
+
return [...records.values()];
|
|
535
|
+
}
|
|
536
|
+
page = next;
|
|
537
|
+
}
|
|
538
|
+
throw new Error(`${path} has more than ${sitemapMaxPages} pages`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Products on sale and in stock states a shopper can open. */
|
|
542
|
+
function productOnSale(product) {
|
|
543
|
+
const status = Number(product?.status?.status ?? product?.status);
|
|
544
|
+
return [2, -2, -3].includes(status);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Blog publications that are published. */
|
|
548
|
+
function postPublished(post) {
|
|
549
|
+
const state = post?.publishing_state?.int ?? post?.publishing_state;
|
|
550
|
+
return state === undefined || state === null || Number(state) === 1;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The languages a publication is written in (it has a title there). */
|
|
554
|
+
function postLocales(post) {
|
|
555
|
+
return Object.entries(post?.locale && typeof post.locale === 'object' ? post.locale : {})
|
|
556
|
+
.filter(([, text]) => typeof text?.post_title === 'string' && text.post_title.trim())
|
|
557
|
+
.map(([code]) => code.toLowerCase());
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** @param {{domain: string, api: URL}} tenant */
|
|
561
|
+
async function buildMarketplaceSitemap(tenant) {
|
|
562
|
+
const appload = await sitemapRequest(tenant, '/api/appload', {
|
|
563
|
+
detalize_mode: 'light',
|
|
564
|
+
ignore_cache: false,
|
|
565
|
+
});
|
|
566
|
+
const init = appload?.page_data?.init;
|
|
567
|
+
const paths = appload?.page_data?.unauth?.paths;
|
|
568
|
+
if (!init || !Array.isArray(init.catalog)) {
|
|
569
|
+
throw new Error('appload returned no catalog');
|
|
570
|
+
}
|
|
571
|
+
const locales = sitemapLocales(init.locale);
|
|
572
|
+
const categories = sitemapCategoryIds(init.catalog);
|
|
573
|
+
const blogPath = sitemapApiPath(paths, 'blog_posts_get');
|
|
574
|
+
const searchPath = sitemapApiPath(paths, 'products_search');
|
|
575
|
+
const [products, posts] = await Promise.all([
|
|
576
|
+
searchPath && categories.length
|
|
577
|
+
? sitemapList(tenant, searchPath, 'products', { categories })
|
|
578
|
+
: [],
|
|
579
|
+
blogPath ? sitemapList(tenant, blogPath, 'posts') : [],
|
|
580
|
+
]);
|
|
581
|
+
const postPages = posts
|
|
582
|
+
.filter(postPublished)
|
|
583
|
+
.sort((left, right) => left.id - right.id)
|
|
584
|
+
.map((post) => ({
|
|
585
|
+
path: `/journal/${post.id}`,
|
|
586
|
+
lastmod: sitemapLastmod(post.date_updated),
|
|
587
|
+
locales: postLocales(post),
|
|
588
|
+
}));
|
|
589
|
+
const newestPost = postPages.map((page) => page.lastmod).filter(Boolean).sort().at(-1) ?? null;
|
|
590
|
+
const pages = [
|
|
591
|
+
{ path: '/' },
|
|
592
|
+
{ path: '/catalog' },
|
|
593
|
+
...(blogPath ? [{ path: '/journal', lastmod: newestPost }] : []),
|
|
594
|
+
...(init.tariffs?.data?.length ? [{ path: '/tariffs' }] : []),
|
|
595
|
+
{ path: '/manufacturers' },
|
|
596
|
+
...(init.visual_settings?.single_seller_mode ? [] : [{ path: '/sellers' }]),
|
|
597
|
+
{ path: '/stores' },
|
|
598
|
+
{ path: '/contact' },
|
|
599
|
+
{ path: '/privacy' },
|
|
600
|
+
...categories.map((id) => ({ path: `/category/${id}` })),
|
|
601
|
+
...products
|
|
602
|
+
.filter(productOnSale)
|
|
603
|
+
.map((product) => ({
|
|
604
|
+
path: `/product/${product.id}`,
|
|
605
|
+
lastmod: sitemapLastmod(product.date_updated),
|
|
606
|
+
})),
|
|
607
|
+
...postPages,
|
|
608
|
+
];
|
|
609
|
+
return {
|
|
610
|
+
version: sitemapVersion,
|
|
611
|
+
domain: tenant.domain,
|
|
612
|
+
built: new Date().toISOString(),
|
|
613
|
+
locales,
|
|
614
|
+
pages,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** How many <url> entries a built sitemap has. */
|
|
619
|
+
export function sitemapUrlCount(sitemap) {
|
|
620
|
+
let count = 0;
|
|
621
|
+
for (const page of sitemap.pages) {
|
|
622
|
+
const own = page.locales
|
|
623
|
+
? sitemap.locales.filter((code) => page.locales.includes(code))
|
|
624
|
+
: sitemap.locales;
|
|
625
|
+
count += page.locales ? own.length : Math.max(own.length, 1);
|
|
626
|
+
}
|
|
627
|
+
return Math.min(count, sitemapMaxUrls);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** The cache folder of built sitemaps. */
|
|
631
|
+
export function sitemapCacheDirectory(root = process.cwd()) {
|
|
632
|
+
return environment.ORIONPLACE_CACHE_DIR
|
|
633
|
+
? resolve(root, environment.ORIONPLACE_CACHE_DIR, 'sitemaps')
|
|
634
|
+
: resolve(root, 'node_modules', '.cache', 'orionplace', 'sitemaps');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** @param {string} domain @param {string} root */
|
|
638
|
+
function sitemapCachePath(domain, root) {
|
|
639
|
+
return resolve(sitemapCacheDirectory(root), `${domain}.json`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** A cached sitemap of the domain, or null. @param {string} domain */
|
|
643
|
+
export async function readSitemapCache(domain, root = process.cwd()) {
|
|
644
|
+
let sitemap;
|
|
645
|
+
try {
|
|
646
|
+
sitemap = JSON.parse(await readFile(sitemapCachePath(domain, root), 'utf8'));
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
return sitemap?.version === sitemapVersion &&
|
|
651
|
+
sitemap.domain === domain &&
|
|
652
|
+
Number.isFinite(Date.parse(sitemap.built)) &&
|
|
653
|
+
Array.isArray(sitemap.locales) &&
|
|
654
|
+
Array.isArray(sitemap.pages)
|
|
655
|
+
? sitemap
|
|
656
|
+
: null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Write the cache file. When root writes it (sudo), the folders it creates
|
|
661
|
+
* and the file go to the project owner, so the storefront service can
|
|
662
|
+
* replace them later.
|
|
663
|
+
*/
|
|
664
|
+
async function writeSitemapCache(sitemap, root) {
|
|
665
|
+
const path = sitemapCachePath(sitemap.domain, root);
|
|
666
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
667
|
+
await mkdir(dirname(path), { recursive: true });
|
|
668
|
+
try {
|
|
669
|
+
await writeFile(temporary, JSON.stringify(sitemap), { mode: 0o644 });
|
|
670
|
+
await rename(temporary, path);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
await unlink(temporary).catch(() => {});
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
if (process.getuid?.() === 0) {
|
|
676
|
+
const owner = await stat(root);
|
|
677
|
+
if (owner.uid !== 0) {
|
|
678
|
+
let cursor = path;
|
|
679
|
+
while (cursor !== root && !relative(root, cursor).startsWith('..')) {
|
|
680
|
+
const info = await stat(cursor);
|
|
681
|
+
if (info.uid === 0) {
|
|
682
|
+
await chown(cursor, owner.uid, owner.gid);
|
|
683
|
+
}
|
|
684
|
+
cursor = dirname(cursor);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return path;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** @param {{domain: string, api: URL}} tenant @param {string} root */
|
|
692
|
+
function buildOnce(tenant, root) {
|
|
693
|
+
let build = sitemapBuilds.get(tenant.domain);
|
|
694
|
+
if (!build) {
|
|
695
|
+
build = buildMarketplaceSitemap(tenant)
|
|
696
|
+
.then(async (sitemap) => {
|
|
697
|
+
sitemapMemory.set(tenant.domain, sitemap);
|
|
698
|
+
try {
|
|
699
|
+
await writeSitemapCache(sitemap, root);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
console.error(`[orionplace] Sitemap cache not written: ${error?.code ?? error?.message ?? error}`);
|
|
702
|
+
}
|
|
703
|
+
return sitemap;
|
|
704
|
+
})
|
|
705
|
+
.finally(() => sitemapBuilds.delete(tenant.domain));
|
|
706
|
+
sitemapBuilds.set(tenant.domain, build);
|
|
707
|
+
}
|
|
708
|
+
return build;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Rebuild the sitemap of one storefront domain now and store it where the
|
|
713
|
+
* running storefront reads it (the "sitemap rebuild" command).
|
|
714
|
+
* @param {string} domain @param {{root?: string}} options
|
|
715
|
+
*/
|
|
716
|
+
export async function rebuildMarketplaceSitemap(domain, options = {}) {
|
|
717
|
+
const root = resolve(options.root ?? process.cwd());
|
|
718
|
+
const tenant = await readMarketplaceDomain(domain, { root });
|
|
719
|
+
const sitemap = await buildMarketplaceSitemap(tenant);
|
|
720
|
+
const path = await writeSitemapCache(sitemap, root);
|
|
721
|
+
return {
|
|
722
|
+
domain: tenant.domain,
|
|
723
|
+
path,
|
|
724
|
+
built: sitemap.built,
|
|
725
|
+
urls: sitemapUrlCount(sitemap),
|
|
726
|
+
pages: sitemap.pages.length,
|
|
727
|
+
publications: sitemap.pages.filter((page) => page.path.startsWith('/journal/')).length,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** The storefront domains of the project (its `sitemaps/*.cfg` files). */
|
|
732
|
+
export async function marketplaceDomains(root = process.cwd()) {
|
|
733
|
+
let names;
|
|
734
|
+
try {
|
|
735
|
+
names = await readdir(marketplaceConfigDirectory(root));
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (error?.code === 'ENOENT') {
|
|
738
|
+
return [];
|
|
739
|
+
}
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
742
|
+
return names
|
|
743
|
+
.filter((name) => name.endsWith('.cfg'))
|
|
744
|
+
.map((name) => normalizeFrontDomain(name.slice(0, -4)))
|
|
745
|
+
.filter(Boolean)
|
|
746
|
+
.sort();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
|
|
750
|
+
export async function marketplaceSitemapResponse(request, configuration = {}) {
|
|
751
|
+
let tenant;
|
|
752
|
+
try {
|
|
753
|
+
tenant = await readMarketplace(request, configuration);
|
|
754
|
+
} catch {
|
|
755
|
+
return failure(503, 'Storefront domain configuration is missing or invalid');
|
|
756
|
+
}
|
|
757
|
+
const root = process.cwd();
|
|
758
|
+
const fresh = (sitemap) =>
|
|
759
|
+
sitemap && Date.now() - Date.parse(sitemap.built) < SITEMAP_LIFETIME;
|
|
760
|
+
// The file wins when it is newer: a "sitemap rebuild" replaced it.
|
|
761
|
+
const file = await readSitemapCache(tenant.domain, root);
|
|
762
|
+
const memory = sitemapMemory.get(tenant.domain);
|
|
763
|
+
const newest = [file, memory]
|
|
764
|
+
.filter(Boolean)
|
|
765
|
+
.sort((left, right) => Date.parse(right.built) - Date.parse(left.built))[0];
|
|
766
|
+
let sitemap = fresh(newest) ? newest : null;
|
|
767
|
+
if (!sitemap) {
|
|
768
|
+
try {
|
|
769
|
+
sitemap = await buildOnce(tenant, root);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
console.error(`[orionplace] Sitemap build failed: ${error?.message ?? error}`);
|
|
772
|
+
if (!newest) {
|
|
773
|
+
return failure(502, 'Marketplace backend is unavailable');
|
|
774
|
+
}
|
|
775
|
+
sitemap = newest;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const origin = marketplacePublicOrigin(request, tenant.domain);
|
|
779
|
+
return new Response(marketplaceSitemapXml(origin, sitemap.pages, sitemap.locales), {
|
|
780
|
+
headers: {
|
|
781
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
782
|
+
'Cache-Control': 'public, max-age=3600',
|
|
783
|
+
'Vary': 'Host, X-Forwarded-Host, X-Front-Domain',
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
}
|
package/src/project-readme.mjs
CHANGED
|
@@ -89,6 +89,7 @@ and Next.js configuration are preserved.
|
|
|
89
89
|
| \`../${base}app/_storefront/\` | Route controllers that connect components, navigation and API actions. |
|
|
90
90
|
| \`../${base}app/api/[...orionplace]/route.ts\` | The route handler serving \`/api/...\` from your project. |
|
|
91
91
|
| \`../${base}app/ps3/blog/[...orionplace]/route.ts\` | The route handler serving guarded blog images and videos under \`/ps3/blog/...\`. |
|
|
92
|
+
| \`../${base}app/robots.txt/route.ts\`, \`../${base}app/sitemap.xml/route.ts\` | \`/robots.txt\` and the storefront sitemap. |
|
|
92
93
|
| \`../${base}proxy.ts\` | Marks personalized pages private and answers HTTP 503 when Application Load fails. |
|
|
93
94
|
| \`../sitemaps/${frontend}.cfg\` | The backend URL used for this frontend domain. |
|
|
94
95
|
| \`../.env\` | Local MIMIC setting; keep secrets out of version control. |
|
|
@@ -389,8 +390,22 @@ location / {
|
|
|
389
390
|
|
|
390
391
|
Here localhost is your frontend process on your VPS. That process selects the
|
|
391
392
|
remote personal API from the domain file. The proxy must overwrite those headers.
|
|
392
|
-
An unknown domain never falls back to another tenant.
|
|
393
|
-
sitemap
|
|
393
|
+
An unknown domain never falls back to another tenant. \`/robots.txt\` points
|
|
394
|
+
crawlers to \`/sitemap.xml\`, which lists the public pages, categories, products
|
|
395
|
+
on sale and journal publications in every storefront language (each publication
|
|
396
|
+
only in the languages it is written in). The sitemap is built from the backend
|
|
397
|
+
and kept for an hour in \`node_modules/.cache/orionplace/sitemaps/\`
|
|
398
|
+
(\`ORIONPLACE_CACHE_DIR\` moves it). After you publish or change content, rebuild
|
|
399
|
+
it at once from the project folder; the running storefront serves the new
|
|
400
|
+
sitemap without a restart:
|
|
401
|
+
|
|
402
|
+
\`\`\`bash
|
|
403
|
+
npm run orionplace:sitemap # every frontend domain
|
|
404
|
+
npx orionplace-react sitemap rebuild shop.example.com # one domain
|
|
405
|
+
npx orionplace-react sitemap status # when each was built
|
|
406
|
+
\`\`\`
|
|
407
|
+
|
|
408
|
+
This setup does not add analytics.
|
|
394
409
|
|
|
395
410
|
## Android app
|
|
396
411
|
|
package/src/setup.mjs
CHANGED
|
@@ -187,6 +187,7 @@ export async function setupProject({ cwd = process.cwd(), action = 'set', field,
|
|
|
187
187
|
pkg.scripts['orionplace:remove-frontend'] ||= 'orionplace-react remove frontend';
|
|
188
188
|
pkg.scripts['orionplace:mimic'] ||= 'orionplace-react set mimic';
|
|
189
189
|
pkg.scripts['orionplace:help'] ||= 'orionplace-react help';
|
|
190
|
+
pkg.scripts['orionplace:sitemap'] ||= 'orionplace-react sitemap rebuild';
|
|
190
191
|
pkg.scripts['orionplace:update'] ||= 'orionplace-react update';
|
|
191
192
|
pkg.dependencies ||= {};
|
|
192
193
|
pkg.dependencies.zustand ||= dependencies.zustand || '^5.0.15';
|
package/src/sitemap.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { relative, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
7
|
+
const cli = metadata.name;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The project's own gateway runtime (orionplace/runtime/marketplace.mjs): the
|
|
11
|
+
* sitemap is built with the code the running storefront uses.
|
|
12
|
+
*/
|
|
13
|
+
async function projectRuntime(root) {
|
|
14
|
+
const path = resolve(root, 'orionplace/runtime/marketplace.mjs');
|
|
15
|
+
if (!existsSync(path)) {
|
|
16
|
+
throw new Error(`No orionplace/runtime/marketplace.mjs in ${root}. Run this in the storefront project (or pass --cwd).`);
|
|
17
|
+
}
|
|
18
|
+
const runtime = await import(pathToFileURL(path).href);
|
|
19
|
+
if (typeof runtime.rebuildMarketplaceSitemap !== 'function') {
|
|
20
|
+
throw new Error(`This project's runtime has no sitemap support. Run npx ${cli} update first.`);
|
|
21
|
+
}
|
|
22
|
+
return runtime;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The storefront reads SITEMAPS_DIR and ORIONPLACE_CACHE_DIR from the
|
|
27
|
+
* project's .env; the command uses the same folders. Variables already set
|
|
28
|
+
* in the shell win.
|
|
29
|
+
*/
|
|
30
|
+
async function projectEnvironment(root) {
|
|
31
|
+
let source = '';
|
|
32
|
+
try { source = await readFile(resolve(root, '.env'), 'utf8'); }
|
|
33
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
34
|
+
for (const line of source.split(/\r?\n/)) {
|
|
35
|
+
const match = /^\s*(?:export\s+)?(SITEMAPS_DIR|ORIONPLACE_CACHE_DIR)\s*=\s*(.*?)\s*$/.exec(line);
|
|
36
|
+
if (!match || process.env[match[1]] !== undefined) continue;
|
|
37
|
+
const value = match[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
38
|
+
if (value) process.env[match[1]] = resolve(root, value);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function domainsFor(runtime, root, domain) {
|
|
43
|
+
if (domain) {
|
|
44
|
+
const normalized = runtime.normalizeFrontDomain(domain);
|
|
45
|
+
if (!normalized) throw new Error(`Invalid frontend domain: ${domain}`);
|
|
46
|
+
return [normalized];
|
|
47
|
+
}
|
|
48
|
+
const domains = await runtime.marketplaceDomains(root);
|
|
49
|
+
if (!domains.length) throw new Error(`No frontend domains yet. Add one: npx ${cli} add frontend <shop.example.com>`);
|
|
50
|
+
return domains;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function age(built, now = Date.now()) {
|
|
54
|
+
const minutes = Math.max(0, Math.round((now - Date.parse(built)) / 60_000));
|
|
55
|
+
return minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `sitemap rebuild [domain]`: build the sitemap of every frontend domain (or
|
|
60
|
+
* one) now. `sitemap status [domain]`: show when each was built.
|
|
61
|
+
* @param {{cwd: string, action: string, domain?: string, log?: (line: string) => void}} options
|
|
62
|
+
* @returns {Promise<number>} exit code
|
|
63
|
+
*/
|
|
64
|
+
export async function sitemapCommand({ cwd, action, domain, log = console.log }) {
|
|
65
|
+
if (!['rebuild', 'status'].includes(action)) {
|
|
66
|
+
throw new Error(`Use: npx ${cli} sitemap rebuild [domain] | sitemap status [domain]`);
|
|
67
|
+
}
|
|
68
|
+
const root = resolve(cwd);
|
|
69
|
+
await projectEnvironment(root);
|
|
70
|
+
const runtime = await projectRuntime(root);
|
|
71
|
+
const domains = await domainsFor(runtime, root, domain);
|
|
72
|
+
let failed = 0;
|
|
73
|
+
for (const name of domains) {
|
|
74
|
+
if (action === 'status') {
|
|
75
|
+
const sitemap = await runtime.readSitemapCache(name, root);
|
|
76
|
+
const stale = sitemap && Date.now() - Date.parse(sitemap.built) >= runtime.SITEMAP_LIFETIME;
|
|
77
|
+
log(sitemap
|
|
78
|
+
? `${name}: built ${sitemap.built} (${age(sitemap.built)}), ${runtime.sitemapUrlCount(sitemap)} URLs${stale ? '; rebuilt at the next crawl' : ''}`
|
|
79
|
+
: `${name}: not built yet; built at the first /sitemap.xml request or with: npx ${cli} sitemap rebuild`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const started = Date.now();
|
|
83
|
+
try {
|
|
84
|
+
const done = await runtime.rebuildMarketplaceSitemap(name, { root });
|
|
85
|
+
log(`${name}: ${done.urls} URLs (${done.pages} pages, ${done.publications} journal publications) in ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
|
86
|
+
log(` saved ${relative(root, done.path)}; the running storefront serves it now at https://${name}/sitemap.xml`);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
failed++;
|
|
89
|
+
log(`${name}: not rebuilt: ${error?.message ?? error}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return failed ? 1 : 0;
|
|
93
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// robots.txt of the storefront domain; it points crawlers to the sitemap.
|
|
2
|
+
import { marketplaceRobotsResponse } from '@orionplace-runtime/marketplace.mjs';
|
|
3
|
+
|
|
4
|
+
export const runtime = 'nodejs';
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
export function GET(request: Request): Promise<Response> {
|
|
8
|
+
return marketplaceRobotsResponse(request, {
|
|
9
|
+
mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '',
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The storefront sitemap: public pages, categories, products and journal
|
|
2
|
+
// publications in every language, built from the backend for an hour.
|
|
3
|
+
import { marketplaceSitemapResponse } from '@orionplace-runtime/marketplace.mjs';
|
|
4
|
+
|
|
5
|
+
export const runtime = 'nodejs';
|
|
6
|
+
export const dynamic = 'force-dynamic';
|
|
7
|
+
|
|
8
|
+
export function GET(request: Request): Promise<Response> {
|
|
9
|
+
return marketplaceSitemapResponse(request, {
|
|
10
|
+
mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '',
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -7,6 +7,8 @@ date of birth. Application Load supplies the initial values. Dates in
|
|
|
7
7
|
`day.month.year` or `day/month/year` form are converted to the ISO value required
|
|
8
8
|
by the native date field and the request. The sign-in phone number is read-only.
|
|
9
9
|
|
|
10
|
+
Under the phone the hint names its country: `user.origin_country` from the application load, shown with the browser's country name («Its country, Russia, decides which payment methods you can use»). Without a country the plain hint stays.
|
|
11
|
+
|
|
10
12
|
Submit is enabled only after an editable value changes and `state.loaded` is
|
|
11
13
|
true with no pending request. Submission trims the two names and calls
|
|
12
14
|
`onRequest` with `{ action: 'details', data: { first_name, last_name, dob } }`.
|
|
@@ -28,6 +30,8 @@ the latest Application Load profile values. Field messages come from
|
|
|
28
30
|
`день.месяц.год` или `день/месяц/год` преобразуется в ISO-значение для системного
|
|
29
31
|
поля даты и запроса. Номер телефона для входа доступен только для чтения.
|
|
30
32
|
|
|
33
|
+
Под телефоном подсказка называет его страну: `user.origin_country` из загрузки приложения с названием страны от браузера («Его страна, Россия, определяет доступные способы оплаты»). Без страны остаётся обычная подсказка.
|
|
34
|
+
|
|
31
35
|
Кнопка сохранения становится доступной только после изменения редактируемого
|
|
32
36
|
значения, когда `state.loaded` равен `true` и нет выполняемого запроса. При
|
|
33
37
|
отправке внешние пробелы в имени и фамилии удаляются, затем `onRequest` получает
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
IStorefrontProfileState,
|
|
12
12
|
} from '@/interfaces/orionplace/accounts/IStorefrontProfile';
|
|
13
13
|
import { profileLabels } from '@/lib/orionplace/profileLabels';
|
|
14
|
-
import { profileDate } from '@/lib/orionplace/profile';
|
|
14
|
+
import { profileCountryName, profileDate } from '@/lib/orionplace/profile';
|
|
15
15
|
import styles from './ProfileDetailsForm.module.css';
|
|
16
16
|
|
|
17
17
|
export interface ProfileDetailsFormProps {
|
|
@@ -26,6 +26,11 @@ export function ProfileDetailsForm({
|
|
|
26
26
|
const profile = useOrionplace(selectProfile);
|
|
27
27
|
const locale = useOrionplaceSelections((value) => value.localeCode);
|
|
28
28
|
const labels = profileLabels(locale);
|
|
29
|
+
// The phone country decides which payment methods the customer gets at checkout.
|
|
30
|
+
const country = profileCountryName(profile?.origin_country, locale);
|
|
31
|
+
const phoneHint = country
|
|
32
|
+
? labels.phoneCountryHint.replace('{country}', country)
|
|
33
|
+
: labels.phoneHint;
|
|
29
34
|
const busy = Boolean(state.pending) || !state.loaded;
|
|
30
35
|
const synchronized = {
|
|
31
36
|
first_name: profile?.first_name ?? '',
|
|
@@ -148,7 +153,7 @@ export function ProfileDetailsForm({
|
|
|
148
153
|
autoComplete="tel"
|
|
149
154
|
readOnly
|
|
150
155
|
/>
|
|
151
|
-
<small>{
|
|
156
|
+
<small>{phoneHint}</small>
|
|
152
157
|
</label>
|
|
153
158
|
<label>
|
|
154
159
|
<span>{labels.dob}</span>
|
|
@@ -30,6 +30,8 @@ The comment field takes up to 1024 characters; its own Save action sends
|
|
|
30
30
|
`cart_customer_commentary_set` with `cart_commentary`. New cart data replaces the local recipient and comment
|
|
31
31
|
drafts.
|
|
32
32
|
|
|
33
|
+
When `payment_methods` is empty, the payment block shows «No payment method is available for this order…» instead of a select. The backend offers a method limited by country only to customers whose phone number is from one of its countries (`origin_country`), not by the delivery address.
|
|
34
|
+
|
|
33
35
|
On the Russian storefront a phone number typed without «+» the way it is written in Russia — «8 900 555-01-72» or «900 555-01-72» — becomes «+79005550172» when the field loses focus and before it is sent: the backend accepts only international numbers. A number typed with «+», other numbers and other languages are sent as typed (`phoneWithCountryCode()`).
|
|
34
36
|
|
|
35
37
|
### Props
|
|
@@ -77,6 +79,8 @@ On the Russian storefront a phone number typed without «+» the way it is writt
|
|
|
77
79
|
`cart_commentary`. Новые данные корзины
|
|
78
80
|
заменяют локальные значения получателя и комментария.
|
|
79
81
|
|
|
82
|
+
Когда `payment_methods` пуст, вместо списка показывается «Для этого заказа нет доступных способов оплаты…». Способ, ограниченный по странам, бэкенд предлагает только покупателям с номером телефона одной из этих стран (`origin_country`), а не по адресу доставки.
|
|
83
|
+
|
|
80
84
|
В русской витрине номер без «+», набранный по-российски — «8 900 555-01-72» или «900 555-01-72», — при выходе из поля и перед отправкой становится «+79005550172»: бэкенд принимает только международный формат. Номер с «+», другие номера и другие языки отправляются как набраны (`phoneWithCountryCode()`).
|
|
81
85
|
|
|
82
86
|
### Свойства
|
|
@@ -269,15 +269,26 @@ export function CartCheckoutDetails({
|
|
|
269
269
|
<>
|
|
270
270
|
<section className={styles.panel}>
|
|
271
271
|
<h2>{labels.payment}</h2>
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
272
|
+
{/* Methods depend on the order and on the customer's phone country:
|
|
273
|
+
an empty list is said, not shown as a select with only the prompt. */}
|
|
274
|
+
{methods.length ? (
|
|
275
|
+
<OrionplaceSelect
|
|
276
|
+
value={String(summary?.selected_payment ?? 0)}
|
|
277
|
+
options={payments}
|
|
278
|
+
icons={paymentIcons}
|
|
279
|
+
label={labels.payment}
|
|
280
|
+
disabled={disabled}
|
|
281
|
+
required
|
|
282
|
+
onValueChange={selectPayment}
|
|
283
|
+
/>
|
|
284
|
+
) : (
|
|
285
|
+
<p
|
|
286
|
+
className={styles.muted}
|
|
287
|
+
role="status"
|
|
288
|
+
>
|
|
289
|
+
{labels.noPayment}
|
|
290
|
+
</p>
|
|
291
|
+
)}
|
|
281
292
|
</section>
|
|
282
293
|
|
|
283
294
|
<section className={styles.panel}>
|
|
@@ -31,6 +31,8 @@ export interface IApiDateTime {
|
|
|
31
31
|
export interface IProfileData {
|
|
32
32
|
id: number;
|
|
33
33
|
phone: string;
|
|
34
|
+
/** ISO 3166-1 alpha-2 country of the phone number; null when the number has none. */
|
|
35
|
+
origin_country: string | null;
|
|
34
36
|
first_name: string;
|
|
35
37
|
last_name: string;
|
|
36
38
|
email: string;
|
|
@@ -6,6 +6,8 @@ export interface IApploadUserProfile {
|
|
|
6
6
|
last_login: string | null;
|
|
7
7
|
date_joined: string;
|
|
8
8
|
phone: string;
|
|
9
|
+
/** ISO 3166-1 alpha-2 country of the phone number; null when the number has none. */
|
|
10
|
+
origin_country: string | null;
|
|
9
11
|
password: boolean;
|
|
10
12
|
dob: string | null;
|
|
11
13
|
notifications_enabled: boolean;
|
|
@@ -17,6 +17,7 @@ const en = {
|
|
|
17
17
|
'The address is saved, but there is no delivery to this country. Choose another address.',
|
|
18
18
|
payment: 'Payment method',
|
|
19
19
|
choosePayment: 'Choose payment method',
|
|
20
|
+
noPayment: 'No payment method is available for this order. Available methods depend on the order and on the country of your phone number.',
|
|
20
21
|
recipient: 'Recipient',
|
|
21
22
|
myself: 'Myself',
|
|
22
23
|
myselfHint: 'Myself: I will receive the order',
|
|
@@ -143,6 +144,7 @@ const ru: typeof en = {
|
|
|
143
144
|
'Адрес сохранён, но доставки в эту страну нет. Выберите другой адрес.',
|
|
144
145
|
payment: 'Способ оплаты',
|
|
145
146
|
choosePayment: 'Выберите способ оплаты',
|
|
147
|
+
noPayment: 'Для этого заказа нет доступных способов оплаты. Они зависят от заказа и от страны вашего номера телефона.',
|
|
146
148
|
recipient: 'Получатель',
|
|
147
149
|
myself: 'Себе',
|
|
148
150
|
myselfHint: 'Себе: получу заказ самостоятельно',
|
|
@@ -34,6 +34,18 @@ export function profileDate(value: string | null | undefined) {
|
|
|
34
34
|
: '';
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** The name of a phone country code (`RU` → «Россия» / «Russia»); the code itself when the browser has no name. */
|
|
38
|
+
export function profileCountryName(code: string | null | undefined, locale: string) {
|
|
39
|
+
if (!code) {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || code;
|
|
44
|
+
} catch {
|
|
45
|
+
return code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export class ProfileRequestError extends Error {
|
|
38
50
|
readonly kind = 'profile-request';
|
|
39
51
|
readonly fields: Record<string, string>;
|
|
@@ -15,6 +15,7 @@ export function profileLabels(locale: string) {
|
|
|
15
15
|
lastName: 'Фамилия',
|
|
16
16
|
phone: 'Номер телефона',
|
|
17
17
|
phoneHint: 'Номер телефона для входа.',
|
|
18
|
+
phoneCountryHint: 'Номер телефона для входа. Его страна, {country}, определяет доступные способы оплаты.',
|
|
18
19
|
dob: 'Дата рождения',
|
|
19
20
|
save: 'Сохранить',
|
|
20
21
|
pending: 'Сохраняем…',
|
|
@@ -89,6 +90,7 @@ export function profileLabels(locale: string) {
|
|
|
89
90
|
lastName: 'Last name',
|
|
90
91
|
phone: 'Phone number',
|
|
91
92
|
phoneHint: 'Your sign-in phone number.',
|
|
93
|
+
phoneCountryHint: 'Your sign-in phone number. Its country, {country}, decides which payment methods you can use.',
|
|
92
94
|
dob: 'Date of birth',
|
|
93
95
|
save: 'Save',
|
|
94
96
|
pending: 'Saving…',
|
package/templates/proxy.ts
CHANGED
|
@@ -50,7 +50,8 @@ export async function proxy(request: NextRequest) {
|
|
|
50
50
|
return response;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// The gateway sets its own headers for /api
|
|
53
|
+
// The gateway sets its own headers for /api, robots.txt and the sitemap;
|
|
54
|
+
// static assets keep their caching.
|
|
54
55
|
export const config = {
|
|
55
|
-
matcher: ['/((?!api/|_next/static|_next/image|favicon.ico).*)'],
|
|
56
|
+
matcher: ['/((?!api/|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
56
57
|
};
|