orionplace-svelte 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 (35) hide show
  1. package/README.md +39 -4
  2. package/bin/orionplace-svelte.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 +3 -0
  7. package/src/service.mjs +186 -0
  8. package/templates/orionplace/COMPONENTS.md +2 -0
  9. package/templates/src/lib/components/orionplace/blog/BlogArticle.README.md +2 -0
  10. package/templates/src/lib/components/orionplace/blog/BlogArticle.svelte +90 -1
  11. package/templates/src/lib/components/orionplace/legal/CookieNotice.README.md +23 -0
  12. package/templates/src/lib/components/orionplace/legal/CookieNotice.svelte +91 -0
  13. package/templates/src/lib/components/orionplace/legal/StorefrontPrivacy.README.md +12 -0
  14. package/templates/src/lib/components/orionplace/legal/StorefrontPrivacy.svelte +117 -0
  15. package/templates/src/lib/components/orionplace/products/ProductReviewImageViewer.README.md +3 -1
  16. package/templates/src/lib/components/orionplace/products/ProductReviewImageViewer.svelte +96 -11
  17. package/templates/src/lib/components/orionplace/storefront/StorefrontFooter.svelte +12 -0
  18. package/templates/src/lib/components/orionplace/subscriptions/StorefrontTariff.svelte +21 -1
  19. package/templates/src/lib/components/orionplace/subscriptions/StorefrontTariffs.svelte +28 -2
  20. package/templates/src/lib/components/orionplace/tariffs/TariffCard.svelte +13 -3
  21. package/templates/src/lib/components/orionplace/tariffs/TariffCardPrices.README.md +1 -1
  22. package/templates/src/lib/components/orionplace/tariffs/TariffCardPrices.svelte +24 -4
  23. package/templates/src/lib/composables/useOrionplaceSubscriptions.svelte.ts +43 -0
  24. package/templates/src/lib/layouts/StorefrontLayout.svelte +11 -0
  25. package/templates/src/lib/stores/orionplaceCatalog.svelte.ts +21 -0
  26. package/templates/src/lib/utils/orionplace/blogImageViewer.ts +84 -0
  27. package/templates/src/lib/utils/orionplace/blogLabels.ts +4 -0
  28. package/templates/src/lib/utils/orionplace/footerLabels.ts +2 -0
  29. package/templates/src/lib/utils/orionplace/pageSeo.ts +10 -0
  30. package/templates/src/lib/utils/orionplace/privacyLabels.ts +198 -0
  31. package/templates/src/lib/utils/orionplace/reviewLabels.ts +4 -0
  32. package/templates/src/lib/utils/orionplace/tariffLabels.ts +2 -0
  33. package/templates/src/lib/utils/orionplace/tariffs.ts +49 -0
  34. package/templates/src/routes/privacy/+page.server.ts +7 -0
  35. package/templates/src/routes/privacy/+page.svelte +23 -0
package/README.md CHANGED
@@ -134,7 +134,7 @@ imports such as `$app`, `$env` and generated `./$types` retain their own names.
134
134
  <!-- components:start (generated by scripts/generate-package-readme-components.mjs) -->
135
135
  ## Components
136
136
 
137
- The starter includes **124 storefront components**, copied into
137
+ The starter includes **126 storefront components**, copied into
138
138
  `src/lib/components/orionplace/` of your project. Each one has a README in English and Russian
139
139
  next to its source that describes its inputs, data and actions.
140
140
 
@@ -299,6 +299,8 @@ Journal content and customer contact flows.
299
299
  | **Contact details** | `AboutPage` | Display the store’s published company and contact information. |
300
300
  | **Contact form** | `ContactForm` | Sending a question to the store. |
301
301
  | **Unsubscribe from emails** | `EmailUnsubscribe` | Confirming an unsubscribe request from an email link. |
302
+ | **Privacy policy page** | `StorefrontPrivacy` | The store’s personal data policy with a cookies section and the operator’s details. |
303
+ | **Cookie notice** | `CookieNotice` | A small notice about the necessary cookies with a link to the cookies section of the policy. |
302
304
 
