vkusvill 0.0.0-dev.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/LICENSE +21 -0
- package/README.md +91 -0
- package/README.ru.md +89 -0
- package/bin/vv.js +12 -0
- package/package.json +39 -0
- package/src/api.js +228 -0
- package/src/app.js +328 -0
- package/src/cli.js +159 -0
- package/src/image.js +144 -0
- package/src/main.js +113 -0
- package/src/product.js +135 -0
- package/src/safe.js +37 -0
- package/src/store.js +190 -0
- package/src/sys.js +36 -0
- package/src/term/input.js +131 -0
- package/src/term/screen.js +204 -0
- package/src/term/width.js +74 -0
- package/src/ui/draw.js +324 -0
- package/src/ui/product-ui.js +192 -0
- package/src/ui/theme.js +47 -0
- package/src/ui/widgets.js +236 -0
- package/src/views/browser.js +251 -0
- package/src/views/cart.js +306 -0
- package/src/views/catalog.js +150 -0
- package/src/views/overlays.js +324 -0
- package/src/views/product.js +308 -0
- package/src/views/profile.js +228 -0
- package/src/views/recipes.js +358 -0
- package/src/views/shops.js +396 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Catalog: live VkusVill search, categories taken from the response, suggestions; an empty query shows today's discounted products.
|
|
2
|
+
// Deals: loyalty-card discounts and "buy N or more" discounts.
|
|
3
|
+
|
|
4
|
+
import { T } from "../ui/theme.js";
|
|
5
|
+
import { TextInput, Menu } from "../ui/widgets.js";
|
|
6
|
+
import { ProductBrowser } from "./browser.js";
|
|
7
|
+
import * as api from "../api.js";
|
|
8
|
+
import { store } from "../store.js";
|
|
9
|
+
import { strWidth } from "../term/width.js";
|
|
10
|
+
|
|
11
|
+
const SUGGEST = ["творог", "сырники", "хумус", "гранола", "кефир", "авокадо", "куриное филе", "пельмени", "хлеб", "сыр", "ягоды", "кофе"];
|
|
12
|
+
|
|
13
|
+
const pageOf = (d) => ({ items: d.items ?? [], total: d.meta?.total, hasMore: !!d.meta?.has_more, facets: d.facets?.categories });
|
|
14
|
+
|
|
15
|
+
export function catalogView(app) {
|
|
16
|
+
const st = { q: "", category: 0, timer: null };
|
|
17
|
+
const input = new TextInput({ placeholder: "что ищем? творог, сырники, хумус…" });
|
|
18
|
+
const b = new ProductBrowser(app, {
|
|
19
|
+
prefKey: "catalog",
|
|
20
|
+
title: "Сегодня со скидкой",
|
|
21
|
+
headerH: 4,
|
|
22
|
+
hintsExtra: [["/", "поиск"], ["g", "категории"]],
|
|
23
|
+
loader: async (page, { sort }) => {
|
|
24
|
+
if (!st.q) return pageOf(await api.discounts({ page, sort }));
|
|
25
|
+
return pageOf(await api.search(st.q, { page, sort, category: st.category }));
|
|
26
|
+
},
|
|
27
|
+
drawHeader: (g, r) => drawSearch(g, r),
|
|
28
|
+
keyHook: (k) => {
|
|
29
|
+
if (b.focus === "search") {
|
|
30
|
+
if (k.type === "paste") { input.key(k); return true; }
|
|
31
|
+
if (k.name === "down" || k.name === "tab" || k.name === "escape") { b.focus = "list"; if (st.q) store.rememberQuery(st.q); return true; }
|
|
32
|
+
if (k.name === "enter") { submit(); b.focus = "list"; return true; }
|
|
33
|
+
if (k.name === "up") return true;
|
|
34
|
+
return input.key(k);
|
|
35
|
+
}
|
|
36
|
+
if (k.type !== "key") return false;
|
|
37
|
+
const h = k.hot ?? k.name;
|
|
38
|
+
if (h === "/" || k.ch === "." || (k.ctrl && k.name === "f")) { b.focus = "search"; return true; }
|
|
39
|
+
if (h === "g") { categoryMenu(); return true; }
|
|
40
|
+
if (k.name === "escape" && st.q) { input.set(""); setQuery(""); b.focus = "search"; return true; }
|
|
41
|
+
if (k.name === "up" && b.list.sel === 0) { b.focus = "search"; return true; }
|
|
42
|
+
return false;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
b.focus = "search";
|
|
46
|
+
|
|
47
|
+
input.onChange = (v) => {
|
|
48
|
+
clearTimeout(st.timer);
|
|
49
|
+
st.timer = setTimeout(() => setQuery(v), 320);
|
|
50
|
+
};
|
|
51
|
+
const submit = () => { clearTimeout(st.timer); setQuery(input.value); store.rememberQuery(input.value); };
|
|
52
|
+
|
|
53
|
+
function setQuery(v) {
|
|
54
|
+
v = v.trim();
|
|
55
|
+
if (v === st.q) return;
|
|
56
|
+
st.q = v;
|
|
57
|
+
st.category = 0;
|
|
58
|
+
b.facets = null;
|
|
59
|
+
b.title = v ? `«${v}»` : "Сегодня со скидкой";
|
|
60
|
+
b.reload();
|
|
61
|
+
app.invalidate();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function search(v) { input.set(v); setQuery(v); store.rememberQuery(v); b.focus = "list"; }
|
|
65
|
+
|
|
66
|
+
function setCategory(id) { st.category = id; b.reload(); }
|
|
67
|
+
|
|
68
|
+
function categoryMenu() {
|
|
69
|
+
if (!b.facets?.length) return app.toast("Категории появятся после поиска", T.dim);
|
|
70
|
+
app.overlay(new Menu({
|
|
71
|
+
title: "Категория",
|
|
72
|
+
items: [{ label: "Все категории", value: 0, checked: !st.category }, ...b.facets.map((c) => ({ label: c.category_name + (c.subcategory_name ? ` · ${c.subcategory_name}` : ""), value: c.category_id, checked: st.category === c.category_id }))],
|
|
73
|
+
onPick: (it) => setCategory(it.value),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function drawSearch(g, r) {
|
|
78
|
+
const focused = b.focus === "search";
|
|
79
|
+
g.fill(r.x, r.y, r.w, r.h, T.bg);
|
|
80
|
+
g.box(r.x, r.y, r.w, 3, { fg: focused ? T.lineHi : T.line, bg: T.panel, title: "Поиск во ВкусВилле", titleFg: focused ? T.lime : T.dim, right: focused ? "⏎ искать · ↓ к списку" : "/ — искать", rightFg: T.dim });
|
|
81
|
+
g.text(r.x + 2, r.y + 1, "⌕", { fg: T.lime, bold: true, bg: T.panel });
|
|
82
|
+
input.draw(g, r.x + 4, r.y + 1, r.w - 8, { focused, bg: T.panel, blink: app.frame % 10 < 7 || !focused });
|
|
83
|
+
app.hit(r.x, r.y, r.w, 3, { click: () => { b.focus = "search"; } });
|
|
84
|
+
// Chips: categories of the results, or suggestions
|
|
85
|
+
let x = r.x + 1;
|
|
86
|
+
const y = r.y + 3;
|
|
87
|
+
const chip = (label, active, fn, color = T.sub) => {
|
|
88
|
+
const w = strWidth(label) + 2;
|
|
89
|
+
if (x + w > r.x + r.w - 1) return;
|
|
90
|
+
g.pill(x, y, label, { fg: active ? T.bg : color, bg: active ? T.lime : T.raise, bold: active });
|
|
91
|
+
app.hit(x, y, w, 1, { click: fn });
|
|
92
|
+
x += w + 1;
|
|
93
|
+
};
|
|
94
|
+
if (st.q && b.facets?.length) {
|
|
95
|
+
x = g.text(x, y, "g ", { fg: T.dim });
|
|
96
|
+
chip("все", !st.category, () => setCategory(0));
|
|
97
|
+
for (const c of b.facets) chip(c.category_name.toLowerCase(), st.category === c.category_id, () => setCategory(c.category_id));
|
|
98
|
+
} else if (!st.q) {
|
|
99
|
+
const recent = store.queries.slice(0, 5);
|
|
100
|
+
if (recent.length) { x = g.text(x, y, "недавно ", { fg: T.dim }); for (const q of recent) chip(q, false, () => search(q), T.mint); x += 1; }
|
|
101
|
+
x = g.text(x, y, "попробуйте ", { fg: T.dim });
|
|
102
|
+
for (const q of SUGGEST) if (!recent.includes(q)) chip(q, false, () => search(q));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const baseHints = b.hints.bind(b);
|
|
107
|
+
b.hints = () => (b.focus === "search" ? [["⏎", "искать"], ["↓", "к списку"], ["^U", "очистить"], ["esc", "к списку"]] : baseHints());
|
|
108
|
+
b.search = search;
|
|
109
|
+
return b;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function dealsView(app) {
|
|
113
|
+
const saved = store.pref("deals", {});
|
|
114
|
+
const st = { type: saved.type === "quantity" ? "quantity" : "card", vvonly: saved.vvonly === 0 ? 0 : 1 };
|
|
115
|
+
const persist = () => store.setPref("deals", { ...st });
|
|
116
|
+
const b = new ProductBrowser(app, {
|
|
117
|
+
prefKey: "deals",
|
|
118
|
+
title: "Акции",
|
|
119
|
+
headerH: 2,
|
|
120
|
+
hintsExtra: [["t", "тип акции"]],
|
|
121
|
+
loader: async (page, { sort }) => pageOf(await api.discounts({ type: st.type, page, sort, vvonly: st.vvonly })),
|
|
122
|
+
drawHeader: (g, r) => {
|
|
123
|
+
g.fill(r.x, r.y, r.w, r.h, T.bg);
|
|
124
|
+
let x = r.x + 1;
|
|
125
|
+
const y = r.y;
|
|
126
|
+
const tab = (label, active, fn) => {
|
|
127
|
+
const w = strWidth(label) + 2;
|
|
128
|
+
g.pill(x, y, label, { fg: active ? T.bg : T.sub, bg: active ? T.orange : T.raise, bold: active });
|
|
129
|
+
app.hit(x, y, w, 1, { click: fn });
|
|
130
|
+
x += w + 1;
|
|
131
|
+
};
|
|
132
|
+
x = g.text(x, y, "% ", { fg: T.orange, bold: true });
|
|
133
|
+
tab("по карте лояльности", st.type === "card", () => setType("card"));
|
|
134
|
+
tab("при покупке от N штук", st.type === "quantity", () => setType("quantity"));
|
|
135
|
+
x += 2;
|
|
136
|
+
tab(st.vvonly ? "только ВкусВилл" : "все бренды", false, () => { st.vvonly = st.vvonly ? 0 : 1; persist(); b.reload(); });
|
|
137
|
+
g.textR(r.x + r.w - 1, y, "скидки обновляются каждый день", { fg: T.dim });
|
|
138
|
+
},
|
|
139
|
+
keyHook: (k) => {
|
|
140
|
+
const h = k.hot ?? k.name;
|
|
141
|
+
if (h === "t") { setType(st.type === "card" ? "quantity" : "card"); return true; }
|
|
142
|
+
if (h === "v") { st.vvonly = st.vvonly ? 0 : 1; persist(); b.reload(); return true; }
|
|
143
|
+
return false;
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
function setType(t) { st.type = t; persist(); b.title = t === "card" ? "Скидки по карте" : "Скидки от нескольких штук"; b.reload(); }
|
|
147
|
+
b.title = st.type === "card" ? "Скидки по карте" : "Скидки от нескольких штук";
|
|
148
|
+
return b;
|
|
149
|
+
}
|
|
150
|
+
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Splash screen, help, command palette, barcode lookup, analogs and product comparison.
|
|
2
|
+
|
|
3
|
+
import { T, mix, ramp, BRAND } from "../ui/theme.js";
|
|
4
|
+
import { TextInput, ListView, shadow } from "../ui/widgets.js";
|
|
5
|
+
import { LEAF, LOGO_GLYPH } from "../ui/draw.js";
|
|
6
|
+
import { ProductBrowser } from "./browser.js";
|
|
7
|
+
import { productView } from "./product.js";
|
|
8
|
+
import { perKg, flagPills } from "../ui/product-ui.js";
|
|
9
|
+
import { image } from "../image.js";
|
|
10
|
+
import * as api from "../api.js";
|
|
11
|
+
import { normalize, rub, compactNum } from "../product.js";
|
|
12
|
+
import { store } from "../store.js";
|
|
13
|
+
import { TABS } from "../app.js";
|
|
14
|
+
import { truncate } from "../term/width.js";
|
|
15
|
+
|
|
16
|
+
// ——— Splash ———
|
|
17
|
+
export class Splash {
|
|
18
|
+
constructor(app, { duration = 1900 } = {}) {
|
|
19
|
+
this.app = app;
|
|
20
|
+
this.t0 = Date.now();
|
|
21
|
+
this.duration = duration;
|
|
22
|
+
this.animated = true;
|
|
23
|
+
this.leaves = Array.from({ length: 26 }, () => ({ x: Math.random(), y: Math.random() * -1, v: 0.004 + Math.random() * 0.01, s: Math.random() * 6, ch: "⠁⠂⠄⡀⢀⠠⠐⠈❖◆✦·"[Math.floor(Math.random() * 12)] }));
|
|
24
|
+
this.timer = setTimeout(() => this.close(), duration);
|
|
25
|
+
}
|
|
26
|
+
close() { clearTimeout(this.timer); this.app.closeOverlay(this); }
|
|
27
|
+
key() { this.close(); }
|
|
28
|
+
hints() { return [["любая клавиша", "войти"]]; }
|
|
29
|
+
draw(g, app) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const t = (now - this.t0) / this.duration;
|
|
32
|
+
// Leaves move by elapsed time, not per frame: the same speed at any redraw rate
|
|
33
|
+
const dt = Math.min(5, (now - (this.last ?? now)) / 90);
|
|
34
|
+
this.last = now;
|
|
35
|
+
g.fill(0, 0, g.w, g.h, T.bg);
|
|
36
|
+
// Falling leaves and dots
|
|
37
|
+
for (const l of this.leaves) {
|
|
38
|
+
l.y += l.v * dt;
|
|
39
|
+
if (l.y > 1) { l.y = -0.05; l.x = Math.random(); }
|
|
40
|
+
const x = Math.floor((l.x + Math.sin(l.y * 6 + l.s) * 0.02) * g.w), y = Math.floor(l.y * g.h);
|
|
41
|
+
if (y >= 0 && y < g.h) g.text(x, y, l.ch, { fg: mix(T.greenDD, T.green, Math.max(0, Math.min(1, l.y))) });
|
|
42
|
+
}
|
|
43
|
+
const big = g.w >= 110 && g.h >= 26;
|
|
44
|
+
const word = "ВКУСВИЛЛ";
|
|
45
|
+
const scale = big ? 2 : 1;
|
|
46
|
+
const ww = g.pixelWidth(word, "logo") * scale;
|
|
47
|
+
const leafW = 10 * scale;
|
|
48
|
+
const total = leafW + 3 * scale + ww;
|
|
49
|
+
const x0 = Math.floor((g.w - total) / 2);
|
|
50
|
+
const y0 = Math.max(1, Math.floor(g.h / 2) - (big ? 9 : 6));
|
|
51
|
+
const sweep = (px, py, w) => ramp(BRAND, ((px / (w || 1)) * 0.6 + t * 1.6 + py * 0.02) % 1);
|
|
52
|
+
// Leaf
|
|
53
|
+
g.bitmap(x0, y0, leafW, 7 * scale, (px, py) => (LEAF[Math.floor(py / scale)]?.[Math.floor(px / scale)] === "#" ? ramp([T.lime, T.green, T.greenD], py / (7 * scale)) : -1));
|
|
54
|
+
// Letters: a pixel-font mask scaled up by `scale`, with a moving gradient
|
|
55
|
+
const mask = logoMask(word);
|
|
56
|
+
const mw = mask[0].length;
|
|
57
|
+
g.bitmap(x0 + leafW + 3 * scale, y0, mw * scale, 7 * scale, (px, py) => (mask[Math.floor(py / scale)]?.[Math.floor(px / scale)] ? sweep(px, py, mw * scale) : -1));
|
|
58
|
+
const ty = y0 + (big ? 8 : 5);
|
|
59
|
+
g.textC(0, g.w, ty, "в т е р м и н а л е", { fg: T.lime, bold: true });
|
|
60
|
+
g.textC(0, g.w, ty + 2, "каталог · акции · рецепты · магазины · корзина с QR-кодом", { fg: T.sub });
|
|
61
|
+
// Progress bar
|
|
62
|
+
const bw = Math.min(40, g.w - 10);
|
|
63
|
+
g.gradBar(Math.floor((g.w - bw) / 2), ty + 4, bw, Math.min(1, t * 1.1), [T.greenD, T.green, T.lime], T.panel);
|
|
64
|
+
g.textC(0, g.w, ty + 6, "данные: официальный MCP ВкусВилла · mcp.vkusvill.ru", { fg: T.mute });
|
|
65
|
+
app.hit(0, 0, g.w, g.h, { click: () => this.close() });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function logoMask(word) {
|
|
70
|
+
const rows = Array.from({ length: 7 }, () => []);
|
|
71
|
+
for (const c of word) {
|
|
72
|
+
const glyph = LOGO_GLYPH(c);
|
|
73
|
+
for (let py = 0; py < 7; py++) rows[py].push(...[...glyph[py]].map((ch) => ch === "#"), false);
|
|
74
|
+
}
|
|
75
|
+
rows.forEach((r) => r.pop());
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ——— Help ———
|
|
80
|
+
export class Help {
|
|
81
|
+
constructor() { this.scroll = 0; this.maxScroll = 0; }
|
|
82
|
+
key(k, app) {
|
|
83
|
+
if (k.name === "down") this.scroll = Math.min(this.maxScroll, this.scroll + 1);
|
|
84
|
+
else if (k.name === "up") this.scroll = Math.max(0, this.scroll - 1);
|
|
85
|
+
else app.closeOverlay(this);
|
|
86
|
+
}
|
|
87
|
+
hints() { return [["любая клавиша", "закрыть"]]; }
|
|
88
|
+
draw(g, app) {
|
|
89
|
+
const cols = [
|
|
90
|
+
["ВЕЗДЕ", [["1…6 / Tab", "вкладки"], ["^K или :", "палитра команд"], ["b", "товар по штрихкоду"], ["c", "корзина"], ["esc", "назад"], ["?", "эта справка"], ["q / ^C", "выход"], ["мышь", "клик, двойной клик, колесо"]]],
|
|
91
|
+
["ТОВАРЫ", [["/ или .", "поиск"], ["↑↓ j k", "выбор"], ["⏎ →", "карточка товара"], ["+ −", "в корзину / убрать"], ["*", "в избранное"], ["s", "сортировка"], ["f", "фильтры состава"], ["g", "категория (каталог)"], ["x / X", "отметить / сравнить"], ["o", "открыть на сайте"]]],
|
|
92
|
+
["КАРТОЧКА", [["← →", "листать фото"], ["[ ]", "соседний товар"], ["a", "аналоги"], ["PgUp PgDn", "прокрутка"]]],
|
|
93
|
+
["КОРЗИНА", [["← → + −", "количество"], ["d / Del", "убрать"], ["o", "оформить: ссылка и QR"], ["u", "сверить цены"], ["D D", "очистить"]]],
|
|
94
|
+
["РЕЦЕПТЫ", [["f", "фильтры, аллергены"], ["␣", "отметить ингредиент"], ["a", "отметить все"], ["+", "ингредиенты в корзину"], ["s", "шаги на узком экране"]]],
|
|
95
|
+
["МАГАЗИНЫ", [["/", "поиск по адресу"], ["r g m f", "регион, город, метро, особ."], ["z ⏎", "приблизить карту"], ["L", "загрузить все на карту"], ["v", "карта на узком экране"], ["p", "Яндекс Карты"], ["y", "скопировать адрес"]]],
|
|
96
|
+
];
|
|
97
|
+
const w = Math.min(g.w - 4, 104), h = Math.min(g.h - 2, 30);
|
|
98
|
+
const x = Math.floor((g.w - w) / 2), y = Math.floor((g.h - h) / 2);
|
|
99
|
+
shadow(g, x, y, w, h);
|
|
100
|
+
g.box(x, y, w, h, { fg: T.lineHi, bg: T.panel2, title: "Клавиши", titleFg: T.lime, right: "горячие клавиши работают и в русской раскладке" });
|
|
101
|
+
const cw = Math.floor((w - 4) / 2);
|
|
102
|
+
const colY = [y + 2, y + 2];
|
|
103
|
+
cols.forEach(([title, rows], i) => {
|
|
104
|
+
const c = colY[0] <= colY[1] ? 0 : 1;
|
|
105
|
+
let cy = colY[c] - this.scroll;
|
|
106
|
+
const cx = x + 2 + c * cw;
|
|
107
|
+
if (cy >= y + 1 && cy < y + h - 1) g.text(cx, cy, title, { fg: T.lime, bold: true, bg: T.panel2 });
|
|
108
|
+
cy++;
|
|
109
|
+
for (const [k, d] of rows) {
|
|
110
|
+
if (cy >= y + 1 && cy < y + h - 1) {
|
|
111
|
+
g.text(cx, cy, truncate(k, 12), { fg: T.yellow, bold: true, bg: T.panel2 });
|
|
112
|
+
g.textW(cx + 13, cy, d, cw - 15, { fg: T.text, bg: T.panel2 });
|
|
113
|
+
}
|
|
114
|
+
cy++;
|
|
115
|
+
}
|
|
116
|
+
colY[c] = cy + 1 + this.scroll;
|
|
117
|
+
});
|
|
118
|
+
this.maxScroll = Math.max(0, Math.max(...colY) - (y + h - 2));
|
|
119
|
+
g.textC(x, w, y + h - 2, `кэш: ${api.CACHE_DIR}`, { fg: T.mute, bg: T.panel2 });
|
|
120
|
+
app.hit(x, y, w, h, { click: () => app.closeOverlay(this) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ——— Command palette ———
|
|
125
|
+
export class Palette {
|
|
126
|
+
constructor(app) {
|
|
127
|
+
this.app = app;
|
|
128
|
+
this.input = new TextInput({ placeholder: "команда или что найти…", onChange: () => { this.list.sel = 0; } });
|
|
129
|
+
this.list = new ListView();
|
|
130
|
+
this.animated = true; // blinking cursor
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
commands() {
|
|
134
|
+
const app = this.app;
|
|
135
|
+
const q = this.input.value.trim();
|
|
136
|
+
const cmds = [];
|
|
137
|
+
if (q) {
|
|
138
|
+
cmds.push({ label: `Искать «${q}» в каталоге`, hint: "⏎", run: () => app.views.searchCatalog(app, q) });
|
|
139
|
+
cmds.push({ label: `Искать рецепты «${q}»`, run: () => app.views.searchRecipes(app, q) });
|
|
140
|
+
if (/^\d{13}$/.test(q)) cmds.push({ label: `Штрихкод ${q}`, run: () => openBarcode(app, q) });
|
|
141
|
+
}
|
|
142
|
+
TABS.forEach((t, i) => cmds.push({ label: `Перейти: ${t.label}`, hint: String(i + 1), run: () => app.switchTab(t.id) }));
|
|
143
|
+
cmds.push({ label: "Оформить корзину — ссылка и QR", hint: rub(store.total), run: () => { app.switchTab("cart"); app.page.checkout?.(); } });
|
|
144
|
+
cmds.push({ label: "Товар по штрихкоду", hint: "b", run: () => app.views.barcode(app) });
|
|
145
|
+
cmds.push({ label: `Сравнить отмеченные (${app.compare.size})`, hint: "X", run: () => app.openCompare() });
|
|
146
|
+
cmds.push({ label: "Очистить сравнение", run: () => { app.compare.clear(); app.toast("Сравнение очищено"); } });
|
|
147
|
+
cmds.push({ label: "Очистить корзину", run: () => { store.clearCart(); app.toast("Корзина очищена", T.orange); } });
|
|
148
|
+
cmds.push({ label: "Справка по клавишам", hint: "?", run: () => app.views.help(app) });
|
|
149
|
+
for (const r of store.queries.slice(0, 8)) cmds.push({ label: `Недавний поиск: ${r}`, run: () => app.views.searchCatalog(app, r) });
|
|
150
|
+
cmds.push({ label: "Выход", hint: "q", run: () => app.quit() });
|
|
151
|
+
if (!q) return cmds;
|
|
152
|
+
const ql = q.toLowerCase();
|
|
153
|
+
const fuzzy = (s) => { let i = 0; for (const c of s.toLowerCase()) if (c === ql[i]) i++; return i === ql.length; };
|
|
154
|
+
return cmds.filter((c, i) => i < 2 || c.label.toLowerCase().includes(ql) || fuzzy(c.label));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
key(k, app) {
|
|
158
|
+
const cmds = this.commands();
|
|
159
|
+
if (k.name === "escape") return app.closeOverlay(this);
|
|
160
|
+
if (k.name === "enter") { const c = cmds[this.list.sel]; app.closeOverlay(this); c?.run(); return; }
|
|
161
|
+
if (["up", "down", "pageup", "pagedown"].includes(k.name)) return this.list.key(k, cmds.length);
|
|
162
|
+
this.input.key(k);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
hints() { return [["↑↓", "выбор"], ["⏎", "выполнить"], ["esc", "закрыть"]]; }
|
|
166
|
+
|
|
167
|
+
draw(g, app) {
|
|
168
|
+
const cmds = this.commands();
|
|
169
|
+
const w = Math.min(g.w - 4, 72), rows = Math.min(cmds.length, g.h - 10, 14);
|
|
170
|
+
const h = rows + 4;
|
|
171
|
+
const x = Math.floor((g.w - w) / 2), y = Math.max(2, Math.floor(g.h / 5));
|
|
172
|
+
shadow(g, x, y, w, h);
|
|
173
|
+
g.box(x, y, w, h, { fg: T.lineHi, bg: T.panel2, title: "Команды", titleFg: T.lime, right: "Ctrl+K" });
|
|
174
|
+
g.text(x + 2, y + 1, "›", { fg: T.lime, bold: true, bg: T.panel2 });
|
|
175
|
+
this.input.draw(g, x + 4, y + 1, w - 6, { bg: T.panel2, blink: app.frame % 10 < 7 });
|
|
176
|
+
g.hline(x + 1, y + 2, w - 2, T.line);
|
|
177
|
+
this.list.draw(g, app, x + 1, y + 3, w - 3, rows, cmds, (c, i, ry, sel) => {
|
|
178
|
+
const bg = sel ? T.selHi : T.panel2;
|
|
179
|
+
g.fill(x + 1, ry, w - 3, 1, bg);
|
|
180
|
+
g.text(x + 2, ry, sel ? "▸" : " ", { fg: T.lime, bg });
|
|
181
|
+
g.textW(x + 4, ry, c.label, w - 16, { fg: sel ? T.white : T.text, bold: sel, bg });
|
|
182
|
+
if (c.hint) g.textR(x + w - 3, ry, c.hint, { fg: T.dim, bg });
|
|
183
|
+
}, { onPick: (c) => { app.closeOverlay(this); c.run(); } });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ——— Barcode ———
|
|
188
|
+
export class Barcode {
|
|
189
|
+
constructor(app) {
|
|
190
|
+
this.app = app;
|
|
191
|
+
this.input = new TextInput({ placeholder: "13 цифр EAN-13", filter: (c) => /\d/.test(c) });
|
|
192
|
+
this.animated = true;
|
|
193
|
+
}
|
|
194
|
+
key(k, app) {
|
|
195
|
+
if (k.name === "escape") return app.closeOverlay(this);
|
|
196
|
+
if (k.name === "enter") {
|
|
197
|
+
const v = this.input.value;
|
|
198
|
+
if (!/^\d{13}$/.test(v)) { this.error = "нужно ровно 13 цифр"; return; }
|
|
199
|
+
app.closeOverlay(this);
|
|
200
|
+
openBarcode(app, v);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.error = null;
|
|
204
|
+
this.input.key(k);
|
|
205
|
+
}
|
|
206
|
+
hints() { return [["⏎", "найти"], ["esc", "отмена"]]; }
|
|
207
|
+
draw(g, app) {
|
|
208
|
+
const w = 52, h = 9;
|
|
209
|
+
const x = Math.floor((g.w - w) / 2), y = Math.floor((g.h - h) / 2);
|
|
210
|
+
shadow(g, x, y, w, h);
|
|
211
|
+
g.box(x, y, w, h, { fg: T.lineHi, bg: T.panel2, title: "Товар по штрихкоду", titleFg: T.lime });
|
|
212
|
+
// A pseudo barcode drawn from the digits typed so far
|
|
213
|
+
const digits = this.input.value.padEnd(13, "0");
|
|
214
|
+
let bars = "";
|
|
215
|
+
for (const d of digits) bars += ["▌▏▍", "▏▌▎", "▎▍▏", "▍▏▌", "▏▎▍", "▌▍▏", "▎▏▌", "▍▌▏", "▏▍▎", "▌▎▏"][+d];
|
|
216
|
+
g.textC(x, w, y + 2, bars, { fg: this.input.value ? T.white : T.mute, bg: T.panel2 });
|
|
217
|
+
g.textC(x, w, y + 3, bars, { fg: this.input.value ? T.white : T.mute, bg: T.panel2 });
|
|
218
|
+
g.fill(x + 13, y + 5, 26, 1, T.raise);
|
|
219
|
+
this.input.draw(g, x + 14, y + 5, 24, { bg: T.raise, blink: app.frame % 10 < 7 });
|
|
220
|
+
g.textR(x + 39, y + 5, `${this.input.value.length}/13`, { fg: T.dim, bg: T.raise });
|
|
221
|
+
if (this.error) g.textC(x, w, y + 7, this.error, { fg: T.red, bg: T.panel2 });
|
|
222
|
+
else g.textC(x, w, y + 7, "цифры под полосками на упаковке", { fg: T.dim, bg: T.panel2 });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function openBarcode(app, code) {
|
|
227
|
+
app.toast(`Ищу штрихкод ${code}…`, T.dim);
|
|
228
|
+
try {
|
|
229
|
+
const p = normalize(await api.barcode(code));
|
|
230
|
+
if (!p?.id) throw new Error("Товар не найден");
|
|
231
|
+
store.viewed(p);
|
|
232
|
+
app.push(productView(app, p));
|
|
233
|
+
} catch (e) {
|
|
234
|
+
app.toast(`⚠ ${e.message}`, T.red);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ——— Analogs ———
|
|
239
|
+
export function analogsView(app, p) {
|
|
240
|
+
const b = new ProductBrowser(app, {
|
|
241
|
+
title: `Аналоги: ${p.title}`,
|
|
242
|
+
serverSort: false,
|
|
243
|
+
autoLoad: false,
|
|
244
|
+
emptyText: "ВкусВилл не знает аналогов этого товара",
|
|
245
|
+
loader: async () => {
|
|
246
|
+
const d = await api.analogs(p.id);
|
|
247
|
+
const items = d.products ?? d.items ?? [];
|
|
248
|
+
return { items, total: items.length, hasMore: false };
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const base = b.hints.bind(b);
|
|
252
|
+
b.hints = () => [["esc", "назад"], ...base()];
|
|
253
|
+
app.push(b);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ——— Comparison ———
|
|
257
|
+
export class CompareView {
|
|
258
|
+
constructor(app) {
|
|
259
|
+
this.app = app;
|
|
260
|
+
this.scroll = 0;
|
|
261
|
+
}
|
|
262
|
+
key(k) {
|
|
263
|
+
const h = k.hot ?? k.name;
|
|
264
|
+
if (h === "x" || h === "D") { this.app.compare.clear(); this.app.pop(); this.app.toast("Сравнение очищено"); return true; }
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
hints() { return [["esc", "назад"], ["x", "очистить сравнение"]]; }
|
|
268
|
+
draw(g, r) {
|
|
269
|
+
const app = this.app;
|
|
270
|
+
const ps = [...app.compare.values()];
|
|
271
|
+
g.fill(r.x, r.y, r.w, r.h, T.bg);
|
|
272
|
+
g.text(r.x + 2, r.y, `СРАВНЕНИЕ · ${ps.length}`, { fg: T.lime, bold: true });
|
|
273
|
+
if (!ps.length) { g.textC(r.x, r.w, r.y + Math.floor(r.h / 2), "Отметьте товары клавишей x в любом списке", { fg: T.dim }); return; }
|
|
274
|
+
const labelW = 16;
|
|
275
|
+
const cw = Math.floor((r.w - labelW - 2) / ps.length);
|
|
276
|
+
const rows = [
|
|
277
|
+
["Цена", (p) => p.price, rub, "min"],
|
|
278
|
+
["За кг", (p) => perKg(p) ?? (p.unit === "кг" ? p.price : null), (v) => (v == null ? "—" : `${rub(v)}/кг`), "min"],
|
|
279
|
+
["Рейтинг", (p) => p.rating || null, (v, p) => (v ? `★ ${v.toFixed(1).replace(".", ",")} · ${compactNum(p.votes)}` : "—"), "max"],
|
|
280
|
+
["Белки", (p) => p.p, (v) => (v == null ? "—" : `${String(v).replace(".", ",")} г`), "max", T.blue],
|
|
281
|
+
["Жиры", (p) => p.f, (v) => (v == null ? "—" : `${String(v).replace(".", ",")} г`), null, T.yellow],
|
|
282
|
+
["Углеводы", (p) => p.c, (v) => (v == null ? "—" : `${String(v).replace(".", ",")} г`), null, T.orange],
|
|
283
|
+
["Калории", (p) => p.kcal, (v) => (v == null ? "—" : `${Math.round(v)} ккал`), null, T.lime],
|
|
284
|
+
["Добавки", (p) => (p.comp ? p.flags.additives : null), (v) => (v == null ? "—" : v ? `${v}` : "нет"), "min"],
|
|
285
|
+
];
|
|
286
|
+
let y = r.y + 2;
|
|
287
|
+
const ph = Math.min(8, Math.floor(r.h * 0.28));
|
|
288
|
+
ps.forEach((p, i) => {
|
|
289
|
+
const x = r.x + labelW + i * cw;
|
|
290
|
+
g.fill(x, y, cw - 1, r.h - 2, i % 2 ? T.panel : T.panel2);
|
|
291
|
+
const img = image(p.thumb ?? p.images?.[0], cw - 3, ph);
|
|
292
|
+
if (img && !img.error) g.photo(x + 1 + Math.floor((cw - 3 - img.w) / 2), y, img);
|
|
293
|
+
g.para(x + 1, y + ph + 1, cw - 3, 2, p.title, { fg: T.white, bold: true });
|
|
294
|
+
app.hit(x, y, cw - 1, ph + 3, { click: () => app.push(productView(app, p)) });
|
|
295
|
+
});
|
|
296
|
+
y += ph + 4;
|
|
297
|
+
for (const [label, get, fmt, best, color] of rows) {
|
|
298
|
+
if (y >= r.y + r.h - 1) break;
|
|
299
|
+
g.text(r.x + 2, y, label, { fg: T.dim, bold: true });
|
|
300
|
+
const vals = ps.map(get);
|
|
301
|
+
const nums = vals.filter((v) => v != null);
|
|
302
|
+
const target = best === "min" ? Math.min(...nums) : best === "max" ? Math.max(...nums) : null;
|
|
303
|
+
const maxv = Math.max(...nums, 0);
|
|
304
|
+
ps.forEach((p, i) => {
|
|
305
|
+
const x = r.x + labelW + i * cw;
|
|
306
|
+
const v = vals[i];
|
|
307
|
+
const isBest = target != null && v === target && nums.length > 1;
|
|
308
|
+
g.textW(x + 1, y, fmt(v, p), cw - 5, { fg: isBest ? T.lime : T.text, bold: isBest });
|
|
309
|
+
if (isBest) g.text(x + cw - 4, y, "▲", { fg: T.lime });
|
|
310
|
+
if (color && v != null && maxv) g.bar(x + 1, y + 1, cw - 4, v / maxv, color, T.raise);
|
|
311
|
+
});
|
|
312
|
+
y += color ? 3 : 2;
|
|
313
|
+
}
|
|
314
|
+
if (y < r.y + r.h - 1) {
|
|
315
|
+
g.text(r.x + 2, y, "Состав", { fg: T.dim, bold: true });
|
|
316
|
+
ps.forEach((p, i) => {
|
|
317
|
+
const x = r.x + labelW + i * cw;
|
|
318
|
+
let cy = y;
|
|
319
|
+
for (const [label, col] of flagPills(p)) { if (cy >= r.y + r.h) break; g.pill(x + 1, cy++, label, { fg: col, bg: mix(col, T.panel, 0.82) }); }
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|