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/views/catalog.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Deals: loyalty-card discounts and "buy N or more" discounts.
|
|
3
3
|
|
|
4
4
|
import { T } from "../ui/theme.js";
|
|
5
|
-
import { TextInput, Menu } from "../ui/widgets.js";
|
|
5
|
+
import { TextInput, Menu, ChipBar } from "../ui/widgets.js";
|
|
6
6
|
import { ProductBrowser } from "./browser.js";
|
|
7
7
|
import * as api from "../api.js";
|
|
8
8
|
import { store } from "../store.js";
|
|
@@ -25,24 +25,49 @@ export function catalogView(app) {
|
|
|
25
25
|
return pageOf(await api.search(st.q, { page, sort, category: st.category }));
|
|
26
26
|
},
|
|
27
27
|
drawHeader: (g, r) => drawSearch(g, r),
|
|
28
|
+
// Zones top to bottom: search box → chips → list. ↑↓ move between them, ← → walk the chips
|
|
28
29
|
keyHook: (k) => {
|
|
29
30
|
if (b.focus === "search") {
|
|
30
31
|
if (k.type === "paste") { input.key(k); return true; }
|
|
31
|
-
if (k.name === "down" || k.name === "tab"
|
|
32
|
+
if (k.name === "down" || k.name === "tab") { if (st.q) store.rememberQuery(st.q); b.focus = chips().length ? "chips" : "list"; return true; }
|
|
33
|
+
if (k.name === "escape") { b.focus = "list"; return true; }
|
|
32
34
|
if (k.name === "enter") { submit(); b.focus = "list"; return true; }
|
|
33
|
-
if (k.name === "up") return
|
|
35
|
+
if (k.name === "up") return false; // on to the header
|
|
34
36
|
return input.key(k);
|
|
35
37
|
}
|
|
36
38
|
if (k.type !== "key") return false;
|
|
39
|
+
if (b.focus === "chips") {
|
|
40
|
+
if (k.name === "up") { b.focus = "search"; return true; }
|
|
41
|
+
if (k.name === "down" || k.name === "escape") { b.focus = "list"; return true; }
|
|
42
|
+
if (bar.key(k, chips())) return true;
|
|
43
|
+
}
|
|
37
44
|
const h = k.hot ?? k.name;
|
|
38
45
|
if (h === "/" || k.ch === "." || (k.ctrl && k.name === "f")) { b.focus = "search"; return true; }
|
|
39
46
|
if (h === "g") { categoryMenu(); return true; }
|
|
40
47
|
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; }
|
|
48
|
+
if (k.name === "up" && b.list.sel === 0) { b.focus = chips().length ? "chips" : "search"; return true; }
|
|
42
49
|
return false;
|
|
43
50
|
},
|
|
44
51
|
});
|
|
45
52
|
b.focus = "search";
|
|
53
|
+
b.focusTop = () => { b.focus = "search"; };
|
|
54
|
+
const bar = new ChipBar();
|
|
55
|
+
|
|
56
|
+
// Chips under the search box: categories of the results, or recent queries and suggestions
|
|
57
|
+
function chips() {
|
|
58
|
+
if (st.q && b.facets?.length) {
|
|
59
|
+
return [
|
|
60
|
+
{ label: "все", prefix: "g ", run: () => setCategory(0), ...(st.category ? {} : { fg: T.bg, bg: T.lime, bold: true }) },
|
|
61
|
+
...b.facets.map((c) => ({ label: c.category_name.toLowerCase(), run: () => setCategory(c.category_id), ...(st.category === c.category_id ? { fg: T.bg, bg: T.lime, bold: true } : {}) })),
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
if (st.q) return [];
|
|
65
|
+
const recent = store.queries.slice(0, 5);
|
|
66
|
+
return [
|
|
67
|
+
...recent.map((q, i) => ({ label: q, prefix: i ? undefined : "недавно ", fg: T.mint, run: () => search(q) })),
|
|
68
|
+
...SUGGEST.filter((q) => !recent.includes(q)).map((q, i) => ({ label: q, prefix: i ? undefined : recent.length ? " попробуйте " : "попробуйте ", run: () => search(q) })),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
46
71
|
|
|
47
72
|
input.onChange = (v) => {
|
|
48
73
|
clearTimeout(st.timer);
|
|
@@ -75,76 +100,101 @@ export function catalogView(app) {
|
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
function drawSearch(g, r) {
|
|
78
|
-
const focused = b.focus === "search";
|
|
103
|
+
const focused = b.focus === "search" && !app.headerFocus;
|
|
79
104
|
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: "Поиск
|
|
105
|
+
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
106
|
g.text(r.x + 2, r.y + 1, "⌕", { fg: T.lime, bold: true, bg: T.panel });
|
|
82
107
|
input.draw(g, r.x + 4, r.y + 1, r.w - 8, { focused, bg: T.panel, blink: app.frame % 10 < 7 || !focused });
|
|
83
108
|
app.hit(r.x, r.y, r.w, 3, { click: () => { b.focus = "search"; } });
|
|
84
109
|
// Chips: categories of the results, or suggestions
|
|
85
|
-
|
|
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
|
-
}
|
|
110
|
+
bar.draw(g, app, r.x + 1, r.y + 3, r.x + r.w - 1, chips(), { focused: b.focus === "chips" && !app.headerFocus });
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
const baseHints = b.hints.bind(b);
|
|
107
|
-
b.hints = () => (b.focus === "search" ? [["⏎", "искать"], ["↓", "
|
|
114
|
+
b.hints = () => (b.focus === "search" ? [["⏎", "искать"], ["↓", "дальше"], ["^U", "очистить"], ["esc", "к списку"]]
|
|
115
|
+
: b.focus === "chips" ? [["← →", "выбор"], ["⏎", "выбрать"], ["↑", "к поиску"], ["↓", "к списку"]]
|
|
116
|
+
: baseHints());
|
|
108
117
|
b.search = search;
|
|
109
118
|
return b;
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
// Combo sets are not a discount type of the MCP server but regular catalog items. Search is fuzzy
|
|
122
|
+
// ("комбо" also finds soup sets and kombucha), so only real combos are kept: "Комбо-набор: …",
|
|
123
|
+
// "Комбо «Кофейная пара» …", "Набор: X + Y", a ready meal with a salad, or a combo shelf. Two queries
|
|
124
|
+
// find different ones. Café combos for pickup ("Комбо дня", "Комбо-обед") are not in the MCP catalog.
|
|
125
|
+
// Combos rank first: pages are fetched two at a time, in parallel (signed-in answers take about
|
|
126
|
+
// two seconds each), and a query stops at the first batch without a single combo.
|
|
127
|
+
// A ready meal "dish and salad" is a combo too ("Сосиски … с картофельным пюре и салат винегрет")
|
|
128
|
+
const isCombo = (it) => {
|
|
129
|
+
const name = it.name ?? "", cats = it.category ?? [];
|
|
130
|
+
if (/комбо/i.test(name) || /^\s*набор:/i.test(name) || cats.some((c) => /комбо/i.test(c.name ?? ""))) return true;
|
|
131
|
+
return /\sи\sсалат(?![а-яё])/i.test(name) && cats.some((c) => /готовая еда/i.test(c.name ?? ""));
|
|
132
|
+
};
|
|
133
|
+
const BATCH = 2; // two queries × two pages fill the four request slots at once
|
|
134
|
+
|
|
135
|
+
async function combos({ sort, vvonly }) {
|
|
136
|
+
const found = new Map();
|
|
137
|
+
await Promise.all(["комбо", "комбо-набор"].map(async (q) => {
|
|
138
|
+
for (let first = 1; first <= 15; first += BATCH) {
|
|
139
|
+
const pages = await Promise.all(Array.from({ length: BATCH }, (_, i) => api.search(q, { page: first + i, sort, vvonly }).catch(() => null)));
|
|
140
|
+
const hits = pages.flatMap((d) => (d?.items ?? []).filter(isCombo));
|
|
141
|
+
hits.forEach((it) => found.set(it.id, it));
|
|
142
|
+
if (!hits.length || pages.some((d) => !d?.meta?.has_more)) break;
|
|
143
|
+
}
|
|
144
|
+
}));
|
|
145
|
+
const items = [...found.values()];
|
|
146
|
+
return { items, total: items.length, hasMore: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const TITLES = { card: "Скидки по карте", quantity: "Скидки от нескольких штук", combo: "Комбо-наборы" };
|
|
150
|
+
|
|
112
151
|
export function dealsView(app) {
|
|
113
152
|
const saved = store.pref("deals", {});
|
|
114
|
-
const st = { type:
|
|
153
|
+
const st = { type: ["quantity", "combo"].includes(saved.type) ? saved.type : "card", vvonly: saved.vvonly === 0 ? 0 : 1 };
|
|
115
154
|
const persist = () => store.setPref("deals", { ...st });
|
|
116
155
|
const b = new ProductBrowser(app, {
|
|
117
156
|
prefKey: "deals",
|
|
118
157
|
title: "Акции",
|
|
119
158
|
headerH: 2,
|
|
120
159
|
hintsExtra: [["t", "тип акции"]],
|
|
121
|
-
loader: async (page, { sort }) =>
|
|
160
|
+
loader: async (page, { sort }) => (st.type === "combo"
|
|
161
|
+
? combos({ sort, vvonly: st.vvonly })
|
|
162
|
+
: pageOf(await api.discounts({ type: st.type, page, sort, vvonly: st.vvonly }))),
|
|
122
163
|
drawHeader: (g, r) => {
|
|
123
164
|
g.fill(r.x, r.y, r.w, r.h, T.bg);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 });
|
|
165
|
+
const x = g.text(r.x + 1, r.y, "% ", { fg: T.orange, bold: true });
|
|
166
|
+
bar.draw(g, app, x, r.y, r.x + r.w - 32, chips(), { focused: b.focus === "chips" && !app.headerFocus });
|
|
167
|
+
g.textR(r.x + r.w - 1, r.y, "скидки обновляются каждый день", { fg: T.dim });
|
|
138
168
|
},
|
|
169
|
+
// Zones: the deal-type switches above the list. ↑ from the first row reaches them, ↑ again the header
|
|
139
170
|
keyHook: (k) => {
|
|
171
|
+
if (k.type !== "key") return false;
|
|
140
172
|
const h = k.hot ?? k.name;
|
|
141
|
-
if (
|
|
142
|
-
|
|
173
|
+
if (b.focus === "chips") {
|
|
174
|
+
if (k.name === "down" || k.name === "escape") { b.focus = "list"; return true; }
|
|
175
|
+
if (k.name === "up") return false;
|
|
176
|
+
if (bar.key(k, chips())) return true;
|
|
177
|
+
} else if (k.name === "up" && b.list.sel === 0) { b.focus = "chips"; return true; }
|
|
178
|
+
if (h === "t") { setType({ card: "quantity", quantity: "combo", combo: "card" }[st.type]); return true; }
|
|
179
|
+
if (h === "v") { toggleBrand(); return true; }
|
|
143
180
|
return false;
|
|
144
181
|
},
|
|
145
182
|
});
|
|
146
|
-
|
|
147
|
-
|
|
183
|
+
const bar = new ChipBar();
|
|
184
|
+
const on = { fg: T.bg, bg: T.orange, bold: true };
|
|
185
|
+
const chips = () => [
|
|
186
|
+
{ label: "по карте лояльности", run: () => setType("card"), ...(st.type === "card" ? on : {}) },
|
|
187
|
+
{ label: "при покупке от N штук", run: () => setType("quantity"), ...(st.type === "quantity" ? on : {}) },
|
|
188
|
+
{ label: "комбо-наборы", run: () => setType("combo"), ...(st.type === "combo" ? on : {}) },
|
|
189
|
+
{ label: st.vvonly ? "только ВкусВилл" : "все бренды", prefix: " ", run: () => toggleBrand() },
|
|
190
|
+
];
|
|
191
|
+
b.focus = "list";
|
|
192
|
+
b.focusTop = () => { b.focus = "chips"; };
|
|
193
|
+
function toggleBrand() { st.vvonly = st.vvonly ? 0 : 1; persist(); b.reload(); }
|
|
194
|
+
const baseHints = b.hints.bind(b);
|
|
195
|
+
b.hints = () => (b.focus === "chips" ? [["← →", "выбор"], ["⏎", "выбрать"], ["↓", "к списку"]] : baseHints());
|
|
196
|
+
function setType(t) { st.type = t; persist(); b.title = TITLES[t]; b.reload(); }
|
|
197
|
+
b.title = TITLES[st.type];
|
|
148
198
|
return b;
|
|
149
199
|
}
|
|
150
200
|
|
package/src/views/overlays.js
CHANGED
|
@@ -8,6 +8,7 @@ import { productView } from "./product.js";
|
|
|
8
8
|
import { perKg, flagPills } from "../ui/product-ui.js";
|
|
9
9
|
import { image } from "../image.js";
|
|
10
10
|
import * as api from "../api.js";
|
|
11
|
+
import * as auth from "../auth.js";
|
|
11
12
|
import { normalize, rub, compactNum } from "../product.js";
|
|
12
13
|
import { store } from "../store.js";
|
|
13
14
|
import { TABS } from "../app.js";
|
|
@@ -87,11 +88,12 @@ export class Help {
|
|
|
87
88
|
hints() { return [["любая клавиша", "закрыть"]]; }
|
|
88
89
|
draw(g, app) {
|
|
89
90
|
const cols = [
|
|
90
|
-
["ВЕЗДЕ", [["1…6 / Tab", "вкладки"], ["^K или :", "палитра команд"], ["b", "товар по штрихкоду"], ["c", "корзина"], ["esc", "назад"], ["?", "эта справка"], ["q / ^C", "выход"], ["мышь", "клик, двойной клик, колесо"]]],
|
|
91
|
+
["ВЕЗДЕ", [["↑ ↓", "по строкам экрана: поиск, фильтры, список"], ["← → ⏎", "внутри строки: чипы, кнопки"], ["↑ у края", "в шапку: ← → вкладки, ↓ обратно"], ["1…6 / Tab", "вкладки"], ["^K или :", "палитра команд"], ["b", "товар по штрихкоду"], ["c", "корзина"], ["esc", "назад"], ["?", "эта справка"], ["q / ^C", "выход"], ["мышь", "клик, двойной клик, колесо"]]],
|
|
91
92
|
["ТОВАРЫ", [["/ или .", "поиск"], ["↑↓ j k", "выбор"], ["⏎ →", "карточка товара"], ["+ −", "в корзину / убрать"], ["*", "в избранное"], ["s", "сортировка"], ["f", "фильтры состава"], ["g", "категория (каталог)"], ["x / X", "отметить / сравнить"], ["o", "открыть на сайте"]]],
|
|
92
|
-
["КАРТОЧКА", [["← →", "листать фото"], ["[ ]", "соседний товар"], ["a", "аналоги"], ["PgUp PgDn", "прокрутка"]]],
|
|
93
|
+
["КАРТОЧКА", [["→ ⏎", "открыть из списка"], ["← esc", "назад к списку"], ["← →", "листать фото"], ["[ ]", "соседний товар"], ["a", "аналоги"], ["PgUp PgDn", "прокрутка"]]],
|
|
93
94
|
["КОРЗИНА", [["← → + −", "количество"], ["d / Del", "убрать"], ["o", "оформить: ссылка и QR"], ["u", "сверить цены"], ["D D", "очистить"]]],
|
|
94
95
|
["РЕЦЕПТЫ", [["f", "фильтры, аллергены"], ["␣", "отметить ингредиент"], ["a", "отметить все"], ["+", "ингредиенты в корзину"], ["s", "шаги на узком экране"]]],
|
|
96
|
+
["ПРОФИЛЬ", [["⏎", "войти во ВкусВилл"], ["c", "сменить client_id"], ["v", "вставить ссылку после входа"], ["L", "выйти и отозвать токены"]]],
|
|
95
97
|
["МАГАЗИНЫ", [["/", "поиск по адресу"], ["r g m f", "регион, город, метро, особ."], ["z ⏎", "приблизить карту"], ["L", "загрузить все на карту"], ["v", "карта на узком экране"], ["p", "Яндекс Карты"], ["y", "скопировать адрес"]]],
|
|
96
98
|
];
|
|
97
99
|
const w = Math.min(g.w - 4, 104), h = Math.min(g.h - 2, 30);
|
|
@@ -139,6 +141,9 @@ export class Palette {
|
|
|
139
141
|
cmds.push({ label: `Искать рецепты «${q}»`, run: () => app.views.searchRecipes(app, q) });
|
|
140
142
|
if (/^\d{13}$/.test(q)) cmds.push({ label: `Штрихкод ${q}`, run: () => openBarcode(app, q) });
|
|
141
143
|
}
|
|
144
|
+
const st = auth.cachedStatus();
|
|
145
|
+
if (!st.signedIn) cmds.push({ label: "Войти", hint: "ВкусВилл", run: () => app.views.signIn(app) });
|
|
146
|
+
else if (!st.viaEnv) cmds.push({ label: "Выйти", hint: "ВкусВилл", run: () => app.views.signOut(app) });
|
|
142
147
|
TABS.forEach((t, i) => cmds.push({ label: `Перейти: ${t.label}`, hint: String(i + 1), run: () => app.switchTab(t.id) }));
|
|
143
148
|
cmds.push({ label: "Оформить корзину — ссылка и QR", hint: rub(store.total), run: () => { app.switchTab("cart"); app.page.checkout?.(); } });
|
|
144
149
|
cmds.push({ label: "Товар по штрихкоду", hint: "b", run: () => app.views.barcode(app) });
|
|
@@ -249,7 +254,7 @@ export function analogsView(app, p) {
|
|
|
249
254
|
},
|
|
250
255
|
});
|
|
251
256
|
const base = b.hints.bind(b);
|
|
252
|
-
b.hints = () => [["esc", "назад"], ...base()];
|
|
257
|
+
b.hints = () => [["← esc", "назад"], ...base()];
|
|
253
258
|
app.push(b);
|
|
254
259
|
}
|
|
255
260
|
|
|
@@ -264,7 +269,7 @@ export class CompareView {
|
|
|
264
269
|
if (h === "x" || h === "D") { this.app.compare.clear(); this.app.pop(); this.app.toast("Сравнение очищено"); return true; }
|
|
265
270
|
return false;
|
|
266
271
|
}
|
|
267
|
-
hints() { return [["esc", "назад"], ["x", "очистить сравнение"]]; }
|
|
272
|
+
hints() { return [["← esc", "назад"], ["x", "очистить сравнение"]]; }
|
|
268
273
|
draw(g, r) {
|
|
269
274
|
const app = this.app;
|
|
270
275
|
const ps = [...app.compare.values()];
|
package/src/views/product.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { T } from "../ui/theme.js";
|
|
5
5
|
import { Braille } from "../ui/draw.js";
|
|
6
6
|
import { drawRich } from "../ui/widgets.js";
|
|
7
|
-
import { flagPills, drawPills, compositionLines, perKg } from "../ui/product-ui.js";
|
|
7
|
+
import { flagPills, drawPills, compositionLines, perKg, stock } from "../ui/product-ui.js";
|
|
8
8
|
import { image } from "../image.js";
|
|
9
9
|
import * as api from "../api.js";
|
|
10
10
|
import { normalize, rub, compactNum, qtyText, qtyStep } from "../product.js";
|
|
@@ -87,9 +87,10 @@ class ProductView {
|
|
|
87
87
|
const h = k.hot ?? k.name;
|
|
88
88
|
const imgs = this.p.images?.length || 1;
|
|
89
89
|
switch (true) {
|
|
90
|
-
|
|
91
|
-
case k.name === "
|
|
92
|
-
case k.name === "
|
|
90
|
+
// ← on the first photo is "back": the app pops the page
|
|
91
|
+
case k.name === "left": if (this.photo <= 0) return false; this.photo--; return true;
|
|
92
|
+
case k.name === "right": this.photo = Math.min(imgs - 1, this.photo + 1); return true;
|
|
93
|
+
case k.name === "up" || h === "k": if (this.scroll <= 0) return false; this.scroll--; return true;
|
|
93
94
|
case k.name === "down" || h === "j": this.scroll++; return true;
|
|
94
95
|
case k.name === "pageup": this.scroll = Math.max(0, this.scroll - 10); return true;
|
|
95
96
|
case k.name === "pagedown" || k.name === "space": this.scroll += 10; return true;
|
|
@@ -108,7 +109,7 @@ class ProductView {
|
|
|
108
109
|
|
|
109
110
|
hints() {
|
|
110
111
|
return [
|
|
111
|
-
["esc", "назад"], ["← →", "фото"], ["+ −", "корзина"],
|
|
112
|
+
["← esc", "назад"], ...((this.p.images?.length ?? 0) > 1 ? [["← →", "фото"]] : []), ["+ −", "корзина"],
|
|
112
113
|
...(this.list.length > 1 ? [["[ ]", "соседний"]] : []),
|
|
113
114
|
["a", "аналоги"], ["*", "избранное"], ["o", "на сайте"], ["x", "сравнить"],
|
|
114
115
|
];
|
|
@@ -166,6 +167,8 @@ class ProductView {
|
|
|
166
167
|
if (p.offInfo) g.textW(r.x + 3, y++, p.offInfo, lw - 6, { fg: T.orange, italic: true, bg: T.panel });
|
|
167
168
|
const pk = perKg(p);
|
|
168
169
|
g.textW(r.x + 3, y++, [p.pack, pk ? `${pk.toLocaleString("ru-RU").replace(/\s/g, " ")} ₽/кг` : ""].filter(Boolean).join(" · "), lw - 6, { fg: T.sub, bg: T.panel });
|
|
170
|
+
const st = stock(p);
|
|
171
|
+
if (st) g.text(r.x + 3, y++, `${st.out ? "○" : "●"} ${st.text}`, { fg: st.fg, bg: T.panel });
|
|
169
172
|
y++;
|
|
170
173
|
// Cart
|
|
171
174
|
const q = store.qty(p.id);
|