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/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-cli ${"$VERSION"}
49
+ ВкусВилл в терминале · vkusvill ${"$VERSION"}
44
50
 
45
- npx vkusvill-cli полноэкранный интерфейс
46
- npx vkusvill-cli <запрос> открыть интерфейс сразу с поиском
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 (заказы и персональные скидки), VV_COLOR=256|truecolor, NO_COLOR
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
- id: item.id,
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
- images: (item.images ?? []).map((i) => i.medium ?? i.small ?? i.large).filter(Boolean),
83
- thumb: item.images?.[0]?.small ?? item.images?.[0]?.medium ?? null,
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
- if (process.platform === "win32") return tryCmd("clip", []).then((ok) => ok || osc());
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
  }
@@ -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
+ }
@@ -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 !== "search", mark: app.compare.has(p.id) ? "⇄" : "" });
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
- if (this.list.key(k, lines.length)) { this.confirmClear = false; return true; }
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
- const rowH = 2;
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
- if (sel) { g.text(r.x, y, "▌", { fg: T.lime, bg }); g.text(r.x, y + 1, "▌", { fg: T.lime, bg }); }
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 right = 30;
113
- g.textW(r.x + 2, y, p.title, lw - right - 4, { fg: T.white, bold: sel, bg });
114
- const sub = [p.pack, p.unit === "кг" ? `${rub(p.price)}/кг` : rub(p.price), p.old ? `было ${rub(p.old)}` : ""].filter(Boolean).join(" · ");
115
- g.textW(r.x + 2, y + 1, sub, lw - right - 4, { fg: p.old ? T.orange : T.dim, bg });
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
- g.textR(r.x + lw - 2, y, rub(lineTotal(p, l.q)), { fg: T.lime, bold: true, bg });
125
- g.text(r.x + lw - 3, y + 1, "×", { fg: T.mute, bg });
126
- app.hit(r.x + lw - 3, y + 1, 1, 1, { click: () => store.remove(p.id) });
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 pulse = mix(T.green, T.lime, (Math.sin(app.frame / 4) + 1) / 4);
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: "Корзина во ВкусВилле", titleFg: T.lime, style: "round" });
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 });