orionplace-svelte 0.2.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
@@ -9,6 +9,8 @@ setup CLI and the full storefront source, so every page and component in your
9
9
  project is yours to change.
10
10
 
11
11
  - **Setup CLI:** `init`, `set backend`, `add frontend`, `set mimic` and `update`.
12
+ - **Server install:** `install` builds the storefront on your Linux server and
13
+ runs it as a system service behind nginx with HTTPS.
12
14
  - **API gateway:** the browser calls `/api/...` on your own domain; the server
13
15
  forwards it to your marketplace backend with cookies bound to the storefront.
14
16
  - **Typed store:** one Application Load request fills it before the first render.
@@ -19,8 +21,8 @@ project is yours to change.
19
21
 
20
22
  **Contents:** [Quick start](#quick-start-from-zero) ·
21
23
  [Existing project](#existing-sveltekit-2-project) · [Components](#components) ·
22
- [Commands](#commands) · [Android app](#android-app) ·
23
- [Update](#update-an-existing-project)
24
+ [Commands](#commands) · [Server install](#install-on-your-server) ·
25
+ [Android app](#android-app) · [Update](#update-an-existing-project)
24
26
 
25
27
  Requires Node.js 20.19+, Svelte 5 and `@sveltejs/adapter-node`. Static and
26
28
  edge-only adapters cannot read the domain files this integration uses; only the
@@ -321,6 +323,9 @@ npx orionplace-svelte add frontend shop.example.com
321
323
  npx orionplace-svelte add frontend www.shop.example.com
322
324
  npx orionplace-svelte remove frontend www.shop.example.com
323
325
  npx orionplace-svelte set mimic shop.example.com
326
+ sudo npx orionplace-svelte install
327
+ npx orionplace-svelte status
328
+ sudo npx orionplace-svelte restart
324
329
  npx orionplace-svelte help
325
330
  ```
326
331
 
@@ -337,8 +342,63 @@ npm run orionplace:update
337
342
  ```
338
343
 
339
344
  `npm set` belongs to npm's own configuration system; use the commands above.
340
- These commands configure local integration files. They do not provision a backend,
341
- change your account, create DNS records, configure TLS, or deploy a VPS.
345
+ The setup commands configure local integration files. They do not provision a
346
+ backend, change your account or create DNS records. `install` deploys the
347
+ storefront on your server; see [Install on your server](#install-on-your-server).
348
+
349
+ ## Install on your server
350
+
351
+ Prepare a Linux server once with the OrionMarketplace server script: it installs
352
+ Node.js, nginx and certbot and adds the `orionplace` command
353
+ ([server guide](https://orionmarket.place/en/sandbox/install)).
354
+
355
+ ```bash
356
+ curl -fsSLo orionplace.sh https://orionmarket.place/downloads/orionplace.sh
357
+ sudo sh orionplace.sh setup
358
+ ```
359
+
360
+ Then create the storefront in `/srv/orionplace` and install it:
361
+
362
+ ```bash
363
+ cd /srv/orionplace
364
+ sudo npx --yes orionplace-svelte init shop
365
+ cd shop
366
+ sudo npm install
367
+ sudo npx orionplace-svelte install
368
+ ```
369
+
370
+ `install` asks for your backend and storefront domain when they are missing,
371
+ builds the project, runs it as the system service `orionplace-shop` on a free
372
+ local port and connects each storefront domain with nginx and a Let's Encrypt
373
+ certificate. Run it again after code or package updates; the service keeps its
374
+ port. Options: `--name <service>`, `--port <port>`, `--user <user>`,
375
+ `--no-build` (install the build already in the folder), `--no-domains`, `--yes`,
376
+ `--dry-run`. Full guide:
377
+ [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront?framework=svelte).
378
+
379
+ ### Manage the service
380
+
381
+ Run these in the project folder, or name the service from anywhere with
382
+ `--name shop`:
383
+
384
+ ```bash
385
+ npx orionplace-svelte status # running?, autostart, port, home page answer, domains
386
+ sudo npx orionplace-svelte start
387
+ sudo npx orionplace-svelte stop
388
+ sudo npx orionplace-svelte restart # waits until the home page answers again
389
+ sudo npx orionplace-svelte logs 200 # last log lines; --follow keeps reading
390
+ npx orionplace-svelte list # every storefront service on this server
391
+ sudo npx orionplace-svelte uninstall # stop and remove the service (alias: cleanup)
392
+ ```
393
+
394
+ `start` and `restart` wait up to 90 seconds for the home page and print the
395
+ last log lines when it does not answer. `status` ends with code 3 when the
396
+ storefront is stopped or does not answer, so scripts and monitors can use it.
397
+ `uninstall` (or `cleanup`) stops the service, takes it out of autostart and
398
+ deletes its unit file and registration; the project folder and its build stay.
399
+ With the `orionplace` server tool it runs `orionplace app remove`, which also
400
+ turns the storefront's domains into a placeholder page. `--dry-run` shows what it
401
+ would remove; `--yes` skips the question.
342
402
 
343
403
  ## Generated files
344
404
 
@@ -409,8 +469,14 @@ The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
409
469
  end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
410
470
  authorization and cookies are forwarded. Backend statuses are retained and cookie
411
471
  Domain attributes are removed to bind cookies to the storefront. Gateway responses
412
- use `Cache-Control: no-store`. Network failures return `502`. Cross-site mutations
413
- are rejected with `403`.
472
+ use `Cache-Control: no-store`. Network failures return `502`.
473
+
474
+ A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
475
+ `403`. A request from the page's own host or the storefront domain passes, and the
476
+ backend receives it with the storefront domain's origin
477
+ (`https://shop.example.com`), the one it accepts forms from. So a host that shows
478
+ the storefront through `X-Front-Domain`, such as a preview host or a local port,
479
+ can sign in too.
414
480
 
415
481
  SvelteKit ignores a request body sent without a `Content-Type` header, so always
416
482
  set it on your own API calls; browsers do this automatically for `fetch`.
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { setupProject } from '../src/setup.mjs';
3
3
  import { updateProject } from '../src/update.mjs';
4
+ import { installService } from '../src/install.mjs';
5
+ import { SERVICE_ACTIONS, SERVICE_ALIASES, controlService, findApp, printService } from '../src/service.mjs';
4
6
  import { readFile } from 'node:fs/promises';
5
7
  import { resolve } from 'node:path';
6
8
  import { createInterface } from 'node:readline/promises';
@@ -29,8 +31,27 @@ Commands for an existing project (run from its root):
29
31
  npx orionplace-svelte set mimic <existing-frontend.example.com>
30
32
  npx orionplace-svelte set frontend <shop.example.com>
31
33
 
34
+ On your Linux server (run from the project root):
35
+ sudo npx orionplace-svelte install
36
+ Builds the storefront, installs it as a system service (systemd or OpenRC)
37
+ and, when the orionplace server tool is set up, connects its domains with
38
+ nginx and HTTPS. Run it again after changes or an update to rebuild and restart.
39
+ Server tool: https://orionmarket.place/downloads/orionplace.sh
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
+
32
51
  Options: --cwd <directory> --dry-run --help --version
33
52
  Update option: --local (apply this CLI version instead of fetching the latest release)
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)
34
55
 
35
56
  Update downloads the latest release, refreshes unedited source, and installs dependencies.
36
57
  It preserves edited source, domain mappings and MIMIC. --dry-run leaves project files unchanged.
@@ -53,19 +74,64 @@ try {
53
74
  let cwd = process.cwd();
54
75
  let dryRun = false;
55
76
  let local = false;
77
+ let follow = false;
78
+ const install = { domains: true, build: true, yes: false };
56
79
  for (let index = 0; index < argv.length; index++) {
57
80
  if (argv[index] === '--dry-run') dryRun = true;
58
81
  else if (argv[index] === '--local') local = true;
59
- else if (argv[index] === '--cwd') {
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;
85
+ else if (argv[index] === '--yes' || argv[index] === '-y') install.yes = true;
86
+ else if (['--name', '--port', '--user'].includes(argv[index])) {
87
+ if (!argv[index + 1]) throw new Error(`${argv[index]} needs a value`);
88
+ install[argv[index].slice(2)] = argv[++index];
89
+ } else if (argv[index] === '--cwd') {
60
90
  if (!argv[index + 1]) throw new Error('--cwd needs a directory');
61
91
  cwd = resolve(argv[++index]);
62
92
  } else if (argv[index].startsWith('--')) throw new Error(`Unknown option: ${argv[index]}`);
63
93
  else positional.push(argv[index]);
64
94
  }
65
- const [command, target] = positional;
95
+ const [given, target] = positional;
96
+ const command = SERVICE_ALIASES[given] || given;
66
97
  if (local && command !== 'update') throw new Error('--local is only supported by update.');
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.');
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.');
67
104
  let result;
68
- if (command === 'init' && positional.length <= 2) {
105
+ if (command === 'install' && positional.length === 1) {
106
+ const done = await installService({ cwd, dryRun, ...install });
107
+ if (done.dryRun) {
108
+ console.log(`Planned service orionplace-${done.name} on 127.0.0.1:${done.port} as ${done.user}`);
109
+ console.log(`Domains: ${done.frontends.join(', ') || 'none yet'} -> ${done.backend || 'no backend yet'}`);
110
+ console.log(`\n${done.unitPath}:\n${done.unit}`);
111
+ } else {
112
+ console.log(`\nInstalled orionplace-${done.name} (port ${done.port}, user ${done.user}).`);
113
+ for (const domain of done.frontends) {
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)`);
116
+ else if (done.failed.includes(domain)) console.log(` ✗ ${domain}: not connected (see above)`);
117
+ else console.log(` ✓ https://${domain}`);
118
+ }
119
+ if (done.failed.length) console.log('Fix the problems above, then run: sudo orionplace domain add <domain>');
120
+ console.log(`Manage it from this folder: npx orionplace-svelte status | sudo npx orionplace-svelte restart | sudo npx orionplace-svelte logs`);
121
+ console.log('After code changes or npx orionplace-svelte update, run sudo npx orionplace-svelte install again.');
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);
134
+ } else if (command === 'init' && positional.length <= 2) {
69
135
  result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
70
136
  } else if (command === 'update' && positional.length === 1) {
71
137
  result = await updateProject({ cwd, dryRun, local });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-svelte",
3
- "version": "0.2.1",
3
+ "version": "0.4.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') ||
@@ -0,0 +1,363 @@
1
+ // Installs the built storefront as a system service on a Linux server and
2
+ // connects its domains through the "orionplace" server tool
3
+ // (https://orionmarket.place/downloads/orionplace.sh) when that tool is set up.
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { request } from 'node:http';
8
+ import { createServer } from 'node:net';
9
+ import { basename, dirname, join, resolve } from 'node:path';
10
+ import { createInterface } from 'node:readline/promises';
11
+ import { setupProject } from './setup.mjs';
12
+
13
+ // adapter-node reads the proxy's headers only when told to; uploads (review
14
+ // photos, avatars, attachments) need more than its 512 KB default body limit.
15
+ export const framework = {
16
+ id: 'svelte',
17
+ title: 'SvelteKit',
18
+ dependency: '@sveltejs/kit',
19
+ output: 'build/index.js',
20
+ start: () => ['build/index.js'],
21
+ environment: {
22
+ PROTOCOL_HEADER: 'x-forwarded-proto',
23
+ HOST_HEADER: 'x-forwarded-host',
24
+ ADDRESS_HEADER: 'x-real-ip',
25
+ BODY_SIZE_LIMIT: '67108864',
26
+ },
27
+ };
28
+
29
+ export const defaultPaths = {
30
+ etc: process.env.ORIONPLACE_ETC || '/etc/orionplace',
31
+ systemd: '/etc/systemd/system',
32
+ openrc: '/etc/init.d',
33
+ logs: '/var/log/orionplace',
34
+ srv: '/srv/orionplace',
35
+ tool: ['/usr/local/bin/orionplace', '/usr/bin/orionplace'],
36
+ };
37
+
38
+ const SERVICE_USER = 'orionplace';
39
+
40
+ // run(command, args, { cwd, capture, env }) -> { code, output }
41
+ export function runCommand(command, args, { cwd, capture = false, env = process.env } = {}) {
42
+ return new Promise((done, reject) => {
43
+ const child = spawn(command, args, { cwd, env, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' });
44
+ let output = '';
45
+ if (capture) {
46
+ child.stdout.on('data', chunk => { output += chunk; });
47
+ child.stderr.on('data', chunk => { output += chunk; });
48
+ }
49
+ child.once('error', reject);
50
+ child.once('exit', code => done({ code: code ?? 1, output }));
51
+ });
52
+ }
53
+
54
+ function parseConf(text) {
55
+ const values = {};
56
+ for (const line of text.split('\n')) {
57
+ const match = /^([A-Z_]+)=(.*)$/.exec(line);
58
+ if (match) values[match[1]] = match[2];
59
+ }
60
+ return values;
61
+ }
62
+
63
+ export async function readApps(paths = defaultPaths) {
64
+ const directory = join(paths.etc, 'apps');
65
+ let names = [];
66
+ try { names = (await readdir(directory)).filter(name => name.endsWith('.conf')); }
67
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
68
+ const apps = [];
69
+ for (const name of names) apps.push(parseConf(await readFile(join(directory, name), 'utf8')));
70
+ return apps;
71
+ }
72
+
73
+ export function serviceName(value) {
74
+ const name = value.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
75
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error('Choose a service name with letters, digits and dashes: --name shop');
76
+ return name;
77
+ }
78
+
79
+ function portIsFree(port) {
80
+ return new Promise(done => {
81
+ const server = createServer();
82
+ server.once('error', () => done(false));
83
+ server.listen({ port, host: '127.0.0.1', exclusive: true }, () => server.close(() => done(true)));
84
+ });
85
+ }
86
+
87
+ export async function choosePort(apps, wanted, isFree = portIsFree) {
88
+ const taken = new Set(apps.map(app => Number(app.PORT)));
89
+ if (wanted) {
90
+ if (!Number.isInteger(wanted) || wanted < 1024 || wanted > 65535) throw new Error('Use a port between 1024 and 65535.');
91
+ return wanted;
92
+ }
93
+ for (let port = 3000; port < 4000; port++) {
94
+ if (!taken.has(port) && await isFree(port)) return port;
95
+ }
96
+ throw new Error('No free port between 3000 and 3999. Choose one with --port.');
97
+ }
98
+
99
+ async function passwdEntry(user) {
100
+ const text = await readFile('/etc/passwd', 'utf8');
101
+ for (const line of text.split('\n')) {
102
+ const [name, , uid, gid, , home] = line.split(':');
103
+ if (name === user) return { name, uid: Number(uid), gid: Number(gid), home };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ async function userByUid(uid) {
109
+ const text = await readFile('/etc/passwd', 'utf8');
110
+ for (const line of text.split('\n')) {
111
+ const [name, , id, gid, , home] = line.split(':');
112
+ if (Number(id) === uid && name) return { name, uid, gid: Number(gid), home };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // Every parent folder must let the service user through.
118
+ async function checkReachable(root, account) {
119
+ let cursor = dirname(root);
120
+ while (true) {
121
+ const info = await stat(cursor);
122
+ const open = info.uid === account.uid ? info.mode & 0o100 : info.gid === account.gid ? info.mode & 0o010 : info.mode & 0o001;
123
+ if (!open) {
124
+ throw new Error(`The folder ${cursor} is closed to the service user ${account.name}. Move the storefront to ${defaultPaths.srv}/ (for example ${defaultPaths.srv}/${basename(root)}) and run install there.`);
125
+ }
126
+ if (cursor === dirname(cursor)) return;
127
+ cursor = dirname(cursor);
128
+ }
129
+ }
130
+
131
+ async function writeAtomic(path, content, mode = 0o644) {
132
+ await mkdir(dirname(path), { recursive: true });
133
+ const temporary = `${path}.orionplace-${process.pid}.tmp`;
134
+ await writeFile(temporary, content, { mode });
135
+ await rename(temporary, path);
136
+ }
137
+
138
+ export function systemdUnit({ name, root, user, group, port, node, env }) {
139
+ const environment = Object.entries({ NODE_ENV: 'production', HOST: '127.0.0.1', PORT: String(port), SITEMAPS_DIR: join(root, 'sitemaps'), ...env })
140
+ .map(([key, value]) => `Environment=${key}=${value}`).join('\n');
141
+ const args = [...(existsSync(join(root, '.env')) ? ['--env-file=.env'] : []), ...framework.start(port)];
142
+ return `# Installed by "orionplace-${framework.id} install". Run it again after changes;
143
+ # manage with: orionplace app status|logs|restart ${name}
144
+ [Unit]
145
+ Description=OrionMarketplace storefront ${name} (${framework.title})
146
+ After=network-online.target
147
+ Wants=network-online.target
148
+
149
+ [Service]
150
+ Type=simple
151
+ User=${user}
152
+ Group=${group}
153
+ WorkingDirectory=${root}
154
+ ${environment}
155
+ ExecStart=${node} ${args.join(' ')}
156
+ Restart=on-failure
157
+ RestartSec=3
158
+ LimitNOFILE=65535
159
+ NoNewPrivileges=true
160
+ PrivateTmp=true
161
+ ProtectSystem=full
162
+
163
+ [Install]
164
+ WantedBy=multi-user.target
165
+ `;
166
+ }
167
+
168
+ export function openrcScript({ name, root, user, group, port, node, env, logs }) {
169
+ const environment = Object.entries({ NODE_ENV: 'production', HOST: '127.0.0.1', PORT: String(port), SITEMAPS_DIR: join(root, 'sitemaps'), ...env })
170
+ .map(([key, value]) => `export ${key}="${value}"`).join('\n');
171
+ const args = [...(existsSync(join(root, '.env')) ? ['--env-file=.env'] : []), ...framework.start(port)];
172
+ return `#!/sbin/openrc-run
173
+ # Installed by "orionplace-${framework.id} install". Run it again after changes;
174
+ # manage with: orionplace app status|logs|restart ${name}
175
+ name="orionplace-${name}"
176
+ description="OrionMarketplace storefront ${name} (${framework.title})"
177
+ supervisor=supervise-daemon
178
+ respawn_delay=3
179
+ command="${node}"
180
+ command_args="${args.join(' ')}"
181
+ command_user="${user}:${group}"
182
+ directory="${root}"
183
+ output_log="${logs}/${name}.log"
184
+ error_log="${logs}/${name}.log"
185
+ ${environment}
186
+
187
+ depend() {
188
+ need net
189
+ after firewall
190
+ }
191
+
192
+ start_pre() {
193
+ checkpath --directory --owner ${user}:${group} --mode 0755 ${logs}
194
+ }
195
+ `;
196
+ }
197
+
198
+ async function prompt(question) {
199
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
200
+ try { return (await terminal.question(question)).trim(); }
201
+ finally { terminal.close(); }
202
+ }
203
+
204
+ function log(message) { console.log(message); }
205
+
206
+ // /usr/local/bin/node from the server tool follows Node.js updates; the
207
+ // versioned path of the running binary would not.
208
+ function stableNode() {
209
+ return ['/usr/local/bin/node', '/usr/bin/node'].find(path => existsSync(path)) || process.execPath;
210
+ }
211
+
212
+ // Fills in the backend and first storefront domain when the project has none.
213
+ async function ensureDomains(root, { yes }) {
214
+ const read = async () => JSON.parse(await readFile(join(root, 'orionplace/settings.json'), 'utf8'));
215
+ let settings = await read();
216
+ const ask = process.stdin.isTTY && !yes;
217
+ if (!settings.backend) {
218
+ if (!ask) throw new Error(`Set the personal backend first: npx orionplace-${framework.id} set backend my-shop.orionplace.app`);
219
+ log('Your personal backend is shown in your orionmarket.place profile, for example my-shop.orionplace.app.');
220
+ await setupProject({ cwd: root, field: 'backend', value: await prompt('Personal backend domain: ') });
221
+ settings = await read();
222
+ }
223
+ if (!settings.frontends?.length) {
224
+ if (!ask) throw new Error(`Add the storefront domain first: npx orionplace-${framework.id} add frontend shop.example.com`);
225
+ await setupProject({ cwd: root, action: 'add', field: 'frontend', value: await prompt('Storefront domain (for example shop.example.com): ') });
226
+ settings = await read();
227
+ }
228
+ return settings;
229
+ }
230
+
231
+ // The storefront's home page as the public domain sees it -> HTTP status, 0 when down.
232
+ export function homeStatus(port, host) {
233
+ return new Promise(done => {
234
+ const call = request({ host: '127.0.0.1', port, path: '/', headers: { host }, timeout: 15000 }, response => {
235
+ response.resume();
236
+ done(response.statusCode);
237
+ });
238
+ call.once('timeout', () => call.destroy());
239
+ call.once('error', () => done(0));
240
+ call.end();
241
+ });
242
+ }
243
+
244
+ export async function waitForHttp(port, host, seconds = 90) {
245
+ const until = Date.now() + seconds * 1000;
246
+ while (Date.now() < until) {
247
+ const status = await homeStatus(port, host);
248
+ if (status) return status;
249
+ await new Promise(done => setTimeout(done, 1000));
250
+ }
251
+ return 0;
252
+ }
253
+
254
+ export async function installService({
255
+ cwd = process.cwd(), name, port, user, build = true, domains = true, yes = false, dryRun = false,
256
+ paths = defaultPaths, run = runCommand, uid = process.getuid?.(), isFree = portIsFree,
257
+ systemd = existsSync('/run/systemd/system'), node = stableNode(),
258
+ } = {}) {
259
+ if (process.platform !== 'linux') throw new Error('install sets up a Linux server service. Run it on your VPS.');
260
+ if (uid !== 0 && !dryRun) throw new Error(`install needs root: sudo npx orionplace-${framework.id} install`);
261
+ const root = resolve(cwd);
262
+ if (!/^\/[A-Za-z0-9._/@+-]+$/.test(root)) throw new Error(`Use a storefront folder path without spaces or special characters (for example ${paths.srv}/shop), not ${root}.`);
263
+ let pkg;
264
+ try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); }
265
+ catch { throw new Error(`Run install in your storefront folder (created with: npx orionplace-${framework.id} init).`); }
266
+ if (!{ ...pkg.devDependencies, ...pkg.dependencies }[framework.dependency] || !existsSync(join(root, 'orionplace/settings.json'))) {
267
+ throw new Error(`This folder is not an Orionplace ${framework.title} storefront. Run install in the folder created with: npx orionplace-${framework.id} init`);
268
+ }
269
+ const openrc = !systemd && existsSync('/sbin/openrc-run');
270
+ if (!systemd && !openrc && !dryRun) throw new Error('Neither systemd nor OpenRC was found; start the storefront with your own process manager (see orionplace/README.md, Deploy on your VPS).');
271
+
272
+ const settings = dryRun ? JSON.parse(await readFile(join(root, 'orionplace/settings.json'), 'utf8')) : await ensureDomains(root, { yes });
273
+ const apps = await readApps(paths);
274
+ const previous = apps.find(app => app.DIR === root);
275
+ const service = serviceName(name || previous?.NAME || basename(root));
276
+ if (apps.some(app => app.NAME === service && app.DIR !== root)) {
277
+ throw new Error(`Another storefront already uses the name ${service} (${apps.find(app => app.NAME === service).DIR}). Choose one with --name.`);
278
+ }
279
+ const chosen = await choosePort(apps.filter(app => app.DIR !== root), port ? Number(port) : Number(previous?.PORT) || 0, isFree);
280
+
281
+ // The service runs as the folder's owner, or as "orionplace" for root-owned folders.
282
+ const owner = await stat(root);
283
+ let account = user ? await passwdEntry(user) : owner.uid === 0 ? await passwdEntry(SERVICE_USER) : await userByUid(owner.uid);
284
+ if (user && !account) throw new Error(`No system user ${user}.`);
285
+ if (!account && !dryRun) {
286
+ if (existsSync('/usr/sbin/useradd') || existsSync('/sbin/useradd')) {
287
+ await run('useradd', ['--system', '--home-dir', paths.srv, '--shell', '/sbin/nologin', '--user-group', SERVICE_USER], { capture: true });
288
+ } else {
289
+ await run('addgroup', ['-S', SERVICE_USER], { capture: true });
290
+ await run('adduser', ['-S', '-D', '-H', '-h', paths.srv, '-s', '/sbin/nologin', '-G', SERVICE_USER, SERVICE_USER], { capture: true });
291
+ }
292
+ account = await passwdEntry(SERVICE_USER);
293
+ if (!account) throw new Error(`Could not create the system user ${SERVICE_USER}.`);
294
+ }
295
+ account ||= { name: SERVICE_USER, uid: -1, gid: -1 };
296
+ const group = account.gid >= 0 ? (await readFile('/etc/group', 'utf8')).split('\n').map(line => line.split(':'))
297
+ .find(([, , gid]) => Number(gid) === account.gid)?.[0] || account.name : account.name;
298
+ if (account.uid >= 0) await checkReachable(root, account);
299
+
300
+ const values = { name: service, root, user: account.name, group, port: chosen, node, env: framework.environment, logs: paths.logs };
301
+ const unitPath = systemd ? join(paths.systemd, `orionplace-${service}.service`) : join(paths.openrc, `orionplace-${service}`);
302
+ const unit = systemd ? systemdUnit(values) : openrcScript(values);
303
+ const conf = [
304
+ `# Managed by orionplace-${framework.id} install. Use "orionplace app" commands to change it.`,
305
+ `NAME=${service}`, `FRAMEWORK=${framework.id}`, `DIR=${root}`, `PORT=${chosen}`,
306
+ `USER=${account.name}`, `NODE=${node}`, `SERVICE=orionplace-${service}`, '',
307
+ ].join('\n');
308
+ if (dryRun) {
309
+ return { dryRun, name: service, port: chosen, user: account.name, unitPath, unit, conf, frontends: settings.frontends || [], backend: settings.backend };
310
+ }
311
+
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)`);
314
+ // The website build; ORIONPLACE_APP_ORIGIN would make the Android app build.
315
+ const buildEnv = { ...process.env };
316
+ delete buildEnv.ORIONPLACE_APP_ORIGIN;
317
+ if (!existsSync(join(root, 'node_modules', framework.dependency))) {
318
+ const installed = await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: root, env: buildEnv });
319
+ if (installed.code) throw new Error('npm install failed; see the messages above.');
320
+ }
321
+ if (rebuild) {
322
+ const built = await run('npm', ['run', 'build'], { cwd: root, env: buildEnv });
323
+ if (built.code) throw new Error('The build failed; see the messages above. The running service (if any) was not changed.');
324
+ }
325
+ if (!existsSync(join(root, framework.output))) throw new Error(`The build did not produce ${framework.output}.`);
326
+ const owned = await run('chown', ['-R', `${account.uid}:${account.gid}`, root], { capture: true });
327
+ if (owned.code) throw new Error(`Could not give ${root} to ${account.name}:\n${owned.output}`);
328
+
329
+ log(`\n==> Installing the service orionplace-${service} on port ${chosen}`);
330
+ await writeAtomic(unitPath, unit, systemd ? 0o644 : 0o755);
331
+ await writeAtomic(join(paths.etc, 'apps', `${service}.conf`), conf);
332
+ const steps = systemd
333
+ ? [['systemctl', ['daemon-reload']], ['systemctl', ['enable', `orionplace-${service}`]], ['systemctl', ['restart', `orionplace-${service}`]]]
334
+ : [['rc-update', ['add', `orionplace-${service}`, 'default']], ['rc-service', [`orionplace-${service}`, 'restart']]];
335
+ for (const [command, args] of steps) {
336
+ const result = await run(command, args, { capture: true });
337
+ if (result.code) throw new Error(`${command} ${args.join(' ')} failed:\n${result.output}`);
338
+ }
339
+ const status = await waitForHttp(chosen, settings.frontends[0]);
340
+ if (!status) {
341
+ const logs = systemd
342
+ ? await run('journalctl', ['-u', `orionplace-${service}`, '-n', '40', '--no-pager'], { capture: true })
343
+ : await run('tail', ['-n', '40', join(paths.logs, `${service}.log`)], { capture: true });
344
+ throw new Error(`The storefront did not start on port ${chosen}. Last log lines:\n${logs.output}`);
345
+ }
346
+ log(` ✓ orionplace-${service} answers on 127.0.0.1:${chosen} (HTTP ${status})`);
347
+
348
+ const tool = paths.tool.find(path => existsSync(path));
349
+ const failed = [];
350
+ if (domains && tool) {
351
+ for (const domain of settings.frontends) {
352
+ log(`\n==> Connecting ${domain}`);
353
+ const backend = settings.backend.replace(/^https:\/\//, '').replace(/\/api$/, '');
354
+ const result = await run(tool, ['domain', 'add', domain, '--app', service, '--backend', backend, '--yes']);
355
+ if (result.code) failed.push(domain);
356
+ }
357
+ } else if (domains) {
358
+ log('\nThe orionplace server tool is not set up, so nginx and HTTPS were not configured. Prepare the server:');
359
+ log(' curl -fsSLo orionplace.sh https://orionmarket.place/downloads/orionplace.sh && sudo sh orionplace.sh setup');
360
+ log(`Then run this install again. Or proxy your HTTPS site to http://127.0.0.1:${chosen} as orionplace/README.md describes.`);
361
+ }
362
+ return { name: service, port: chosen, user: account.name, unitPath, frontends: settings.frontends, failed, connected: domains && Boolean(tool) };
363
+ }
@@ -338,6 +338,16 @@ If installation fails after source generation, run \`npm install\` and retry.
338
338
 
339
339
  ## Deploy on your VPS
340
340
 
341
+ The quickest way: prepare the server with the OrionMarketplace server script,
342
+ then run \`sudo npx orionplace-svelte install\` in this folder. It builds the
343
+ project, runs it as a system service and connects your domains with nginx and
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.
348
+
349
+ To set the server up yourself instead:
350
+
341
351
  This integration needs a Node.js server and its domain configuration files.
342
352
  It uses \`@sveltejs/adapter-node\`; static and edge-only adapters cannot read
343
353
  domain files. Deploy the build, dependencies, \`sitemaps\`, and your environment
@@ -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
+ }
package/src/setup.mjs CHANGED
@@ -188,6 +188,9 @@ export async function setupProject({ cwd = process.cwd(), action = 'set', field,
188
188
  pkg.scripts['orionplace:help'] ||= 'orionplace-svelte help';
189
189
  pkg.scripts['orionplace:update'] ||= 'orionplace-svelte update';
190
190
  pkg.dependencies ||= {};
191
+ pkg.dependencies['country-flag-icons'] ||= dependencies['country-flag-icons'] || '^1.6.20';
192
+ pkg.dependencies['libphonenumber-js'] ||= dependencies['libphonenumber-js'] || '^1.13.10';
193
+ pkg.dependencies.dompurify ||= dependencies.dompurify || '^3.4.13';
191
194
  pkg.dependencies['orionplace-svelte'] ||= dependencies['orionplace-svelte'] || `^${metadata.version}`;
192
195
  pkg.devDependencies ||= {};
193
196
  pkg.devDependencies['@sveltejs/adapter-node'] ||= dependencies['@sveltejs/adapter-node'] || '^5.5.7';
@@ -22,6 +22,7 @@
22
22
  tariffMediaItems,
23
23
  tariffPagePath,
24
24
  } from '@/lib/utils/orionplace/tariffPage';
25
+ import { tariffResumable } from '@/lib/utils/orionplace/tariffs';
25
26
  import {
26
27
  getAvailableNestedValueFromObject,
27
28
  plainText,
@@ -102,6 +103,18 @@
102
103
  const tariff = computed(() =>
103
104
  confirmed.value ? catalogStore.getTariff(inputs.tariffId) : undefined,
104
105
  );
106
+ /*
107
+ * A trial chosen before sign-in opens again only once the customer's
108
+ * subscription records are known and the customer is new to this one;
109
+ * the backend refuses a trial to a returning customer.
110
+ */
111
+ const resumable = computed(() =>
112
+ tariffResumable(
113
+ inputs.resume,
114
+ tariff.value,
115
+ catalogStore.usedSubscriptionsOf(store.profile?.id),
116
+ ),
117
+ );
105
118
  // The heading names the plan; `tariff_title` is its SEO page title.
106
119
  const title = computed(
107
120
  () =>
@@ -218,6 +231,7 @@
218
231
  if (!confirmed.value) {
219
232
  void load();
220
233
  }
234
+ void account.loadUsed();
221
235
  });
222
236
 
223
237
  watch(media, () => {
@@ -229,7 +243,13 @@
229
243
  }
230
244
  });
231
245
  watch(
232
- () => inputs.resume,
246
+ () => store.profile?.id,
247
+ () => {
248
+ void account.loadUsed();
249
+ },
250
+ );
251
+ watch(
252
+ resumable,
233
253
  (value) => {
234
254
  if (
235
255
  value?.tariff === inputs.tariffId &&
@@ -1,10 +1,16 @@
1
1
  <script lang="ts">
2
2
  import { routeHref } from '@/lib/orionplace/navigation.svelte';
3
- import { computed, onMounted, ref } from '@/lib/orionplace/reactivity.svelte';
3
+ import {
4
+ computed,
5
+ onMounted,
6
+ ref,
7
+ watch,
8
+ } from '@/lib/orionplace/reactivity.svelte';
4
9
  import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
5
10
  import { useOrionplaceCatalogStore } from '@/lib/stores/orionplaceCatalog.svelte';
6
11
  import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
7
12
  import { useOrionplaceSubscriptions } from '@/lib/composables/useOrionplaceSubscriptions.svelte';
13
+ import { tariffResumable } from '@/lib/utils/orionplace/tariffs';
8
14
  import {
9
15
  useOrionplaceFeatureApi,
10
16
  useOrionplaceTask,
@@ -60,7 +66,20 @@
60
66
  const busy = computed(() =>
61
67
  Boolean(task.state.pending || account.task.state.pending),
62
68
  );
63
- const open = computed(() => (guest.value ? undefined : inputs.resume));
69
+ /*
70
+ * The choice to reopen after sign-in. A trial waits for the customer's
71
+ * subscription records and opens only for a customer new to the record;
72
+ * the backend refuses a trial to a returning customer.
73
+ */
74
+ const open = computed(() =>
75
+ guest.value
76
+ ? undefined
77
+ : tariffResumable(
78
+ inputs.resume,
79
+ inputs.resume && catalogStore.getTariff(inputs.resume.tariff),
80
+ catalogStore.usedSubscriptionsOf(store.profile?.id),
81
+ ),
82
+ );
64
83
  // The plans this page shows: every loaded plan, or only the requested IDs.
65
84
  const shown = computed(() => {
66
85
  const data = catalogStore.tariffs?.data ?? [];
@@ -154,7 +173,14 @@
154
173
  if (!catalogStore.tariffs || catalogStore.tariffsSnapshot) {
155
174
  void load();
156
175
  }
176
+ void account.loadUsed();
157
177
  });
178
+ watch(
179
+ () => store.profile?.id,
180
+ () => {
181
+ void account.loadUsed();
182
+ },
183
+ );
158
184
  </script>
159
185
 
160
186
  <section
@@ -2,11 +2,13 @@
2
2
  import { routeHref } from '@/lib/orionplace/navigation.svelte';
3
3
  import type { Snippet } from 'svelte';
4
4
  import { computed, ref, watch } from '@/lib/orionplace/reactivity.svelte';
5
+ import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
5
6
  import { useOrionplaceCatalogStore } from '@/lib/stores/orionplaceCatalog.svelte';
6
7
  import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
7
8
  import {
8
9
  tariffIsOpen,
9
10
  tariffSubscribeRequest,
11
+ tariffTrialOpen,
10
12
  } from '@/lib/utils/orionplace/tariffs';
11
13
  import {
12
14
  tariffDays,
@@ -121,6 +123,7 @@
121
123
  return handlers[event]?.(...args);
122
124
  };
123
125
 
126
+ const store = useOrionplaceStore();
124
127
  const catalogStore = useOrionplaceCatalogStore();
125
128
  const selectionStore = useOrionplaceSelectionsStore();
126
129
  const tariff = computed(() => catalogStore.getTariff(inputs.tariffId));
@@ -135,15 +138,22 @@
135
138
  // The option chosen in the card; the dialog opens on it and keeps its own
136
139
  // choice from there.
137
140
  const optionId = ref<number>();
141
+ // No trial badge for a customer who already had this subscription record.
138
142
  const trialBadge = computed(() => {
139
- const days = tariff.value?.trial_days ?? 0;
143
+ const value = tariff.value;
140
144
 
141
- if (days <= 0) {
145
+ if (
146
+ !value ||
147
+ !tariffTrialOpen(
148
+ value,
149
+ catalogStore.usedSubscriptionsOf(store.profile?.id),
150
+ )
151
+ ) {
142
152
  return '';
143
153
  }
144
154
 
145
155
  return tariffText(tariffLabels(selectionStore.localeCode).trialBadge, {
146
- days: tariffDays(days, selectionStore.localeCode),
156
+ days: tariffDays(value.trial_days, selectionStore.localeCode),
147
157
  });
148
158
  });
149
159
 
@@ -1,6 +1,6 @@
1
1
  # Plan prices
2
2
 
3
- The period switcher, the price for the chosen period and the Subscribe and Try actions. A plan that only the store administrator assigns, or a closed plan, shows a note instead of actions.
3
+ The period switcher, the price for the chosen period and the Subscribe and Try actions. A plan that only the store administrator assigns, or a closed plan, shows a note instead of actions. A signed-in customer who already had the plan's subscription sees no Try action and no trial badge: the backend gives a trial once per subscription. The plan pages load the customer's records with `subscriptions_get`; until they arrive, the trial stays shown.
4
4
 
5
5
  Source: `src/lib/components/orionplace/tariffs/TariffCardPrices.svelte`.
6
6
 
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { modelCell } from '@/lib/orionplace/reactivity.svelte';
3
3
  import { computed } from '@/lib/orionplace/reactivity.svelte';
4
+ import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
4
5
  import { useOrionplaceCatalogStore } from '@/lib/stores/orionplaceCatalog.svelte';
5
6
  import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
6
7
  import { useOrionplaceSignIn } from '@/lib/composables/useOrionplaceSignIn.svelte';
@@ -10,6 +11,7 @@
10
11
  tariffMoney,
11
12
  tariffPaidPeriods,
12
13
  tariffReturnPath,
14
+ tariffTrialOpen,
13
15
  } from '@/lib/utils/orionplace/tariffs';
14
16
  import {
15
17
  tariffDays,
@@ -83,6 +85,7 @@
83
85
  },
84
86
  );
85
87
 
88
+ const store = useOrionplaceStore();
86
89
  const catalogStore = useOrionplaceCatalogStore();
87
90
  const selectionStore = useOrionplaceSelectionsStore();
88
91
  const signIn = useOrionplaceSignIn();
@@ -122,11 +125,24 @@
122
125
  paidPeriods.value.find((item) => item.price === selectedPeriod.value) ??
123
126
  paidPeriods.value[0],
124
127
  );
125
- const trialDuration = computed(() => {
126
- const days = tariff.value?.trial_days ?? 0;
128
+ // A customer who already had this subscription record gets no trial.
129
+ const trialUsed = computed(() => {
130
+ const value = tariff.value;
127
131
 
128
- return days > 0 ? duration(days) : '';
132
+ return Boolean(
133
+ value &&
134
+ value.trial_days > 0 &&
135
+ !tariffTrialOpen(
136
+ value,
137
+ catalogStore.usedSubscriptionsOf(store.profile?.id),
138
+ ),
139
+ );
129
140
  });
141
+ const trialDuration = computed(() =>
142
+ tariff.value && !trialUsed.value && tariff.value.trial_days > 0
143
+ ? duration(tariff.value.trial_days)
144
+ : '',
145
+ );
130
146
  const actions = computed(() => {
131
147
  const list: TariffPeriodAction[] = [];
132
148
 
@@ -165,7 +181,11 @@
165
181
  return copy.value.closed;
166
182
  }
167
183
 
168
- return actions.value.length ? '' : copy.value.noPeriods;
184
+ if (actions.value.length) {
185
+ return '';
186
+ }
187
+
188
+ return trialUsed.value ? copy.value.trialUsed : copy.value.noPeriods;
169
189
  });
170
190
  const actionClasses = `
171
191
  box-border inline-flex h-11 w-full items-center justify-center px-6
@@ -5,11 +5,13 @@ import {
5
5
  watch,
6
6
  } from '@/lib/orionplace/reactivity.svelte';
7
7
  import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
8
+ import { useOrionplaceCatalogStore } from '@/lib/stores/orionplaceCatalog.svelte';
8
9
  import {
9
10
  useOrionplaceFeatureApi,
10
11
  useOrionplaceTask,
11
12
  } from '@/lib/composables/useOrionplaceFeature.svelte';
12
13
  import { useOrionplaceSignIn } from '@/lib/composables/useOrionplaceSignIn.svelte';
14
+ import { tariffUsedSubscriptions } from '@/lib/utils/orionplace/tariffs';
13
15
  import type {
14
16
  ICustomerSubscription,
15
17
  ICustomerSubscriptionsResponse,
@@ -24,6 +26,7 @@ import type { IProfileData } from '@/lib/interfaces/orionplace/accounts/IProfile
24
26
 
25
27
  export function useOrionplaceSubscriptions(loadOnMount = true) {
26
28
  const store = useOrionplaceStore();
29
+ const catalogStore = useOrionplaceCatalogStore();
27
30
  const api = useOrionplaceFeatureApi();
28
31
  const task = useOrionplaceTask();
29
32
  const signIn = useOrionplaceSignIn();
@@ -43,6 +46,7 @@ export function useOrionplaceSubscriptions(loadOnMount = true) {
43
46
  subscriptions.value = result.data;
44
47
  tariffs.value = result.tariffs;
45
48
  loaded.value = true;
49
+ rememberUsed(result);
46
50
  const profileData = await api.request<{ profile_info: IProfileData[] }>(
47
51
  'profile_get',
48
52
  { filters: {} },
@@ -63,6 +67,44 @@ export function useOrionplaceSubscriptions(loadOnMount = true) {
63
67
  }
64
68
  }
65
69
 
70
+ function rememberUsed(
71
+ result: NonNullable<ICustomerSubscriptionsResponse['page_data']>,
72
+ ) {
73
+ if (store.profile) {
74
+ catalogStore.setUsedSubscriptions(
75
+ store.profile.id,
76
+ tariffUsedSubscriptions(result),
77
+ );
78
+ }
79
+ }
80
+
81
+ /*
82
+ * The plan pages hide the trial of a subscription record the customer
83
+ * already had (the backend refuses it). They read the records once per
84
+ * profile; a failed read leaves the trial shown, as for an unknown customer.
85
+ */
86
+ async function loadUsed() {
87
+ const profile = store.profile?.id;
88
+
89
+ if (
90
+ !store.isAuthenticated ||
91
+ profile === undefined ||
92
+ catalogStore.usedSubscriptionsOf(profile)
93
+ ) {
94
+ return;
95
+ }
96
+ try {
97
+ rememberUsed(
98
+ await api.request<NonNullable<ICustomerSubscriptionsResponse['page_data']>>(
99
+ 'subscriptions_get',
100
+ {},
101
+ ),
102
+ );
103
+ } catch {
104
+ catalogStore.setUsedSubscriptions(profile, []);
105
+ }
106
+ }
107
+
66
108
  /*
67
109
  * Each plan the customer buys is its own subscription, so a purchase that
68
110
  * already succeeded must not block the next one. Only repeating the same
@@ -211,6 +253,7 @@ export function useOrionplaceSubscriptions(loadOnMount = true) {
211
253
  completed,
212
254
  needsRefresh,
213
255
  load,
256
+ loadUsed,
214
257
  subscribe,
215
258
  renew,
216
259
  setRenewal,
@@ -17,6 +17,9 @@ import { resolveAppLoadProducts } from '@/lib/utils/orionplace/productSeller';
17
17
  * `storeTariffs` is every plan the storefront sells; the subscriptions page
18
18
  * and its dialogs read it. `tariffs` is what the plan pages show: the same
19
19
  * list, or a search result or one plan set with `setTariffs`.
20
+ * `usedSubscriptions` holds the subscription records the signed-in customer
21
+ * already had, for the profile that loaded them; a new Application Load
22
+ * keeps it, another profile ignores it.
20
23
  */
21
24
  export function useOrionplaceCatalog(
22
25
  scope: string,
@@ -30,6 +33,8 @@ export function useOrionplaceCatalog(
30
33
  shallowRef<NonNullable<IAppLoadCommon['tariffs']>>();
31
34
  const storeTariffsSnapshot =
32
35
  shallowRef<NonNullable<IAppLoadCommon['tariffs']>>();
36
+ const usedSubscriptions =
37
+ shallowRef<{ profile: number; ids: number[] }>();
33
38
  const catalog = computed(
34
39
  () => catalogSnapshot.value ?? init.value?.catalog ?? [],
35
40
  );
@@ -71,6 +76,19 @@ export function useOrionplaceCatalog(
71
76
  tariffsSnapshot.value = undefined;
72
77
  }
73
78
 
79
+ /** The records the customer had, see `tariffUsedSubscriptions`. */
80
+ function setUsedSubscriptions(profile: number, ids: number[]) {
81
+ usedSubscriptions.value = { profile, ids };
82
+ }
83
+
84
+ /** Those records for this profile; undefined until they are loaded. */
85
+ function usedSubscriptionsOf(profile: number | undefined) {
86
+ return usedSubscriptions.value &&
87
+ usedSubscriptions.value.profile === profile
88
+ ? usedSubscriptions.value.ids
89
+ : undefined;
90
+ }
91
+
74
92
  function getTariff(id: number) {
75
93
  return tariffs.value?.data.find((tariff) => tariff.id === id);
76
94
  }
@@ -97,6 +115,7 @@ export function useOrionplaceCatalog(
97
115
  catalogSnapshot,
98
116
  tariffsSnapshot,
99
117
  storeTariffsSnapshot,
118
+ usedSubscriptions,
100
119
  catalog,
101
120
  tariffs,
102
121
  storeTariffs,
@@ -108,6 +127,8 @@ export function useOrionplaceCatalog(
108
127
  setCatalogTree,
109
128
  setTariffs,
110
129
  setStoreTariffs,
130
+ setUsedSubscriptions,
131
+ usedSubscriptionsOf,
111
132
  getTariff,
112
133
  getCatalogCategory,
113
134
  getCatalogProducts,
@@ -190,8 +190,8 @@ export class OrionplaceApi {
190
190
  const body = response.body as {
191
191
  page_data?: { csrf?: boolean };
192
192
  } | null;
193
- // Only a CSRF rejection permits one automatic retry: Django views answer
194
- // 419, API endpoints answer 403 with page_data.csrf set to false.
193
+ // A CSRF rejection: Django views answer 419, API endpoints answer 403
194
+ // with page_data.csrf set to false. The request is not repeated.
195
195
  const csrfRejected =
196
196
  response.status === 419 ||
197
197
  (response.status === 403 && body?.page_data?.csrf === false);
@@ -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}',
@@ -201,6 +201,55 @@ export function tariffSubscribeRequest(
201
201
  };
202
202
  }
203
203
 
204
+ /**
205
+ * The subscription records the customer already had a period of, paid or
206
+ * trial, read from the `subscriptions_get` response. The backend gives a trial
207
+ * only to a customer new to the record, so the plans of these records offer
208
+ * none.
209
+ */
210
+ export function tariffUsedSubscriptions(page: {
211
+ data: { subscription: number | null; tariff: number | null }[];
212
+ tariffs: { id: number; subscription: number }[];
213
+ }) {
214
+ const ids = page.data.map(
215
+ (item) =>
216
+ item.subscription ??
217
+ page.tariffs.find((tariff) => tariff.id === item.tariff)?.subscription,
218
+ );
219
+
220
+ return [...new Set(ids.filter((id): id is number => typeof id === 'number'))];
221
+ }
222
+
223
+ /**
224
+ * Whether the plan offers its trial: it has trial days and the customer is
225
+ * not known to have had its subscription record (`used`, see
226
+ * `tariffUsedSubscriptions`). A guest and a customer whose records are not
227
+ * loaded yet see the trial; the backend refuses it to a returning customer.
228
+ */
229
+ export function tariffTrialOpen(
230
+ tariff: ISubscribeTariff,
231
+ used: readonly number[] = [],
232
+ ) {
233
+ return tariff.trial_days > 0 && !used.includes(tariff.subscription);
234
+ }
235
+
236
+ /**
237
+ * The plan choice to open again after sign-in: a paid period as it is, a
238
+ * trial only once the customer's records are loaded (`used`) and the
239
+ * customer is new to the record.
240
+ */
241
+ export function tariffResumable(
242
+ selection: ITariffSelection | undefined,
243
+ tariff: ISubscribeTariff | undefined,
244
+ used: readonly number[] | undefined,
245
+ ) {
246
+ if (!selection || selection.price !== null) {
247
+ return selection;
248
+ }
249
+
250
+ return tariff && used && tariffTrialOpen(tariff, used) ? selection : undefined;
251
+ }
252
+
204
253
  export interface ITariffFeature {
205
254
  key: string;
206
255
  label: string;