orionplace-react 0.3.1 → 0.4.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 CHANGED
@@ -317,6 +317,8 @@ npx orionplace-react add frontend www.shop.example.com
317
317
  npx orionplace-react remove frontend www.shop.example.com
318
318
  npx orionplace-react set mimic shop.example.com
319
319
  sudo npx orionplace-react install
320
+ npx orionplace-react status
321
+ sudo npx orionplace-react restart
320
322
  npx orionplace-react help
321
323
  ```
322
324
 
@@ -363,9 +365,34 @@ builds the project, runs it as the system service `orionplace-shop` on a free
363
365
  local port and connects each storefront domain with nginx and a Let's Encrypt
364
366
  certificate. Run it again after code or package updates; the service keeps its
365
367
  port. Options: `--name <service>`, `--port <port>`, `--user <user>`,
366
- `--no-domains`, `--yes`, `--dry-run`. Full guide:
368
+ `--no-build` (install the build already in the folder), `--no-domains`, `--yes`,
369
+ `--dry-run`. Full guide:
367
370
  [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront?framework=react).
368
371
 
372
+ ### Manage the service
373
+
374
+ Run these in the project folder, or name the service from anywhere with
375
+ `--name shop`:
376
+
377
+ ```bash
378
+ npx orionplace-react status # running?, autostart, port, home page answer, domains
379
+ sudo npx orionplace-react start
380
+ sudo npx orionplace-react stop
381
+ sudo npx orionplace-react restart # waits until the home page answers again
382
+ sudo npx orionplace-react logs 200 # last log lines; --follow keeps reading
383
+ npx orionplace-react list # every storefront service on this server
384
+ sudo npx orionplace-react uninstall # stop and remove the service (alias: cleanup)
385
+ ```
386
+
387
+ `start` and `restart` wait up to 90 seconds for the home page and print the
388
+ last log lines when it does not answer. `status` ends with code 3 when the
389
+ storefront is stopped or does not answer, so scripts and monitors can use it.
390
+ `uninstall` (or `cleanup`) stops the service, takes it out of autostart and
391
+ deletes its unit file and registration; the project folder and its build stay.
392
+ With the `orionplace` server tool it runs `orionplace app remove`, which also
393
+ turns the storefront's domains into a placeholder page. `--dry-run` shows what it
394
+ would remove; `--yes` skips the question.
395
+
369
396
  ## Generated files
370
397
 
371
398
  | Path | Purpose |
@@ -440,8 +467,14 @@ The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
440
467
  end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
441
468
  authorization and cookies are forwarded. Backend statuses are retained and cookie
442
469
  Domain attributes are removed to bind cookies to the storefront. Gateway responses
443
- use `Cache-Control: no-store`. Network failures return `502`. Cross-site mutations
444
- are rejected with `403`.
470
+ use `Cache-Control: no-store`. Network failures return `502`.
471
+
472
+ A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
473
+ `403`. A request from the page's own host or the storefront domain passes, and the
474
+ backend receives it with the storefront domain's origin
475
+ (`https://shop.example.com`), the one it accepts forms from. So a host that shows
476
+ the storefront through `X-Front-Domain`, such as a preview host or a local port,
477
+ can sign in too.
445
478
 
446
479
  Browser requests use `/api/...`. Server components call the same gateway in process
447
480
  through `lib/orionplace/appLoadServer.ts`, which forwards the current request's
