orionplace-nuxt 0.3.0 → 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.
Files changed (34) hide show
  1. package/README.md +51 -4
  2. package/bin/orionplace-nuxt.mjs +38 -7
  3. package/package.json +1 -1
  4. package/runtime/marketplace.mjs +12 -0
  5. package/src/install.mjs +5 -4
  6. package/src/project-readme.mjs +8 -3
  7. package/src/service.mjs +186 -0
  8. package/templates/app/components/orionplace/blog/BlogArticle.README.md +1 -1
  9. package/templates/app/components/orionplace/blog/BlogArticle.vue +78 -1
  10. package/templates/app/components/orionplace/legal/CookieNotice.README.md +34 -0
  11. package/templates/app/components/orionplace/legal/CookieNotice.vue +84 -0
  12. package/templates/app/components/orionplace/legal/StorefrontPrivacy.README.md +35 -0
  13. package/templates/app/components/orionplace/legal/StorefrontPrivacy.vue +124 -0
  14. package/templates/app/components/orionplace/products/ProductReviewImageViewer.README.md +3 -2
  15. package/templates/app/components/orionplace/products/ProductReviewImageViewer.vue +85 -11
  16. package/templates/app/components/orionplace/storefront/StorefrontFooter.vue +12 -0
  17. package/templates/app/components/orionplace/subscriptions/StorefrontTariff.vue +21 -1
  18. package/templates/app/components/orionplace/subscriptions/StorefrontTariffs.vue +23 -2
  19. package/templates/app/components/orionplace/tariffs/TariffCard.vue +13 -3
  20. package/templates/app/components/orionplace/tariffs/TariffCardPrices.README.md +1 -1
  21. package/templates/app/components/orionplace/tariffs/TariffCardPrices.vue +24 -4
  22. package/templates/app/composables/useOrionplaceSubscriptions.ts +43 -0
  23. package/templates/app/layouts/default.vue +7 -0
  24. package/templates/app/pages/privacy.vue +17 -0
  25. package/templates/app/stores/orionplaceCatalog.ts +20 -0
  26. package/templates/app/utils/orionplace/blogImageViewer.ts +84 -0
  27. package/templates/app/utils/orionplace/blogLabels.ts +4 -0
  28. package/templates/app/utils/orionplace/footerLabels.ts +2 -0
  29. package/templates/app/utils/orionplace/pageSeo.ts +10 -0
  30. package/templates/app/utils/orionplace/privacyLabels.ts +198 -0
  31. package/templates/app/utils/orionplace/reviewLabels.ts +4 -0
  32. package/templates/app/utils/orionplace/tariffLabels.ts +2 -0
  33. package/templates/app/utils/orionplace/tariffs.ts +49 -0
  34. package/templates/orionplace/COMPONENTS.md +13 -3
package/README.md CHANGED
@@ -129,7 +129,7 @@ Framework imports such as `#imports` keep their own names.
129
129
  <!-- components:start (generated by scripts/generate-package-readme-components.mjs) -->
130
130
  ## Components
131
131
 
132
- The starter includes **124 storefront components**, copied into
132
+ The starter includes **126 storefront components**, copied into
133
133
  `app/components/orionplace/` of your project. Each one has a README in English and Russian
134
134
  next to its source that describes its inputs, data and actions.
135
135
 
@@ -294,6 +294,8 @@ Journal content and customer contact flows.
294
294
  | **Contact details** | `AboutPage` | Display the store’s published company and contact information. |
295
295
  | **Contact form** | `ContactForm` | Sending a question to the store. |
296
296
  | **Unsubscribe from emails** | `EmailUnsubscribe` | Confirming an unsubscribe request from an email link. |
297
+ | **Privacy policy page** | `StorefrontPrivacy` | The store’s personal data policy with a cookies section and the operator’s details. |
298
+ | **Cookie notice** | `CookieNotice` | A small notice about the necessary cookies with a link to the cookies section of the policy. |
297
299
 
298
300
  ### Shared interface
299
301
 
@@ -319,6 +321,8 @@ npx orionplace-nuxt add frontend www.shop.example.com
319
321
  npx orionplace-nuxt remove frontend www.shop.example.com
320
322
  npx orionplace-nuxt set mimic shop.example.com
