orionplace-react 0.3.1 → 0.5.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 +39 -4
- package/bin/orionplace-react.mjs +38 -7
- package/package.json +1 -1
- package/runtime/marketplace.mjs +12 -0
- package/src/install.mjs +5 -4
- package/src/project-readme.mjs +3 -0
- package/src/service.mjs +186 -0
- package/templates/app/StorefrontShell.tsx +8 -0
- package/templates/app/_storefront/PrivacyPage.tsx +15 -0
- package/templates/app/_storefront/storefrontRoutes.tsx +3 -0
- package/templates/app/privacy/page.tsx +9 -0
- package/templates/components/orionplace/blog/BlogArticle.README.md +16 -0
- package/templates/components/orionplace/blog/BlogArticle.module.css +5 -0
- package/templates/components/orionplace/blog/BlogArticle.tsx +60 -1
- package/templates/components/orionplace/legal/CookieNotice.README.md +42 -0
- package/templates/components/orionplace/legal/CookieNotice.module.css +48 -0
- package/templates/components/orionplace/legal/CookieNotice.tsx +82 -0
- package/templates/components/orionplace/legal/StorefrontPrivacy.README.md +49 -0
- package/templates/components/orionplace/legal/StorefrontPrivacy.module.css +166 -0
- package/templates/components/orionplace/legal/StorefrontPrivacy.tsx +83 -0
- package/templates/components/orionplace/products/ProductReviewImageViewer.README.md +13 -2
- package/templates/components/orionplace/products/ProductReviewImageViewer.module.css +10 -1
- package/templates/components/orionplace/products/ProductReviewImageViewer.tsx +61 -9
- package/templates/components/orionplace/storefront/StorefrontFooter.tsx +3 -0
- package/templates/components/orionplace/subscriptions/StorefrontTariff.README.md +13 -0
- package/templates/components/orionplace/subscriptions/StorefrontTariff.tsx +17 -6
- package/templates/components/orionplace/subscriptions/StorefrontTariffs.README.md +13 -0
- package/templates/components/orionplace/subscriptions/StorefrontTariffs.tsx +19 -2
- package/templates/components/orionplace/tariffs/TariffCard.tsx +8 -1
- package/templates/components/orionplace/tariffs/TariffCardPrices.README.md +10 -2
- package/templates/components/orionplace/tariffs/TariffCardPrices.tsx +12 -2
- package/templates/hooks/useOrionplaceTariffPurchase.ts +60 -0
- package/templates/lib/orionplace/blogImageViewer.ts +84 -0
- package/templates/lib/orionplace/blogLabels.ts +4 -0
- package/templates/lib/orionplace/footerLabels.ts +2 -0
- package/templates/lib/orionplace/pageMetadata.ts +1 -0
- package/templates/lib/orionplace/pageSeo.ts +10 -0
- package/templates/lib/orionplace/privacyLabels.ts +198 -0
- package/templates/lib/orionplace/reviewLabels.ts +4 -0
- package/templates/lib/orionplace/tariffLabels.ts +2 -0
- package/templates/lib/orionplace/tariffs.ts +49 -0
- package/templates/stores/orionplaceCatalog.ts +18 -0
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ starting the application. Imports then work at any component directory depth.
|
|
|
127
127
|
<!-- components:start (generated by scripts/generate-package-readme-components.mjs) -->
|
|
128
128
|
## Components
|
|
129
129
|
|
|
130
|
-
The starter includes **
|
|
130
|
+
The starter includes **126 storefront components**, copied into
|
|
131
131
|
`components/orionplace/` of your project. Each one has a README in English and Russian
|
|
132
132
|
next to its source that describes its inputs, data and actions.
|
|
133
133
|
|
|
@@ -292,6 +292,8 @@ Journal content and customer contact flows.
|
|
|
292
292
|
| **Contact details** | `AboutPage` | Display the store’s published company and contact information. |
|
|
293
293
|
| **Contact form** | `ContactForm` | Sending a question to the store. |
|
|
294
294
|
| **Unsubscribe from emails** | `EmailUnsubscribe` | Confirming an unsubscribe request from an email link. |
|
|
295
|
+
| **Privacy policy page** | `StorefrontPrivacy` | The store’s personal data policy with a cookies section and the operator’s details. |
|
|
296
|
+
| **Cookie notice** | `CookieNotice` | A small notice about the necessary cookies with a link to the cookies section of the policy. |
|
|
295
297
|
|
|
296
298
|
### Shared interface
|
|
297
299
|
|
|
@@ -317,6 +319,8 @@ npx orionplace-react add frontend www.shop.example.com
|
|
|
317
319
|
npx orionplace-react remove frontend www.shop.example.com
|
|
318
320
|
npx orionplace-react set mimic shop.example.com
|
|
319
321
|
sudo npx orionplace-react install
|
|
322
|
+
npx orionplace-react status
|
|
323
|
+
sudo npx orionplace-react restart
|
|
320
324
|
npx orionplace-react help
|
|
321
325
|
```
|
|
322
326
|
|
|
@@ -363,9 +367,34 @@ builds the project, runs it as the system service `orionplace-shop` on a free
|
|
|
363
367
|
local port and connects each storefront domain with nginx and a Let's Encrypt
|
|
364
368
|
certificate. Run it again after code or package updates; the service keeps its
|
|
365
369
|
port. Options: `--name <service>`, `--port <port>`, `--user <user>`,
|
|
366
|
-
`--no-domains`, `--yes`,
|
|
370
|
+
`--no-build` (install the build already in the folder), `--no-domains`, `--yes`,
|
|
371
|
+
`--dry-run`. Full guide:
|
|
367
372
|
[Install a storefront](https://orionmarket.place/en/sandbox/install/storefront?framework=react).
|
|
368
373
|
|
|
374
|
+
### Manage the service
|
|
375
|
+
|
|
376
|
+
Run these in the project folder, or name the service from anywhere with
|
|
377
|
+
`--name shop`:
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
npx orionplace-react status # running?, autostart, port, home page answer, domains
|
|
381
|
+
sudo npx orionplace-react start
|
|
382
|
+
sudo npx orionplace-react stop
|
|
383
|
+
sudo npx orionplace-react restart # waits until the home page answers again
|
|
384
|
+
sudo npx orionplace-react logs 200 # last log lines; --follow keeps reading
|
|
385
|
+
npx orionplace-react list # every storefront service on this server
|
|
386
|
+
sudo npx orionplace-react uninstall # stop and remove the service (alias: cleanup)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`start` and `restart` wait up to 90 seconds for the home page and print the
|
|
390
|
+
last log lines when it does not answer. `status` ends with code 3 when the
|
|
391
|
+
storefront is stopped or does not answer, so scripts and monitors can use it.
|
|
392
|
+
`uninstall` (or `cleanup`) stops the service, takes it out of autostart and
|
|
393
|
+
deletes its unit file and registration; the project folder and its build stay.
|
|
394
|
+
With the `orionplace` server tool it runs `orionplace app remove`, which also
|
|
395
|
+
turns the storefront's domains into a placeholder page. `--dry-run` shows what it
|
|
396
|
+
would remove; `--yes` skips the question.
|
|
397
|
+
|
|
369
398
|
## Generated files
|
|
370
399
|
|
|
371
400
|
| Path | Purpose |
|
|
@@ -440,8 +469,14 @@ The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
|
|
|
440
469
|
end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
|
|
441
470
|
authorization and cookies are forwarded. Backend statuses are retained and cookie
|
|
442
471
|
Domain attributes are removed to bind cookies to the storefront. Gateway responses
|
|
443
|
-
use `Cache-Control: no-store`. Network failures return `502`.
|
|
444
|
-
|
|
472
|
+
use `Cache-Control: no-store`. Network failures return `502`.
|
|
473
|
+
|
|
474
|
+
A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
|
|
475
|
+
`403`. A request from the page's own host or the storefront domain passes, and the
|
|
476
|
+
backend receives it with the storefront domain's origin
|
|
477
|
+
(`https://shop.example.com`), the one it accepts forms from. So a host that shows
|
|
478
|
+
the storefront through `X-Front-Domain`, such as a preview host or a local port,
|
|
479
|
+
can sign in too.
|
|
445
480
|
|
|
446
481
|
Browser requests use `/api/...`. Server components call the same gateway in process
|
|
447
482
|
through `lib/orionplace/appLoadServer.ts`, which forwards the current request's
|
package/bin/orionplace-react.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { setupProject } from '../src/setup.mjs';
|
|
3
3
|
import { updateProject } from '../src/update.mjs';
|
|
4
4
|
import { installService } from '../src/install.mjs';
|
|
5
|
+
import { SERVICE_ACTIONS, SERVICE_ALIASES, controlService, findApp, printService } from '../src/service.mjs';
|
|
5
6
|
import { readFile } from 'node:fs/promises';
|
|
6
7
|
import { resolve } from 'node:path';
|
|
7
8
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -37,9 +38,20 @@ On your Linux server (run from the project root):
|
|
|
37
38
|
nginx and HTTPS. Run it again after changes or an update to rebuild and restart.
|
|
38
39
|
Server tool: https://orionmarket.place/downloads/orionplace.sh
|
|
39
40
|
|
|
41
|
+
Manage the installed service (from the project root, or name it with --name):
|
|
42
|
+
npx orionplace-react status service, autostart, port and home page answer
|
|
43
|
+
sudo npx orionplace-react start
|
|
44
|
+
sudo npx orionplace-react stop
|
|
45
|
+
sudo npx orionplace-react restart restart and wait until the home page answers
|
|
46
|
+
sudo npx orionplace-react logs [lines] last log lines (100); --follow keeps reading
|
|
47
|
+
npx orionplace-react list every storefront service on this server
|
|
48
|
+
sudo npx orionplace-react uninstall stop the service and remove it (alias: cleanup);
|
|
49
|
+
the project folder stays
|
|
50
|
+
|
|
40
51
|
Options: --cwd <directory> --dry-run --help --version
|
|
41
52
|
Update option: --local (apply this CLI version instead of fetching the latest release)
|
|
42
|
-
Install options: --name <service> --port <port> --user <user> --no-domains --yes
|
|
53
|
+
Install options: --name <service> --port <port> --user <user> --no-build --no-domains --yes
|
|
54
|
+
Service options: --name <service> --follow (logs) --dry-run --yes (uninstall)
|
|
43
55
|
|
|
44
56
|
Update downloads the latest release, refreshes unedited source, and installs dependencies.
|
|
45
57
|
It preserves edited source, domain mappings and MIMIC. --dry-run leaves project files unchanged.
|
|
@@ -62,11 +74,14 @@ try {
|
|
|
62
74
|
let cwd = process.cwd();
|
|
63
75
|
let dryRun = false;
|
|
64
76
|
let local = false;
|
|
65
|
-
|
|
77
|
+
let follow = false;
|
|
78
|
+
const install = { domains: true, build: true, yes: false };
|
|
66
79
|
for (let index = 0; index < argv.length; index++) {
|
|
67
80
|
if (argv[index] === '--dry-run') dryRun = true;
|
|
68
81
|
else if (argv[index] === '--local') local = true;
|
|
69
82
|
else if (argv[index] === '--no-domains') install.domains = false;
|
|
83
|
+
else if (argv[index] === '--no-build') install.build = false;
|
|
84
|
+
else if (argv[index] === '--follow' || argv[index] === '-f') follow = true;
|
|
70
85
|
else if (argv[index] === '--yes' || argv[index] === '-y') install.yes = true;
|
|
71
86
|
else if (['--name', '--port', '--user'].includes(argv[index])) {
|
|
72
87
|
if (!argv[index + 1]) throw new Error(`${argv[index]} needs a value`);
|
|
@@ -77,11 +92,15 @@ try {
|
|
|
77
92
|
} else if (argv[index].startsWith('--')) throw new Error(`Unknown option: ${argv[index]}`);
|
|
78
93
|
else positional.push(argv[index]);
|
|
79
94
|
}
|
|
80
|
-
const [
|
|
95
|
+
const [given, target] = positional;
|
|
96
|
+
const command = SERVICE_ALIASES[given] || given;
|
|
81
97
|
if (local && command !== 'update') throw new Error('--local is only supported by update.');
|
|
82
|
-
|
|
83
|
-
|
|
98
|
+
const service = SERVICE_ACTIONS.includes(command);
|
|
99
|
+
if (command !== 'install' && (install.port || install.user || !install.domains || !install.build || (install.yes && command !== 'uninstall') || (install.name && !service))) {
|
|
100
|
+
throw new Error('--port, --user, --no-build, --no-domains and --yes are install options (--yes also for uninstall); --name is for install and the service commands.');
|
|
84
101
|
}
|
|
102
|
+
if (follow && command !== 'logs') throw new Error('--follow is a logs option.');
|
|
103
|
+
if (dryRun && service && command !== 'uninstall') throw new Error('--dry-run is only for uninstall among the service commands; status shows the state without changes.');
|
|
85
104
|
let result;
|
|
86
105
|
if (command === 'install' && positional.length === 1) {
|
|
87
106
|
const done = await installService({ cwd, dryRun, ...install });
|
|
@@ -92,14 +111,26 @@ try {
|
|
|
92
111
|
} else {
|
|
93
112
|
console.log(`\nInstalled orionplace-${done.name} (port ${done.port}, user ${done.user}).`);
|
|
94
113
|
for (const domain of done.frontends) {
|
|
95
|
-
if (!
|
|
114
|
+
if (!install.domains) console.log(` - ${domain}: not connected (--no-domains)`);
|
|
115
|
+
else if (!done.connected) console.log(` - ${domain}: not connected to nginx (see above)`);
|
|
96
116
|
else if (done.failed.includes(domain)) console.log(` ✗ ${domain}: not connected (see above)`);
|
|
97
117
|
else console.log(` ✓ https://${domain}`);
|
|
98
118
|
}
|
|
99
119
|
if (done.failed.length) console.log('Fix the problems above, then run: sudo orionplace domain add <domain>');
|
|
100
|
-
console.log(`Manage it
|
|
120
|
+
console.log(`Manage it from this folder: npx orionplace-react status | sudo npx orionplace-react restart | sudo npx orionplace-react logs`);
|
|
101
121
|
console.log('After code changes or npx orionplace-react update, run sudo npx orionplace-react install again.');
|
|
102
122
|
}
|
|
123
|
+
} else if (service && positional.length <= (command === 'logs' ? 2 : 1)) {
|
|
124
|
+
if (command === 'uninstall' && !dryRun && !install.yes && process.stdin.isTTY) {
|
|
125
|
+
const app = await findApp({ cwd, name: install.name });
|
|
126
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
127
|
+
let answer;
|
|
128
|
+
try { answer = await prompt.question(`Stop and remove the service orionplace-${app.NAME}? The project stays in ${app.DIR}. [y/N] `); }
|
|
129
|
+
finally { prompt.close(); }
|
|
130
|
+
if (!/^y(es)?$/i.test(answer.trim())) { console.log('Nothing changed.'); process.exit(0); }
|
|
131
|
+
}
|
|
132
|
+
const done = await controlService({ action: command, cwd, name: install.name, lines: positional[1] ?? 100, follow, dryRun });
|
|
133
|
+
process.exitCode = printService(done);
|
|
103
134
|
} else if (command === 'init' && positional.length <= 2) {
|
|
104
135
|
result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
|
|
105
136
|
} 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.5.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",
|
package/runtime/marketplace.mjs
CHANGED
|
@@ -208,6 +208,18 @@ export async function forwardMarketplace(request, configuration = {}) {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
headers.set('X-Front-Domain', tenant.domain);
|
|
211
|
+
// The backend accepts forms from the storefront domain's own origin. A public
|
|
212
|
+
// host that shows this storefront (MIMIC, a preview host or a local port)
|
|
213
|
+
// passed the same-origin check above, so it is sent as that origin.
|
|
214
|
+
let ownOrigin = false;
|
|
215
|
+
try {
|
|
216
|
+
ownOrigin = Boolean(origin) && originHosts.includes(new URL(origin).host);
|
|
217
|
+
} catch {
|
|
218
|
+
ownOrigin = false;
|
|
219
|
+
}
|
|
220
|
+
if (ownOrigin) {
|
|
221
|
+
headers.set('origin', `https://${tenant.domain}`);
|
|
222
|
+
}
|
|
211
223
|
headers.set('Accept-Encoding', 'identity');
|
|
212
224
|
const clientIp =
|
|
213
225
|
request.headers.get('x-init-ip') ||
|
package/src/install.mjs
CHANGED
|
@@ -222,7 +222,7 @@ async function ensureDomains(root, { yes }) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
// The storefront's home page as the public domain sees it -> HTTP status, 0 when down.
|
|
225
|
-
function homeStatus(port, host) {
|
|
225
|
+
export function homeStatus(port, host) {
|
|
226
226
|
return new Promise(done => {
|
|
227
227
|
const call = request({ host: '127.0.0.1', port, path: '/', headers: { host }, timeout: 15000 }, response => {
|
|
228
228
|
response.resume();
|
|
@@ -234,7 +234,7 @@ function homeStatus(port, host) {
|
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
async function waitForHttp(port, host, seconds = 90) {
|
|
237
|
+
export async function waitForHttp(port, host, seconds = 90) {
|
|
238
238
|
const until = Date.now() + seconds * 1000;
|
|
239
239
|
while (Date.now() < until) {
|
|
240
240
|
const status = await homeStatus(port, host);
|
|
@@ -302,7 +302,8 @@ export async function installService({
|
|
|
302
302
|
return { dryRun, name: service, port: chosen, user: account.name, unitPath, unit, conf, frontends: settings.frontends || [], backend: settings.backend };
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
|
|
305
|
+
const rebuild = build || !existsSync(join(root, framework.output));
|
|
306
|
+
log(rebuild ? `\n==> Building the ${framework.title} storefront ${service}` : `\n==> Using the existing build of ${service} (--no-build)`);
|
|
306
307
|
// The website build; ORIONPLACE_APP_ORIGIN would make the Android app build.
|
|
307
308
|
const buildEnv = { ...process.env };
|
|
308
309
|
delete buildEnv.ORIONPLACE_APP_ORIGIN;
|
|
@@ -310,7 +311,7 @@ export async function installService({
|
|
|
310
311
|
const installed = await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: root, env: buildEnv });
|
|
311
312
|
if (installed.code) throw new Error('npm install failed; see the messages above.');
|
|
312
313
|
}
|
|
313
|
-
if (
|
|
314
|
+
if (rebuild) {
|
|
314
315
|
const built = await run('npm', ['run', 'build'], { cwd: root, env: buildEnv });
|
|
315
316
|
if (built.code) throw new Error('The build failed; see the messages above. The running service (if any) was not changed.');
|
|
316
317
|
}
|
package/src/project-readme.mjs
CHANGED
|
@@ -353,6 +353,9 @@ The quickest way: prepare the server with the OrionMarketplace server script,
|
|
|
353
353
|
then run \`sudo npx orionplace-react install\` in this folder. It builds the
|
|
354
354
|
project, runs it as a system service and connects your domains with nginx and
|
|
355
355
|
HTTPS. Steps: [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront?framework=react).
|
|
356
|
+
Then manage it from this folder: \`npx orionplace-react status\`, \`sudo npx orionplace-react restart\`,
|
|
357
|
+
\`sudo npx orionplace-react stop\`, \`sudo npx orionplace-react start\` and \`sudo npx orionplace-react logs\`.
|
|
358
|
+
\`sudo npx orionplace-react uninstall\` (or \`cleanup\`) stops and removes the service; this folder stays.
|
|
356
359
|
|
|
357
360
|
To set the server up yourself instead:
|
|
358
361
|
|
package/src/service.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Manages the storefront service that "install" set up: start, stop, restart,
|
|
2
|
+
// status, logs and the list of every storefront service on this server.
|
|
3
|
+
// The service is found by its folder (the project root) or by --name.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { defaultPaths, framework, homeStatus, readApps, runCommand, waitForHttp } from './install.mjs';
|
|
8
|
+
|
|
9
|
+
export const SERVICE_ACTIONS = ['start', 'stop', 'restart', 'status', 'logs', 'list', 'uninstall'];
|
|
10
|
+
// "cleanup" is another name for uninstall.
|
|
11
|
+
export const SERVICE_ALIASES = { cleanup: 'uninstall' };
|
|
12
|
+
|
|
13
|
+
async function frontends(dir) {
|
|
14
|
+
try { return JSON.parse(await readFile(join(dir, 'orionplace/settings.json'), 'utf8')).frontends || []; }
|
|
15
|
+
catch { return []; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The registered storefront of this folder, or the one named with --name.
|
|
19
|
+
export async function findApp({ cwd = process.cwd(), name, paths = defaultPaths } = {}) {
|
|
20
|
+
const apps = await readApps(paths);
|
|
21
|
+
const root = resolve(cwd);
|
|
22
|
+
const app = name ? apps.find(item => item.NAME === name) : apps.find(item => item.DIR === root);
|
|
23
|
+
if (app) return app;
|
|
24
|
+
const known = apps.map(item => item.NAME).join(', ');
|
|
25
|
+
if (name) throw new Error(`No storefront service named ${name}.${known ? ` Installed: ${known}.` : ''}`);
|
|
26
|
+
throw new Error(`${root} is not installed as a service. Install it first: sudo npx orionplace-${framework.id} install`
|
|
27
|
+
+ (known ? `\nOr name another storefront: --name ${apps[0].NAME} (installed: ${known})` : ''));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// true / false; "unknown" when neither systemd nor OpenRC answers.
|
|
31
|
+
async function isRunning(service, { run, systemd }) {
|
|
32
|
+
const result = systemd
|
|
33
|
+
? await run('systemctl', ['is-active', '--quiet', service], { capture: true })
|
|
34
|
+
: await run('rc-service', [service, 'status'], { capture: true });
|
|
35
|
+
return result.code === 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function isEnabled(service, { run, systemd }) {
|
|
39
|
+
const result = systemd
|
|
40
|
+
? await run('systemctl', ['is-enabled', '--quiet', service], { capture: true })
|
|
41
|
+
: await run('rc-update', ['show', 'default'], { capture: true });
|
|
42
|
+
return systemd ? result.code === 0 : new RegExp(`^\\s*${service}\\s`, 'm').test(result.output);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function lastLogs(app, { run, systemd, paths, lines = 40 }) {
|
|
46
|
+
const result = systemd
|
|
47
|
+
? await run('journalctl', ['-u', app.SERVICE || `orionplace-${app.NAME}`, '-n', String(lines), '--no-pager'], { capture: true })
|
|
48
|
+
: await run('tail', ['-n', String(lines), join(paths.logs, `${app.NAME}.log`)], { capture: true });
|
|
49
|
+
return result.output;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One storefront's state: service, autostart, and the home page as its first domain sees it.
|
|
53
|
+
export async function serviceStatus(app, { run = runCommand, systemd = existsSync('/run/systemd/system'), probe = homeStatus } = {}) {
|
|
54
|
+
const service = app.SERVICE || `orionplace-${app.NAME}`;
|
|
55
|
+
const domains = await frontends(app.DIR);
|
|
56
|
+
const running = await isRunning(service, { run, systemd });
|
|
57
|
+
const enabled = await isEnabled(service, { run, systemd });
|
|
58
|
+
const http = running ? await probe(Number(app.PORT), domains[0] || 'localhost') : 0;
|
|
59
|
+
return { name: app.NAME, service, framework: app.FRAMEWORK, dir: app.DIR, port: Number(app.PORT), running, enabled, http, domains };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* controlService({ action, cwd, name, lines, follow }) runs one action and
|
|
64
|
+
* returns what happened; logs and follow print straight to the terminal.
|
|
65
|
+
*/
|
|
66
|
+
export async function controlService({
|
|
67
|
+
action, cwd = process.cwd(), name, lines = 100, follow = false,
|
|
68
|
+
paths = defaultPaths, run = runCommand, uid = process.getuid?.(),
|
|
69
|
+
systemd = existsSync('/run/systemd/system'), openrc = existsSync('/sbin/openrc-run'),
|
|
70
|
+
platform = process.platform, probe = homeStatus, wait = waitForHttp, dryRun = false,
|
|
71
|
+
} = {}) {
|
|
72
|
+
action = SERVICE_ALIASES[action] || action;
|
|
73
|
+
if (!SERVICE_ACTIONS.includes(action)) throw new Error(`Unknown service command ${action}. Use ${SERVICE_ACTIONS.join(', ')}.`);
|
|
74
|
+
if (platform !== 'linux') throw new Error(`${action} manages the Linux service that install set up. Run it on your server.`);
|
|
75
|
+
if (!systemd && !openrc) throw new Error('Neither systemd nor OpenRC was found on this server.');
|
|
76
|
+
|
|
77
|
+
if (action === 'list') {
|
|
78
|
+
const apps = await readApps(paths);
|
|
79
|
+
return { action, apps: await Promise.all(apps.map(app => serviceStatus(app, { run, systemd, probe }))) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const app = await findApp({ cwd, name, paths });
|
|
83
|
+
const service = app.SERVICE || `orionplace-${app.NAME}`;
|
|
84
|
+
if (action === 'status') return { action, ...await serviceStatus(app, { run, systemd, probe }) };
|
|
85
|
+
|
|
86
|
+
if (action === 'logs') {
|
|
87
|
+
const count = Number(lines);
|
|
88
|
+
if (!Number.isInteger(count) || count < 1) throw new Error('Give the number of log lines as a whole number, for example: logs 200');
|
|
89
|
+
const args = systemd
|
|
90
|
+
? ['journalctl', ['-u', service, '-n', String(count), '--no-pager', ...(follow ? ['--follow'] : [])]]
|
|
91
|
+
: ['tail', ['-n', String(count), ...(follow ? ['-F'] : []), join(paths.logs, `${app.NAME}.log`)]];
|
|
92
|
+
const result = await run(...args);
|
|
93
|
+
if (result.code) throw new Error(`${args[0]} could not read the logs of ${service}${uid !== 0 ? `; try: sudo npx orionplace-${framework.id} logs` : ''}.`);
|
|
94
|
+
return { action, name: app.NAME, service };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (action === 'uninstall') return uninstallService(app, { paths, run, uid, systemd, name, dryRun });
|
|
98
|
+
if (uid !== 0) throw new Error(`${action} needs root: sudo npx orionplace-${framework.id} ${action}${name ? ` --name ${name}` : ''}`);
|
|
99
|
+
const result = systemd
|
|
100
|
+
? await run('systemctl', [action, service], { capture: true })
|
|
101
|
+
: await run('rc-service', [service, action], { capture: true });
|
|
102
|
+
if (result.code) throw new Error(`Could not ${action} ${service}:\n${result.output.trim()}`);
|
|
103
|
+
if (action === 'stop') return { action, name: app.NAME, service, running: false };
|
|
104
|
+
|
|
105
|
+
const domains = await frontends(app.DIR);
|
|
106
|
+
const http = await wait(Number(app.PORT), domains[0] || 'localhost', 90);
|
|
107
|
+
if (!http) {
|
|
108
|
+
throw new Error(`${service} did not answer on port ${app.PORT} within 90 seconds. Last log lines:\n${await lastLogs(app, { run, systemd, paths })}`);
|
|
109
|
+
}
|
|
110
|
+
return { action, name: app.NAME, service, port: Number(app.PORT), running: true, http, domains };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stops the service, removes it from autostart, deletes its unit or init
|
|
115
|
+
* script and its registration. The project folder and its build stay. With
|
|
116
|
+
* the orionplace server tool, "orionplace app remove" does it and also turns
|
|
117
|
+
* the storefront's domains into a placeholder page.
|
|
118
|
+
*/
|
|
119
|
+
async function uninstallService(app, { paths, run, uid, systemd, name, dryRun }) {
|
|
120
|
+
const service = app.SERVICE || `orionplace-${app.NAME}`;
|
|
121
|
+
const unitPath = systemd ? join(paths.systemd, `${service}.service`) : join(paths.openrc, service);
|
|
122
|
+
const confPath = join(paths.etc, 'apps', `${app.NAME}.conf`);
|
|
123
|
+
const tool = (paths.tool || []).find(path => existsSync(path));
|
|
124
|
+
const result = { action: 'uninstall', name: app.NAME, service, dir: app.DIR, domains: await frontends(app.DIR), tool: Boolean(tool), unitPath, confPath, dryRun };
|
|
125
|
+
if (dryRun) return result;
|
|
126
|
+
if (uid !== 0) throw new Error(`uninstall needs root: sudo npx orionplace-${framework.id} uninstall${name ? ` --name ${name}` : ''}`);
|
|
127
|
+
if (tool) {
|
|
128
|
+
const removed = await run(tool, ['app', 'remove', app.NAME, '--yes']);
|
|
129
|
+
if (removed.code) throw new Error(`orionplace app remove ${app.NAME} failed; see the messages above.`);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
// A service that is already stopped or missing is fine: the goal is that it is gone.
|
|
133
|
+
if (systemd) await run('systemctl', ['disable', '--now', service], { capture: true });
|
|
134
|
+
else {
|
|
135
|
+
await run('rc-service', [service, 'stop'], { capture: true });
|
|
136
|
+
await run('rc-update', ['del', service, 'default'], { capture: true });
|
|
137
|
+
}
|
|
138
|
+
await rm(unitPath, { force: true });
|
|
139
|
+
if (systemd) await run('systemctl', ['daemon-reload'], { capture: true });
|
|
140
|
+
await rm(confPath, { force: true });
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The CLI output of controlService; returns the process exit code.
|
|
145
|
+
export function printService(result, log = console.log) {
|
|
146
|
+
const line = item => {
|
|
147
|
+
const state = item.running ? (item.http ? `running, home page HTTP ${item.http}` : 'running, home page does not answer') : 'stopped';
|
|
148
|
+
return `${item.name.padEnd(16)} ${String(item.framework || '').padEnd(7)} ${String(item.port).padEnd(6)} ${state}`;
|
|
149
|
+
};
|
|
150
|
+
if (result.action === 'list') {
|
|
151
|
+
if (!result.apps.length) {
|
|
152
|
+
log(`No storefront services on this server. Install one from its folder: sudo npx orionplace-${framework.id} install`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
log(`${'NAME'.padEnd(16)} ${'TYPE'.padEnd(7)} ${'PORT'.padEnd(6)} STATE`);
|
|
156
|
+
for (const item of result.apps) log(line(item));
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
if (result.action === 'status') {
|
|
160
|
+
log(`${result.service} (${result.framework || framework.id}) in ${result.dir}`);
|
|
161
|
+
log(` Service: ${result.running ? 'running' : 'stopped'}${result.enabled ? ', starts with the server' : ', does not start with the server'}`);
|
|
162
|
+
log(` Port: 127.0.0.1:${result.port}`);
|
|
163
|
+
log(` Home page: ${result.running ? (result.http ? `HTTP ${result.http}` : 'does not answer') : '-'}`);
|
|
164
|
+
for (const domain of result.domains) log(` Domain: https://${domain}`);
|
|
165
|
+
return result.running && result.http && result.http < 500 ? 0 : 3;
|
|
166
|
+
}
|
|
167
|
+
if (result.action === 'uninstall') {
|
|
168
|
+
if (result.dryRun) {
|
|
169
|
+
log(`Would stop and remove ${result.service}:`);
|
|
170
|
+
log(result.tool ? ` orionplace app remove ${result.name} --yes (also turns its domains into a placeholder)` : ` ${result.unitPath}\n ${result.confPath}`);
|
|
171
|
+
log(`The project stays in ${result.dir}.`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
log(`Removed the service ${result.service}. The project and its build stay in ${result.dir}; delete the folder when you no longer need it.`);
|
|
175
|
+
if (!result.tool && result.domains.length) {
|
|
176
|
+
log(`nginx still sends ${result.domains.join(', ')} to the stopped service; remove those sites from your nginx configuration.`);
|
|
177
|
+
}
|
|
178
|
+
log(`Install it again with: sudo npx orionplace-${framework.id} install`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
if (result.action === 'stop') log(`Stopped ${result.service}.`);
|
|
182
|
+
else if (result.action === 'start' || result.action === 'restart') {
|
|
183
|
+
log(`${result.action === 'start' ? 'Started' : 'Restarted'} ${result.service}: home page HTTP ${result.http} on 127.0.0.1:${result.port}.`);
|
|
184
|
+
}
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { OrionplaceThemeProvider } from '@/components/orionplace/theme/OrionplaceThemeProvider';
|
|
21
21
|
import { StorefrontHeader } from '@/components/orionplace/storefront/StorefrontHeader';
|
|
22
22
|
import { StorefrontFooter } from '@/components/orionplace/storefront/StorefrontFooter';
|
|
23
|
+
import { CookieNotice } from '@/components/orionplace/legal/CookieNotice';
|
|
23
24
|
import { CartPreview } from '@/components/orionplace/cart/CartPreview';
|
|
24
25
|
import { useOrionplaceApi } from '@/hooks/useOrionplaceApi';
|
|
25
26
|
import { useOrionplaceAppLoad } from '@/hooks/useOrionplaceAppLoad';
|
|
@@ -354,6 +355,13 @@ function ShellContent({ children }: { children: ReactNode }) {
|
|
|
354
355
|
/>
|
|
355
356
|
<div className={styles.content}>{children}</div>
|
|
356
357
|
<StorefrontFooter onNavigate={route} />
|
|
358
|
+
{/* The notice is for the website; the Android app links its policy from the store listing. */}
|
|
359
|
+
{APP_ORIGIN ? null : (
|
|
360
|
+
<CookieNotice
|
|
361
|
+
href={`${localized('/privacy')}#cookies`}
|
|
362
|
+
onNavigate={route}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
357
365
|
</div>
|
|
358
366
|
);
|
|
359
367
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { StorefrontPrivacy } from '@/components/orionplace/legal/StorefrontPrivacy';
|
|
4
|
+
import styles from '@/app/storefront.module.css';
|
|
5
|
+
|
|
6
|
+
export function PrivacyPage() {
|
|
7
|
+
return (
|
|
8
|
+
<main
|
|
9
|
+
className={styles.page}
|
|
10
|
+
data-storefront-page="privacy"
|
|
11
|
+
>
|
|
12
|
+
<StorefrontPrivacy />
|
|
13
|
+
</main>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -13,6 +13,7 @@ import { ManufacturersPage } from '@/app/_storefront/ManufacturersPage';
|
|
|
13
13
|
import { OrdersPage } from '@/app/_storefront/OrdersPage';
|
|
14
14
|
import { ProductPage } from '@/app/_storefront/ProductPage';
|
|
15
15
|
import { ProfilePage } from '@/app/_storefront/ProfilePage';
|
|
16
|
+
import { PrivacyPage } from '@/app/_storefront/PrivacyPage';
|
|
16
17
|
import { PromocodesPage } from '@/app/_storefront/PromocodesPage';
|
|
17
18
|
import { PromoPage } from '@/app/_storefront/PromoPage';
|
|
18
19
|
import { SearchPage } from '@/app/_storefront/SearchPage';
|
|
@@ -451,6 +452,8 @@ const routes: Record<string, Loader> = {
|
|
|
451
452
|
};
|
|
452
453
|
},
|
|
453
454
|
|
|
455
|
+
'/privacy': async () => ({ page: <PrivacyPage /> }),
|
|
456
|
+
|
|
454
457
|
'/profile': async () => ({ page: <ProfilePage /> }),
|
|
455
458
|
|
|
456
459
|
'/promo/[id]': async (context) => {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderStorefrontRoute,
|
|
3
|
+
type StorefrontPageProps,
|
|
4
|
+
} from '@/lib/orionplace/serverRoute';
|
|
5
|
+
|
|
6
|
+
// The page and its first data: app/_storefront/storefrontRoutes.tsx.
|
|
7
|
+
export default function Page(props: StorefrontPageProps) {
|
|
8
|
+
return renderStorefrontRoute('/privacy', props);
|
|
9
|
+
}
|
|
@@ -21,6 +21,14 @@ the image fails. Article videos accept HTTP or HTTPS addresses and the
|
|
|
21
21
|
backend's `/ps3/...` paths. In an Android app build those paths (cover, videos,
|
|
22
22
|
images and videos inside the text) load from the storefront address.
|
|
23
23
|
|
|
24
|
+
The cover and the images of the text open larger: a click, Enter or Space on
|
|
25
|
+
one opens `ProductReviewImageViewer` at that image, with the article's other
|
|
26
|
+
images a step away and each image's caption (its `figcaption`, else its
|
|
27
|
+
`title`, else its `alt`) under it. The images get `role="button"`, a keyboard
|
|
28
|
+
stop and an «Open image» name; a zoom-in cursor marks them. Images inside a
|
|
29
|
+
link, a button or a product placement keep their own action. The helper is
|
|
30
|
+
`lib/orionplace/blogImageViewer.ts`.
|
|
31
|
+
|
|
24
32
|
During server rendering, editor HTML is converted to escaped text paragraphs.
|
|
25
33
|
After hydration, DOMPurify replaces those paragraphs with sanitized formatting.
|
|
26
34
|
The allowlist retains headings, text formatting, links, lists, quotes, code,
|
|
@@ -91,6 +99,14 @@ ID тарифов, ссылка также передаёт их в `ids`. Дл
|
|
|
91
99
|
`preview_image_urls`. Область сохраняет пропорции 3:2. После ошибки загрузки
|
|
92
100
|
обложка исчезает. Компонент выводит только видео с адресами HTTP или HTTPS.
|
|
93
101
|
|
|
102
|
+
Обложка и изображения текста открываются крупно: нажатие, Enter или пробел на
|
|
103
|
+
изображении открывает `ProductReviewImageViewer` на нём, остальные изображения
|
|
104
|
+
статьи — в одном шаге, а под каждым — его подпись (`figcaption`, иначе `title`,
|
|
105
|
+
иначе `alt`). Изображения получают `role="button"`, остановку клавиатуры и
|
|
106
|
+
название «Открыть изображение»; их отмечает курсор с лупой. Изображения внутри
|
|
107
|
+
ссылки, кнопки или размещения товара сохраняют своё действие. Вспомогательный
|
|
108
|
+
модуль — `lib/orionplace/blogImageViewer.ts`.
|
|
109
|
+
|
|
94
110
|
При серверном рендеринге HTML редактора превращается в экранированные
|
|
95
111
|
текстовые абзацы. После гидратации DOMPurify заменяет их очищенным
|
|
96
112
|
форматированным HTML. Разрешены заголовки, оформление текста, ссылки, списки,
|
|
@@ -302,3 +302,8 @@
|
|
|
302
302
|
@media (prefers-reduced-motion: reduce) {
|
|
303
303
|
.skeleton { animation: none; }
|
|
304
304
|
}
|
|
305
|
+
/* Images that open in the viewer. */
|
|
306
|
+
.cover[data-blog-image-viewer],
|
|
307
|
+
.prose img[data-blog-image-viewer] { cursor: zoom-in; }
|
|
308
|
+
.cover[data-blog-image-viewer]:focus-visible,
|
|
309
|
+
.prose img[data-blog-image-viewer]:focus-visible { outline: 2px solid var(--orionplace-accent); outline-offset: 3px; }
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useMemo,
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
|
+
type SyntheticEvent,
|
|
9
10
|
} from 'react';
|
|
10
11
|
import {
|
|
11
12
|
useOrionplace,
|
|
@@ -41,6 +42,16 @@ import {
|
|
|
41
42
|
BlogComments,
|
|
42
43
|
type BlogCommentsProps,
|
|
43
44
|
} from './BlogComments';
|
|
45
|
+
import {
|
|
46
|
+
blogViewerImage,
|
|
47
|
+
blogViewerImages,
|
|
48
|
+
blogViewerIndex,
|
|
49
|
+
blogViewerPhotos,
|
|
50
|
+
isBlogViewerOpenKey,
|
|
51
|
+
markBlogViewerImages,
|
|
52
|
+
type IBlogViewerImage,
|
|
53
|
+
} from '@/lib/orionplace/blogImageViewer';
|
|
54
|
+
import { ProductReviewImageViewer } from '@/components/orionplace/products/ProductReviewImageViewer';
|
|
44
55
|
import styles from './BlogArticle.module.css';
|
|
45
56
|
|
|
46
57
|
export interface BlogArticleProps extends ProductGroupEvents {
|
|
@@ -92,6 +103,9 @@ export function BlogArticle({
|
|
|
92
103
|
const [mounted, setMounted] = useState(false);
|
|
93
104
|
const [coverFailed, setCoverFailed] = useState(false);
|
|
94
105
|
const articleRef = useRef<HTMLDivElement>(null);
|
|
106
|
+
const coverRef = useRef<HTMLImageElement>(null);
|
|
107
|
+
// The cover and the text images open larger in the viewer.
|
|
108
|
+
const [viewer, setViewer] = useState<{ images: IBlogViewerImage[]; index: number }>();
|
|
95
109
|
const requestRef = useRef<AbortController | undefined>(undefined);
|
|
96
110
|
const previousAuthentication = useRef(authenticated);
|
|
97
111
|
const wasShown = useRef(Boolean(preloaded));
|
|
@@ -177,6 +191,30 @@ export function BlogArticle({
|
|
|
177
191
|
setCoverFailed(false);
|
|
178
192
|
}, [cover]);
|
|
179
193
|
|
|
194
|
+
// The text is set as HTML: its images become buttons after each render.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
markBlogViewerImages(
|
|
197
|
+
blogViewerImages([coverRef.current, articleRef.current]),
|
|
198
|
+
labels.imageOpen,
|
|
199
|
+
);
|
|
200
|
+
}, [html, cover, coverFailed, mounted, labels.imageOpen, post?.id]);
|
|
201
|
+
|
|
202
|
+
const openViewer = (event: SyntheticEvent) => {
|
|
203
|
+
const images = blogViewerImages([coverRef.current, articleRef.current]);
|
|
204
|
+
const index = blogViewerIndex(event.target, images);
|
|
205
|
+
if (index < 0) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
setViewer({
|
|
210
|
+
index,
|
|
211
|
+
images: images.map((image) => {
|
|
212
|
+
const shown = blogViewerImage(image);
|
|
213
|
+
return image === coverRef.current ? { ...shown, alt: shown.alt || title } : shown;
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
180
218
|
useEffect(() => {
|
|
181
219
|
requestRef.current?.abort();
|
|
182
220
|
setAccessChanged(false);
|
|
@@ -240,7 +278,16 @@ export function BlogArticle({
|
|
|
240
278
|
) : null}
|
|
241
279
|
|
|
242
280
|
{post ? (
|
|
243
|
-
<article
|
|
281
|
+
<article
|
|
282
|
+
className={styles.article}
|
|
283
|
+
data-blog-article={post.id}
|
|
284
|
+
onClick={openViewer}
|
|
285
|
+
onKeyDown={(event) => {
|
|
286
|
+
if (isBlogViewerOpenKey(event.nativeEvent)) {
|
|
287
|
+
openViewer(event);
|
|
288
|
+
}
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
244
291
|
<header className={styles.header}>
|
|
245
292
|
<h1>{title}</h1>
|
|
246
293
|
<div className={styles.meta}>
|
|
@@ -252,6 +299,7 @@ export function BlogArticle({
|
|
|
252
299
|
</div>
|
|
253
300
|
{cover && !coverFailed ? (
|
|
254
301
|
<img
|
|
302
|
+
ref={coverRef}
|
|
255
303
|
src={cover}
|
|
256
304
|
alt=""
|
|
257
305
|
width={3}
|
|
@@ -344,6 +392,17 @@ export function BlogArticle({
|
|
|
344
392
|
) : settled && !pending && !error ? (
|
|
345
393
|
<OrionplaceEmptyState kind="journal" message={labels.unavailable} />
|
|
346
394
|
) : null}
|
|
395
|
+
{viewer && viewer.images.length ? (
|
|
396
|
+
<ProductReviewImageViewer
|
|
397
|
+
images={blogViewerPhotos(viewer.images)}
|
|
398
|
+
index={viewer.index}
|
|
399
|
+
label={labels.imageViewer}
|
|
400
|
+
alt={title || labels.imageViewer}
|
|
401
|
+
captions={viewer.images.map((image) => image.caption || image.alt)}
|
|
402
|
+
wide
|
|
403
|
+
onClose={() => setViewer(undefined)}
|
|
404
|
+
/>
|
|
405
|
+
) : null}
|
|
347
406
|
</section>
|
|
348
407
|
);
|
|
349
408
|
}
|