303
305
  ### Shared interface
304
306
 
@@ -324,6 +326,8 @@ npx orionplace-svelte add frontend www.shop.example.com
324
326
  npx orionplace-svelte remove frontend www.shop.example.com
325
327
  npx orionplace-svelte set mimic shop.example.com
326
328
  sudo npx orionplace-svelte install
329
+ npx orionplace-svelte status
330
+ sudo npx orionplace-svelte restart
327
331
  npx orionplace-svelte help
328
332
  ```
329
333
 
@@ -370,9 +374,34 @@ builds the project, runs it as the system service `orionplace-shop` on a free
370
374
  local port and connects each storefront domain with nginx and a Let's Encrypt
371
375
  certificate. Run it again after code or package updates; the service keeps its
372
376
  port. Options: `--name <service>`, `--port <port>`, `--user <user>`,
373
- `--no-domains`, `--yes`, `--dry-run`. Full guide:
377
+ `--no-build` (install the build already in the folder), `--no-domains`, `--yes`,
378
+ `--dry-run`. Full guide:
374
379
  [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront?framework=svelte).
375
380
 
381
+ ### Manage the service
382
+
383
+ Run these in the project folder, or name the service from anywhere with
384
+ `--name shop`:
385
+
386
+ ```bash
387
+ npx orionplace-svelte status # running?, autostart, port, home page answer, domains
388
+ sudo npx orionplace-svelte start
389
+ sudo npx orionplace-svelte stop
390
+ sudo npx orionplace-svelte restart # waits until the home page answers again
391
+ sudo npx orionplace-svelte logs 200 # last log lines; --follow keeps reading
392
+ npx orionplace-svelte list # every storefront service on this server
393
+ sudo npx orionplace-svelte uninstall # stop and remove the service (alias: cleanup)
394
+ ```
395
+
396
+ `start` and `restart` wait up to 90 seconds for the home page and print the
397
+ last log lines when it does not answer. `status` ends with code 3 when the
398
+ storefront is stopped or does not answer, so scripts and monitors can use it.
399
+ `uninstall` (or `cleanup`) stops the service, takes it out of autostart and
400
+ deletes its unit file and registration; the project folder and its build stay.
401
+ With the `orionplace` server tool it runs `orionplace app remove`, which also
402
+ turns the storefront's domains into a placeholder page. `--dry-run` shows what it
403
+ would remove; `--yes` skips the question.
404
+
376
405
  ## Generated files
377
406
 
378
407
  | Path | Purpose |
@@ -442,8 +471,14 @@ The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
442
471
  end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
443
472
  authorization and cookies are forwarded. Backend statuses are retained and cookie
444
473
  Domain attributes are removed to bind cookies to the storefront. Gateway responses
445
- use `Cache-Control: no-store`. Network failures return `502`. Cross-site mutations
446
- are rejected with `403`.
474
+ use `Cache-Control: no-store`. Network failures return `502`.
475
+
476
+ A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
477
+ `403`. A request from the page's own host or the storefront domain passes, and the
478
+ backend receives it with the storefront domain's origin
479
+ (`https://shop.example.com`), the one it accepts forms from. So a host that shows
480
+ the storefront through `X-Front-Domain`, such as a preview host or a local port,
481
+ can sign in too.
447
482
 
448
483
  SvelteKit ignores a request body sent without a `Content-Type` header, so always
449
484
  set it on your own API calls; browsers do this automatically for `fetch`.