321
323
  sudo npx orionplace-nuxt install
324
+ npx orionplace-nuxt status
325
+ sudo npx orionplace-nuxt restart
322
326
  npx orionplace-nuxt help
323
327
  ```
324
328
 
@@ -365,9 +369,34 @@ builds the project, runs it as the system service `orionplace-shop` on a free
365
369
  local port and connects each storefront domain with nginx and a Let's Encrypt
366
370
  certificate. Run it again after code or package updates; the service keeps its
367
371
  port. Options: `--name <service>`, `--port <port>`, `--user <user>`,
368
- `--no-domains`, `--yes`, `--dry-run`. Full guide:
372
+ `--no-build` (install the build already in the folder), `--no-domains`, `--yes`,
373
+ `--dry-run`. Full guide:
369
374
  [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront).
370
375
 
376
+ ### Manage the service
377
+
378
+ Run these in the project folder, or name the service from anywhere with
379
+ `--name shop`:
380
+
381
+ ```bash
382
+ npx orionplace-nuxt status # running?, autostart, port, home page answer, domains
383
+ sudo npx orionplace-nuxt start
384
+ sudo npx orionplace-nuxt stop
385
+ sudo npx orionplace-nuxt restart # waits until the home page answers again
386
+ sudo npx orionplace-nuxt logs 200 # last log lines; --follow keeps reading
387
+ npx orionplace-nuxt list # every storefront service on this server
388
+ sudo npx orionplace-nuxt uninstall # stop and remove the service (alias: cleanup)
389
+ ```
390
+
391
+ `start` and `restart` wait up to 90 seconds for the home page and print the
392
+ last log lines when it does not answer. `status` ends with code 3 when the
393
+ storefront is stopped or does not answer, so scripts and monitors can use it.
394
+ `uninstall` (or `cleanup`) stops the service, takes it out of autostart and
395
+ deletes its unit file and registration; the project folder and its build stay.
396
+ With the `orionplace` server tool it runs `orionplace app remove`, which also
397
+ turns the storefront's domains into a placeholder page. `--dry-run` shows what it
398
+ would remove; `--yes` skips the question.
399
+
371
400
  ## Generated files
372
401
 
373
402
  | Path | Purpose |
@@ -430,6 +459,20 @@ authorization and cookies are forwarded. Backend statuses are retained and cooki
430
459
  Domain attributes are removed to bind cookies to the storefront. Gateway responses
431
460
  use `Cache-Control: no-store`. Network failures return `502`.
432
461
 
462
+ A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
463
+ `403`. A request from the page's own host or the storefront domain passes, and the
464
+ backend receives it with the storefront domain's origin
465
+ (`https://shop.example.com`), the one it accepts forms from. So a host that shows
466
+ the storefront through `X-Front-Domain`, such as a preview host or a local port,
467
+ can sign in too.
468
+
469
+ A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
470
+ `403`. A request from the page's own host or the storefront domain passes, and the
471
+ backend receives it with the storefront domain's origin
472
+ (`https://shop.example.com`), the one it accepts forms from. So a host that shows
473
+ the storefront through `X-Front-Domain`, such as a preview host or a local port,
474
+ can sign in too.
475
+
433
476
  Browser requests use `/api/...`. For SSR, use Nuxt's `useRequestFetch()` to pass
434
477
  the current request's domain and authentication to the same gateway. See the
435
478
  project README for examples and cookie handling.
@@ -1055,10 +1098,14 @@ red `*` directly after the label text.
1055
1098
 
1056
1099
  The default layout renders the [footer](https://orionmarket.place/en/sandbox/components/nuxt/footer)
1057
1100
  with real store categories, home-section anchors, profile/journal navigation,
1058
- a contact-page link and social links. On `/contact`, `StorefrontContact` places
1101
+ a contact-page link, a `/privacy` link and social links. On `/contact`, `StorefrontContact` places
1059
1102
  company details (address, `tel:` phone link, registration numbers with copy
1060
1103
  buttons and social links) in a column beside the bordered message-form panel;
1061
- the columns stack on narrow screens and empty details are hidden.
1104
+ the columns stack on narrow screens and empty details are hidden. `/privacy`
1105
+ shows the store's privacy policy (`StorefrontPrivacy`, texts in
1106
+ `app/utils/orionplace/privacyLabels.ts`) with the operator's details; on the
1107
+ website the layout's `CookieNotice` links its `#cookies` section until the
1108
+ visitor dismisses it.
1062
1109
  The home banner spans the available viewport
