vkusvill 0.0.0-dev.0 → 0.0.0-dev.1
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 +41 -10
- package/README.ru.md +41 -10
- package/package.json +16 -6
- package/src/api.js +102 -26
- package/src/app.js +75 -7
- package/src/auth.js +457 -0
- package/src/cli.js +121 -6
- package/src/main.js +26 -8
- package/src/paths.js +20 -0
- package/src/product.js +5 -3
- package/src/secrets.js +133 -0
- package/src/store.js +1 -2
- package/src/sys.js +13 -1
- package/src/ui/product-ui.js +13 -1
- package/src/ui/widgets.js +101 -0
- package/src/views/browser.js +1 -1
- package/src/views/cart.js +37 -15
- package/src/views/catalog.js +96 -46
- package/src/views/overlays.js +9 -4
- package/src/views/product.js +8 -5
- package/src/views/profile.js +317 -34
- package/src/views/recipes.js +74 -35
- package/src/views/shops.js +66 -39
package/src/main.js
CHANGED
|
@@ -29,6 +29,12 @@ export const VIEWS = {
|
|
|
29
29
|
app.switchTab("catalog");
|
|
30
30
|
app.stacks.catalog[0].search(q);
|
|
31
31
|
},
|
|
32
|
+
signIn: (app) => {
|
|
33
|
+
app.stacks.profile.length = 1;
|
|
34
|
+
app.switchTab("profile");
|
|
35
|
+
app.page.openSignIn();
|
|
36
|
+
},
|
|
37
|
+
signOut: (app) => app.stacks.profile[0].signOut(),
|
|
32
38
|
searchRecipes: (app, q) => {
|
|
33
39
|
app.stacks.recipes.length = 1;
|
|
34
40
|
app.switchTab("recipes");
|
|
@@ -40,14 +46,14 @@ export const VIEWS = {
|
|
|
40
46
|
};
|
|
41
47
|
|
|
42
48
|
const HELP = `
|
|
43
|
-
ВкусВилл в терминале · vkusvill
|
|
49
|
+
ВкусВилл в терминале · vkusvill ${"$VERSION"}
|
|
44
50
|
|
|
45
|
-
npx vkusvill
|
|
46
|
-
npx vkusvill
|
|
51
|
+
npx vkusvill полноэкранный интерфейс
|
|
52
|
+
npx vkusvill <запрос> открыть интерфейс сразу с поиском
|
|
47
53
|
|
|
48
54
|
Команды с обычным выводом (для скриптов, --json — сырые данные):
|
|
49
55
|
vv search <запрос> поиск товаров [--sort price_asc|price_desc|rating|new] [--page N]
|
|
50
|
-
vv deals скидки по карте [--quantity — «от N штук»]
|
|
56
|
+
vv deals скидки по карте [--quantity — «от N штук»] [--personal — «6 скидок для меня»]
|
|
51
57
|
vv product <id> карточка товара
|
|
52
58
|
vv barcode <EAN-13> товар по штрихкоду
|
|
53
59
|
vv recipes <запрос> рецепты
|
|
@@ -57,12 +63,20 @@ const HELP = `
|
|
|
57
63
|
vv cart link ссылка на корзину ВкусВилла и QR-код
|
|
58
64
|
vv cart clear очистить корзину
|
|
59
65
|
|
|
66
|
+
Вход (заказы, любимый продукт, 6 скидок, персональная выдача):
|
|
67
|
+
vv login войти; client_id — из письма ВкусВилла после регистрации клиента
|
|
68
|
+
[--client-id ID] [--client-secret S] [--redirect URI] [--no-browser]
|
|
69
|
+
[--scope all] — заново запросить все права (если отметили новые на сайте)
|
|
70
|
+
vv auth статус входа и права
|
|
71
|
+
vv orders заказы за 30 дней [--page N]
|
|
72
|
+
vv logout выйти и отозвать токены [--forget — стереть и клиента с секретом]
|
|
73
|
+
|
|
60
74
|
Флаги: --json --no-splash --version --help
|
|
61
|
-
Окружение: VV_TOKEN (
|
|
75
|
+
Окружение: VV_TOKEN (готовый токен вместо входа), VV_COLOR=256|truecolor, NO_COLOR
|
|
62
76
|
`;
|
|
63
77
|
|
|
64
|
-
const VALUE_FLAGS = new Set(["--sort", "--page"]);
|
|
65
|
-
const BOOL_FLAGS = new Set(["--json", "--quantity", "--no-splash", "--version", "--help"]);
|
|
78
|
+
const VALUE_FLAGS = new Set(["--sort", "--page", "--client-id", "--client-secret", "--redirect", "--scope"]);
|
|
79
|
+
const BOOL_FLAGS = new Set(["--json", "--quantity", "--personal", "--no-splash", "--version", "--help", "--no-browser", "--forget"]);
|
|
66
80
|
const SORTS = ["popularity", "rating", "price_asc", "price_desc", "new"];
|
|
67
81
|
|
|
68
82
|
// Splits argv into positional words, boolean flags and flag values (`--page 2` or `--page=2`)
|
|
@@ -94,12 +108,16 @@ export async function main(argv) {
|
|
|
94
108
|
const cmd = args[0];
|
|
95
109
|
const commands = {
|
|
96
110
|
search: () => cli.search(args.slice(1).join(" "), { json, sort: values.sort, page: +(values.page ?? 1) }),
|
|
97
|
-
deals: () => cli.deals({ json, type: flags.has("--quantity") ? "quantity" : "card" }),
|
|
111
|
+
deals: () => cli.deals({ json, type: flags.has("--quantity") ? "quantity" : "card", personal: flags.has("--personal") }),
|
|
112
|
+
orders: () => cli.orders({ json, page: +(values.page ?? 1) }),
|
|
98
113
|
product: () => cli.product(args[1], { json }),
|
|
99
114
|
barcode: () => cli.barcode(args[1], { json }),
|
|
100
115
|
recipes: () => cli.recipes(args.slice(1).join(" "), { json }),
|
|
101
116
|
shops: () => cli.shops(args.slice(1).join(" "), { json }),
|
|
102
117
|
cart: () => cli.cart(args.slice(1), { json }),
|
|
118
|
+
login: () => cli.login({ clientId: values["client-id"], clientSecret: values["client-secret"], redirect: values.redirect, scope: values.scope, noBrowser: flags.has("--no-browser") }),
|
|
119
|
+
logout: () => cli.logout({ forget: flags.has("--forget") }),
|
|
120
|
+
auth: () => cli.authStatus({ json }),
|
|
103
121
|
};
|
|
104
122
|
if (commands[cmd]) return commands[cmd]();
|
|
105
123
|
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Where vkusvill keeps its files. Config (state, sign-in) is private to the user; the cache is disposable.
|
|
2
|
+
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
function configBase() {
|
|
7
|
+
if (process.env.XDG_CONFIG_HOME) return process.env.XDG_CONFIG_HOME;
|
|
8
|
+
if (process.platform === "win32" && process.env.APPDATA) return process.env.APPDATA;
|
|
9
|
+
return path.join(homedir(), ".config");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function cacheBase() {
|
|
13
|
+
if (process.env.XDG_CACHE_HOME) return process.env.XDG_CACHE_HOME;
|
|
14
|
+
if (process.platform === "darwin") return path.join(homedir(), "Library", "Caches");
|
|
15
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA;
|
|
16
|
+
return path.join(homedir(), ".cache");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CONFIG_DIR = process.env.VV_CONFIG_DIR ?? path.join(configBase(), "vv-cli");
|
|
20
|
+
export const CACHE_DIR = process.env.VV_CACHE_DIR ?? path.join(cacheBase(), "vv-cli");
|
package/src/product.js
CHANGED
|
@@ -62,7 +62,8 @@ export function normalize(item) {
|
|
|
62
62
|
const { title, pack, whole } = splitName(name);
|
|
63
63
|
return {
|
|
64
64
|
__vv: true,
|
|
65
|
-
|
|
65
|
+
// The favourite-product tool sends ids as numeric strings
|
|
66
|
+
id: typeof item.id === "string" && /^\d+$/.test(item.id) ? Number(item.id) : item.id,
|
|
66
67
|
name,
|
|
67
68
|
title,
|
|
68
69
|
pack: pack || (grams && whole && !/\d\s*(г|кг|мл|л)(?![а-яё])/iu.test(name) ? (grams >= 1000 ? `${+(grams / 1000).toFixed(2)} кг` : `${grams} г`) : ""),
|
|
@@ -79,8 +80,9 @@ export function normalize(item) {
|
|
|
79
80
|
per100: grams ? Math.round((price / grams) * 1000) / 10 : null,
|
|
80
81
|
rating: item.rating?.average ? Math.round(item.rating.average * 10) / 10 : 0,
|
|
81
82
|
votes: item.rating?.count ?? 0,
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
// Most tools send `images` with sizes; the favourite-product tool sends a single `img`
|
|
84
|
+
images: item.images?.length ? item.images.map((i) => i.medium ?? i.small ?? i.large).filter(Boolean) : typeof item.img === "string" ? [item.img] : [],
|
|
85
|
+
thumb: item.images?.[0]?.small ?? item.images?.[0]?.medium ?? (typeof item.img === "string" ? item.img : null),
|
|
84
86
|
category: (item.category ?? []).map((c) => c.name).reverse(),
|
|
85
87
|
props,
|
|
86
88
|
comp,
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Where the client secret and the tokens live: the OS credential store when there is one, a private
|
|
2
|
+
// file otherwise — the same trade-off as gh or docker, never less safe than npm's ~/.npmrc.
|
|
3
|
+
//
|
|
4
|
+
// - macOS: the login Keychain through `security -i`. The commands go to its stdin and the secret is
|
|
5
|
+
// passed hex-encoded (-X), so it never appears in process arguments (visible to others in `ps`).
|
|
6
|
+
// - Windows: a file encrypted with DPAPI for the current Windows user (the mechanism behind the
|
|
7
|
+
// Credential Manager), through PowerShell; data on stdin, no native modules.
|
|
8
|
+
// - Linux desktop: the Secret Service (GNOME Keyring, KWallet) through `secret-tool`, secret on stdin.
|
|
9
|
+
// Only tried with a D-Bus session: over SSH, in containers or WSL it would only stall.
|
|
10
|
+
// - Anything else, or a store that refuses: auth.json, owner-only (0600; %APPDATA% on Windows).
|
|
11
|
+
// VV_SECRETS=file|keychain|dpapi|secret-service forces a backend.
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { CONFIG_DIR } from "./paths.js";
|
|
17
|
+
|
|
18
|
+
const SERVICE = process.env.VV_KEYCHAIN_SERVICE ?? "vkusvill-cli";
|
|
19
|
+
const ACCOUNT = "auth";
|
|
20
|
+
const LABEL = "ВкусВилл в терминале";
|
|
21
|
+
|
|
22
|
+
const run = (cmd, args, input, timeout = 10e3) => {
|
|
23
|
+
try {
|
|
24
|
+
return spawnSync(cmd, args, { input, encoding: "utf8", timeout, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
25
|
+
} catch {
|
|
26
|
+
return { status: -1, stdout: "", stderr: "", error: true };
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// PowerShell one-liners for DPAPI; base64 in and out keeps code pages out of the way
|
|
31
|
+
const PS = ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"];
|
|
32
|
+
const dpapi = (op) => `Add-Type -AssemblyName System.Security; $d = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); `
|
|
33
|
+
+ `$r = [Security.Cryptography.ProtectedData]::${op}($d, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); `
|
|
34
|
+
+ `[Console]::Out.Write([Convert]::ToBase64String($r))`;
|
|
35
|
+
const DPAPI_FILE = path.join(CONFIG_DIR, "secrets.dpapi");
|
|
36
|
+
|
|
37
|
+
const hasDbus = () => !!process.env.DBUS_SESSION_BUS_ADDRESS || (!!process.env.XDG_RUNTIME_DIR && existsSync(path.join(process.env.XDG_RUNTIME_DIR, "bus")));
|
|
38
|
+
|
|
39
|
+
const BACKENDS = {
|
|
40
|
+
keychain: {
|
|
41
|
+
label: "связка ключей macOS",
|
|
42
|
+
available: () => process.platform === "darwin" && !run("security", ["help"]).error,
|
|
43
|
+
get() {
|
|
44
|
+
const r = run("security", ["find-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-w"]);
|
|
45
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
46
|
+
},
|
|
47
|
+
set(text) {
|
|
48
|
+
const hex = Buffer.from(text, "utf8").toString("hex");
|
|
49
|
+
const r = run("security", ["-i"], `add-generic-password -U -a ${ACCOUNT} -s ${SERVICE} -l "${LABEL}" -X ${hex}\n`);
|
|
50
|
+
// `security -i` exits 0 even when a command fails; read the value back to be sure
|
|
51
|
+
return r.status === 0 && this.get() === text;
|
|
52
|
+
},
|
|
53
|
+
remove() { run("security", ["delete-generic-password", "-a", ACCOUNT, "-s", SERVICE]); },
|
|
54
|
+
},
|
|
55
|
+
dpapi: {
|
|
56
|
+
label: "файл, зашифрованный Windows (DPAPI)",
|
|
57
|
+
available: () => process.platform === "win32" && !run("powershell.exe", [...PS, "exit 0"], "", 15e3).error,
|
|
58
|
+
get() {
|
|
59
|
+
let blob;
|
|
60
|
+
try { blob = readFileSync(DPAPI_FILE, "utf8").trim(); } catch { return null; }
|
|
61
|
+
const r = run("powershell.exe", [...PS, dpapi("Unprotect")], blob, 15e3);
|
|
62
|
+
return r.status === 0 && r.stdout ? Buffer.from(r.stdout.trim(), "base64").toString("utf8") : null;
|
|
63
|
+
},
|
|
64
|
+
set(text) {
|
|
65
|
+
const r = run("powershell.exe", [...PS, dpapi("Protect")], Buffer.from(text, "utf8").toString("base64"), 15e3);
|
|
66
|
+
if (r.status !== 0 || !r.stdout) return false;
|
|
67
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
68
|
+
const tmp = `${DPAPI_FILE}.${process.pid}.tmp`;
|
|
69
|
+
writeFileSync(tmp, r.stdout.trim(), { mode: 0o600 });
|
|
70
|
+
renameSync(tmp, DPAPI_FILE);
|
|
71
|
+
return this.get() === text;
|
|
72
|
+
},
|
|
73
|
+
remove() { try { unlinkSync(DPAPI_FILE); } catch {} },
|
|
74
|
+
},
|
|
75
|
+
"secret-service": {
|
|
76
|
+
label: "Secret Service (связка ключей)",
|
|
77
|
+
available: () => process.platform === "linux" && hasDbus() && !run("secret-tool", ["--version"], "", 3e3).error,
|
|
78
|
+
get() {
|
|
79
|
+
const r = run("secret-tool", ["lookup", "service", SERVICE, "account", ACCOUNT]);
|
|
80
|
+
return r.status === 0 && r.stdout ? r.stdout.trim() : null;
|
|
81
|
+
},
|
|
82
|
+
set(text) {
|
|
83
|
+
const r = run("secret-tool", ["store", `--label=${LABEL}`, "service", SERVICE, "account", ACCOUNT], text);
|
|
84
|
+
return r.status === 0 && this.get() === text;
|
|
85
|
+
},
|
|
86
|
+
remove() { run("secret-tool", ["clear", "service", SERVICE, "account", ACCOUNT]); },
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const NATIVE = { darwin: "keychain", win32: "dpapi", linux: "secret-service" };
|
|
91
|
+
let chosen; // undefined: not decided yet; null: the file
|
|
92
|
+
|
|
93
|
+
function vault() {
|
|
94
|
+
if (chosen !== undefined) return chosen;
|
|
95
|
+
const forced = process.env.VV_SECRETS;
|
|
96
|
+
if (forced === "file") return (chosen = null);
|
|
97
|
+
const name = forced && BACKENDS[forced] ? forced : NATIVE[process.platform];
|
|
98
|
+
chosen = name && BACKENDS[name].available() ? BACKENDS[name] : null;
|
|
99
|
+
return chosen;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function storageLabel() { return vault()?.label ?? "файл auth.json (только для вас)"; }
|
|
103
|
+
|
|
104
|
+
// Why the secrets are in a file, and what would move them to a store; null when nothing can be done
|
|
105
|
+
export function fileHint() {
|
|
106
|
+
if (process.env.VV_SECRETS === "file") return "так задано в VV_SECRETS=file";
|
|
107
|
+
if (process.platform === "linux") {
|
|
108
|
+
if (!hasDbus()) return "без сеанса рабочего стола (SSH, WSL, контейнер) связки ключей нет — файл 0600, как ~/.npmrc у npm";
|
|
109
|
+
if (run("secret-tool", ["--version"], "", 3e3).error) return "поставьте secret-tool (пакет libsecret-tools), чтобы хранить секреты в связке ключей";
|
|
110
|
+
}
|
|
111
|
+
if (process.platform === "win32") return "PowerShell с DPAPI недоступен — файл в %APPDATA%, доступный только вам";
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The secrets as an object, or null when the store holds none
|
|
116
|
+
export function load() {
|
|
117
|
+
const v = vault();
|
|
118
|
+
if (!v) return null;
|
|
119
|
+
const text = v.get();
|
|
120
|
+
if (!text) return null;
|
|
121
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// True when the store took them; false means the caller keeps them in the file
|
|
125
|
+
export function save(secrets) {
|
|
126
|
+
const v = vault();
|
|
127
|
+
if (!v) return false;
|
|
128
|
+
if (v.set(JSON.stringify(secrets))) return true;
|
|
129
|
+
chosen = null; // a store that refuses once (locked, no session) is not retried in this process
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function remove() { vault()?.remove(); }
|
package/src/store.js
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
// - Another process (a second TUI, `vv cart add`) may change the file: the TUI watches it and reloads.
|
|
8
8
|
|
|
9
9
|
import { mkdirSync, readFileSync, writeFileSync, renameSync, statSync, chmodSync, watchFile, unwatchFile } from "node:fs";
|
|
10
|
-
import { homedir } from "node:os";
|
|
11
10
|
import path from "node:path";
|
|
11
|
+
import { CONFIG_DIR as DIR } from "./paths.js";
|
|
12
12
|
import { snapshot, fromSnapshot, lineTotal, qtyStep } from "./product.js";
|
|
13
13
|
|
|
14
14
|
const VERSION = 2;
|
|
15
|
-
const DIR = process.env.VV_CONFIG_DIR ?? path.join(process.env.XDG_CONFIG_HOME ?? (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : path.join(homedir(), ".config")), "vv-cli");
|
|
16
15
|
const FILE = path.join(DIR, "state.json");
|
|
17
16
|
|
|
18
17
|
export const CART_MAX = 100;
|
package/src/sys.js
CHANGED
|
@@ -31,6 +31,18 @@ export function copy(text) {
|
|
|
31
31
|
});
|
|
32
32
|
const osc = () => { if (process.stdout.isTTY) process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); return true; };
|
|
33
33
|
if (process.platform === "darwin") return tryCmd("pbcopy", []).then((ok) => ok || osc());
|
|
34
|
-
|
|
34
|
+
// clip.exe reads the console code page and garbles Cyrillic; PowerShell gets UTF-8 as base64
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
const ps = "$t = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())); Set-Clipboard -Value $t";
|
|
37
|
+
const viaPs = () => new Promise((resolve) => {
|
|
38
|
+
try {
|
|
39
|
+
const p = spawn("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", ps], { stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
|
|
40
|
+
p.on("error", () => resolve(false));
|
|
41
|
+
p.on("close", (code) => resolve(code === 0));
|
|
42
|
+
p.stdin.end(Buffer.from(text, "utf8").toString("base64"));
|
|
43
|
+
} catch { resolve(false); }
|
|
44
|
+
});
|
|
45
|
+
return viaPs().then((ok) => ok || tryCmd("clip", [])).then((ok) => ok || osc());
|
|
46
|
+
}
|
|
35
47
|
return tryCmd("wl-copy", []).then((ok) => ok || tryCmd("xclip", ["-selection", "clipboard"])).then((ok) => ok || osc());
|
|
36
48
|
}
|
package/src/ui/product-ui.js
CHANGED
|
@@ -7,6 +7,14 @@ import { image } from "../image.js";
|
|
|
7
7
|
import { richWrap, drawRich } from "./widgets.js";
|
|
8
8
|
import { store } from "../store.js";
|
|
9
9
|
|
|
10
|
+
// Stock at the signed-in user's address; null when unknown (signed out, or the tool did not say)
|
|
11
|
+
export function stock(p) {
|
|
12
|
+
if (p.available == null) return null;
|
|
13
|
+
if (p.available <= 0) return { text: "нет в наличии", fg: T.red, out: true };
|
|
14
|
+
const n = p.unit === "кг" ? `${+Number(p.available).toFixed(1)} кг` : `${p.available} шт`;
|
|
15
|
+
return { text: `в наличии ${n}`, fg: T.green, out: false };
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export const perKg = (p) => (p.unit === "кг" ? null : p.grams ? Math.round((p.price / p.grams) * 1000) : null);
|
|
11
19
|
|
|
12
20
|
// Composition flags → pills
|
|
@@ -85,8 +93,10 @@ export function productRow(g, p, x, y, w, { selected, focused = true, mark, bg:
|
|
|
85
93
|
// Columns, right to left
|
|
86
94
|
let rx = x + w - 1;
|
|
87
95
|
const cartW = 6;
|
|
96
|
+
const st = stock(p);
|
|
88
97
|
if (q) g.textR(rx, y, p.unit === "кг" ? `${+q.toFixed(1)}кг` : `×${q}`, { fg: T.lime, bg, bold: true });
|
|
89
98
|
else if (mark) g.textR(rx, y, mark, { fg: T.violet, bg, bold: true });
|
|
99
|
+
else if (st?.out) g.textR(rx, y, "нет", { fg: T.red, bg });
|
|
90
100
|
rx -= cartW;
|
|
91
101
|
g.textR(rx, y, rub(p.price) + (p.unit === "кг" ? "/кг" : ""), { fg: p.old ? T.orange : T.white, bg, bold: true });
|
|
92
102
|
rx -= 12;
|
|
@@ -108,7 +118,7 @@ export function productRow(g, p, x, y, w, { selected, focused = true, mark, bg:
|
|
|
108
118
|
const nameW = rx - cx;
|
|
109
119
|
if (nameW > 4) {
|
|
110
120
|
const t = truncate(p.title, nameW);
|
|
111
|
-
cx = g.text(cx, y, t, { fg: selected ? T.white : T.text, bg, bold: selected });
|
|
121
|
+
cx = g.text(cx, y, t, { fg: st?.out ? T.dim : selected ? T.white : T.text, bg, bold: selected });
|
|
112
122
|
if (p.pack && strWidth(t) < nameW - 2 && t === p.title) g.textW(cx + 1, y, p.pack, nameW - strWidth(t) - 1, { fg: T.dim, bg });
|
|
113
123
|
}
|
|
114
124
|
}
|
|
@@ -138,6 +148,8 @@ export function productPane(g, app, p, x, y, w, h, { compact = false } = {}) {
|
|
|
138
148
|
cy += g.para(x + 1, cy, iw, 2, p.title, { fg: T.white, bold: true, bg: T.panel });
|
|
139
149
|
const meta = [p.pack, p.category?.at(-1)].filter(Boolean).join(" · ");
|
|
140
150
|
if (meta && left() > 0) g.textW(x + 1, cy++, meta, iw, { fg: T.dim, bg: T.panel });
|
|
151
|
+
const st = stock(p);
|
|
152
|
+
if (st && left() > 0) g.text(x + 1, cy++, `${st.out ? "○" : "●"} ${st.text}`, { fg: st.fg, bg: T.panel });
|
|
141
153
|
cy++;
|
|
142
154
|
// Big price
|
|
143
155
|
if (left() > 7) {
|
package/src/ui/widgets.js
CHANGED
|
@@ -77,7 +77,10 @@ export class ListView {
|
|
|
77
77
|
this.top = Math.max(0, Math.min(this.top, Math.max(0, n - this.height)));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Returns false for a move up from the first row: the key goes on to whatever sits above the list
|
|
80
81
|
key(k, n) {
|
|
82
|
+
const up = ["up", "pageup", "home"].includes(k.name) || (k.hot === "k" && !k.ctrl);
|
|
83
|
+
if (up && this.sel <= 0) return false;
|
|
81
84
|
switch (k.name) {
|
|
82
85
|
case "up": this.sel--; break;
|
|
83
86
|
case "down": this.sel++; break;
|
|
@@ -234,3 +237,101 @@ export function drawRich(g, x, y, w, maxLines, lines, base) {
|
|
|
234
237
|
}
|
|
235
238
|
return n;
|
|
236
239
|
}
|
|
240
|
+
|
|
241
|
+
// Modal form: one or more text fields, Tab moves between them, Enter submits.
|
|
242
|
+
// fields: [{ label, placeholder, hidden, value }]; onSubmit(values) may return an error string.
|
|
243
|
+
export class Prompt {
|
|
244
|
+
constructor({ title, note = [], fields, onSubmit, onCancel }) {
|
|
245
|
+
this.title = title;
|
|
246
|
+
this.note = note;
|
|
247
|
+
this.fields = fields.map((f) => ({ ...f, input: new TextInput({ placeholder: f.placeholder ?? "", value: f.value ?? "" }) }));
|
|
248
|
+
this.focus = 0;
|
|
249
|
+
this.onSubmit = onSubmit;
|
|
250
|
+
this.onCancel = onCancel;
|
|
251
|
+
this.error = null;
|
|
252
|
+
this.animated = true; // blinking cursor
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
key(k, app) {
|
|
256
|
+
if (k.name === "escape") { app.closeOverlay(this); this.onCancel?.(); return; }
|
|
257
|
+
if (k.name === "tab" || k.name === "down") { this.focus = (this.focus + 1) % this.fields.length; return; }
|
|
258
|
+
if (k.name === "backtab" || k.name === "up") { this.focus = (this.focus + this.fields.length - 1) % this.fields.length; return; }
|
|
259
|
+
if (k.name === "enter") {
|
|
260
|
+
if (this.focus < this.fields.length - 1 && !this.fields[this.focus + 1].optional) { this.focus++; return; }
|
|
261
|
+
const values = this.fields.map((f) => f.input.value.trim());
|
|
262
|
+
const err = this.onSubmit?.(values);
|
|
263
|
+
if (err) { this.error = err; return; }
|
|
264
|
+
app.closeOverlay(this);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.error = null;
|
|
268
|
+
this.fields[this.focus].input.key(k);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
hints() { return [["⏎", "готово"], ...(this.fields.length > 1 ? [["Tab", "следующее поле"]] : []), ["esc", "отмена"]]; }
|
|
272
|
+
|
|
273
|
+
draw(g, app) {
|
|
274
|
+
const w = Math.min(g.w - 4, 76);
|
|
275
|
+
// border, blank, notes (+ blank), fields as label/input/blank (the last blank holds an error), border
|
|
276
|
+
const h = 3 + this.note.length + (this.note.length ? 1 : 0) + this.fields.length * 3;
|
|
277
|
+
const x = Math.floor((g.w - w) / 2), y = Math.max(1, Math.floor((g.h - h) / 2));
|
|
278
|
+
shadow(g, x, y, w, h);
|
|
279
|
+
g.box(x, y, w, h, { fg: T.lineHi, bg: T.panel2, title: this.title, titleFg: T.lime });
|
|
280
|
+
app.hit(x, y, w, h, { click: () => {} });
|
|
281
|
+
let cy = y + 2;
|
|
282
|
+
if (!this.note.length) cy = y + 1;
|
|
283
|
+
for (const line of this.note) g.textW(x + 3, cy++, line, w - 6, { fg: T.sub, bg: T.panel2 });
|
|
284
|
+
if (this.note.length) cy++;
|
|
285
|
+
this.fields.forEach((f, i) => {
|
|
286
|
+
const active = i === this.focus;
|
|
287
|
+
g.text(x + 3, cy, f.label, { fg: active ? T.lime : T.dim, bold: active, bg: T.panel2 });
|
|
288
|
+
g.fill(x + 3, cy + 1, w - 6, 1, active ? T.raise : T.panel);
|
|
289
|
+
// A hidden field shows bullets with the real cursor position
|
|
290
|
+
const shown = f.hidden ? new TextInput({ value: "•".repeat([...f.input.value].length), placeholder: f.input.placeholder }) : f.input;
|
|
291
|
+
if (f.hidden) shown.cursor = f.input.cursor;
|
|
292
|
+
shown.draw(g, x + 4, cy + 1, w - 8, { focused: active, bg: active ? T.raise : T.panel, blink: app.frame % 10 < 7 });
|
|
293
|
+
app.hit(x + 3, cy, w - 6, 2, { click: () => { this.focus = i; } });
|
|
294
|
+
cy += 3;
|
|
295
|
+
});
|
|
296
|
+
if (this.error) g.textW(x + 3, cy - 1, `⚠ ${this.error}`, w - 6, { fg: T.red, bg: T.panel2 });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// A row of pills the keyboard can walk: ← → move, Enter or space presses. The page supplies the
|
|
301
|
+
// items on every draw ({ label, run, fg, bg, bold, prefix }) and tells whether the row has the focus.
|
|
302
|
+
// Only pills that fit on screen can be selected.
|
|
303
|
+
export class ChipBar {
|
|
304
|
+
constructor() { this.sel = 0; this.shown = null; }
|
|
305
|
+
|
|
306
|
+
key(k, items) {
|
|
307
|
+
const n = Math.min(items.length, this.shown ?? items.length);
|
|
308
|
+
if (!n) return false;
|
|
309
|
+
this.sel = Math.min(this.sel, n - 1);
|
|
310
|
+
if (k.name === "left") { this.sel = Math.max(0, this.sel - 1); return true; }
|
|
311
|
+
if (k.name === "right") { this.sel = Math.min(n - 1, this.sel + 1); return true; }
|
|
312
|
+
if (k.name === "home") { this.sel = 0; return true; }
|
|
313
|
+
if (k.name === "end") { this.sel = n - 1; return true; }
|
|
314
|
+
if (k.name === "enter" || k.name === "space") { items[this.sel]?.run(); return true; }
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Draws the pills from x on row y up to maxX; returns the x after the last one drawn
|
|
319
|
+
draw(g, app, x, y, maxX, items, { focused = false } = {}) {
|
|
320
|
+
this.shown = 0;
|
|
321
|
+
for (const [i, it] of items.entries()) {
|
|
322
|
+
const pre = it.prefix ? strWidth(it.prefix) : 0;
|
|
323
|
+
const w = strWidth(it.label) + 2;
|
|
324
|
+
if (x + pre + w > maxX) break;
|
|
325
|
+
if (it.prefix) x = g.text(x, y, it.prefix, { fg: T.dim });
|
|
326
|
+
this.shown = i + 1;
|
|
327
|
+
const active = focused && i === Math.min(this.sel, items.length - 1);
|
|
328
|
+
// The keyboard's pill looks like a focused header tab: bright and underlined
|
|
329
|
+
g.pill(x, y, it.label, { fg: active ? T.bg : it.fg ?? T.sub, bg: active ? T.lime : it.bg ?? T.raise, bold: active || it.bold });
|
|
330
|
+
if (active) g.s.put(x + 1, y, it.label, { fg: T.bg, bg: T.lime, bold: true, underline: true });
|
|
331
|
+
app.hit(x, y, w, 1, { click: () => { this.sel = i; it.run(); } });
|
|
332
|
+
x += w + 1;
|
|
333
|
+
}
|
|
334
|
+
this.sel = Math.min(this.sel, Math.max(0, this.shown - 1));
|
|
335
|
+
return x;
|
|
336
|
+
}
|
|
337
|
+
}
|
package/src/views/browser.js
CHANGED
|
@@ -227,7 +227,7 @@ export class ProductBrowser {
|
|
|
227
227
|
else if (this.loading) loader(g, r.x + Math.floor(listW / 2) - 6, ly + Math.floor(lh / 2) + 1, app.frame);
|
|
228
228
|
} else {
|
|
229
229
|
this.list.draw(g, app, lx - 1, ly, listW - 1, lh, vis, (p, i, ry, sel) => {
|
|
230
|
-
productRow(g, p, lx - 1, ry, listW - 1, { selected: sel, focused: this.focus
|
|
230
|
+
productRow(g, p, lx - 1, ry, listW - 1, { selected: sel, focused: !["search", "chips"].includes(this.focus) && !app.headerFocus, mark: app.compare.has(p.id) ? "⇄" : "" });
|
|
231
231
|
}, { onPick: (p) => this.open(p) });
|
|
232
232
|
if (this.loading) g.textC(r.x, listW, r.y + r.h - 1, ` ${"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[app.frame % 10]} подгружаю… `, { fg: T.lime, bg: T.panel });
|
|
233
233
|
}
|
package/src/views/cart.js
CHANGED
|
@@ -31,7 +31,13 @@ class CartView {
|
|
|
31
31
|
key(k) {
|
|
32
32
|
if (k.type !== "key") return false;
|
|
33
33
|
const lines = this.lines;
|
|
34
|
-
|
|
34
|
+
// The checkout button is the zone below the last line: ↓ reaches it, ↑ comes back
|
|
35
|
+
if (this.focus === "checkout") {
|
|
36
|
+
if (k.name === "up" || k.name === "escape") { this.focus = "list"; return true; }
|
|
37
|
+
if (k.name === "enter" || k.name === "space") { this.checkout(); return true; }
|
|
38
|
+
if (k.name === "down") return true;
|
|
39
|
+
} else if (k.name === "down" && lines.length && this.list.sel >= lines.length - 1) { this.focus = "checkout"; return true; }
|
|
40
|
+
if (this.focus !== "checkout" && this.list.key(k, lines.length)) { this.confirmClear = false; return true; }
|
|
35
41
|
const line = lines[this.list.sel];
|
|
36
42
|
const h = k.hot ?? k.name;
|
|
37
43
|
if (line && (h === "+" || h === "=" || k.name === "right")) { store.add(line.p, qtyStep(line.p)); return true; }
|
|
@@ -51,6 +57,7 @@ class CartView {
|
|
|
51
57
|
|
|
52
58
|
hints() {
|
|
53
59
|
if (!this.lines.length) return [["1", "в каталог"], ["2", "акции"], ["3", "рецепты"]];
|
|
60
|
+
if (this.focus === "checkout") return [["⏎", "оформить: ссылка и QR"], ["↑", "к товарам"]];
|
|
54
61
|
return [["↑↓", "выбор"], ["← →", "кол-во"], ["d", "убрать"], ["⏎", "товар"], ["o", "оформить"], ["u", "обновить цены"], ["D", "очистить"]];
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -103,16 +110,28 @@ class CartView {
|
|
|
103
110
|
g.spans(r.x + 2, r.y, [["Корзина", { fg: T.lime, bold: true, bg: T.panel }], [` ${n} ${plural(n, "позиция", "позиции", "позиций")} · до ${CART_MAX}`, { fg: T.dim, bg: T.panel }]]);
|
|
104
111
|
g.hline(r.x, r.y + 1, lw, T.line);
|
|
105
112
|
// Table: product | − qty + | sum
|
|
106
|
-
|
|
113
|
+
// Wide enough: one line per item, the price details in a column of their own; otherwise two lines
|
|
114
|
+
const right = 30, DETAILS = 30;
|
|
115
|
+
const oneLine = lw - 4 - right - DETAILS >= 24;
|
|
116
|
+
const rowH = oneLine ? 1 : 2;
|
|
107
117
|
this.list.draw(g, app, r.x, r.y + 2, lw - 1, r.h - 2 - (sideW ? 0 : 1), lines, (l, i, y, sel) => {
|
|
108
|
-
const bg = sel ? T.selHi : T.panel;
|
|
118
|
+
const bg = sel ? (this.focus === "checkout" ? T.sel : T.selHi) : T.panel;
|
|
109
119
|
g.fill(r.x, y, lw - 1, rowH, bg);
|
|
110
|
-
|
|
120
|
+
for (let j = 0; j < rowH; j++) if (sel) g.text(r.x, y + j, "▌", { fg: T.lime, bg });
|
|
111
121
|
const p = l.p;
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
122
|
+
const details = [p.unit === "кг" ? `${rub(p.price)}/кг` : rub(p.price), p.old ? `было ${rub(p.old)}` : ""].filter(Boolean).join(" · ");
|
|
123
|
+
// Stock at the user's address is known once prices were checked while signed in
|
|
124
|
+
const stock = p.available === 0 ? ["нет в наличии", T.red] : p.available != null && p.available < l.q ? [`в наличии только ${p.available}`, T.orange] : null;
|
|
125
|
+
if (oneLine) {
|
|
126
|
+
const dx = r.x + lw - right - DETAILS;
|
|
127
|
+
const tx = g.textW(r.x + 2, y, p.title, dx - r.x - 4, { fg: T.white, bold: sel, bg });
|
|
128
|
+
if (p.pack) g.textW(tx + 1, y, p.pack, dx - tx - 2, { fg: T.dim, bg });
|
|
129
|
+
g.textW(dx, y, stock ? stock[0] : details, DETAILS - 2, { fg: stock ? stock[1] : p.old ? T.orange : T.dim, bg });
|
|
130
|
+
} else {
|
|
131
|
+
g.textW(r.x + 2, y, p.title, lw - right - 4, { fg: T.white, bold: sel, bg });
|
|
132
|
+
const sx2 = g.textW(r.x + 2, y + 1, [p.pack, details].filter(Boolean).join(" · "), lw - right - 4, { fg: p.old ? T.orange : T.dim, bg });
|
|
133
|
+
if (stock) g.textW(sx2 + 2, y + 1, stock[0], Math.max(0, r.x + lw - right - 2 - sx2), { fg: stock[1], bg });
|
|
134
|
+
}
|
|
116
135
|
// Stepper
|
|
117
136
|
const sx = r.x + lw - right;
|
|
118
137
|
g.text(sx, y, " − ", { fg: T.white, bg: sel ? T.orange : T.raise, bold: true });
|
|
@@ -121,9 +140,11 @@ class CartView {
|
|
|
121
140
|
g.textC(sx + 3, 10, y, qt, { fg: T.white, bold: true, bg });
|
|
122
141
|
g.text(sx + 13, y, " + ", { fg: T.bg, bg: sel ? T.lime : T.green, bold: true });
|
|
123
142
|
app.hit(sx + 13, y, 3, 1, { click: () => store.add(p, qtyStep(p)) });
|
|
124
|
-
|
|
125
|
-
g.
|
|
126
|
-
|
|
143
|
+
// The remove cross sits under the total on two lines, after it on one
|
|
144
|
+
g.textR(r.x + lw - (oneLine ? 4 : 2), y, rub(lineTotal(p, l.q)), { fg: T.lime, bold: true, bg });
|
|
145
|
+
const xy = oneLine ? y : y + 1;
|
|
146
|
+
g.text(r.x + lw - 3, xy, "×", { fg: T.mute, bg });
|
|
147
|
+
app.hit(r.x + lw - 3, xy, 1, 1, { click: () => store.remove(p.id) });
|
|
127
148
|
}, { rowH, onPick: (l, i) => this.openLine(i) });
|
|
128
149
|
if (sideW) this.drawSummary(g, { x: r.x + lw + 1, y: r.y, w: sideW - 1, h: r.h });
|
|
129
150
|
else {
|
|
@@ -131,7 +152,7 @@ class CartView {
|
|
|
131
152
|
g.fill(r.x, y, r.w, 1, T.panel2);
|
|
132
153
|
g.spans(r.x + 2, y, [["Итого ", { fg: T.sub, bg: T.panel2 }], [rub(store.total), { fg: T.lime, bold: true, bg: T.panel2 }], [store.savings ? ` экономия ${rub(store.savings)}` : "", { fg: T.orange, bg: T.panel2 }]]);
|
|
133
154
|
const label = " o ОФОРМИТЬ ▶ ";
|
|
134
|
-
g.textR(r.x + r.w - 1, y, label, { fg: T.bg, bg: T.lime, bold: true });
|
|
155
|
+
g.textR(r.x + r.w - 1, y, label, { fg: T.bg, bg: T.lime, bold: true, underline: this.focus === "checkout" && !app.headerFocus });
|
|
135
156
|
app.hit(r.x + r.w - 1 - strWidth(label), y, strWidth(label), 1, { click: () => this.checkout() });
|
|
136
157
|
}
|
|
137
158
|
}
|
|
@@ -150,9 +171,10 @@ class CartView {
|
|
|
150
171
|
// Checkout button
|
|
151
172
|
const label = " ОФОРМИТЬ ВО ВКУСВИЛЛЕ ▶ ";
|
|
152
173
|
const bw = Math.min(w, strWidth(label));
|
|
153
|
-
const
|
|
174
|
+
const onBtn = this.focus === "checkout" && !app.headerFocus;
|
|
175
|
+
const pulse = onBtn ? T.lime : mix(T.green, T.lime, (Math.sin(app.frame / 4) + 1) / 4);
|
|
154
176
|
g.fill(x, y, bw, 3, pulse);
|
|
155
|
-
g.textC(x, bw, y + 1, label.trim(), { fg: T.bg, bg: pulse, bold: true });
|
|
177
|
+
g.textC(x, bw, y + 1, label.trim(), { fg: T.bg, bg: pulse, bold: true, underline: onBtn });
|
|
156
178
|
app.hit(x, y, bw, 3, { click: () => this.checkout() });
|
|
157
179
|
this.animated = true;
|
|
158
180
|
y += 4;
|
|
@@ -269,7 +291,7 @@ class Checkout {
|
|
|
269
291
|
const h = Math.min(g.h - 2, compact ? qs.h + 4 : qs.h + 10);
|
|
270
292
|
const x = Math.floor((g.w - w) / 2), y = Math.max(1, Math.floor((g.h - h) / 2));
|
|
271
293
|
shadow(g, x, y, w, h);
|
|
272
|
-
g.box(x, y, w, h, { fg: T.lineHi, bg: T.panel2, title: "
|
|
294
|
+
g.box(x, y, w, h, { fg: T.lineHi, bg: T.panel2, title: "Оформление", titleFg: T.lime, style: "round" });
|
|
273
295
|
app.hit(x, y, w, h, { click: () => {} });
|
|
274
296
|
if (this.error) {
|
|
275
297
|
g.textC(x, w, y + Math.floor(h / 2) - 1, "⚠ Не получилось создать корзину", { fg: T.red, bold: true, bg: T.panel2 });
|