orionplace-nuxt 0.3.0 → 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 +42 -1
- package/bin/orionplace-nuxt.mjs +38 -7
- package/package.json +1 -1
- package/runtime/marketplace.mjs +12 -0
- package/src/install.mjs +5 -4
- package/src/project-readme.mjs +3 -0
- package/src/service.mjs +186 -0
- package/templates/app/components/orionplace/subscriptions/StorefrontTariff.vue +21 -1
- package/templates/app/components/orionplace/subscriptions/StorefrontTariffs.vue +23 -2
- package/templates/app/components/orionplace/tariffs/TariffCard.vue +13 -3
- package/templates/app/components/orionplace/tariffs/TariffCardPrices.README.md +1 -1
- package/templates/app/components/orionplace/tariffs/TariffCardPrices.vue +24 -4
- package/templates/app/composables/useOrionplaceSubscriptions.ts +43 -0
- package/templates/app/stores/orionplaceCatalog.ts +20 -0
- package/templates/app/utils/orionplace/tariffLabels.ts +2 -0
- package/templates/app/utils/orionplace/tariffs.ts +49 -0
- package/templates/orionplace/COMPONENTS.md +5 -1
package/README.md
CHANGED
|
@@ -319,6 +319,8 @@ npx orionplace-nuxt add frontend www.shop.example.com
|
|
|
319
319
|
npx orionplace-nuxt remove frontend www.shop.example.com
|
|
320
320
|
npx orionplace-nuxt set mimic shop.example.com
|
|
321
321
|
sudo npx orionplace-nuxt install
|
|
322
|
+
npx orionplace-nuxt status
|
|
323
|
+
sudo npx orionplace-nuxt restart
|
|
322
324
|
npx orionplace-nuxt help
|
|
323
325
|
```
|
|
324
326
|
|
|
@@ -365,9 +367,34 @@ builds the project, runs it as the system service `orionplace-shop` on a free
|
|
|
365
367
|
local port and connects each storefront domain with nginx and a Let's Encrypt
|
|
366
368
|
certificate. Run it again after code or package updates; the service keeps its
|
|
367
369
|
port. Options: `--name <service>`, `--port <port>`, `--user <user>`,
|
|
368
|
-
`--no-domains`, `--yes`,
|
|
370
|
+
`--no-build` (install the build already in the folder), `--no-domains`, `--yes`,
|
|
371
|
+
`--dry-run`. Full guide:
|
|
369
372
|
[Install a storefront](https://orionmarket.place/en/sandbox/install/storefront).
|
|
370
373
|
|
|
374
|
+
### Manage the service
|
|
375
|
+
|
|
376
|
+
Run these in the project folder, or name the service from anywhere with
|
|
377
|
+
`--name shop`:
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
npx orionplace-nuxt status # running?, autostart, port, home page answer, domains
|
|
381
|
+
sudo npx orionplace-nuxt start
|
|
382
|
+
sudo npx orionplace-nuxt stop
|
|
383
|
+
sudo npx orionplace-nuxt restart # waits until the home page answers again
|
|
384
|
+
sudo npx orionplace-nuxt logs 200 # last log lines; --follow keeps reading
|
|
385
|
+
npx orionplace-nuxt list # every storefront service on this server
|
|
386
|
+
sudo npx orionplace-nuxt uninstall # stop and remove the service (alias: cleanup)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`start` and `restart` wait up to 90 seconds for the home page and print the
|
|
390
|
+
last log lines when it does not answer. `status` ends with code 3 when the
|
|
391
|
+
storefront is stopped or does not answer, so scripts and monitors can use it.
|
|
392
|
+
`uninstall` (or `cleanup`) stops the service, takes it out of autostart and
|
|
393
|
+
deletes its unit file and registration; the project folder and its build stay.
|
|
394
|
+
With the `orionplace` server tool it runs `orionplace app remove`, which also
|
|
395
|
+
turns the storefront's domains into a placeholder page. `--dry-run` shows what it
|
|
396
|
+
would remove; `--yes` skips the question.
|
|
397
|
+
|
|
371
398
|
## Generated files
|
|
372
399
|
|
|
373
400
|
| Path | Purpose |
|
|
@@ -430,6 +457,20 @@ authorization and cookies are forwarded. Backend statuses are retained and cooki
|
|
|
430
457
|
Domain attributes are removed to bind cookies to the storefront. Gateway responses
|
|
431
458
|
use `Cache-Control: no-store`. Network failures return `502`.
|
|
432
459
|
|
|
460
|
+
A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
|
|
461
|
+
`403`. A request from the page's own host or the storefront domain passes, and the
|
|
462
|
+
backend receives it with the storefront domain's origin
|
|
463
|
+
(`https://shop.example.com`), the one it accepts forms from. So a host that shows
|
|
464
|
+
the storefront through `X-Front-Domain`, such as a preview host or a local port,
|
|
465
|
+
can sign in too.
|
|
466
|
+
|
|
467
|
+
A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
|
|
468
|
+
`403`. A request from the page's own host or the storefront domain passes, and the
|
|
469
|
+
backend receives it with the storefront domain's origin
|
|
470
|
+
(`https://shop.example.com`), the one it accepts forms from. So a host that shows
|
|
471
|
+
the storefront through `X-Front-Domain`, such as a preview host or a local port,
|
|
472
|
+
can sign in too.
|
|
473
|
+
|
|
433
474
|
Browser requests use `/api/...`. For SSR, use Nuxt's `useRequestFetch()` to pass
|
|
434
475
|
the current request's domain and authentication to the same gateway. See the
|
|
435
476
|
project README for examples and cookie handling.
|
package/bin/orionplace-nuxt.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { setupProject } from '../src/setup.mjs';
|
|
3
3
|
import { updateProject } from '../src/update.mjs';
|
|
4
4
|
import { installService } from '../src/install.mjs';
|
|
5
|
+
import { SERVICE_ACTIONS, SERVICE_ALIASES, controlService, findApp, printService } from '../src/service.mjs';
|
|
5
6
|
import { readFile } from 'node:fs/promises';
|
|
6
7
|
import { resolve } from 'node:path';
|
|
7
8
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -37,9 +38,20 @@ On your Linux server (run from the project root):
|
|
|
37
38
|
nginx and HTTPS. Run it again after changes or an update to rebuild and restart.
|
|
38
39
|
Server tool: https://orionmarket.place/downloads/orionplace.sh
|
|
39
40
|
|
|
41
|
+
Manage the installed service (from the project root, or name it with --name):
|
|
42
|
+
npx orionplace-nuxt status service, autostart, port and home page answer
|
|
43
|
+
sudo npx orionplace-nuxt start
|
|
44
|
+
sudo npx orionplace-nuxt stop
|
|
45
|
+
sudo npx orionplace-nuxt restart restart and wait until the home page answers
|
|
46
|
+
sudo npx orionplace-nuxt logs [lines] last log lines (100); --follow keeps reading
|
|
47
|
+
npx orionplace-nuxt list every storefront service on this server
|
|
48
|
+
sudo npx orionplace-nuxt uninstall stop the service and remove it (alias: cleanup);
|
|
49
|
+
the project folder stays
|
|
50
|
+
|
|
40
51
|
Options: --cwd <directory> --dry-run --help --version
|
|
41
52
|
Update option: --local (apply this CLI version instead of fetching the latest release)
|
|
42
|
-
Install options: --name <service> --port <port> --user <user> --no-domains --yes
|
|
53
|
+
Install options: --name <service> --port <port> --user <user> --no-build --no-domains --yes
|
|
54
|
+
Service options: --name <service> --follow (logs) --dry-run --yes (uninstall)
|
|
43
55
|
|
|
44
56
|
Update downloads the latest release, refreshes unedited source, and installs dependencies.
|
|
45
57
|
It preserves edited source, domain mappings and MIMIC. --dry-run leaves project files unchanged.
|
|
@@ -61,11 +73,14 @@ try {
|
|
|
61
73
|
let cwd = process.cwd();
|
|
62
74
|
let dryRun = false;
|
|
63
75
|
let local = false;
|
|
64
|
-
|
|
76
|
+
let follow = false;
|
|
77
|
+
const install = { domains: true, build: true, yes: false };
|
|
65
78
|
for (let index = 0; index < argv.length; index++) {
|
|
66
79
|
if (argv[index] === '--dry-run') dryRun = true;
|
|
67
80
|
else if (argv[index] === '--local') local = true;
|
|
68
81
|
else if (argv[index] === '--no-domains') install.domains = false;
|
|
82
|
+
else if (argv[index] === '--no-build') install.build = false;
|
|
83
|
+
else if (argv[index] === '--follow' || argv[index] === '-f') follow = true;
|
|
69
84
|
else if (argv[index] === '--yes' || argv[index] === '-y') install.yes = true;
|
|
70
85
|
else if (['--name', '--port', '--user'].includes(argv[index])) {
|
|
71
86
|
if (!argv[index + 1]) throw new Error(`${argv[index]} needs a value`);
|
|
@@ -76,11 +91,15 @@ try {
|
|
|
76
91
|
} else if (argv[index].startsWith('--')) throw new Error(`Unknown option: ${argv[index]}`);
|
|
77
92
|
else positional.push(argv[index]);
|
|
78
93
|
}
|
|
79
|
-
const [
|
|
94
|
+
const [given, target] = positional;
|
|
95
|
+
const command = SERVICE_ALIASES[given] || given;
|
|
80
96
|
if (local && command !== 'update') throw new Error('--local is only supported by update.');
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
const service = SERVICE_ACTIONS.includes(command);
|
|
98
|
+
if (command !== 'install' && (install.port || install.user || !install.domains || !install.build || (install.yes && command !== 'uninstall') || (install.name && !service))) {
|
|
99
|
+
throw new Error('--port, --user, --no-build, --no-domains and --yes are install options (--yes also for uninstall); --name is for install and the service commands.');
|
|
83
100
|
}
|
|
101
|
+
if (follow && command !== 'logs') throw new Error('--follow is a logs option.');
|
|
102
|
+
if (dryRun && service && command !== 'uninstall') throw new Error('--dry-run is only for uninstall among the service commands; status shows the state without changes.');
|
|
84
103
|
let result;
|
|
85
104
|
if (command === 'install' && positional.length === 1) {
|
|
86
105
|
const done = await installService({ cwd, dryRun, ...install });
|
|
@@ -91,14 +110,26 @@ try {
|
|
|
91
110
|
} else {
|
|
92
111
|
console.log(`\nInstalled orionplace-${done.name} (port ${done.port}, user ${done.user}).`);
|
|
93
112
|
for (const domain of done.frontends) {
|
|
94
|
-
if (!
|
|
113
|
+
if (!install.domains) console.log(` - ${domain}: not connected (--no-domains)`);
|
|
114
|
+
else if (!done.connected) console.log(` - ${domain}: not connected to nginx (see above)`);
|
|
95
115
|
else if (done.failed.includes(domain)) console.log(` ✗ ${domain}: not connected (see above)`);
|
|
96
116
|
else console.log(` ✓ https://${domain}`);
|
|
97
117
|
}
|
|
98
118
|
if (done.failed.length) console.log('Fix the problems above, then run: sudo orionplace domain add <domain>');
|
|
99
|
-
console.log(`Manage it
|
|
119
|
+
console.log(`Manage it from this folder: npx orionplace-nuxt status | sudo npx orionplace-nuxt restart | sudo npx orionplace-nuxt logs`);
|
|
100
120
|
console.log('After code changes or npx orionplace-nuxt update, run sudo npx orionplace-nuxt install again.');
|
|
101
121
|
}
|
|
122
|
+
} else if (service && positional.length <= (command === 'logs' ? 2 : 1)) {
|
|
123
|
+
if (command === 'uninstall' && !dryRun && !install.yes && process.stdin.isTTY) {
|
|
124
|
+
const app = await findApp({ cwd, name: install.name });
|
|
125
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
126
|
+
let answer;
|
|
127
|
+
try { answer = await prompt.question(`Stop and remove the service orionplace-${app.NAME}? The project stays in ${app.DIR}. [y/N] `); }
|
|
128
|
+
finally { prompt.close(); }
|
|
129
|
+
if (!/^y(es)?$/i.test(answer.trim())) { console.log('Nothing changed.'); process.exit(0); }
|
|
130
|
+
}
|
|
131
|
+
const done = await controlService({ action: command, cwd, name: install.name, lines: positional[1] ?? 100, follow, dryRun });
|
|
132
|
+
process.exitCode = printService(done);
|
|
102
133
|
} else if (command === 'init' && positional.length <= 2) {
|
|
103
134
|
result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
|
|
104
135
|
} else if (command === 'update' && positional.length === 1) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-nuxt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Nuxt 4 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/runtime/marketplace.mjs
CHANGED
|
@@ -208,6 +208,18 @@ export async function forwardMarketplace(request, configuration = {}) {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
headers.set('X-Front-Domain', tenant.domain);
|
|
211
|
+
// The backend accepts forms from the storefront domain's own origin. A public
|
|
212
|
+
// host that shows this storefront (MIMIC, a preview host or a local port)
|
|
213
|
+
// passed the same-origin check above, so it is sent as that origin.
|
|
214
|
+
let ownOrigin = false;
|
|
215
|
+
try {
|
|
216
|
+
ownOrigin = Boolean(origin) && originHosts.includes(new URL(origin).host);
|
|
217
|
+
} catch {
|
|
218
|
+
ownOrigin = false;
|
|
219
|
+
}
|
|
220
|
+
if (ownOrigin) {
|
|
221
|
+
headers.set('origin', `https://${tenant.domain}`);
|
|
222
|
+
}
|
|
211
223
|
headers.set('Accept-Encoding', 'identity');
|
|
212
224
|
const clientIp =
|
|
213
225
|
request.headers.get('x-init-ip') ||
|
package/src/install.mjs
CHANGED
|
@@ -222,7 +222,7 @@ async function ensureDomains(root, { yes }) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
// The storefront's home page as the public domain sees it -> HTTP status, 0 when down.
|
|
225
|
-
function homeStatus(port, host) {
|
|
225
|
+
export function homeStatus(port, host) {
|
|
226
226
|
return new Promise(done => {
|
|
227
227
|
const call = request({ host: '127.0.0.1', port, path: '/', headers: { host }, timeout: 15000 }, response => {
|
|
228
228
|
response.resume();
|
|
@@ -234,7 +234,7 @@ function homeStatus(port, host) {
|
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
async function waitForHttp(port, host, seconds = 90) {
|
|
237
|
+
export async function waitForHttp(port, host, seconds = 90) {
|
|
238
238
|
const until = Date.now() + seconds * 1000;
|
|
239
239
|
while (Date.now() < until) {
|
|
240
240
|
const status = await homeStatus(port, host);
|
|
@@ -302,7 +302,8 @@ export async function installService({
|
|
|
302
302
|
return { dryRun, name: service, port: chosen, user: account.name, unitPath, unit, conf, frontends: settings.frontends || [], backend: settings.backend };
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
|
|
305
|
+
const rebuild = build || !existsSync(join(root, framework.output));
|
|
306
|
+
log(rebuild ? `\n==> Building the ${framework.title} storefront ${service}` : `\n==> Using the existing build of ${service} (--no-build)`);
|
|
306
307
|
// The website build; ORIONPLACE_APP_ORIGIN would make the Android app build.
|
|
307
308
|
const buildEnv = { ...process.env };
|
|
308
309
|
delete buildEnv.ORIONPLACE_APP_ORIGIN;
|
|
@@ -310,7 +311,7 @@ export async function installService({
|
|
|
310
311
|
const installed = await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: root, env: buildEnv });
|
|
311
312
|
if (installed.code) throw new Error('npm install failed; see the messages above.');
|
|
312
313
|
}
|
|
313
|
-
if (
|
|
314
|
+
if (rebuild) {
|
|
314
315
|
const built = await run('npm', ['run', 'build'], { cwd: root, env: buildEnv });
|
|
315
316
|
if (built.code) throw new Error('The build failed; see the messages above. The running service (if any) was not changed.');
|
|
316
317
|
}
|
package/src/project-readme.mjs
CHANGED
|
@@ -517,6 +517,9 @@ The quickest way: prepare the server with the OrionMarketplace server script,
|
|
|
517
517
|
then run \`sudo npx orionplace-nuxt install\` in this folder. It builds the
|
|
518
518
|
project, runs it as a system service and connects your domains with nginx and
|
|
519
519
|
HTTPS. Steps: [Install a storefront](https://orionmarket.place/en/sandbox/install/storefront).
|
|
520
|
+
Then manage it from this folder: \`npx orionplace-nuxt status\`, \`sudo npx orionplace-nuxt restart\`,
|
|
521
|
+
\`sudo npx orionplace-nuxt stop\`, \`sudo npx orionplace-nuxt start\` and \`sudo npx orionplace-nuxt logs\`.
|
|
522
|
+
\`sudo npx orionplace-nuxt uninstall\` (or \`cleanup\`) stops and removes the service; this folder stays.
|
|
520
523
|
|
|
521
524
|
To set the server up yourself instead:
|
|
522
525
|
|
package/src/service.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Manages the storefront service that "install" set up: start, stop, restart,
|
|
2
|
+
// status, logs and the list of every storefront service on this server.
|
|
3
|
+
// The service is found by its folder (the project root) or by --name.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { defaultPaths, framework, homeStatus, readApps, runCommand, waitForHttp } from './install.mjs';
|
|
8
|
+
|
|
9
|
+
export const SERVICE_ACTIONS = ['start', 'stop', 'restart', 'status', 'logs', 'list', 'uninstall'];
|
|
10
|
+
// "cleanup" is another name for uninstall.
|
|
11
|
+
export const SERVICE_ALIASES = { cleanup: 'uninstall' };
|
|
12
|
+
|
|
13
|
+
async function frontends(dir) {
|
|
14
|
+
try { return JSON.parse(await readFile(join(dir, 'orionplace/settings.json'), 'utf8')).frontends || []; }
|
|
15
|
+
catch { return []; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The registered storefront of this folder, or the one named with --name.
|
|
19
|
+
export async function findApp({ cwd = process.cwd(), name, paths = defaultPaths } = {}) {
|
|
20
|
+
const apps = await readApps(paths);
|
|
21
|
+
const root = resolve(cwd);
|
|
22
|
+
const app = name ? apps.find(item => item.NAME === name) : apps.find(item => item.DIR === root);
|
|
23
|
+
if (app) return app;
|
|
24
|
+
const known = apps.map(item => item.NAME).join(', ');
|
|
25
|
+
if (name) throw new Error(`No storefront service named ${name}.${known ? ` Installed: ${known}.` : ''}`);
|
|
26
|
+
throw new Error(`${root} is not installed as a service. Install it first: sudo npx orionplace-${framework.id} install`
|
|
27
|
+
+ (known ? `\nOr name another storefront: --name ${apps[0].NAME} (installed: ${known})` : ''));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// true / false; "unknown" when neither systemd nor OpenRC answers.
|
|
31
|
+
async function isRunning(service, { run, systemd }) {
|
|
32
|
+
const result = systemd
|
|
33
|
+
? await run('systemctl', ['is-active', '--quiet', service], { capture: true })
|
|
34
|
+
: await run('rc-service', [service, 'status'], { capture: true });
|
|
35
|
+
return result.code === 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function isEnabled(service, { run, systemd }) {
|
|
39
|
+
const result = systemd
|
|
40
|
+
? await run('systemctl', ['is-enabled', '--quiet', service], { capture: true })
|
|
41
|
+
: await run('rc-update', ['show', 'default'], { capture: true });
|
|
42
|
+
return systemd ? result.code === 0 : new RegExp(`^\\s*${service}\\s`, 'm').test(result.output);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function lastLogs(app, { run, systemd, paths, lines = 40 }) {
|
|
46
|
+
const result = systemd
|
|
47
|
+
? await run('journalctl', ['-u', app.SERVICE || `orionplace-${app.NAME}`, '-n', String(lines), '--no-pager'], { capture: true })
|
|
48
|
+
: await run('tail', ['-n', String(lines), join(paths.logs, `${app.NAME}.log`)], { capture: true });
|
|
49
|
+
return result.output;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One storefront's state: service, autostart, and the home page as its first domain sees it.
|
|
53
|
+
export async function serviceStatus(app, { run = runCommand, systemd = existsSync('/run/systemd/system'), probe = homeStatus } = {}) {
|
|
54
|
+
const service = app.SERVICE || `orionplace-${app.NAME}`;
|
|
55
|
+
const domains = await frontends(app.DIR);
|
|
56
|
+
const running = await isRunning(service, { run, systemd });
|
|
57
|
+
const enabled = await isEnabled(service, { run, systemd });
|
|
58
|
+
const http = running ? await probe(Number(app.PORT), domains[0] || 'localhost') : 0;
|
|
59
|
+
return { name: app.NAME, service, framework: app.FRAMEWORK, dir: app.DIR, port: Number(app.PORT), running, enabled, http, domains };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* controlService({ action, cwd, name, lines, follow }) runs one action and
|
|
64
|
+
* returns what happened; logs and follow print straight to the terminal.
|
|
65
|
+
*/
|
|
66
|
+
export async function controlService({
|
|
67
|
+
action, cwd = process.cwd(), name, lines = 100, follow = false,
|
|
68
|
+
paths = defaultPaths, run = runCommand, uid = process.getuid?.(),
|
|
69
|
+
systemd = existsSync('/run/systemd/system'), openrc = existsSync('/sbin/openrc-run'),
|
|
70
|
+
platform = process.platform, probe = homeStatus, wait = waitForHttp, dryRun = false,
|
|
71
|
+
} = {}) {
|
|
72
|
+
action = SERVICE_ALIASES[action] || action;
|
|
73
|
+
if (!SERVICE_ACTIONS.includes(action)) throw new Error(`Unknown service command ${action}. Use ${SERVICE_ACTIONS.join(', ')}.`);
|
|
74
|
+
if (platform !== 'linux') throw new Error(`${action} manages the Linux service that install set up. Run it on your server.`);
|
|
75
|
+
if (!systemd && !openrc) throw new Error('Neither systemd nor OpenRC was found on this server.');
|
|
76
|
+
|
|
77
|
+
if (action === 'list') {
|
|
78
|
+
const apps = await readApps(paths);
|
|
79
|
+
return { action, apps: await Promise.all(apps.map(app => serviceStatus(app, { run, systemd, probe }))) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const app = await findApp({ cwd, name, paths });
|
|
83
|
+
const service = app.SERVICE || `orionplace-${app.NAME}`;
|
|
84
|
+
if (action === 'status') return { action, ...await serviceStatus(app, { run, systemd, probe }) };
|
|
85
|
+
|
|
86
|
+
if (action === 'logs') {
|
|
87
|
+
const count = Number(lines);
|
|
88
|
+
if (!Number.isInteger(count) || count < 1) throw new Error('Give the number of log lines as a whole number, for example: logs 200');
|
|
89
|
+
const args = systemd
|
|
90
|
+
? ['journalctl', ['-u', service, '-n', String(count), '--no-pager', ...(follow ? ['--follow'] : [])]]
|
|
91
|
+
: ['tail', ['-n', String(count), ...(follow ? ['-F'] : []), join(paths.logs, `${app.NAME}.log`)]];
|
|
92
|
+
const result = await run(...args);
|
|
93
|
+
if (result.code) throw new Error(`${args[0]} could not read the logs of ${service}${uid !== 0 ? `; try: sudo npx orionplace-${framework.id} logs` : ''}.`);
|
|
94
|
+
return { action, name: app.NAME, service };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (action === 'uninstall') return uninstallService(app, { paths, run, uid, systemd, name, dryRun });
|
|
98
|
+
if (uid !== 0) throw new Error(`${action} needs root: sudo npx orionplace-${framework.id} ${action}${name ? ` --name ${name}` : ''}`);
|
|
99
|
+
const result = systemd
|
|
100
|
+
? await run('systemctl', [action, service], { capture: true })
|
|
101
|
+
: await run('rc-service', [service, action], { capture: true });
|
|
102
|
+
if (result.code) throw new Error(`Could not ${action} ${service}:\n${result.output.trim()}`);
|
|
103
|
+
if (action === 'stop') return { action, name: app.NAME, service, running: false };
|
|
104
|
+
|
|
105
|
+
const domains = await frontends(app.DIR);
|
|
106
|
+
const http = await wait(Number(app.PORT), domains[0] || 'localhost', 90);
|
|
107
|
+
if (!http) {
|
|
108
|
+
throw new Error(`${service} did not answer on port ${app.PORT} within 90 seconds. Last log lines:\n${await lastLogs(app, { run, systemd, paths })}`);
|
|
109
|
+
}
|
|
110
|
+
return { action, name: app.NAME, service, port: Number(app.PORT), running: true, http, domains };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stops the service, removes it from autostart, deletes its unit or init
|
|
115
|
+
* script and its registration. The project folder and its build stay. With
|
|
116
|
+
* the orionplace server tool, "orionplace app remove" does it and also turns
|
|
117
|
+
* the storefront's domains into a placeholder page.
|
|
118
|
+
*/
|
|
119
|
+
async function uninstallService(app, { paths, run, uid, systemd, name, dryRun }) {
|
|
120
|
+
const service = app.SERVICE || `orionplace-${app.NAME}`;
|
|
121
|
+
const unitPath = systemd ? join(paths.systemd, `${service}.service`) : join(paths.openrc, service);
|
|
122
|
+
const confPath = join(paths.etc, 'apps', `${app.NAME}.conf`);
|
|
123
|
+
const tool = (paths.tool || []).find(path => existsSync(path));
|
|
124
|
+
const result = { action: 'uninstall', name: app.NAME, service, dir: app.DIR, domains: await frontends(app.DIR), tool: Boolean(tool), unitPath, confPath, dryRun };
|
|
125
|
+
if (dryRun) return result;
|
|
126
|
+
if (uid !== 0) throw new Error(`uninstall needs root: sudo npx orionplace-${framework.id} uninstall${name ? ` --name ${name}` : ''}`);
|
|
127
|
+
if (tool) {
|
|
128
|
+
const removed = await run(tool, ['app', 'remove', app.NAME, '--yes']);
|
|
129
|
+
if (removed.code) throw new Error(`orionplace app remove ${app.NAME} failed; see the messages above.`);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
// A service that is already stopped or missing is fine: the goal is that it is gone.
|
|
133
|
+
if (systemd) await run('systemctl', ['disable', '--now', service], { capture: true });
|
|
134
|
+
else {
|
|
135
|
+
await run('rc-service', [service, 'stop'], { capture: true });
|
|
136
|
+
await run('rc-update', ['del', service, 'default'], { capture: true });
|
|
137
|
+
}
|
|
138
|
+
await rm(unitPath, { force: true });
|
|
139
|
+
if (systemd) await run('systemctl', ['daemon-reload'], { capture: true });
|
|
140
|
+
await rm(confPath, { force: true });
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The CLI output of controlService; returns the process exit code.
|
|
145
|
+
export function printService(result, log = console.log) {
|
|
146
|
+
const line = item => {
|
|
147
|
+
const state = item.running ? (item.http ? `running, home page HTTP ${item.http}` : 'running, home page does not answer') : 'stopped';
|
|
148
|
+
return `${item.name.padEnd(16)} ${String(item.framework || '').padEnd(7)} ${String(item.port).padEnd(6)} ${state}`;
|
|
149
|
+
};
|
|
150
|
+
if (result.action === 'list') {
|
|
151
|
+
if (!result.apps.length) {
|
|
152
|
+
log(`No storefront services on this server. Install one from its folder: sudo npx orionplace-${framework.id} install`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
log(`${'NAME'.padEnd(16)} ${'TYPE'.padEnd(7)} ${'PORT'.padEnd(6)} STATE`);
|
|
156
|
+
for (const item of result.apps) log(line(item));
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
if (result.action === 'status') {
|
|
160
|
+
log(`${result.service} (${result.framework || framework.id}) in ${result.dir}`);
|
|
161
|
+
log(` Service: ${result.running ? 'running' : 'stopped'}${result.enabled ? ', starts with the server' : ', does not start with the server'}`);
|
|
162
|
+
log(` Port: 127.0.0.1:${result.port}`);
|
|
163
|
+
log(` Home page: ${result.running ? (result.http ? `HTTP ${result.http}` : 'does not answer') : '-'}`);
|
|
164
|
+
for (const domain of result.domains) log(` Domain: https://${domain}`);
|
|
165
|
+
return result.running && result.http && result.http < 500 ? 0 : 3;
|
|
166
|
+
}
|
|
167
|
+
if (result.action === 'uninstall') {
|
|
168
|
+
if (result.dryRun) {
|
|
169
|
+
log(`Would stop and remove ${result.service}:`);
|
|
170
|
+
log(result.tool ? ` orionplace app remove ${result.name} --yes (also turns its domains into a placeholder)` : ` ${result.unitPath}\n ${result.confPath}`);
|
|
171
|
+
log(`The project stays in ${result.dir}.`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
log(`Removed the service ${result.service}. The project and its build stay in ${result.dir}; delete the folder when you no longer need it.`);
|
|
175
|
+
if (!result.tool && result.domains.length) {
|
|
176
|
+
log(`nginx still sends ${result.domains.join(', ')} to the stopped service; remove those sites from your nginx configuration.`);
|
|
177
|
+
}
|
|
178
|
+
log(`Install it again with: sudo npx orionplace-${framework.id} install`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
if (result.action === 'stop') log(`Stopped ${result.service}.`);
|
|
182
|
+
else if (result.action === 'start' || result.action === 'restart') {
|
|
183
|
+
log(`${result.action === 'start' ? 'Started' : 'Restarted'} ${result.service}: home page HTTP ${result.http} on 127.0.0.1:${result.port}.`);
|
|
184
|
+
}
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
tariffMediaItems,
|
|
21
21
|
tariffPagePath,
|
|
22
22
|
} from '@/utils/orionplace/tariffPage';
|
|
23
|
+
import { tariffResumable } from '@/utils/orionplace/tariffs';
|
|
23
24
|
import {
|
|
24
25
|
getAvailableNestedValueFromObject,
|
|
25
26
|
plainText,
|
|
@@ -74,6 +75,18 @@
|
|
|
74
75
|
const tariff = computed(() =>
|
|
75
76
|
confirmed.value ? catalogStore.getTariff(props.tariffId) : undefined,
|
|
76
77
|
);
|
|
78
|
+
/*
|
|
79
|
+
* A trial chosen before sign-in opens again only once the customer's
|
|
80
|
+
* subscription records are known and the customer is new to this one;
|
|
81
|
+
* the backend refuses a trial to a returning customer.
|
|
82
|
+
*/
|
|
83
|
+
const resumable = computed(() =>
|
|
84
|
+
tariffResumable(
|
|
85
|
+
props.resume,
|
|
86
|
+
tariff.value,
|
|
87
|
+
catalogStore.usedSubscriptionsOf(store.profile?.id),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
77
90
|
// The heading names the plan; `tariff_title` is its SEO page title.
|
|
78
91
|
const title = computed(
|
|
79
92
|
() =>
|
|
@@ -190,6 +203,7 @@
|
|
|
190
203
|
if (!confirmed.value) {
|
|
191
204
|
void load();
|
|
192
205
|
}
|
|
206
|
+
void account.loadUsed();
|
|
193
207
|
});
|
|
194
208
|
|
|
195
209
|
watch(media, () => {
|
|
@@ -201,7 +215,13 @@
|
|
|
201
215
|
}
|
|
202
216
|
});
|
|
203
217
|
watch(
|
|
204
|
-
() =>
|
|
218
|
+
() => store.profile?.id,
|
|
219
|
+
() => {
|
|
220
|
+
void account.loadUsed();
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
watch(
|
|
224
|
+
resumable,
|
|
205
225
|
(value) => {
|
|
206
226
|
if (
|
|
207
227
|
value?.tariff === props.tariffId &&
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, onMounted, ref } from 'vue';
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue';
|
|
3
3
|
import { NuxtLink } from '#components';
|
|
4
4
|
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
5
5
|
import { useOrionplaceCatalogStore } from '@/stores/orionplaceCatalog';
|
|
6
6
|
import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
|
|
7
7
|
import { useOrionplaceSubscriptions } from '@/composables/useOrionplaceSubscriptions';
|
|
8
|
+
import { tariffResumable } from '@/utils/orionplace/tariffs';
|
|
8
9
|
import {
|
|
9
10
|
useOrionplaceFeatureApi,
|
|
10
11
|
useOrionplaceTask,
|
|
@@ -47,7 +48,20 @@
|
|
|
47
48
|
const busy = computed(() =>
|
|
48
49
|
Boolean(task.state.pending || account.task.state.pending),
|
|
49
50
|
);
|
|
50
|
-
|
|
51
|
+
/*
|
|
52
|
+
* The choice to reopen after sign-in. A trial waits for the customer's
|
|
53
|
+
* subscription records and opens only for a customer new to the record;
|
|
54
|
+
* the backend refuses a trial to a returning customer.
|
|
55
|
+
*/
|
|
56
|
+
const open = computed(() =>
|
|
57
|
+
guest.value
|
|
58
|
+
? undefined
|
|
59
|
+
: tariffResumable(
|
|
60
|
+
props.resume,
|
|
61
|
+
props.resume && catalogStore.getTariff(props.resume.tariff),
|
|
62
|
+
catalogStore.usedSubscriptionsOf(store.profile?.id),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
51
65
|
// The plans this page shows: every loaded plan, or only the requested IDs.
|
|
52
66
|
const shown = computed(() => {
|
|
53
67
|
const data = catalogStore.tariffs?.data ?? [];
|
|
@@ -141,7 +155,14 @@
|
|
|
141
155
|
if (!catalogStore.tariffs || catalogStore.tariffsSnapshot) {
|
|
142
156
|
void load();
|
|
143
157
|
}
|
|
158
|
+
void account.loadUsed();
|
|
144
159
|
});
|
|
160
|
+
watch(
|
|
161
|
+
() => store.profile?.id,
|
|
162
|
+
() => {
|
|
163
|
+
void account.loadUsed();
|
|
164
|
+
},
|
|
165
|
+
);
|
|
145
166
|
</script>
|
|
146
167
|
<template>
|
|
147
168
|
<section
|
|
@@ -104,11 +104,13 @@
|
|
|
104
104
|
<script setup lang="ts">
|
|
105
105
|
import { computed, ref, watch } from 'vue';
|
|
106
106
|
import { NuxtLink } from '#components';
|
|
107
|
+
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
107
108
|
import { useOrionplaceCatalogStore } from '@/stores/orionplaceCatalog';
|
|
108
109
|
import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
|
|
109
110
|
import {
|
|
110
111
|
tariffIsOpen,
|
|
111
112
|
tariffSubscribeRequest,
|
|
113
|
+
tariffTrialOpen,
|
|
112
114
|
} from '@/utils/orionplace/tariffs';
|
|
113
115
|
import {
|
|
114
116
|
tariffDays,
|
|
@@ -164,6 +166,7 @@
|
|
|
164
166
|
select: [selection: ITariffSelection];
|
|
165
167
|
subscribe: [request: ITariffSubscribeRequest];
|
|
166
168
|
}>();
|
|
169
|
+
const store = useOrionplaceStore();
|
|
167
170
|
const catalogStore = useOrionplaceCatalogStore();
|
|
168
171
|
const selectionStore = useOrionplaceSelectionsStore();
|
|
169
172
|
const tariff = computed(() => catalogStore.getTariff(props.tariffId));
|
|
@@ -178,15 +181,22 @@
|
|
|
178
181
|
// The option chosen in the card; the dialog opens on it and keeps its own
|
|
179
182
|
// choice from there.
|
|
180
183
|
const optionId = ref<number>();
|
|
184
|
+
// No trial badge for a customer who already had this subscription record.
|
|
181
185
|
const trialBadge = computed(() => {
|
|
182
|
-
const
|
|
186
|
+
const value = tariff.value;
|
|
183
187
|
|
|
184
|
-
if (
|
|
188
|
+
if (
|
|
189
|
+
!value ||
|
|
190
|
+
!tariffTrialOpen(
|
|
191
|
+
value,
|
|
192
|
+
catalogStore.usedSubscriptionsOf(store.profile?.id),
|
|
193
|
+
)
|
|
194
|
+
) {
|
|
185
195
|
return '';
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
return tariffText(tariffLabels(selectionStore.localeCode).trialBadge, {
|
|
189
|
-
days: tariffDays(
|
|
199
|
+
days: tariffDays(value.trial_days, selectionStore.localeCode),
|
|
190
200
|
});
|
|
191
201
|
});
|
|
192
202
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TariffCardPrices · Nuxt
|
|
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 plan that no longer accepts new subscriptions, shows a note instead of actions. The plan and its parent subscription each carry `is_new_subs_allowed` and `only_admin_can_subscribe`, and both have to allow the purchase.
|
|
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 plan that no longer accepts new subscriptions, shows a note instead of actions. The plan and its parent subscription each carry `is_new_subs_allowed` and `only_admin_can_subscribe`, and both have to allow the purchase. 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
|
[English guide](https://orionmarket.place/en/sandbox/components/nuxt/tariff-prices) ·
|
|
6
6
|
[Russian guide](https://orionmarket.place/ru/sandbox/components/nuxt/tariff-prices)
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
</template>
|
|
117
117
|
<script setup lang="ts">
|
|
118
118
|
import { computed, useId } from 'vue';
|
|
119
|
+
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
119
120
|
import { useOrionplaceCatalogStore } from '@/stores/orionplaceCatalog';
|
|
120
121
|
import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
|
|
121
122
|
import { useOrionplaceSignIn } from '@/composables/useOrionplaceSignIn';
|
|
@@ -125,6 +126,7 @@
|
|
|
125
126
|
tariffMoney,
|
|
126
127
|
tariffPaidPeriods,
|
|
127
128
|
tariffReturnPath,
|
|
129
|
+
tariffTrialOpen,
|
|
128
130
|
} from '@/utils/orionplace/tariffs';
|
|
129
131
|
import {
|
|
130
132
|
tariffDays,
|
|
@@ -155,6 +157,7 @@
|
|
|
155
157
|
// The chosen paid period as its `tariff_cost_id`.
|
|
156
158
|
const selectedPeriod = defineModel<number>();
|
|
157
159
|
const emit = defineEmits<{ select: [selection: ITariffSelection] }>();
|
|
160
|
+
const store = useOrionplaceStore();
|
|
158
161
|
const catalogStore = useOrionplaceCatalogStore();
|
|
159
162
|
const selectionStore = useOrionplaceSelectionsStore();
|
|
160
163
|
const signIn = useOrionplaceSignIn();
|
|
@@ -194,11 +197,24 @@
|
|
|
194
197
|
paidPeriods.value.find((item) => item.price === selectedPeriod.value) ??
|
|
195
198
|
paidPeriods.value[0],
|
|
196
199
|
);
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
// A customer who already had this subscription record gets no trial.
|
|
201
|
+
const trialUsed = computed(() => {
|
|
202
|
+
const value = tariff.value;
|
|
199
203
|
|
|
200
|
-
return
|
|
204
|
+
return Boolean(
|
|
205
|
+
value &&
|
|
206
|
+
value.trial_days > 0 &&
|
|
207
|
+
!tariffTrialOpen(
|
|
208
|
+
value,
|
|
209
|
+
catalogStore.usedSubscriptionsOf(store.profile?.id),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
201
212
|
});
|
|
213
|
+
const trialDuration = computed(() =>
|
|
214
|
+
tariff.value && !trialUsed.value && tariff.value.trial_days > 0
|
|
215
|
+
? duration(tariff.value.trial_days)
|
|
216
|
+
: '',
|
|
217
|
+
);
|
|
202
218
|
const actions = computed(() => {
|
|
203
219
|
const list: TariffPeriodAction[] = [];
|
|
204
220
|
|
|
@@ -237,7 +253,11 @@
|
|
|
237
253
|
return copy.value.closed;
|
|
238
254
|
}
|
|
239
255
|
|
|
240
|
-
|
|
256
|
+
if (actions.value.length) {
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return trialUsed.value ? copy.value.trialUsed : copy.value.noPeriods;
|
|
241
261
|
});
|
|
242
262
|
const actionClasses = `
|
|
243
263
|
box-border inline-flex h-11 w-full items-center justify-center px-6
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { onMounted, ref, shallowRef, watch } from 'vue';
|
|
2
2
|
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
3
|
+
import { useOrionplaceCatalogStore } from '@/stores/orionplaceCatalog';
|
|
3
4
|
import {
|
|
4
5
|
useOrionplaceFeatureApi,
|
|
5
6
|
useOrionplaceTask,
|
|
6
7
|
} from '@/composables/useOrionplaceFeature';
|
|
7
8
|
import { useOrionplaceSignIn } from '@/composables/useOrionplaceSignIn';
|
|
9
|
+
import { tariffUsedSubscriptions } from '@/utils/orionplace/tariffs';
|
|
8
10
|
import type {
|
|
9
11
|
ICustomerSubscription,
|
|
10
12
|
ICustomerSubscriptionsResponse,
|
|
@@ -19,6 +21,7 @@ import type { IProfileData } from '@/interfaces/orionplace/accounts/IProfileData
|
|
|
19
21
|
|
|
20
22
|
export function useOrionplaceSubscriptions(loadOnMount = true) {
|
|
21
23
|
const store = useOrionplaceStore();
|
|
24
|
+
const catalogStore = useOrionplaceCatalogStore();
|
|
22
25
|
const api = useOrionplaceFeatureApi();
|
|
23
26
|
const task = useOrionplaceTask();
|
|
24
27
|
const signIn = useOrionplaceSignIn();
|
|
@@ -38,6 +41,7 @@ export function useOrionplaceSubscriptions(loadOnMount = true) {
|
|
|
38
41
|
subscriptions.value = result.data;
|
|
39
42
|
tariffs.value = result.tariffs;
|
|
40
43
|
loaded.value = true;
|
|
44
|
+
rememberUsed(result);
|
|
41
45
|
const profileData = await api.request<{ profile_info: IProfileData[] }>(
|
|
42
46
|
'profile_get',
|
|
43
47
|
{ filters: {} },
|
|
@@ -58,6 +62,44 @@ export function useOrionplaceSubscriptions(loadOnMount = true) {
|
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
function rememberUsed(
|
|
66
|
+
result: NonNullable<ICustomerSubscriptionsResponse['page_data']>,
|
|
67
|
+
) {
|
|
68
|
+
if (store.profile) {
|
|
69
|
+
catalogStore.setUsedSubscriptions(
|
|
70
|
+
store.profile.id,
|
|
71
|
+
tariffUsedSubscriptions(result),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
* The plan pages hide the trial of a subscription record the customer
|
|
78
|
+
* already had (the backend refuses it). They read the records once per
|
|
79
|
+
* profile; a failed read leaves the trial shown, as for an unknown customer.
|
|
80
|
+
*/
|
|
81
|
+
async function loadUsed() {
|
|
82
|
+
const profile = store.profile?.id;
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
!store.isAuthenticated ||
|
|
86
|
+
profile === undefined ||
|
|
87
|
+
catalogStore.usedSubscriptionsOf(profile)
|
|
88
|
+
) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
rememberUsed(
|
|
93
|
+
await api.request<NonNullable<ICustomerSubscriptionsResponse['page_data']>>(
|
|
94
|
+
'subscriptions_get',
|
|
95
|
+
{},
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
} catch {
|
|
99
|
+
catalogStore.setUsedSubscriptions(profile, []);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
61
103
|
/*
|
|
62
104
|
* Each plan the customer buys is its own subscription, so a purchase that
|
|
63
105
|
* already succeeded must not block the next one. Only repeating the same
|
|
@@ -206,6 +248,7 @@ export function useOrionplaceSubscriptions(loadOnMount = true) {
|
|
|
206
248
|
completed,
|
|
207
249
|
needsRefresh,
|
|
208
250
|
load,
|
|
251
|
+
loadUsed,
|
|
209
252
|
subscribe,
|
|
210
253
|
renew,
|
|
211
254
|
setRenewal,
|
|
@@ -17,6 +17,9 @@ import { resolveAppLoadProducts } from '@/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,
|
|
@@ -27,6 +30,7 @@ export function useOrionplaceCatalog(
|
|
|
27
30
|
const catalogSnapshot = shallowRef<NonNullable<IAppLoadCommon['catalog']>>();
|
|
28
31
|
const tariffsSnapshot = shallowRef<NonNullable<IAppLoadCommon['tariffs']>>();
|
|
29
32
|
const storeTariffsSnapshot = shallowRef<NonNullable<IAppLoadCommon['tariffs']>>();
|
|
33
|
+
const usedSubscriptions = shallowRef<{ profile: number; ids: number[] }>();
|
|
30
34
|
const catalog = computed(
|
|
31
35
|
() => catalogSnapshot.value ?? init.value?.catalog ?? [],
|
|
32
36
|
);
|
|
@@ -68,6 +72,19 @@ export function useOrionplaceCatalog(
|
|
|
68
72
|
tariffsSnapshot.value = undefined;
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
/** The records the customer had, see `tariffUsedSubscriptions`. */
|
|
76
|
+
function setUsedSubscriptions(profile: number, ids: number[]) {
|
|
77
|
+
usedSubscriptions.value = { profile, ids };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Those records for this profile; undefined until they are loaded. */
|
|
81
|
+
function usedSubscriptionsOf(profile: number | undefined) {
|
|
82
|
+
return usedSubscriptions.value &&
|
|
83
|
+
usedSubscriptions.value.profile === profile
|
|
84
|
+
? usedSubscriptions.value.ids
|
|
85
|
+
: undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
function getTariff(id: number) {
|
|
72
89
|
return tariffs.value?.data.find((tariff) => tariff.id === id);
|
|
73
90
|
}
|
|
@@ -94,6 +111,7 @@ export function useOrionplaceCatalog(
|
|
|
94
111
|
catalogSnapshot,
|
|
95
112
|
tariffsSnapshot,
|
|
96
113
|
storeTariffsSnapshot,
|
|
114
|
+
usedSubscriptions,
|
|
97
115
|
catalog,
|
|
98
116
|
tariffs,
|
|
99
117
|
storeTariffs,
|
|
@@ -105,6 +123,8 @@ export function useOrionplaceCatalog(
|
|
|
105
123
|
setCatalogTree,
|
|
106
124
|
setTariffs,
|
|
107
125
|
setStoreTariffs,
|
|
126
|
+
setUsedSubscriptions,
|
|
127
|
+
usedSubscriptionsOf,
|
|
108
128
|
getTariff,
|
|
109
129
|
getCatalogCategory,
|
|
110
130
|
getCatalogProducts,
|
|
@@ -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}',
|
|
@@ -57,6 +58,7 @@ const labels = {
|
|
|
57
58
|
adminOnly:
|
|
58
59
|
'Этот тариф подключает администратор магазина. Самостоятельное подключение недоступно.',
|
|
59
60
|
closed: 'Новые подключения к этому тарифу закрыты.',
|
|
61
|
+
trialUsed: 'Пробный период этой подписки вы уже использовали.',
|
|
60
62
|
subscribe: 'Подключить',
|
|
61
63
|
tryTrial: 'Попробовать',
|
|
62
64
|
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;
|
|
@@ -705,7 +705,11 @@ the short description comes from `tariff_short_description` with HTML stripped,
|
|
|
705
705
|
and the full `tariff_description` opens under Details after sanitizing. Both use
|
|
706
706
|
available translation fallback. The image uses `tariff_image.small[0]`; a plan
|
|
707
707
|
without it shows no picture and leaves that row of the card empty. A positive
|
|
708
|
-
`trial_days` adds a trial badge
|
|
708
|
+
`trial_days` adds a trial badge and the Try action, except for a signed-in
|
|
709
|
+
customer who already had the plan's subscription: the backend gives a trial
|
|
710
|
+
once per subscription. The plan pages read the customer's records with
|
|
711
|
+
`subscriptions_get` and keep them in the catalog store
|
|
712
|
+
(`usedSubscriptionsOf(profileId)`).
|
|
709
713
|
|
|
710
714
|
`TariffCardFeatures` lists the flags and numeric limits of
|
|
711
715
|
`tariff_variables_json`: `true` is shown as included, `false` as not included,
|