1063
1110
  width without a frame. Its media area keeps the same proportions at every width; the image fills the
1064
1111
  area without distortion and crops its edges when needed. Event media keeps its
@@ -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-nuxt status service, autostart, port and home page answer
43
+ sudo npx orionplace-nuxt start
44
+ sudo npx orionplace-nuxt stop
45
+ sudo npx orionplace-nuxt restart restart and wait until the home page answers
46
+ sudo npx orionplace-nuxt logs [lines] last log lines (100); --follow keeps reading
47
+ npx orionplace-nuxt list every storefront service on this server
48
+ sudo npx orionplace-nuxt 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.
@@ -61,11 +73,14 @@ try {
61
73
  let cwd = process.cwd();
62
74
  let dryRun = false;
63
75
  let local = false;
64
- const install = { domains: true, yes: false };
76
+ let follow = false;
77
+ const install = { domains: true, build: true, yes: false };
65
78
  for (let index = 0; index < argv.length; index++) {
66
79
  if (argv[index] === '--dry-run') dryRun = true;
67
80
  else if (argv[index] === '--local') local = true;
68
81
  else if (argv[index] === '--no-domains') install.domains = false;
82
+ else if (argv[index] === '--no-build') install.build = false;
83
+ else if (argv[index] === '--follow' || argv[index] === '-f') follow = true;
69
84
  else if (argv[index] === '--yes' || argv[index] === '-y') install.yes = true;
70
85
  else if (['--name', '--port', '--user'].includes(argv[index])) {
71
86
  if (!argv[index + 1]) throw new Error(`${argv[index]} needs a value`);
@@ -76,11 +91,15 @@ try {
76
91
  } else if (argv[index].startsWith('--')) throw new Error(`Unknown option: ${argv[index]}`);
77
92
  else positional.push(argv[index]);
78
93
  }
79
- const [command, target] = positional;
94
+ const [given, target] = positional;
95
+ const command = SERVICE_ALIASES[given] || given;
80
96
  if (local && command !== 'update') throw new Error('--local is only supported by update.');
81
- if (command !== 'install' && (install.name || install.port || install.user || !install.domains || install.yes)) {
82
- throw new Error('--name, --port, --user, --no-domains and --yes are install options.');
97
+ const service = SERVICE_ACTIONS.includes(command);
98
+ if (command !== 'install' && (install.port || install.user || !install.domains || !install.build || (install.yes && command !== 'uninstall') || (install.name && !service))) {
99
+ 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.');
83
100
  }
101
+ if (follow && command !== 'logs') throw new Error('--follow is a logs option.');
102
+ if (dryRun && service && command !== 'uninstall') throw new Error('--dry-run is only for uninstall among the service commands; status shows the state without changes.');
84
103
  let result;
85
104
  if (command === 'install' && positional.length === 1) {
86
105
  const done = await installService({ cwd, dryRun, ...install });
@@ -91,14 +110,26 @@ try {
91
110
  } else {
92
111
  console.log(`\nInstalled orionplace-${done.name} (port ${done.port}, user ${done.user}).`);
93
112
  for (const domain of done.frontends) {
94
- if (!done.connected) console.log(` - ${domain}: not connected to nginx (see above)`);
113
+ if (!install.domains) console.log(` - ${domain}: not connected (--no-domains)`);
114
+ else if (!done.connected) console.log(` - ${domain}: not connected to nginx (see above)`);
95
115
  else if (done.failed.includes(domain)) console.log(` ✗ ${domain}: not connected (see above)`);
96
116
  else console.log(` ✓ https://${domain}`);
97
117
  }
98
118
  if (done.failed.length) console.log('Fix the problems above, then run: sudo orionplace domain add <domain>');
99
- console.log(`Manage it with: orionplace app status ${done.name} | orionplace app logs ${done.name}`);
119
+ console.log(`Manage it from this folder: npx orionplace-nuxt status | sudo npx orionplace-nuxt restart | sudo npx orionplace-nuxt logs`);
100
120
  console.log('After code changes or npx orionplace-nuxt update, run sudo npx orionplace-nuxt install again.');
101
121
  }
122
+ } else if (service && positional.length <= (command === 'logs' ? 2 : 1)) {
123
+ if (command === 'uninstall' && !dryRun && !install.yes && process.stdin.isTTY) {
124
+ const app = await findApp({ cwd, name: install.name });
125
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
126
+ let answer;
127
+ try { answer = await prompt.question(`Stop and remove the service orionplace-${app.NAME}? The project stays in ${app.DIR}. [y/N] `); }
128
+ finally { prompt.close(); }
129
+ if (!/^y(es)?$/i.test(answer.trim())) { console.log('Nothing changed.'); process.exit(0); }
130
+ }
131
+ const done = await controlService({ action: command, cwd, name: install.name, lines: positional[1] ?? 100, follow, dryRun });
132
+ process.exitCode = printService(done);
102
133
  } else if (command === 'init' && positional.length <= 2) {
103
134
  result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
104
135
  } else if (command === 'update' && positional.length === 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-nuxt",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Nuxt 4 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
  }
@@ -122,9 +122,11 @@ red \`*\` directly after the label text.
122
122
 
123
123
  The default layout renders the [footer](https://orionmarket.place/en/sandbox/components/nuxt/footer)
124
124
  with real store categories, home-section anchors, profile/journal navigation,
125
- a \`/contact\` link, social links and the company name in its copyright line.
126
- Company contact and registration details appear beside the message form on
127
- \`/contact\`. The home banner spans the available viewport
125
+ a \`/contact\` link, a \`/privacy\` link, social links and the company name in
126
+ its copyright line. Company contact and registration details appear beside the
127
+ message form on \`/contact\`. \`/privacy\` shows the store's privacy policy
128
+ (texts in \`app/utils/orionplace/privacyLabels.ts\`); on the website a cookie
129
+ notice links its \`#cookies\` section until the visitor dismisses it. The home banner spans the available viewport
128
130
  width without a frame. Its media area keeps the same proportions at every width; the image fills the
129
131
  area without distortion and crops its edges when needed. Event media keeps its
130
132
  own reserved proportions, which differ on a narrow screen.
@@ -517,6 +519,9 @@ The quickest way: prepare the server with the OrionMarketplace server script,
517
519
  then run \`sudo npx orionplace-nuxt install\` in this folder. It builds the
518
520
  project, runs it as a system service and connects your domains with nginx and
519
521
  HTTPS. Steps: [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront).
522
+ Then manage it from this folder: \`npx orionplace-nuxt status\`, \`sudo npx orionplace-nuxt restart\`,
523
+ \`sudo npx orionplace-nuxt stop\`, \`sudo npx orionplace-nuxt start\` and \`sudo npx orionplace-nuxt logs\`.
524
+ \`sudo npx orionplace-nuxt uninstall\` (or \`cleanup\`) stops and removes the service; this folder stays.
520
525
 
521
526
  To set the server up yourself instead:
522
527
 
@@ -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,7 +20,7 @@ After client-side navigation, while the first `blog_posts_get` request runs, a p
20
20
 
21
21
  When `has_full_access` is false, the article shows its preview and an access panel: the text explains that the full article opens with an active subscription plan that includes it, and the Plans link opens the storefront plans page `/tariffs` with the current `locale`. `premium_tariff_ids` lists the IDs of the plans that include the article; the link passes them as `ids`, so the plans page shows only those plans. Without those IDs the link opens the whole list.
22
22
 
23
- The first preview image is shown above the text, in an area that keeps its proportions. A post without a preview image, or with an image that fails to load, shows no cover. Article videos are shown with their poster image; their addresses may be backend paths (`/ps3/...`). In an Android app build the backend's own media paths (`/ps3/...`: cover, article videos, images and videos inside the text) load from the storefront address.
23
+ The first preview image is shown above the text, in an area that keeps its proportions. A post without a preview image, or with an image that fails to load, shows no cover. The cover and the images of the text open larger: a click, `Enter` or `Space` on one opens `ProductReviewImageViewer` at that image, with the article's other images a step away and each image's caption (its `figcaption`, else its `title`, else its `alt`) under it. The images get `role="button"`, a keyboard stop and an «Open image» name; a zoom-in cursor marks them. Images inside a link, a button or a product placement keep their own action. The helper is `app/utils/orionplace/blogImageViewer.ts`. Article videos are shown with their poster image; their addresses may be backend paths (`/ps3/...`). In an Android app build the backend's own media paths (`/ps3/...`: cover, article videos, images and videos inside the text) load from the storefront address.
24
24
 
25
25
  When the first successful `blog_posts_get` response has no article with this ID, the server render of the supplied page answers HTTP 404; after client-side navigation the component emits `missing`. The backend returns an empty list for an unknown ID, an unpublished article and a subscription-only article that the current visitor cannot open. An article returned with `has_full_access = false` keeps its preview and the access panel with the Plans link. An article that disappears after sign-out keeps the “This article is unavailable” state on the page. A failed request is not a missing article: the error notice keeps its Refresh button. The supplied `app/pages/journal/[id].vue` opens the storefront 404 page on `missing` through `useOrionplaceMissingPage()`.
26
26
 
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, ref, watch } from 'vue';
2
+ import { computed, nextTick, onMounted, ref, watch } from 'vue';
3
3
  import { useRuntimeConfig } from '#imports';
4
4
  import { useOrionplaceStore } from '@/stores/orionplace';
5
5
  import { useOrionplaceProductsStore } from '@/stores/orionplaceProducts';
@@ -25,6 +25,16 @@
25
25
  nativeAppOrigin,
26
26
  } from '@/utils/orionplace/nativeApp';
27
27
  import { requestBlogPost } from '@/utils/orionplace/blogLayout';
28
+ import {
29
+ blogViewerImage,
30
+ blogViewerImages,
31
+ blogViewerPhotos,
32
+ blogViewerIndex,
33
+ isBlogViewerOpenKey,
34
+ markBlogViewerImages,
35
+ } from '@/utils/orionplace/blogImageViewer';
36
+ import type { IBlogViewerImage } from '@/utils/orionplace/blogImageViewer';
37
+ import ProductReviewImageViewer from '@/components/orionplace/products/ProductReviewImageViewer.vue';
28
38
  import type { IBlogPost } from '@/interfaces/orionplace/blog/IBlogApi';
29
39
  import type { IProductSecondaryVariantRequest } from '@/interfaces/orionplace/products/IProductSecondaryVariantRequest';
30
40
  import type {
@@ -75,6 +85,10 @@
75
85
  const task = useOrionplaceTask();
76
86
  const post = ref<IBlogPost>();
77
87
  const article = ref<HTMLElement>();
88
+ const coverImage = ref<HTMLImageElement>();
89
+ // The cover and the text images open larger in the viewer.
90
+ const viewerImages = ref<IBlogViewerImage[]>([]);
91
+ const viewerStart = ref(-1);
78
92
  const accessChanged = ref(false);
79
93
  const coverFailed = ref(false);
80
94
  const appOrigin = nativeAppOrigin(
@@ -230,6 +244,44 @@
230
244
  wasShown.value = true;
231
245
  showProducts(props.initialPost.related_products);
232
246
  }
247
+ function openableImages() {
248
+ return blogViewerImages([coverImage.value, article.value]);
249
+ }
250
+
251
+ // The text is set with v-html: its images become buttons after each render.
252
+ watch(
253
+ [html, cover, mounted, () => labels.value.imageOpen, () => post.value?.id],
254
+ () => {
255
+ void nextTick(() => markBlogViewerImages(openableImages(), labels.value.imageOpen));
256
+ },
257
+ { flush: 'post' },
258
+ );
259
+
260
+ function openViewer(event: Event) {
261
+ const images = openableImages();
262
+ const index = blogViewerIndex(event.target, images);
263
+ if (index < 0) {
264
+ return;
265
+ }
266
+ event.preventDefault();
267
+ viewerImages.value = images.map((image) => {
268
+ const shown = blogViewerImage(image);
269
+ return image === coverImage.value ? { ...shown, alt: shown.alt || title.value } : shown;
270
+ });
271
+ viewerStart.value = index;
272
+ }
273
+
274
+ function onArticleKeydown(event: KeyboardEvent) {
275
+ if (isBlogViewerOpenKey(event)) {
276
+ openViewer(event);
277
+ }
278
+ }
279
+
280
+ // The viewer gives the focus back to the image that opened it.
281
+ function closeViewer() {
282
+ viewerStart.value = -1;
283
+ }
284
+
233
285
  onMounted(() => {
234
286
  mounted.value = true;
235
287
  if (!preloaded) {
@@ -277,6 +329,8 @@
277
329
  <article
278
330
  v-if="post"
279
331
  class="op-feature-stack mx-auto w-full max-w-[720px]"
332
+ @click="openViewer"
333
+ @keydown="onArticleKeydown"
280
334
  >
281
335
  <header class="op-feature-stack">
282
336
  <h1
@@ -302,6 +356,7 @@
302
356
  </div>
303
357
  <img
304
358
  v-if="cover && !coverFailed"
359
+ ref="coverImage"
305
360
  :src="cover"
306
361
  alt=""
307
362
  width="3"
@@ -471,6 +526,16 @@
471
526
  kind="journal"
472
527
  :message="labels.unavailable"
473
528
  />
529
+ <ProductReviewImageViewer
530
+ v-if="viewerStart >= 0 && viewerImages.length"
531
+ :images="blogViewerPhotos(viewerImages)"
532
+ :index="viewerStart"
533
+ :label="labels.imageViewer"
534
+ :alt="title || labels.imageViewer"
535
+ :captions="viewerImages.map((image) => image.caption || image.alt)"
536
+ wide
537
+ @close="closeViewer"
538
+ />
474
539
  </section>
475
540
  </template>
476
541
 
@@ -683,6 +748,18 @@
683
748
  vertical-align: bottom;
684
749
  }
685
750
 
751
+ /* Images that open in the viewer. */
752
+ [data-blog-article-cover][data-blog-image-viewer],
753
+ .op-feature-prose :deep(img[data-blog-image-viewer]) {
754
+ cursor: zoom-in;
755
+ }
756
+
757
+ [data-blog-article-cover][data-blog-image-viewer]:focus-visible,
758
+ .op-feature-prose :deep(img[data-blog-image-viewer]:focus-visible) {
759
+ outline: 2px solid var(--orionplace-accent);
760
+ outline-offset: 3px;
761
+ }
762
+
686
763
  .op-feature-prose :deep(:where(img, video)) {
687
764
  display: block;
688
765
  max-width: 100%;
@@ -0,0 +1,34 @@
1
+ # CookieNotice · Nuxt
2
+
3
+ A small notice about the necessary cookies with a link to the cookies section of the policy.
4
+
5
+ The supplied layout shows it on every page of the website, not in the Android app build.
6
+ [English guide](https://orionmarket.place/en/sandbox/components/nuxt/cookie-notice) ·
7
+ [Russian guide](https://orionmarket.place/ru/sandbox/components/nuxt/cookie-notice)
8
+
9
+ ## Contract and dependencies
10
+
11
+ After mounting, the notice appears at the bottom of the screen until the
12
+ visitor presses OK; the choice is kept in local storage under
13
+ `orionplace-cookie-notice-seen` (`COOKIE_NOTICE_STORAGE_KEY` in
14
+ `app/utils/orionplace/privacyLabels.ts`). Server HTML never contains it. The
15
+ storefront sets only necessary cookies, so the notice asks for no consent; a
16
+ store that adds analytics or advertising cookies needs a consent form instead.
17
+
18
+ `href` is the address of the cookies section with the language, for example
19
+ `/privacy?locale=ru#cookies`; the default is `/privacy#cookies`. An ordinary
20
+ click on the link emits `navigate` with `href`; the layout navigates.
21
+
22
+ Source is maintained in
23
+ `app/components/orionplace/legal/CookieNotice.vue`.
24
+
25
+ The configured Nuxt starter initializes Application Load and its named API client.
26
+ Component-source ZIPs contain imported children, types,
27
+ composables, labels and styles. The included `orionplace/COMPONENT-DOWNLOAD.md`
28
+ describes dependencies, theme and consumer configuration. Standalone file
29
+ copying requires the same dependencies. Links to storefront routes use the
30
+ pages supplied by the full starter.
31
+
32
+ ## Endpoint coverage
33
+
34
+ None.