@@ -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-svelte status service, autostart, port and home page answer
43
+ sudo npx orionplace-svelte start
44
+ sudo npx orionplace-svelte stop
45
+ sudo npx orionplace-svelte restart restart and wait until the home page answers
46
+ sudo npx orionplace-svelte logs [lines] last log lines (100); --follow keeps reading
47
+ npx orionplace-svelte list every storefront service on this server
48
+ sudo npx orionplace-svelte 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-svelte status | sudo npx orionplace-svelte restart | sudo npx orionplace-svelte logs`);
101
121
  console.log('After code changes or npx orionplace-svelte update, run sudo npx orionplace-svelte 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-svelte",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "SvelteKit 2 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
@@ -229,7 +229,7 @@ async function ensureDomains(root, { yes }) {
229
229
  }
230
230
 
231
231
  // The storefront's home page as the public domain sees it -> HTTP status, 0 when down.
232
- function homeStatus(port, host) {
232
+ export function homeStatus(port, host) {
233
233
  return new Promise(done => {
234
234
  const call = request({ host: '127.0.0.1', port, path: '/', headers: { host }, timeout: 15000 }, response => {
235
235
  response.resume();
@@ -241,7 +241,7 @@ function homeStatus(port, host) {
241
241
  });
242
242
  }
243
243
 
244
- async function waitForHttp(port, host, seconds = 90) {
244
+ export async function waitForHttp(port, host, seconds = 90) {
245
245
  const until = Date.now() + seconds * 1000;
246
246
  while (Date.now() < until) {
247
247
  const status = await homeStatus(port, host);
@@ -309,7 +309,8 @@ export async function installService({
309
309
  return { dryRun, name: service, port: chosen, user: account.name, unitPath, unit, conf, frontends: settings.frontends || [], backend: settings.backend };
310
310
  }
311
311
 
312
- log(`\n==> Building the ${framework.title} storefront ${service}`);
312
+ const rebuild = build || !existsSync(join(root, framework.output));
313
+ log(rebuild ? `\n==> Building the ${framework.title} storefront ${service}` : `\n==> Using the existing build of ${service} (--no-build)`);
313
314
  // The website build; ORIONPLACE_APP_ORIGIN would make the Android app build.
314
315
  const buildEnv = { ...process.env };
315
316
  delete buildEnv.ORIONPLACE_APP_ORIGIN;
@@ -317,7 +318,7 @@ export async function installService({
317
318
  const installed = await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: root, env: buildEnv });
318
319
  if (installed.code) throw new Error('npm install failed; see the messages above.');
319
320
  }
320
- if (build || !existsSync(join(root, framework.output))) {
321
+ if (rebuild) {
321
322
  const built = await run('npm', ['run', 'build'], { cwd: root, env: buildEnv });
322
323
  if (built.code) throw new Error('The build failed; see the messages above. The running service (if any) was not changed.');
323
324
  }
@@ -342,6 +342,9 @@ The quickest way: prepare the server with the OrionMarketplace server script,
342
342
  then run \`sudo npx orionplace-svelte install\` in this folder. It builds the
343
343
  project, runs it as a system service and connects your domains with nginx and
344
344
  HTTPS. Steps: [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront?framework=svelte).
345
+ Then manage it from this folder: \`npx orionplace-svelte status\`, \`sudo npx orionplace-svelte restart\`,
346
+ \`sudo npx orionplace-svelte stop\`, \`sudo npx orionplace-svelte start\` and \`sudo npx orionplace-svelte logs\`.
347
+ \`sudo npx orionplace-svelte uninstall\` (or \`cleanup\`) stops and removes the service; this folder stays.
345
348
 
346
349
  To set the server up yourself instead:
347
350
 
@@ -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
+ }
@@ -45,6 +45,8 @@ The storefront uses native Svelte 5 components and SvelteKit server loads.
45
45
  | [Currency selector](../src/lib/components/orionplace/currencies/MainCurrencySwitcher.svelte) | Selects the current storefront currency from the available options. |
46
46
  | [Event block and promotion enrollment](../src/lib/components/orionplace/events/EventEnrollmentDialog.svelte) | Displaying the event block and opening promotion enrollment from the selected event. |
47
47
  | [Storefront events](../src/lib/components/orionplace/events/EventsBlock.svelte) | A full-width carousel of layout-event images for the current storefront language, with responsive image formats. |
48
+ | [Cookie notice](../src/lib/components/orionplace/legal/CookieNotice.svelte) | A small notice about the necessary cookies with a link to the cookies section of the policy. |
49
+ | [Privacy policy page](../src/lib/components/orionplace/legal/StorefrontPrivacy.svelte) | The store’s personal data policy with a cookies section and the operator’s details. |
48
50
  | [Language selector](../src/lib/components/orionplace/locale/MainLanguageSwitcher.svelte) | Selects the storefront language and its associated currency and measurement units. |
49
51
  | [Manufacturer index](../src/lib/components/orionplace/manufacturers/ManufacturerIndex.svelte) | Manufacturer logos and letter groups. A–Z, А–Я and 0–9 select the alphabet; choosing a name or letter passes the selection to the application. |
50
52
  | [Manufacturers and their products](../src/lib/components/orionplace/manufacturers/StorefrontManufacturers.svelte) | Alphabetical index, letter-based lists and manufacturer details. |
@@ -6,6 +6,8 @@ The article page shows one journal article: its cover, its text, the products at
6
6
 
7
7
  Article videos may come as the backend's own paths (`/ps3/...`). In an Android app build those paths (cover, videos, images and videos inside the text) load from the storefront address.
8
8
 
9
+ 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 `src/lib/utils/orionplace/blogImageViewer.ts`.
10
+
9
11
  Source: `src/lib/components/orionplace/blog/BlogArticle.svelte`.
10
12
 
11
13
  ## Inputs
@@ -2,6 +2,7 @@
2
2
  import { routeHref } from '@/lib/orionplace/navigation.svelte';
3
3
  import {
4
4
  computed,
5
+ nextTick,
5
6
  onMounted,
6
7
  ref,
7
8
  watch,
@@ -39,6 +40,16 @@
39
40
  import ProductGroup from '@/lib/components/orionplace/products/ProductGroup.svelte';
40
41
  import '@/lib/components/orionplace/shared/features.css';
41
42
  import OrionplaceEmptyState from '@/lib/components/orionplace/shared/OrionplaceEmptyState.svelte';
43
+ import ProductReviewImageViewer from '@/lib/components/orionplace/products/ProductReviewImageViewer.svelte';
44
+ import {
45
+ blogViewerImage,
46
+ blogViewerImages,
47
+ blogViewerIndex,
48
+ blogViewerPhotos,
49
+ isBlogViewerOpenKey,
50
+ markBlogViewerImages,
51
+ } from '@/lib/utils/orionplace/blogImageViewer';
52
+ import type { IBlogViewerImage } from '@/lib/utils/orionplace/blogImageViewer';
42
53
 
43
54
  type Props = {
44
55
  postId: number;
@@ -135,6 +146,10 @@
135
146
  const task = useOrionplaceTask();
136
147
  const post = ref<IBlogPost>();
137
148
  const article = ref<HTMLElement>();
149
+ const coverImage = ref<HTMLImageElement>();
150
+ // The cover and the text images open larger in the viewer.
151
+ const viewerImages = ref<IBlogViewerImage[]>([]);
152
+ const viewerStart = ref(-1);
138
153
  const accessChanged = ref(false);
139
154
  const coverFailed = ref(false);
140
155
  const productIds = ref<number[]>([]);
@@ -285,6 +300,51 @@
285
300
  wasShown.value = true;
286
301
  showProducts(inputs.initialPost.related_products);
287
302
  }
303
+ function openableImages() {
304
+ return blogViewerImages([coverImage.value, article.value]);
305
+ }
306
+
307
+ // The text is set with {@html}: its images become buttons after each render.
308
+ watch(
309
+ [
310
+ () => html.value,
311
+ () => cover.value,
312
+ () => coverFailed.value,
313
+ () => mounted.value,
314
+ () => labels.value.imageOpen,
315
+ () => post.value?.id,
316
+ ],
317
+ () => {
318
+ void nextTick(() => markBlogViewerImages(openableImages(), labels.value.imageOpen));
319
+ },
320
+ { immediate: true },
321
+ );
322
+
323
+ function openViewer(event: Event) {
324
+ const images = openableImages();
325
+ const index = blogViewerIndex(event.target, images);
326
+ if (index < 0) {
327
+ return;
328
+ }
329
+ event.preventDefault();
330
+ viewerImages.value = images.map((image) => {
331
+ const shown = blogViewerImage(image);
332
+ return image === coverImage.value ? { ...shown, alt: shown.alt || title.value } : shown;
333
+ });
334
+ viewerStart.value = index;
335
+ }
336
+
337
+ function onArticleKeydown(event: KeyboardEvent) {
338
+ if (isBlogViewerOpenKey(event)) {
339
+ openViewer(event);
340
+ }
341
+ }
342
+
343
+ // The viewer gives the focus back to the image that opened it.
344
+ function closeViewer() {
345
+ viewerStart.value = -1;
346
+ }
347
+
288
348
  onMounted(() => {
289
349
  mounted.value = true;
290
350
  if (!preloaded) {
@@ -337,7 +397,12 @@
337
397
  {/if}
338
398
 
339
399
  {#if post.value}
340
- <article class={['op-feature-stack mx-auto w-full max-w-[720px]']}>
400
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
401
+ <article
402
+ class={['op-feature-stack mx-auto w-full max-w-[720px]']}
403
+ onclick={openViewer}
404
+ onkeydown={onArticleKeydown}
405
+ >
341
406
  <header class={['op-feature-stack']}>
342
407
  <h1
343
408
  class={[
@@ -372,6 +437,7 @@
372
437
 
373
438
  {#if cover.value && !coverFailed.value}
374
439
  <img
440
+ bind:this={coverImage.value}
375
441
  src={cover.value}
376
442
  alt=""
377
443
  width="3"
@@ -575,6 +641,17 @@
575
641
  message={labels.value.unavailable}
576
642
  />
577
643
  {/if}
644
+ {#if viewerStart.value >= 0 && viewerImages.value.length}
645
+ <ProductReviewImageViewer
646
+ images={blogViewerPhotos(viewerImages.value)}
647
+ index={viewerStart.value}
648
+ label={labels.value.imageViewer}
649
+ alt={title.value || labels.value.imageViewer}
650
+ captions={viewerImages.value.map((image) => image.caption || image.alt)}
651
+ wide
652
+ onClose={closeViewer}
653
+ />
654
+ {/if}
578
655
  </section>
579
656
 
580
657
  <style>
@@ -787,6 +864,18 @@
787
864
  vertical-align: bottom;
788
865
  }
789
866
 
867
+ /* Images that open in the viewer. */
868
+ [data-blog-article-cover][data-blog-image-viewer],
869
+ .op-feature-prose :global(img[data-blog-image-viewer]) {
870
+ cursor: zoom-in;
871
+ }
872
+
873
+ [data-blog-article-cover][data-blog-image-viewer]:focus-visible,
874
+ .op-feature-prose :global(img[data-blog-image-viewer]:focus-visible) {
875
+ outline: 2px solid var(--orionplace-accent);
876
+ outline-offset: 3px;
877
+ }
878
+
790
879
  .op-feature-prose :global(:where(img, video)) {
791
880
  display: block;
792
881
  max-width: 100%;
@@ -0,0 +1,23 @@
1
+ # Cookie notice
2
+
3
+ A small notice about the necessary cookies with a link to the cookies section of the policy.
4
+
5
+ After mounting, the notice appears at the bottom of the screen until the visitor presses
6
+ OK; the choice is kept in local storage under `orionplace-cookie-notice-seen`. The
7
+ storefront sets only necessary cookies, so the notice asks for no consent. An ordinary
8
+ click on the link calls `onNavigate` with `href`; without it the link opens normally.
9
+ The supplied layout does not show the notice in the Android app build.
10
+
11
+ Source: `src/lib/components/orionplace/legal/CookieNotice.svelte`.
12
+
13
+ ## Inputs
14
+
15
+ | Input | Type | Default |
16
+ | --- | --- | --- |
17
+ | `href` | `string` | `'/privacy#cookies'` |
18
+
19
+ ## Callbacks
20
+
21
+ | Callback | Arguments |
22
+ | --- | --- |
23
+ | `onNavigate` | `[path: string]` |