@@ -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
- const install = { domains: true, yes: false };
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 [command, target] = positional;
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
- if (command !== 'install' && (install.name || install.port || install.user || !install.domains || install.yes)) {
83
- throw new Error('--name, --port, --user, --no-domains and --yes are install options.');
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 (!done.connected) console.log(` - ${domain}: not connected to nginx (see above)`);
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 with: orionplace app status ${done.name} | orionplace app logs ${done.name}`);
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.1",
3
+ "version": "0.4.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",
@@ -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
- log(`\n==> Building the ${framework.title} storefront ${service}`);
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 (build || !existsSync(join(root, framework.output))) {
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
  }
@@ -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
 
@@ -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
+ }
@@ -30,6 +30,12 @@ the callbacks from `TariffPurchaseActions`. A failed required refresh hides the
30
30
  dialog, blocks another purchase and exposes Refresh without discarding the
31
31
  dialog fields.
32
32
 
33
+ For a signed-in customer the component also reads `subscriptions_get` once
34
+ through `onLoadSubscriptions`: plans of a subscription the customer already
35
+ had offer no trial, and a resumed trial opens only after that read, for a
36
+ customer new to the subscription. A failed read leaves the trial shown; the
37
+ backend still refuses a repeated trial.
38
+
33
39
  The component makes requests only through its callbacks. Source is maintained
34
40
  in `components/orionplace/subscriptions/StorefrontTariff.tsx`; the plan contract
35
41
  is `interfaces/orionplace/subscribeTariff/ISubscribeTariff.ts`.
@@ -85,6 +91,13 @@ is `interfaces/orionplace/subscribeTariff/ISubscribeTariff.ts`.
85
91
  обновление не удалось, окно скрывается, новое подключение блокируется и
86
92
  появляется кнопка обновления, а заполненные поля окна сохраняются.
87
93
 
94
+ Для авторизованного покупателя компонент также один раз читает
95
+ `subscriptions_get` через `onLoadSubscriptions`: тарифы подписки, которая у
96
+ покупателя уже была, не предлагают пробный период, а восстановленный пробный
97
+ выбор открывается только после этого чтения и только для нового покупателя
98
+ подписки. При ошибке чтения пробный период остаётся видимым; повторный пробный
99
+ период всё равно отклоняет сервер.
100
+
88
101
  Компонент отправляет запросы только через переданные функции. Исходный файл:
89
102
  `components/orionplace/subscriptions/StorefrontTariff.tsx`; контракт тарифа:
90
103
  `interfaces/orionplace/subscribeTariff/ISubscribeTariff.ts`.
@@ -11,7 +11,12 @@ import {
11
11
  useOrionplaceCatalogStore,
12
12
  useOrionplaceSelections,
13
13
  } from '@/providers/OrionplaceProvider';
14
- import { selectIsAuthenticated } from '@/stores/orionplace';
14
+ import {
15
+ selectIsAuthenticated,
16
+ selectProfile,
17
+ } from '@/stores/orionplace';
18
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
19
+ import { tariffResumable } from '@/lib/orionplace/tariffs';
15
20
  import type { IBreadcrumb } from '@/interfaces/orionplace/shared/IBreadcrumb';
16
21
  import type { ISubscribeTariff } from '@/interfaces/orionplace/subscribeTariff/ISubscribeTariff';
17
22
  import type { ITariffSelection } from '@/interfaces/orionplace/subscribeTariff/ITariffSelection';
@@ -89,6 +94,8 @@ export function StorefrontTariff({
89
94
  state.tariffs?.data.find((entry) => entry.id === tariffId),
90
95
  );
91
96
  const catalogStore = useOrionplaceCatalogStore();
97
+ const profile = useOrionplace((state) => selectProfile(state)?.id);
98
+ const used = useOrionplaceCatalog(selectUsedSubscriptions(profile));
92
99
  const locale = useOrionplaceSelections((state) => state.localeCode);
93
100
  const copy = tariffLabels(locale);
94
101
  const common = featureLabels(locale);
@@ -273,22 +280,26 @@ export function StorefrontTariff({
273
280
  }
274
281
  }, [tariff]);
275
282
 
283
+ // A trial chosen before sign-in opens again only for a customer new to
284
+ // the subscription record (see `tariffResumable`).
285
+ const resumable = tariffResumable(resume, tariff, used);
286
+
276
287
  useEffect(() => {
277
288
  if (
278
- !resume ||
279
- resume.tariff !== tariffId ||
289
+ !resumable ||
290
+ resumable.tariff !== tariffId ||
280
291
  !authenticated ||
281
292
  !tariff
282
293
  ) {
283
294
  return;
284
295
  }
285
- const key = `${resume.tariff}:${resume.price ?? 'trial'}`;
296
+ const key = `${resumable.tariff}:${resumable.price ?? 'trial'}`;
286
297
  if (opened.current === key) {
287
298
  return;
288
299
  }
289
300
  opened.current = key;
290
- choose(resume);
291
- }, [resume?.tariff, resume?.price, authenticated, tariff]);
301
+ choose(resumable);
302
+ }, [resumable?.tariff, resumable?.price, authenticated, tariff]);
292
303
 
293
304
  return (
294
305
  <section
@@ -27,6 +27,12 @@ required refresh fails, another purchase remains blocked and Refresh repeats
27
27
  both reads. Repeating the already confirmed request is ignored; a different
28
28
  plan can still be purchased.
29
29
 
30
+ For a signed-in customer the component also reads `subscriptions_get` once
31
+ through `onLoadSubscriptions`: plans of a subscription the customer already
32
+ had offer no trial, and a resumed trial opens only after that read, for a
33
+ customer new to the subscription. A failed read leaves the trial shown; the
34
+ backend still refuses a repeated trial.
35
+
30
36
  The component makes requests only through those callbacks. Source is
31
37
  maintained in
32
38
  `components/orionplace/subscriptions/StorefrontTariffs.tsx`; the response
@@ -78,6 +84,13 @@ contract is `interfaces/orionplace/subscribes/ISubscriptionApi.ts`.
78
84
  обновления повторяет оба чтения. Повтор уже подтверждённого запроса
79
85
  игнорируется; другой тариф можно подключить.
80
86
 
87
+ Для авторизованного покупателя компонент также один раз читает
88
+ `subscriptions_get` через `onLoadSubscriptions`: тарифы подписки, которая у
89
+ покупателя уже была, не предлагают пробный период, а восстановленный пробный
90
+ выбор открывается только после этого чтения и только для нового покупателя
91
+ подписки. При ошибке чтения пробный период остаётся видимым; повторный пробный
92
+ период всё равно отклоняет сервер.
93
+
81
94
  Компонент отправляет запросы только через эти функции. Исходный файл:
82
95
  `components/orionplace/subscriptions/StorefrontTariffs.tsx`; контракт ответа:
83
96
  `interfaces/orionplace/subscribes/ISubscriptionApi.ts`.
@@ -7,7 +7,12 @@ import {
7
7
  useOrionplaceCatalogStore,
8
8
  useOrionplaceSelections,
9
9
  } from '@/providers/OrionplaceProvider';
10
- import { selectIsAuthenticated } from '@/stores/orionplace';
10
+ import {
11
+ selectIsAuthenticated,
12
+ selectProfile,
13
+ } from '@/stores/orionplace';
14
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
15
+ import { tariffResumable } from '@/lib/orionplace/tariffs';
11
16
  import type { ISubscriptionTariffsResponse } from '@/interfaces/orionplace/subscribes/ISubscriptionApi';
12
17
  import type { ITariffSelection } from '@/interfaces/orionplace/subscribeTariff/ITariffSelection';
13
18
  import {
@@ -69,6 +74,12 @@ export function StorefrontTariffs({
69
74
  (state) => state.tariffsSnapshot !== undefined,
70
75
  );
71
76
  const catalogStore = useOrionplaceCatalogStore();
77
+ const profile = useOrionplace(
78
+ (state) => selectProfile(state)?.id,
79
+ );
80
+ const used = useOrionplaceCatalog(
81
+ selectUsedSubscriptions(profile),
82
+ );
72
83
  const locale = useOrionplaceSelections((state) => state.localeCode);
73
84
  const labels = subscriptionLabels(locale);
74
85
  const common = featureLabels(locale);
@@ -331,7 +342,13 @@ export function StorefrontTariffs({
331
342
  subscriptionId={group.id}
332
343
  tariffIds={ids}
333
344
  guest={!authenticated}
334
- open={authenticated ? resume : undefined}
345
+ open={authenticated
346
+ ? tariffResumable(
347
+ resume,
348
+ resume && tariffs?.data.find((entry) => entry.id === resume.tariff),
349
+ used,
350
+ )
351
+ : undefined}
335
352
  disabled={busy || purchase.needsRefresh}
336
353
  pending={purchase.pending}
337
354
  suspended={purchase.needsRefresh}
@@ -7,9 +7,12 @@ import {
7
7
  type ReactNode,
8
8
  } from 'react';
9
9
  import {
10
+ useOrionplace,
10
11
  useOrionplaceCatalog,
11
12
  useOrionplaceSelections,
12
13
  } from '@/providers/OrionplaceProvider';
14
+ import { selectProfile } from '@/stores/orionplace';
15
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
13
16
  import type { ISubscribeTariff } from '@/interfaces/orionplace/subscribeTariff/ISubscribeTariff';
14
17
  import type {
15
18
  ITariffSelection,
@@ -18,6 +21,7 @@ import type {
18
21
  import {
19
22
  tariffIsOpen,
20
23
  tariffSubscribeRequest,
24
+ tariffTrialOpen,
21
25
  } from '@/lib/orionplace/tariffs';
22
26
  import { tariffPageHref } from '@/lib/orionplace/tariffPage';
23
27
  import {
@@ -82,13 +86,16 @@ export function TariffCard({
82
86
  }: TariffCardProps) {
83
87
  const tariffs = useOrionplaceCatalog((state) => state.tariffs);
84
88
  const locale = useOrionplaceSelections((state) => state.localeCode);
89
+ const profile = useOrionplace((state) => selectProfile(state)?.id);
90
+ const used = useOrionplaceCatalog(selectUsedSubscriptions(profile));
85
91
  const tariff = tariffs?.data.find((entry) => entry.id === tariffId);
86
92
  const [selection, setSelection] = useState<ITariffSelection>();
87
93
  const [period, setPeriod] = useState<number>();
88
94
  const [option, setOption] = useState<number>();
89
95
  const opened = useRef('');
90
96
  const labels = tariffLabels(locale);
91
- const trialBadge = tariff && tariff.trial_days > 0
97
+ // No trial badge for a customer who already had this subscription record.
98
+ const trialBadge = tariff && tariffTrialOpen(tariff, used)
92
99
  ? tariffText(labels.trialBadge, {
93
100
  days: tariffDays(tariff.trial_days, locale),
94
101
  })
@@ -7,7 +7,11 @@ currency from the isolated stores. Paid periods are ordered by duration. When
7
7
  several are available, choosing one calls `onPeriodChange(tariff_cost_id)`.
8
8
  `selectedPeriod` selects that period; a missing or unavailable value falls back
9
9
  to the shortest returned paid period. Each amount uses the selected currency's
10
- left and right symbols. A positive `trial_days` adds the trial action.
10
+ left and right symbols. A positive `trial_days` adds the trial action, unless
11
+ the signed-in customer already had the plan's subscription: the backend gives a
12
+ trial once per subscription. Such a trial-only plan shows that note instead.
13
+ The plan pages load the customer's records with `subscriptions_get` through
14
+ `onLoadSubscriptions`; until they arrive, the trial stays shown.
11
15
 
12
16
  The plan and its parent subscription must both allow customer subscriptions.
13
17
  An administrator-only or closed plan shows its localized reason instead of
@@ -43,7 +47,11 @@ The component makes no requests. Source is maintained in
43
47
  `onPeriodChange(tariff_cost_id)`. `selectedPeriod` задаёт период; отсутствующее
44
48
  или недоступное значение заменяется самым коротким полученным платным периодом.
45
49
  Сумма использует левый и правый символы выбранной валюты. Положительное
46
- `trial_days` добавляет действие пробного периода.
50
+ `trial_days` добавляет действие пробного периода, если у авторизованного
51
+ покупателя ещё не было подписки этого тарифа: пробный период даётся один раз на
52
+ подписку. Тариф только с пробным периодом тогда показывает это пояснение.
53
+ Страницы тарифов загружают подписки покупателя через `subscriptions_get`
54
+ (`onLoadSubscriptions`); до их получения пробный период показывается.
47
55
 
48
56
  Тариф и его родительская подписка должны разрешать самостоятельное
49
57
  подключение. Для закрытого тарифа или тарифа администратора вместо действий
@@ -2,9 +2,12 @@
2
2
 
3
3
  import { useId } from 'react';
4
4
  import {
5
+ useOrionplace,
5
6
  useOrionplaceCatalog,
6
7
  useOrionplaceSelections,
7
8
  } from '@/providers/OrionplaceProvider';
9
+ import { selectProfile } from '@/stores/orionplace';
10
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
8
11
  import type { ITariffSelection } from '@/interfaces/orionplace/subscribeTariff/ITariffSelection';
9
12
  import { getAvailableNestedValueFromObject } from '@/lib/orionplace/presentation';
10
13
  import {
@@ -13,6 +16,7 @@ import {
13
16
  tariffMoney,
14
17
  tariffPaidPeriods,
15
18
  tariffReturnPath,
19
+ tariffTrialOpen,
16
20
  } from '@/lib/orionplace/tariffs';
17
21
  import {
18
22
  tariffDays,
@@ -45,6 +49,8 @@ export function TariffCardPrices({
45
49
  onRequireSignIn,
46
50
  }: TariffCardPricesProps) {
47
51
  const tariffs = useOrionplaceCatalog((state) => state.tariffs);
52
+ const profile = useOrionplace((state) => selectProfile(state)?.id);
53
+ const used = useOrionplaceCatalog(selectUsedSubscriptions(profile));
48
54
  const locale = useOrionplaceSelections((state) => state.localeCode);
49
55
  const currencyId = useOrionplaceSelections((state) => state.currencyId);
50
56
  const currency = useOrionplaceSelections(
@@ -73,7 +79,9 @@ export function TariffCardPrices({
73
79
  }));
74
80
  const selected = paidPeriods.find((entry) => entry.price === selectedPeriod) ??
75
81
  paidPeriods[0];
76
- const trialDuration = tariff.trial_days > 0
82
+ // A customer who already had this subscription record gets no trial.
83
+ const trialUsed = tariff.trial_days > 0 && !tariffTrialOpen(tariff, used);
84
+ const trialDuration = tariff.trial_days > 0 && !trialUsed
77
85
  ? duration(tariff.trial_days)
78
86
  : '';
79
87
  const name = getAvailableNestedValueFromObject(
@@ -87,7 +95,9 @@ export function TariffCardPrices({
87
95
  ? labels.closed
88
96
  : selected || trialDuration
89
97
  ? ''
90
- : labels.noPeriods;
98
+ : trialUsed
99
+ ? labels.trialUsed
100
+ : labels.noPeriods;
91
101
 
92
102
  function choose(price: number | null) {
93
103
  if (disabled || availability !== 'open') {
@@ -5,6 +5,16 @@ import type { IProfileResponse } from '@/interfaces/orionplace/accounts/IProfile
5
5
  import type { ITariffSubscribeRequest } from '@/interfaces/orionplace/subscribeTariff/ITariffSelection';
6
6
  import type { ICustomerSubscriptionsResponse } from '@/interfaces/orionplace/subscribes/ISubscriptionApi';
7
7
  import { apiErrorMessage } from '@/lib/orionplace/api';
8
+ import { tariffUsedSubscriptions } from '@/lib/orionplace/tariffs';
9
+ import {
10
+ useOrionplace,
11
+ useOrionplaceCatalogStore,
12
+ } from '@/providers/OrionplaceProvider';
13
+ import {
14
+ selectIsAuthenticated,
15
+ selectProfile,
16
+ } from '@/stores/orionplace';
17
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
8
18
 
9
19
  type SubscriptionsPage = NonNullable<
10
20
  ICustomerSubscriptionsResponse['page_data']
@@ -55,13 +65,28 @@ export function useOrionplaceTariffPurchase(
55
65
  const [error, setError] = useState('');
56
66
  const [completed, setCompleted] = useState(false);
57
67
  const [needsRefresh, setNeedsRefresh] = useState(false);
68
+ const catalogStore = useOrionplaceCatalogStore();
69
+ const authenticated = useOrionplace(selectIsAuthenticated);
70
+ const profileId = useOrionplace((state) => selectProfile(state)?.id);
71
+
72
+ function rememberUsed(owner: number | undefined, page: SubscriptionsPage) {
73
+ if (owner !== undefined) {
74
+ catalogStore
75
+ .getState()
76
+ .setUsedSubscriptions(owner, tariffUsedSubscriptions(page));
77
+ }
78
+ }
58
79
 
59
80
  async function loadAccount(controller: AbortController) {
81
+ const owner = profileId;
60
82
  const subscriptions = await callbacks.current.onLoadSubscriptions({
61
83
  endpoint: 'subscriptions_get',
62
84
  request: {},
63
85
  signal: controller.signal,
64
86
  });
87
+ if (!controller.signal.aborted) {
88
+ rememberUsed(owner, subscriptions);
89
+ }
65
90
  const profile = await callbacks.current.onLoadProfile({
66
91
  endpoint: 'profile_get',
67
92
  request: { filters: {} },
@@ -138,6 +163,41 @@ export function useOrionplaceTariffPurchase(
138
163
  }
139
164
  }
140
165
 
166
+ /*
167
+ * The plan pages hide the trial of a subscription record the customer
168
+ * already had (the backend refuses it). They read the records once per
169
+ * profile; a failed read leaves the trial shown, as for an unknown customer.
170
+ */
171
+ useEffect(() => {
172
+ if (
173
+ !authenticated ||
174
+ profileId === undefined ||
175
+ selectUsedSubscriptions(profileId)(catalogStore.getState())
176
+ ) {
177
+ return;
178
+ }
179
+ const controller = new AbortController();
180
+ Promise.resolve(
181
+ callbacks.current.onLoadSubscriptions({
182
+ endpoint: 'subscriptions_get',
183
+ request: {},
184
+ signal: controller.signal,
185
+ }),
186
+ ).then(
187
+ (page) => {
188
+ if (!controller.signal.aborted) {
189
+ rememberUsed(profileId, page);
190
+ }
191
+ },
192
+ () => {
193
+ if (!controller.signal.aborted) {
194
+ catalogStore.getState().setUsedSubscriptions(profileId, []);
195
+ }
196
+ },
197
+ );
198
+ return () => controller.abort();
199
+ }, [authenticated, profileId, catalogStore]);
200
+
141
201
  useEffect(() => () => active.current?.abort(), []);
142
202
 
143
203
  return {
@@ -9,6 +9,7 @@ const labels = {
9
9
  adminOnly:
10
10
  'This plan is assigned by the store administrator. It cannot be subscribed to online.',
11
11
  closed: 'New subscriptions to this plan are closed.',
12
+ trialUsed: 'You have already used the trial of this subscription.',
12
13
  subscribe: 'Subscribe',
13
14
  tryTrial: 'Try',
14
15
  perPeriod: 'for {days}',
@@ -56,6 +57,7 @@ const labels = {
56
57
  adminOnly:
57
58
  'Этот тариф подключает администратор магазина. Самостоятельное подключение недоступно.',
58
59
  closed: 'Новые подключения к этому тарифу закрыты.',
60
+ trialUsed: 'Пробный период этой подписки вы уже использовали.',
59
61
  subscribe: 'Подключить',
60
62
  tryTrial: 'Попробовать',
61
63
  perPeriod: 'за {days}',
@@ -133,6 +133,55 @@ export function tariffSubscribeRequest(
133
133
  };
134
134
  }
135
135
 
136
+ /**
137
+ * The subscription records the customer already had a period of, paid or
138
+ * trial, read from the `subscriptions_get` response. The backend gives a trial
139
+ * only to a customer new to the record, so the plans of these records offer
140
+ * none.
141
+ */
142
+ export function tariffUsedSubscriptions(page: {
143
+ data: { subscription: number | null; tariff: number | null }[];
144
+ tariffs: { id: number; subscription: number }[];
145
+ }) {
146
+ const ids = page.data.map(
147
+ (item) =>
148
+ item.subscription ??
149
+ page.tariffs.find((tariff) => tariff.id === item.tariff)?.subscription,
150
+ );
151
+
152
+ return [...new Set(ids.filter((id): id is number => typeof id === 'number'))];
153
+ }
154
+
155
+ /**
156
+ * Whether the plan offers its trial: it has trial days and the customer is
157
+ * not known to have had its subscription record (`used`, see
158
+ * `tariffUsedSubscriptions`). A guest and a customer whose records are not
159
+ * loaded yet see the trial; the backend refuses it to a returning customer.
160
+ */
161
+ export function tariffTrialOpen(
162
+ tariff: ISubscribeTariff,
163
+ used: readonly number[] = [],
164
+ ) {
165
+ return tariff.trial_days > 0 && !used.includes(tariff.subscription);
166
+ }
167
+
168
+ /**
169
+ * The plan choice to open again after sign-in: a paid period as it is, a
170
+ * trial only once the customer's records are loaded (`used`) and the
171
+ * customer is new to the record.
172
+ */
173
+ export function tariffResumable(
174
+ selection: ITariffSelection | undefined,
175
+ tariff: ISubscribeTariff | undefined,
176
+ used: readonly number[] | undefined,
177
+ ) {
178
+ if (!selection || selection.price !== null) {
179
+ return selection;
180
+ }
181
+
182
+ return tariff && used && tariffTrialOpen(tariff, used) ? selection : undefined;
183
+ }
184
+
136
185
  export interface TariffFeature {
137
186
  key: string;
138
187
  label: string;
@@ -23,9 +23,16 @@ export interface OrionplaceCatalogState {
23
23
  tariffs: IAppLoadCommon['tariffs'] | null;
24
24
  /** Every plan the storefront sells; the subscriptions page reads it. */
25
25
  storeTariffs: IAppLoadCommon['tariffs'] | null;
26
+ /**
27
+ * The subscription records the signed-in customer already had, for the
28
+ * profile that loaded them (see `tariffUsedSubscriptions`). A new
29
+ * Application Load keeps them; another profile ignores them.
30
+ */
31
+ usedSubscriptions?: { profile: number; ids: number[] };
26
32
  setCatalogTree: (tree: CatalogTree) => void;
27
33
  setTariffs: (tariffs: Tariffs) => void;
28
34
  setStoreTariffs: (tariffs: Tariffs) => void;
35
+ setUsedSubscriptions: (profile: number, ids: number[]) => void;
29
36
  getTariff: (id: number) => Tariffs['data'][number] | undefined;
30
37
  getCatalogCategory: (id: number) => ICategory | undefined;
31
38
  getCatalogProducts: (
@@ -81,6 +88,10 @@ export function createOrionplaceCatalogStore(
81
88
  });
82
89
  },
83
90
 
91
+ setUsedSubscriptions(profile, ids) {
92
+ set({ usedSubscriptions: { profile, ids } });
93
+ },
94
+
84
95
  getTariff(id) {
85
96
  return get().tariffs?.data.find((tariff) => tariff.id === id);
86
97
  },
@@ -131,6 +142,13 @@ export function createOrionplaceCatalogStore(
131
142
  }));
132
143
  }
133
144
 
145
+ /** The records a profile had; undefined until they are loaded. */
146
+ export const selectUsedSubscriptions =
147
+ (profile: number | undefined) => (state: OrionplaceCatalogState) =>
148
+ state.usedSubscriptions && state.usedSubscriptions.profile === profile
149
+ ? state.usedSubscriptions.ids
150
+ : undefined;
151
+
134
152
  export const selectCatalogRoots = (state: OrionplaceCatalogState) =>
135
153
  navigationCatalogCategories(state.catalog);
136
154