@cerkel/cli 0.0.0-stage → 1.0.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 +497 -2
- package/dist/api-url.js +42 -0
- package/dist/api.js +48 -0
- package/dist/bin.js +157 -0
- package/dist/chunk.js +31 -0
- package/dist/cli.js +497 -0
- package/dist/client.js +125 -0
- package/dist/command.js +2 -0
- package/dist/commands/args.js +107 -0
- package/dist/commands/auth.js +255 -0
- package/dist/commands/call.js +67 -0
- package/dist/commands/collection.js +121 -0
- package/dist/commands/content.js +281 -0
- package/dist/commands/customer.js +126 -0
- package/dist/commands/dev.js +261 -0
- package/dist/commands/draft.js +101 -0
- package/dist/commands/file.js +53 -0
- package/dist/commands/index.js +43 -0
- package/dist/commands/inventory.js +103 -0
- package/dist/commands/offer.js +92 -0
- package/dist/commands/order-args.js +37 -0
- package/dist/commands/order.js +219 -0
- package/dist/commands/price.js +55 -0
- package/dist/commands/product.js +265 -0
- package/dist/commands/promo.js +140 -0
- package/dist/commands/redirect.js +94 -0
- package/dist/commands/report.js +93 -0
- package/dist/commands/section.js +233 -0
- package/dist/commands/store.js +23 -0
- package/dist/commands/theme-tools.js +432 -0
- package/dist/commands/theme.js +388 -0
- package/dist/commands/whatsapp.js +39 -0
- package/dist/commands/zone.js +90 -0
- package/dist/commerce.js +138 -0
- package/dist/content-files.js +246 -0
- package/dist/context.js +5 -0
- package/dist/credentials.js +201 -0
- package/dist/diff.js +146 -0
- package/dist/env.js +24 -0
- package/dist/errors.js +73 -0
- package/dist/format.js +13 -0
- package/dist/layout.js +122 -0
- package/dist/logins.js +49 -0
- package/dist/raizn-alias.js +6 -0
- package/dist/redact.js +27 -0
- package/dist/session.js +266 -0
- package/dist/state.js +153 -0
- package/dist/sync.js +121 -0
- package/dist/zip.js +88 -0
- package/package.json +37 -3
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { bool, str } from "../command.js";
|
|
2
|
+
import { READ_HELP, READ_OPTIONS, WRITE_HELP, WRITE_OPTIONS, flagsOf, moneyFlag, runRead, runWrite, show } from "../commerce.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { table } from "../format.js";
|
|
5
|
+
const PROMO_OPTIONS = {
|
|
6
|
+
percent: { type: "string" }, amount: { type: "string" }, "free-delivery": { type: "boolean" },
|
|
7
|
+
min: { type: "string" }, starts: { type: "string" }, expires: { type: "string" }, limit: { type: "string" },
|
|
8
|
+
once: { type: "boolean" }, "any-customer": { type: "boolean" }, note: { type: "string" }, active: { type: "boolean" }, inactive: { type: "boolean" },
|
|
9
|
+
};
|
|
10
|
+
const none = (v) => v !== undefined && ["", "none", "null", "-"].includes(v.trim().toLowerCase());
|
|
11
|
+
/** Flags → create/update_promo_code arguments. Amounts go to the server as typed (it parses them in the store's currency). */
|
|
12
|
+
function promoArgs(v, creating) {
|
|
13
|
+
const kinds = [str(v, "percent") !== undefined, str(v, "amount") !== undefined, bool(v, "free-delivery")].filter(Boolean).length;
|
|
14
|
+
if (kinds > 1)
|
|
15
|
+
throw new CliError("Pick one of --percent, --amount or --free-delivery.");
|
|
16
|
+
if (creating && kinds === 0)
|
|
17
|
+
throw new CliError("Say what the code gives: --percent <1-100>, --amount <money> or --free-delivery.");
|
|
18
|
+
if (bool(v, "active") && bool(v, "inactive"))
|
|
19
|
+
throw new CliError("--active or --inactive, not both.");
|
|
20
|
+
if (bool(v, "once") && bool(v, "any-customer"))
|
|
21
|
+
throw new CliError("--once or --any-customer, not both.");
|
|
22
|
+
const out = {};
|
|
23
|
+
if (str(v, "percent") !== undefined) {
|
|
24
|
+
out["type"] = "percent";
|
|
25
|
+
out["value"] = str(v, "percent").trim().replace(/%$/, "");
|
|
26
|
+
}
|
|
27
|
+
if (str(v, "amount") !== undefined) {
|
|
28
|
+
out["type"] = "fixed";
|
|
29
|
+
out["value"] = str(v, "amount").trim();
|
|
30
|
+
}
|
|
31
|
+
if (bool(v, "free-delivery"))
|
|
32
|
+
out["type"] = "free_delivery";
|
|
33
|
+
const min = moneyFlag(str(v, "min"));
|
|
34
|
+
if (min !== undefined)
|
|
35
|
+
out["minSubtotal"] = min;
|
|
36
|
+
for (const [flag, key] of [["starts", "startsOn"], ["expires", "expiresOn"]]) {
|
|
37
|
+
const d = str(v, flag);
|
|
38
|
+
if (d !== undefined)
|
|
39
|
+
out[key] = none(d) ? null : d.trim();
|
|
40
|
+
}
|
|
41
|
+
const limit = str(v, "limit");
|
|
42
|
+
if (limit !== undefined) {
|
|
43
|
+
if (none(limit))
|
|
44
|
+
out["usageLimit"] = null;
|
|
45
|
+
else {
|
|
46
|
+
const n = Number(limit);
|
|
47
|
+
if (!Number.isInteger(n) || n < 1)
|
|
48
|
+
throw new CliError("--limit is a whole number of uses (or none).");
|
|
49
|
+
out["usageLimit"] = n;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (bool(v, "once"))
|
|
53
|
+
out["oncePerCustomer"] = true;
|
|
54
|
+
if (bool(v, "any-customer"))
|
|
55
|
+
out["oncePerCustomer"] = false;
|
|
56
|
+
const note = str(v, "note");
|
|
57
|
+
if (note !== undefined)
|
|
58
|
+
out["note"] = none(note) ? null : note;
|
|
59
|
+
if (bool(v, "active"))
|
|
60
|
+
out["active"] = true;
|
|
61
|
+
if (bool(v, "inactive"))
|
|
62
|
+
out["active"] = false;
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
const valueText = (p) => (p.type === "percent" ? `${p.percent}%` : p.type === "fixed" ? show(p.amountOff) : "free delivery");
|
|
66
|
+
/** `cerkel promo …` — promo codes (list_promo_codes, create / update / delete_promo_code). */
|
|
67
|
+
export const PROMO_COMMANDS = {
|
|
68
|
+
"promo list": {
|
|
69
|
+
usage: "cerkel promo list [--state active|inactive|scheduled|expired|used_up] [--store <slug>] [--json]",
|
|
70
|
+
summary: "List the store's promo codes and what each gives today",
|
|
71
|
+
help: [READ_HELP],
|
|
72
|
+
options: { ...READ_OPTIONS, state: { type: "string" } },
|
|
73
|
+
run: (s, v) => runRead(s, flagsOf(v), "list_promo_codes", str(v, "state") ? { state: str(v, "state") } : {}, (d) => table([["CODE", "GIVES", "MIN", "FROM", "UNTIL", "USED", "STATE"], ...d.promoCodes.map((p) => [
|
|
74
|
+
p.code, valueText(p), show(p.minSubtotal), p.startsOn ?? "—", p.expiresOn ?? "—", `${p.used}${p.usageLimit ? `/${p.usageLimit}` : ""}`, p.state,
|
|
75
|
+
])])),
|
|
76
|
+
},
|
|
77
|
+
"promo create": {
|
|
78
|
+
usage: "cerkel promo create <code> (--percent <n> | --amount <money> | --free-delivery) [--min <money>] [--starts YYYY-MM-DD] [--expires YYYY-MM-DD] [--limit <n>] [--once] [--note <text>] [--inactive] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
79
|
+
summary: "Create a promo code",
|
|
80
|
+
help: ["Amounts in the store's currency: 2.000 (JOD), 5000 (IQD, steps of 250). Days are the shop's local days.", WRITE_HELP],
|
|
81
|
+
options: { ...WRITE_OPTIONS, ...PROMO_OPTIONS },
|
|
82
|
+
positionals: true,
|
|
83
|
+
run: (s, v, pos) => {
|
|
84
|
+
if (pos.length !== 1)
|
|
85
|
+
throw new CliError("Usage: cerkel promo create <code> --percent <n> | --amount <money> | --free-delivery");
|
|
86
|
+
return runWrite(s, flagsOf(v), "create_promo_code", { code: pos[0], ...promoArgs(v, true) });
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
"promo update": {
|
|
90
|
+
usage: "cerkel promo update <code> [--new-code <code>] [--percent <n> | --amount <money> | --free-delivery] [--min <money|none>] [--starts <day|none>] [--expires <day|none>] [--limit <n|none>] [--once | --any-customer] [--note <text|none>] [--active | --inactive] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
91
|
+
summary: "Change a promo code",
|
|
92
|
+
help: [WRITE_HELP],
|
|
93
|
+
options: { ...WRITE_OPTIONS, ...PROMO_OPTIONS, "new-code": { type: "string" } },
|
|
94
|
+
positionals: true,
|
|
95
|
+
run: (s, v, pos) => {
|
|
96
|
+
if (pos.length !== 1)
|
|
97
|
+
throw new CliError("Usage: cerkel promo update <code> [options]");
|
|
98
|
+
const args = { ...promoArgs(v, false), ...(str(v, "new-code") ? { newCode: str(v, "new-code") } : {}) };
|
|
99
|
+
if (!Object.keys(args).length)
|
|
100
|
+
throw new CliError("Nothing to change: pass at least one option (see cerkel promo update --help).");
|
|
101
|
+
return runWrite(s, flagsOf(v), "update_promo_code", { code: pos[0], ...args });
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
"promo activate": {
|
|
105
|
+
usage: "cerkel promo activate <code> [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
106
|
+
summary: "Switch a promo code on",
|
|
107
|
+
help: [WRITE_HELP],
|
|
108
|
+
options: WRITE_OPTIONS,
|
|
109
|
+
positionals: true,
|
|
110
|
+
run: (s, v, pos) => {
|
|
111
|
+
if (pos.length !== 1)
|
|
112
|
+
throw new CliError("Usage: cerkel promo activate <code>");
|
|
113
|
+
return runWrite(s, flagsOf(v), "update_promo_code", { code: pos[0], active: true });
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
"promo deactivate": {
|
|
117
|
+
usage: "cerkel promo deactivate <code> [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
118
|
+
summary: "Switch a promo code off (customers can no longer use it)",
|
|
119
|
+
help: [WRITE_HELP],
|
|
120
|
+
options: WRITE_OPTIONS,
|
|
121
|
+
positionals: true,
|
|
122
|
+
run: (s, v, pos) => {
|
|
123
|
+
if (pos.length !== 1)
|
|
124
|
+
throw new CliError("Usage: cerkel promo deactivate <code>");
|
|
125
|
+
return runWrite(s, flagsOf(v), "update_promo_code", { code: pos[0], active: false });
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
"promo delete": {
|
|
129
|
+
usage: "cerkel promo delete <code> [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
130
|
+
summary: "Delete a promo code",
|
|
131
|
+
help: ["To pause a code instead, use cerkel promo deactivate.", WRITE_HELP],
|
|
132
|
+
options: WRITE_OPTIONS,
|
|
133
|
+
positionals: true,
|
|
134
|
+
run: (s, v, pos) => {
|
|
135
|
+
if (pos.length !== 1)
|
|
136
|
+
throw new CliError("Usage: cerkel promo delete <code>");
|
|
137
|
+
return runWrite(s, flagsOf(v), "delete_promo_code", { code: pos[0] });
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { bool, str } from "../command.js";
|
|
4
|
+
import { READ_HELP, READ_OPTIONS, WRITE_HELP, WRITE_OPTIONS, flagsOf, runRead, runWrite } from "../commerce.js";
|
|
5
|
+
import { CliError } from "../errors.js";
|
|
6
|
+
import { table } from "../format.js";
|
|
7
|
+
const statusOf = (v) => {
|
|
8
|
+
const raw = typeof v["status"] === "string" ? v["status"] : undefined;
|
|
9
|
+
if (bool(v, "temporary"))
|
|
10
|
+
return 302;
|
|
11
|
+
if (raw === undefined)
|
|
12
|
+
return undefined;
|
|
13
|
+
if (raw === "301" || raw === "302")
|
|
14
|
+
return Number(raw);
|
|
15
|
+
throw new CliError("--status is 301 (permanent) or 302 (temporary).");
|
|
16
|
+
};
|
|
17
|
+
/** `cerkel redirect …` — the store's URL redirects (list_redirects, create/update/delete_redirect, import_redirects). */
|
|
18
|
+
export const REDIRECT_COMMANDS = {
|
|
19
|
+
"redirect list": {
|
|
20
|
+
usage: "cerkel redirect list [--search <text>] [--sort path|hits|updated] [--store <slug>] [--json]",
|
|
21
|
+
summary: "List the store's URL redirects with how many visitors each caught",
|
|
22
|
+
help: [READ_HELP],
|
|
23
|
+
options: { ...READ_OPTIONS, search: { type: "string" }, sort: { type: "string" } },
|
|
24
|
+
run: (s, v) => {
|
|
25
|
+
const sort = str(v, "sort");
|
|
26
|
+
if (sort && !["path", "hits", "updated"].includes(sort))
|
|
27
|
+
throw new CliError("--sort is path, hits or updated.");
|
|
28
|
+
return runRead(s, flagsOf(v), "list_redirects", { limit: 200, ...(sort ? { sort } : {}), ...(str(v, "search") ? { q: str(v, "search") } : {}) }, (d) => [
|
|
29
|
+
...table([["FROM", "TO", "STATUS", "HITS", "SOURCE"], ...d.redirects.map((r) => [r.from, r.to, String(r.status), String(r.hits), r.source])]),
|
|
30
|
+
...(d.total > d.redirects.length ? [`… ${d.total - d.redirects.length} more (use --search)`] : []),
|
|
31
|
+
]);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
"redirect add": {
|
|
35
|
+
usage: "cerkel redirect add <from> <to> [--temporary] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
36
|
+
summary: "Send visitors of an old path to a new one (301; --temporary for 302)",
|
|
37
|
+
help: ["<to> is a path (/collections/all) or an https:// URL. Paths are stored lower-case, without a language prefix.", WRITE_HELP],
|
|
38
|
+
options: { ...WRITE_OPTIONS, temporary: { type: "boolean" } },
|
|
39
|
+
positionals: true,
|
|
40
|
+
run: (s, v, pos) => {
|
|
41
|
+
if (pos.length !== 2)
|
|
42
|
+
throw new CliError("Usage: cerkel redirect add <from> <to> [--temporary]");
|
|
43
|
+
const status = statusOf(v);
|
|
44
|
+
return runWrite(s, flagsOf(v), "create_redirect", { from: pos[0], to: pos[1], ...(status ? { status } : {}) });
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"redirect update": {
|
|
48
|
+
usage: "cerkel redirect update <from> [--to <path|url>] [--from <new path>] [--status 301|302] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
49
|
+
summary: "Change a redirect's target, path or status",
|
|
50
|
+
help: [WRITE_HELP],
|
|
51
|
+
options: { ...WRITE_OPTIONS, to: { type: "string" }, from: { type: "string" }, status: { type: "string" } },
|
|
52
|
+
positionals: true,
|
|
53
|
+
run: (s, v, pos) => {
|
|
54
|
+
if (pos.length !== 1)
|
|
55
|
+
throw new CliError("Usage: cerkel redirect update <from> [--to …] [--from …] [--status 301|302]");
|
|
56
|
+
const status = statusOf(v);
|
|
57
|
+
const args = { redirect: pos[0], ...(str(v, "to") ? { to: str(v, "to") } : {}), ...(str(v, "from") ? { from: str(v, "from") } : {}), ...(status ? { status } : {}) };
|
|
58
|
+
if (Object.keys(args).length === 1)
|
|
59
|
+
throw new CliError("Nothing to change: pass --to, --from or --status.");
|
|
60
|
+
return runWrite(s, flagsOf(v), "update_redirect", args);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"redirect delete": {
|
|
64
|
+
usage: "cerkel redirect delete <from> [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
65
|
+
summary: "Delete a redirect",
|
|
66
|
+
help: [WRITE_HELP],
|
|
67
|
+
options: WRITE_OPTIONS,
|
|
68
|
+
positionals: true,
|
|
69
|
+
run: (s, v, pos) => {
|
|
70
|
+
if (pos.length !== 1)
|
|
71
|
+
throw new CliError("Usage: cerkel redirect delete <from>");
|
|
72
|
+
return runWrite(s, flagsOf(v), "delete_redirect", { redirect: pos[0] });
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
"redirect import": {
|
|
76
|
+
usage: "cerkel redirect import <file.csv> [--skip-existing] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
77
|
+
summary: "Add or update many redirects from a CSV file (from,to[,301|302] per line)",
|
|
78
|
+
help: ["Lines that would loop or are otherwise refused are listed by line number; the rest import. --skip-existing leaves paths that already have a rule alone.", WRITE_HELP],
|
|
79
|
+
options: { ...WRITE_OPTIONS, "skip-existing": { type: "boolean" } },
|
|
80
|
+
positionals: true,
|
|
81
|
+
run: async (s, v, pos) => {
|
|
82
|
+
if (pos.length !== 1)
|
|
83
|
+
throw new CliError("Usage: cerkel redirect import <file.csv>");
|
|
84
|
+
let csv;
|
|
85
|
+
try {
|
|
86
|
+
csv = await fs.readFile(path.resolve(s.ctx.cwd, pos[0]), "utf8");
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new CliError(`Cannot read ${pos[0]}.`);
|
|
90
|
+
}
|
|
91
|
+
return runWrite(s, flagsOf(v), "import_redirects", { csv, ...(bool(v, "skip-existing") ? { skip_existing: true } : {}) });
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { bool, str } from "../command.js";
|
|
2
|
+
import { READ_OPTIONS, flagsOf, runRead, show } from "../commerce.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { table } from "../format.js";
|
|
5
|
+
const REPORT_OPTIONS = {
|
|
6
|
+
...READ_OPTIONS,
|
|
7
|
+
since: { type: "string" }, until: { type: "string" }, preset: { type: "string" }, grain: { type: "string" },
|
|
8
|
+
compare: { type: "boolean" }, variants: { type: "boolean" }, "delivered-only": { type: "boolean" }, csv: { type: "boolean" },
|
|
9
|
+
};
|
|
10
|
+
const n = (v) => (typeof v === "number" ? (Number.isInteger(v) ? String(v) : v.toFixed(2)) : show(v));
|
|
11
|
+
const pct = (v) => (typeof v === "number" ? `${(v * 100).toFixed(1)}%` : "—");
|
|
12
|
+
const rows = (v) => (Array.isArray(v) ? v : []);
|
|
13
|
+
/** A short human rendering per report; --json has everything. */
|
|
14
|
+
function render(report, d) {
|
|
15
|
+
const head = [`${report} — ${String(d["range"]?.["since"] ?? d["window"]?.["since"] ?? "")} … ${String(d["range"]?.["until"] ?? d["window"]?.["until"] ?? "")} (${String(d["currency"] ?? "")})`];
|
|
16
|
+
const t = (d["totals"] ?? {});
|
|
17
|
+
switch (report) {
|
|
18
|
+
case "sales":
|
|
19
|
+
return [...head, ...table([["PERIOD", "ORDERS", "REVENUE", "DELIVERED", "DELIVERED REVENUE", "REFUSED"], ...rows(d["buckets"]).map((b) => [String(b["bucket"]), n(b["orders"]), n(b["revenue"]), n(b["delivered"]), n(b["deliveredRevenue"]), n(b["refused"])]),
|
|
20
|
+
["TOTAL", n(t["orders"]), n(t["revenue"]), n(t["delivered"]), n(t["deliveredRevenue"]), n(t["refused"])]])];
|
|
21
|
+
case "products":
|
|
22
|
+
return [...head, ...table([["PRODUCT", "SKU", "UNITS", "REVENUE", "DELIVERED UNITS", "REFUSED UNITS"], ...rows(d["rows"]).map((r) => [`${String(r["title"])}${r["variantTitle"] ? ` — ${String(r["variantTitle"])}` : ""}`, String(r["sku"] ?? "—"), n(r["units"]), n(r["revenue"]), n(r["deliveredUnits"]), n(r["refusedUnits"])])])];
|
|
23
|
+
case "governorates":
|
|
24
|
+
return [...head, ...table([["GOVERNORATE", "ORDERS", "REVENUE", "DELIVERED", "REFUSED"], ...rows(d["rows"]).map((r) => [String(r["name"]), n(r["orders"]), n(r["revenue"]), n(r["delivered"]), n(r["refused"])])])];
|
|
25
|
+
case "funnel":
|
|
26
|
+
return [...head, ...table([["STEP", "ORDERS", "OF CREATED", "OF PREVIOUS"], ...rows(d["steps"]).map((r) => [String(r["step"]), n(r["orders"]), pct(r["ofCreated"]), pct(r["ofPrevious"])])]),
|
|
27
|
+
"", ...table([["GOVERNORATE", "DECIDED", "REFUSED", "REFUSAL RATE"], ...rows(d["byGovernorate"]).map((r) => [String(r["label"]), n(r["decided"]), n(r["refused"]), pct(r["refusalRate"])])])];
|
|
28
|
+
case "couriers":
|
|
29
|
+
return [...head, ...table([["COURIER", "DISPATCHED", "DELIVERED", "REFUSED", "DELIVERY RATE", "AVG DAYS", ...(rows(d["rows"]).some((r) => "cashInTransit" in r) ? ["CASH WITH COURIER"] : [])],
|
|
30
|
+
...rows(d["rows"]).map((r) => [String(r["courier"] ?? "(none)"), n(r["dispatched"]), n(r["delivered"]), n(r["refused"]), pct(r["deliveryRate"]), r["avgDaysToDeliver"] == null ? "—" : n(r["avgDaysToDeliver"]), ...("cashInTransit" in r ? [n(r["cashInTransit"])] : [])])])];
|
|
31
|
+
case "customers":
|
|
32
|
+
return [...head, `Customers ${n(t["customers"])} (new ${n(t["newCustomers"])}, returning ${n(t["returningCustomers"])}), orders ${n(t["orders"])}, revenue ${n(t["revenue"])}`, "",
|
|
33
|
+
...table([["CUSTOMER", "PHONE", "ORDERS", "DELIVERED", "ORDERED", "COLLECTED"], ...rows(d["top"]).map((c) => [String(c["name"] ?? "—"), String(c["phone"]), n(c["orders"]), n(c["delivered"]), n(c["ordered"]), n(c["collected"])])])];
|
|
34
|
+
case "discounts":
|
|
35
|
+
return [...head, ...table([["CODE", "ORDERS", "DELIVERED", "REFUSED", "REVENUE"], ...rows(d["rows"]).map((r) => [String(r["code"]), n(r["orders"]), n(r["delivered"]), n(r["refused"]), n(r["revenue"])])])];
|
|
36
|
+
case "growth": {
|
|
37
|
+
const roas = (t["roas"] ?? {});
|
|
38
|
+
return [...head, `Spend ${n(t["spend"])} · orders ${n(t["orders"])} (${n(t["revenue"])}) · delivered ${n(t["delivered"])} (${n(t["deliveredRevenue"])}) · ROAS ordered ${n(roas["ordered"])}, delivered ${n(roas["delivered"])} · CAC ${n(t["cac"])}`, "",
|
|
39
|
+
...table([["CAMPAIGN", "PROVIDER", "SPEND", "ORDERS", "DELIVERED", "ROAS (DELIVERED)"], ...rows(d["campaigns"]).map((c) => [String(c["campaignName"] ?? c["campaignId"]), String(c["provider"]), n(c["spend"]), n(c["orders"]), n(c["delivered"]), n(c["roas"]?.["delivered"])])])];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function reportArgs(report, v) {
|
|
44
|
+
const out = { report };
|
|
45
|
+
for (const k of ["since", "until", "preset", "grain"])
|
|
46
|
+
if (str(v, k) !== undefined)
|
|
47
|
+
out[k] = str(v, k);
|
|
48
|
+
if (bool(v, "compare"))
|
|
49
|
+
out["compare"] = true;
|
|
50
|
+
if (bool(v, "variants"))
|
|
51
|
+
out["level"] = "variant";
|
|
52
|
+
if (bool(v, "delivered-only"))
|
|
53
|
+
out["deliveredOnly"] = true;
|
|
54
|
+
if (bool(v, "csv"))
|
|
55
|
+
out["format"] = "csv";
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
const reportCommand = (report, summary) => ({
|
|
59
|
+
usage: `cerkel report ${report} [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--preset last7|last30|this_month|…]${report === "sales" || report === "customers" ? " [--grain day|week|month] [--compare]" : ""}${report === "products" ? " [--variants] [--delivered-only]" : ""} [--csv] [--store <slug>] [--json]`,
|
|
60
|
+
summary,
|
|
61
|
+
help: [
|
|
62
|
+
"Days are the shop's days (Asia/Amman). Default: the last 30 days. Money is the value stamped on each order when it was placed.",
|
|
63
|
+
"--csv prints the same CSV the admin downloads (needs the report download permission). --json prints everything.",
|
|
64
|
+
],
|
|
65
|
+
options: REPORT_OPTIONS,
|
|
66
|
+
run: async (s, v) => {
|
|
67
|
+
const flags = flagsOf(v);
|
|
68
|
+
if (bool(v, "csv") && flags.json)
|
|
69
|
+
throw new CliError("--csv or --json, not both.");
|
|
70
|
+
if (bool(v, "csv"))
|
|
71
|
+
return runRead(s, flags, "run_report", reportArgs(report, v), (d) => [d.csv.replace(/\r\n$/, "")]);
|
|
72
|
+
return runRead(s, flags, "run_report", reportArgs(report, v), (d) => render(report, d));
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
export const REPORT_COMMANDS = {
|
|
76
|
+
"report sales": reportCommand("sales", "Sales: ordered, delivered and refused revenue per day, week or month"),
|
|
77
|
+
"report products": reportCommand("products", "Units and revenue per product (or variant)"),
|
|
78
|
+
"report governorates": reportCommand("governorates", "Orders, delivery and refusals per governorate"),
|
|
79
|
+
"report funnel": reportCommand("funnel", "The order funnel and refusals by governorate, product and channel"),
|
|
80
|
+
"report couriers": reportCommand("couriers", "Delivery and refusal rates per courier (cash with couriers with finance access)"),
|
|
81
|
+
"report customers": reportCommand("customers", "New vs returning customers, repeat rate and top customers"),
|
|
82
|
+
"report discounts": reportCommand("discounts", "Orders and revenue per promo code"),
|
|
83
|
+
"report growth": reportCommand("growth", "Ad spend, ROAS and cost per new customer (finance access)"),
|
|
84
|
+
"report live": {
|
|
85
|
+
usage: "cerkel report live [--store <slug>] [--json]",
|
|
86
|
+
summary: "Who is on the store's website right now",
|
|
87
|
+
help: ["Counted only when the store's website runs on Cerkel."],
|
|
88
|
+
options: READ_OPTIONS,
|
|
89
|
+
run: (s, v) => runRead(s, flagsOf(v), "get_live_visitors", {}, (d) => d["tracked"] === false
|
|
90
|
+
? [String(d["reason"] ?? "Visits are not counted for this store.")]
|
|
91
|
+
: [`${n(d["count"])} visitor(s) in the last ${n(d["windowMinutes"])} minutes`, "", ...table([["PAGE", "VISITORS"], ...rows(d["pages"]).map((p) => [String(p["path"]), n(p["visitors"])])])]),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { deleteThemeFiles, getThemeFiles, listSectionStarters, listThemeFileVersions, nameOf, restoreThemeFileVersion, } from "../api.js";
|
|
4
|
+
import { CliError } from "../errors.js";
|
|
5
|
+
import { table } from "../format.js";
|
|
6
|
+
import { SECTION_TYPE_RE, bytesOf, hashBytes, isDeletablePath, isGeneratedPath, isLayoutPath, sectionPath, textOf, toDiskPath, toLayoutPath } from "../layout.js";
|
|
7
|
+
import { readLocal, writeState } from "../state.js";
|
|
8
|
+
/**
|
|
9
|
+
* Sections are theme files (docs/liquid-themes.md): ONE `sections/<type>.liquid` per type, holding the
|
|
10
|
+
* Liquid, its `{% stylesheet %}` and its `{% schema %}`, belonging to the checkout's draft theme. These
|
|
11
|
+
* commands are shortcuts over that file; `cerkel theme history|restore` do the same for any file.
|
|
12
|
+
*/
|
|
13
|
+
function checkType(type, what = "Which section? Pass its type, e.g. `cerkel section new promo-strip`.") {
|
|
14
|
+
if (!type)
|
|
15
|
+
throw new CliError(what);
|
|
16
|
+
if (!SECTION_TYPE_RE.test(type)) {
|
|
17
|
+
throw new CliError(`"${type}" is not a valid section type: use 2–80 lowercase letters, digits, "-" or "_", starting with a letter or digit.`);
|
|
18
|
+
}
|
|
19
|
+
return type;
|
|
20
|
+
}
|
|
21
|
+
/** The starter as ONE section file. Older servers sent only the parts; assemble them the same way the server does. */
|
|
22
|
+
export function starterFile(st) {
|
|
23
|
+
if (typeof st.content === "string" && st.content.length > 0)
|
|
24
|
+
return st.content;
|
|
25
|
+
const schema = typeof st.schema === "string" ? st.schema : JSON.stringify({ ...st.schema, name: st.name, ...(st.group ? { group: st.group } : {}) }, null, 2);
|
|
26
|
+
const body = (st.template ?? "").replace(/\s+$/, "");
|
|
27
|
+
const css = typeof st.css === "string" && st.css.trim() ? `\n\n{% stylesheet %}\n${st.css.replace(/\s+$/, "")}\n{% endstylesheet %}` : "";
|
|
28
|
+
return `${body}${css}\n\n{% schema %}\n${schema}\n{% endschema %}\n`;
|
|
29
|
+
}
|
|
30
|
+
async function writeNew(s, root, p, content) {
|
|
31
|
+
const disk = toDiskPath(root, p);
|
|
32
|
+
await fs.mkdir(path.dirname(disk), { recursive: true });
|
|
33
|
+
await fs.writeFile(disk, bytesOf(content), { flag: "wx" });
|
|
34
|
+
s.out(` created ${p}`);
|
|
35
|
+
}
|
|
36
|
+
// --- new ---------------------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* Local only. The section reaches the server with the next push or dev, like any other edit —
|
|
39
|
+
* so it gets validated, conflict-checked and audited on the same path as everything else.
|
|
40
|
+
*/
|
|
41
|
+
export async function sectionNew(s, rawType, opts) {
|
|
42
|
+
const type = checkType(rawType);
|
|
43
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
44
|
+
const p = sectionPath(type);
|
|
45
|
+
if (state.files[p] || (await readLocal(root, p)))
|
|
46
|
+
throw new CliError(`Section "${type}" already exists (${p}). Pick another type, or edit that file.`);
|
|
47
|
+
const { starters } = await listSectionStarters(await s.client(state));
|
|
48
|
+
if (!starters || starters.length === 0)
|
|
49
|
+
throw new CliError("The server offered no section starters.");
|
|
50
|
+
let starter;
|
|
51
|
+
if (opts.starter) {
|
|
52
|
+
starter = starters.find((x) => x.key === opts.starter);
|
|
53
|
+
if (!starter)
|
|
54
|
+
throw new CliError(`No starter "${opts.starter}". Available: ${starters.map((x) => x.key).join(", ")}.`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const fallback = starters.find((x) => x.key === "blank") ?? starters[0];
|
|
58
|
+
if (s.ctx.io.isTTY) {
|
|
59
|
+
s.err("Starters:");
|
|
60
|
+
for (const line of table(starters.map((x, i) => [` ${i + 1}.`, x.key, nameOf(x.name), x.group ?? ""])))
|
|
61
|
+
s.err(line);
|
|
62
|
+
const answer = (await s.ctx.io.prompt(`Start from which? [${fallback.key}] `)).trim();
|
|
63
|
+
if (!answer)
|
|
64
|
+
starter = fallback;
|
|
65
|
+
else {
|
|
66
|
+
const n = Number(answer);
|
|
67
|
+
starter = Number.isInteger(n) && n >= 1 ? starters[n - 1] : starters.find((x) => x.key === answer);
|
|
68
|
+
if (!starter)
|
|
69
|
+
throw new CliError(`No starter "${answer}".`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
starter = fallback;
|
|
74
|
+
s.err(`Using the "${starter.key}" starter (pass --starter <key> to choose).`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
await writeNew(s, root, p, starterFile(starter));
|
|
78
|
+
s.out(`Section "${type}" created locally from "${starter.key}". Set its "name" in the {% schema %} of ${p}, then \`cerkel theme push\` (or keep \`cerkel theme dev\` running) to add it to "${state.themeName}".`);
|
|
79
|
+
s.out("It belongs to this draft theme: it goes live when the draft is approved and published.");
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
// --- duplicate ---------------------------------------------------------------
|
|
83
|
+
/** Marks the copy's display name so "Add section" does not show two identical entries. */
|
|
84
|
+
export function renameCopy(content) {
|
|
85
|
+
const m = /(\{%-?\s*schema\s*-?%\})([\s\S]*?)(\{%-?\s*endschema\s*-?%\})/.exec(content);
|
|
86
|
+
if (!m)
|
|
87
|
+
return content;
|
|
88
|
+
let schema;
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(m[2]);
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
92
|
+
return content;
|
|
93
|
+
schema = parsed;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return content; // the server will say what is wrong with it on push
|
|
97
|
+
}
|
|
98
|
+
const suffix = { ar: " (نسخة)", en: " (copy)", ckb: " (کۆپی)" };
|
|
99
|
+
const name = schema["name"];
|
|
100
|
+
if (typeof name === "string")
|
|
101
|
+
schema["name"] = `${name} (copy)`;
|
|
102
|
+
else if (name && typeof name === "object") {
|
|
103
|
+
schema["name"] = Object.fromEntries(Object.entries(name).map(([k, v]) => [k, typeof v === "string" ? `${v}${suffix[k] ?? " (copy)"}` : v]));
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
return content;
|
|
107
|
+
return content.slice(0, m.index) + `${m[1]}\n${JSON.stringify(schema, null, 2)}\n${m[3]}` + content.slice(m.index + m[0].length);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `cerkel section duplicate <from> <to>`: a new section type that starts as a copy of another — the way
|
|
111
|
+
* to redesign a section without touching the one the templates place today. Local only, like `new`.
|
|
112
|
+
*/
|
|
113
|
+
export async function sectionDuplicate(s, rawFrom, rawTo) {
|
|
114
|
+
const from = checkType(rawFrom, "Which section to copy? e.g. `cerkel section duplicate promo-strip promo-strip-v2`.");
|
|
115
|
+
const to = checkType(rawTo, "What is the new section's type? e.g. `cerkel section duplicate promo-strip promo-strip-v2`.");
|
|
116
|
+
if (from === to)
|
|
117
|
+
throw new CliError("The copy needs a different type than the section it copies.");
|
|
118
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
119
|
+
const src = await readLocal(root, sectionPath(from));
|
|
120
|
+
if (!src) {
|
|
121
|
+
throw new CliError(`There is no ${sectionPath(from)} in this checkout. Only sections that are files of this theme can be copied; built-in sections are not files (yet).`);
|
|
122
|
+
}
|
|
123
|
+
const dst = sectionPath(to);
|
|
124
|
+
if (state.files[dst] || (await readLocal(root, dst)))
|
|
125
|
+
throw new CliError(`Section "${to}" already exists (${dst}). Pick another type.`);
|
|
126
|
+
await writeNew(s, root, dst, renameCopy(textOf(src)));
|
|
127
|
+
s.out(`Copied "${from}" to "${to}". Edit ${dst}, push it, then place it with the visual editor (or in a template) instead of "${from}".`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
// --- delete ------------------------------------------------------------------
|
|
131
|
+
/** Delete files from the checkout's draft on the server, then locally. */
|
|
132
|
+
export async function deleteFiles(s, paths) {
|
|
133
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
134
|
+
for (const p of paths) {
|
|
135
|
+
if (isGeneratedPath(p))
|
|
136
|
+
throw new CliError(`${p} is generated by Cerkel and cannot be deleted.`);
|
|
137
|
+
if (!isDeletablePath(p))
|
|
138
|
+
throw new CliError(`${p} cannot be deleted: only sections/, blocks/, snippets/ and assets/ files can (templates, layout, config/settings.json, assets/custom.css and locales always exist).`);
|
|
139
|
+
}
|
|
140
|
+
const r = await deleteThemeFiles(await s.client(state), state.store, state.themeId, paths.map((p) => ({ path: p, baseTag: state.files[p]?.tag ?? null })));
|
|
141
|
+
let failed = 0;
|
|
142
|
+
for (const res of r.results ?? []) {
|
|
143
|
+
if (res.status === "deleted" || res.status === "unchanged") {
|
|
144
|
+
await fs.rm(toDiskPath(root, res.path), { force: true });
|
|
145
|
+
delete state.files[res.path];
|
|
146
|
+
s.out(` ${res.status === "deleted" ? "deleted" : "gone "} ${res.path}${res.status === "unchanged" ? " (the draft did not have it)" : ""}`);
|
|
147
|
+
}
|
|
148
|
+
else if (res.status === "in_use") {
|
|
149
|
+
failed++;
|
|
150
|
+
s.out(` in use ${res.path} — still placed by ${(res.usedIn ?? []).join(", ") || "a template"}. Remove it from there (and push) first.`);
|
|
151
|
+
}
|
|
152
|
+
else if (res.status === "conflict") {
|
|
153
|
+
failed++;
|
|
154
|
+
s.out(` conflict ${res.path} — changed on the server since you last synced it. Pull first, then delete.`);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
failed++;
|
|
158
|
+
s.out(` refused ${res.path}${res.message ? ` — ${res.message}` : ""}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await writeState(root, state);
|
|
162
|
+
if (failed === 0)
|
|
163
|
+
s.out("Deleted files stay in their history: `cerkel theme history <file>` and `cerkel theme restore <file> <version>` bring them back.");
|
|
164
|
+
return failed > 0 ? 1 : 0;
|
|
165
|
+
}
|
|
166
|
+
export async function sectionDelete(s, rawType) {
|
|
167
|
+
return deleteFiles(s, [sectionPath(checkType(rawType))]);
|
|
168
|
+
}
|
|
169
|
+
// --- history / restore --------------------------------------------------------
|
|
170
|
+
/** A command-line file argument → layout path (relative to the cwd, or already a layout path). */
|
|
171
|
+
export function fileArg(s, root, raw) {
|
|
172
|
+
if (!raw)
|
|
173
|
+
throw new CliError("Which file? e.g. `sections/promo-strip.liquid`.");
|
|
174
|
+
const viaDisk = toLayoutPath(root, path.resolve(s.ctx.cwd, raw));
|
|
175
|
+
const p = viaDisk && isLayoutPath(viaDisk) ? viaDisk : raw.split(path.sep).join("/");
|
|
176
|
+
if (!isLayoutPath(p))
|
|
177
|
+
throw new CliError(`${raw} is not a theme file path.`);
|
|
178
|
+
return p;
|
|
179
|
+
}
|
|
180
|
+
export async function fileHistory(s, p, opts) {
|
|
181
|
+
const { state } = await s.requireCheckout({ editable: false });
|
|
182
|
+
const r = await listThemeFileVersions(await s.client(state), state.store, p, opts.limit);
|
|
183
|
+
if (r.versions.length === 0) {
|
|
184
|
+
s.out(`${p} has no saved versions in ${state.store}.`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
s.out(`${p} — newest first, across every theme of ${state.store}:`);
|
|
188
|
+
const rows = r.versions.map((v) => [
|
|
189
|
+
`v${v.version}`,
|
|
190
|
+
v.op + (v.restoredFrom ? ` (from v${v.restoredFrom})` : ""),
|
|
191
|
+
v.theme ?? "—",
|
|
192
|
+
v.createdAt,
|
|
193
|
+
v.author ?? "—",
|
|
194
|
+
v.tag && state.files[p]?.tag === v.tag ? "← your checkout" : "",
|
|
195
|
+
]);
|
|
196
|
+
for (const line of table([["VERSION", "WHAT", "THEME", "WHEN", "WHO", ""], ...rows]))
|
|
197
|
+
s.out(line);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
export async function fileRestore(s, p, rawVersion, opts) {
|
|
201
|
+
const version = Number(rawVersion?.replace(/^v/i, ""));
|
|
202
|
+
if (!rawVersion || !Number.isInteger(version) || version < 1)
|
|
203
|
+
throw new CliError(`Which version? e.g. \`cerkel theme restore ${p} 3\` (see \`cerkel theme history ${p}\`).`);
|
|
204
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
205
|
+
// Restoring rewrites the local file; do not silently discard edits made to it.
|
|
206
|
+
if (!opts.force) {
|
|
207
|
+
const bytes = await readLocal(root, p);
|
|
208
|
+
const known = state.files[p];
|
|
209
|
+
if (bytes && (!known || known.hash !== hashBytes(bytes))) {
|
|
210
|
+
throw new CliError(`${p} has local changes that restoring would overwrite. Push or discard them, or re-run with --force.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const client = await s.client(state);
|
|
214
|
+
const r = await restoreThemeFileVersion(client, state.store, state.themeId, p, version);
|
|
215
|
+
s.out(`Restored ${p} from v${r.restoredFrom} into "${state.themeName}" as a new version (history is kept).`);
|
|
216
|
+
const all = await getThemeFiles(client, state.store, state.themeId);
|
|
217
|
+
const fresh = all.files.find((f) => f.path === p);
|
|
218
|
+
const disk = toDiskPath(root, p);
|
|
219
|
+
if (fresh) {
|
|
220
|
+
await fs.mkdir(path.dirname(disk), { recursive: true });
|
|
221
|
+
await fs.writeFile(disk, bytesOf(fresh.content));
|
|
222
|
+
state.files[p] = { tag: fresh.tag, hash: hashBytes(bytesOf(fresh.content)) };
|
|
223
|
+
s.out(` updated ${p}`);
|
|
224
|
+
}
|
|
225
|
+
await writeState(root, state);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
export async function sectionHistory(s, rawType, opts) {
|
|
229
|
+
return fileHistory(s, sectionPath(checkType(rawType)), opts);
|
|
230
|
+
}
|
|
231
|
+
export async function sectionRestore(s, rawType, rawVersion, opts) {
|
|
232
|
+
return fileRestore(s, sectionPath(checkType(rawType)), rawVersion, opts);
|
|
233
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {} from "../command.js";
|
|
2
|
+
import { READ_HELP, READ_OPTIONS, flagsOf, runRead, show } from "../commerce.js";
|
|
3
|
+
import { table } from "../format.js";
|
|
4
|
+
/** `cerkel store info` — get_shop_info. */
|
|
5
|
+
export const STORE_COMMANDS = {
|
|
6
|
+
"store info": {
|
|
7
|
+
usage: "cerkel store info [--store <slug>] [--json]",
|
|
8
|
+
summary: "Show the store's currency, languages, domains, cash-on-delivery policy and order numbering",
|
|
9
|
+
help: ["Every amount the other commands take is in this currency (IQD prices in steps of 250).", READ_HELP],
|
|
10
|
+
options: READ_OPTIONS,
|
|
11
|
+
run: (s, v) => runRead(s, flagsOf(v), "get_shop_info", {}, (d) => table([
|
|
12
|
+
["Store", `${d.store.name} (${d.store.slug})`],
|
|
13
|
+
["Status", d.store.status],
|
|
14
|
+
["Currency", `${d.currency.code} — ${d.currency.decimals} decimals${d.currency.cashStep > 1 ? `, prices in steps of ${d.currency.cashStep}` : ""}`],
|
|
15
|
+
["Languages", d.locales.join(", ") + ` (default ${d.defaultLocale})`],
|
|
16
|
+
["Domain", d.domains.primary ?? "—"],
|
|
17
|
+
["Next order", `${d.orderNumbering.next}${d.orderNumbering.continuesShopify ? " (continues Shopify's numbering)" : ""}`],
|
|
18
|
+
["Delivery", `${d.deliveryEtaDays} days`],
|
|
19
|
+
["COD caps", `new customers ${show(d.cashOnDelivery["newCustomerCap"])}, high risk ${show(d.cashOnDelivery["highRiskCap"])} (${String(d.cashOnDelivery["highRiskAction"])})`],
|
|
20
|
+
["You may", d.you.permissions.join(", ") || "—"],
|
|
21
|
+
])),
|
|
22
|
+
},
|
|
23
|
+
};
|