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.
@@ -0,0 +1,358 @@
1
+ // VkusVill recipes: search, filters (allergens, time, difficulty, cooking method), a preview card with a photo,
2
+ // and a recipe page with the steps and an ingredient checklist that goes into the cart with one key.
3
+
4
+ import { T, mix } from "../ui/theme.js";
5
+ import { TextInput, ListView, Menu } from "../ui/widgets.js";
6
+ import { image } from "../image.js";
7
+ import * as api from "../api.js";
8
+ import { normalize, qtyStep, plural, decode } from "../product.js";
9
+ import { store } from "../store.js";
10
+ import { productView } from "./product.js";
11
+ import { loader } from "./browser.js";
12
+ import { openUrl } from "../sys.js";
13
+ import { wrap, strWidth } from "../term/width.js";
14
+
15
+ const PARAMS = { exclude_allergens: "allergens", cooking_time: "time", complexity: "complexity", cooking_method: "method", feature: "feature", category: "category" };
16
+
17
+ const emptyFilters = () => ({ allergens: [], time: 0, complexity: 0, method: 0, feature: 0, category: 0 });
18
+
19
+ // Saved filters are validated: ids must be positive integers
20
+ function loadRecipeFilters() {
21
+ const saved = store.pref("recipes", {});
22
+ const f = emptyFilters();
23
+ const isId = (v) => Number.isInteger(v) && v > 0;
24
+ if (Array.isArray(saved.allergens)) f.allergens = saved.allergens.filter(isId).slice(0, 20);
25
+ for (const k of ["time", "complexity", "method", "feature", "category"]) if (isId(saved[k])) f[k] = saved[k];
26
+ return f;
27
+ }
28
+
29
+ export function recipesView(app) {
30
+ return new RecipesView(app);
31
+ }
32
+
33
+ class RecipesView {
34
+ constructor(app) {
35
+ this.app = app;
36
+ this.input = new TextInput({ placeholder: "блюдо или продукт: сырники, тыква, курица…" });
37
+ this.input.onChange = (v) => { clearTimeout(this.timer); this.timer = setTimeout(() => this.setQuery(v), 350); };
38
+ this.list = new ListView();
39
+ this.q = "";
40
+ this.f = loadRecipeFilters();
41
+ this.filterDefs = [];
42
+ this.focus = "list";
43
+ this.reset();
44
+ }
45
+
46
+ reset() { this.items = []; this.page = 0; this.hasMore = true; this.total = null; this.loading = false; this.error = null; this.req = (this.req ?? 0) + 1; this.list.sel = 0; this.list.top = 0; }
47
+ busy() { return this.loading; }
48
+ onShow() { if (!this.page && !this.loading) this.loadMore(); }
49
+
50
+ saveFilters() { store.setPref("recipes", this.f); }
51
+
52
+ tick() {
53
+ if (this.hasMore && !this.loading && !this.error && this.page && this.list.sel >= this.items.length - 4) this.loadMore();
54
+ }
55
+
56
+ setQuery(v) { v = v.trim(); if (v === this.q) return; this.q = v; this.reset(); this.loadMore(); }
57
+
58
+ async loadMore() {
59
+ if (this.loading || !this.hasMore) return;
60
+ this.loading = true;
61
+ const req = this.req;
62
+ try {
63
+ const d = await api.recipes({ q: this.q, page: this.page + 1, ...this.f });
64
+ if (req !== this.req) return;
65
+ this.items.push(...(d.items ?? []));
66
+ this.page++;
67
+ this.total = d.meta?.total;
68
+ this.hasMore = !!d.meta?.has_more;
69
+ if (d.meta?.filters?.length) this.filterDefs = d.meta.filters;
70
+ } catch (e) {
71
+ if (req === this.req) { this.error = e.message; this.hasMore = false; }
72
+ } finally {
73
+ if (req === this.req) this.loading = false;
74
+ this.app.invalidate();
75
+ }
76
+ }
77
+
78
+ filterMenu() {
79
+ const items = [];
80
+ for (const def of this.filterDefs) {
81
+ const key = PARAMS[def.param];
82
+ if (!key) continue;
83
+ items.push({ label: def.name.toUpperCase(), section: true });
84
+ for (const it of def.items) {
85
+ const checked = key === "allergens" ? this.f.allergens.includes(it.id) : this.f[key] === it.id;
86
+ items.push({ label: (key === "allergens" ? "без: " : "") + it.name.toLowerCase(), value: { key, id: it.id }, checked });
87
+ }
88
+ }
89
+ if (!items.length) return this.app.toast("Фильтры ещё загружаются", T.dim);
90
+ this.app.overlay(new Menu({
91
+ title: "Фильтры рецептов", multi: true, items, filter: true,
92
+ onPick: (it, all) => {
93
+ if (!it.value) { it.checked = false; return; }
94
+ const { key, id } = it.value;
95
+ if (key === "allergens") {
96
+ this.f.allergens = it.checked ? [...this.f.allergens, id] : this.f.allergens.filter((x) => x !== id);
97
+ } else {
98
+ // Single choice within a group
99
+ for (const o of all) if (o.value?.key === key && o !== it) o.checked = false;
100
+ this.f[key] = it.checked ? id : 0;
101
+ }
102
+ this.saveFilters();
103
+ this.reset();
104
+ this.loadMore();
105
+ },
106
+ }));
107
+ }
108
+
109
+ activeFilters() {
110
+ const out = [];
111
+ for (const def of this.filterDefs) {
112
+ const key = PARAMS[def.param];
113
+ for (const it of def.items) {
114
+ if (key === "allergens" ? this.f.allergens.includes(it.id) : this.f[key] === it.id) out.push({ key, id: it.id, label: (key === "allergens" ? "без " : "") + it.name.toLowerCase() });
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ open(r) { if (r) this.app.push(new RecipeView(this.app, r)); }
121
+
122
+ key(k) {
123
+ if (this.focus === "search") {
124
+ if (k.type === "paste") return this.input.key(k);
125
+ if (["down", "tab", "escape"].includes(k.name)) { this.focus = "list"; return true; }
126
+ if (k.name === "enter") { clearTimeout(this.timer); this.setQuery(this.input.value); this.focus = "list"; return true; }
127
+ return this.input.key(k);
128
+ }
129
+ if (k.type !== "key") return false;
130
+ const h = k.hot ?? k.name;
131
+ if (k.name === "up" && this.list.sel === 0) { this.focus = "search"; return true; }
132
+ if (this.list.key(k, this.items.length)) return true;
133
+ if (h === "/" || k.ch === ".") { this.focus = "search"; return true; }
134
+ if (h === "f") { this.filterMenu(); return true; }
135
+ if (k.name === "enter" || k.name === "right") { this.open(this.items[this.list.sel]); return true; }
136
+ if (h === "o" && this.items[this.list.sel]) { openUrl(this.items[this.list.sel].url); return true; }
137
+ if (k.name === "escape" && (this.q || this.activeFilters().length)) { this.input.set(""); this.q = ""; this.f = emptyFilters(); this.saveFilters(); this.reset(); this.loadMore(); return true; }
138
+ return false;
139
+ }
140
+
141
+ hints() {
142
+ if (this.focus === "search") return [["⏎", "искать"], ["↓", "к списку"]];
143
+ return [["↑↓", "выбор"], ["⏎", "рецепт"], ["/", "поиск"], ["f", "фильтры"], ["o", "на сайте"], ["esc", "сбросить"]];
144
+ }
145
+
146
+ draw(g, r) {
147
+ const app = this.app;
148
+ g.fill(r.x, r.y, r.w, r.h, T.bg);
149
+ const focused = this.focus === "search";
150
+ 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: `${this.total ?? "…"} ${plural(this.total ?? 0, "рецепт", "рецепта", "рецептов")}` });
151
+ g.text(r.x + 2, r.y + 1, "⌕", { fg: T.lime, bold: true, bg: T.panel });
152
+ this.input.draw(g, r.x + 4, r.y + 1, r.w - 8, { focused, bg: T.panel, blink: app.frame % 10 < 7 || !focused });
153
+ app.hit(r.x, r.y, r.w, 3, { click: () => { this.focus = "search"; } });
154
+ // Active filters
155
+ let x = r.x + 1;
156
+ const fy = r.y + 3;
157
+ x = g.text(x, fy, "f ", { fg: T.dim });
158
+ const act = this.activeFilters();
159
+ if (!act.length) {
160
+ const label = "фильтры: аллергены, время, сложность…";
161
+ g.pill(x, fy, label, { fg: T.sub, bg: T.raise });
162
+ app.hit(x, fy, strWidth(label) + 2, 1, { click: () => this.filterMenu() });
163
+ }
164
+ for (const a of act) {
165
+ const w = strWidth(a.label) + 4;
166
+ if (x + w > r.x + r.w - 2) break;
167
+ g.pill(x, fy, a.label + " ×", { fg: T.bg, bg: a.key === "allergens" ? T.orange : T.lime, bold: true });
168
+ app.hit(x, fy, w, 1, { click: () => {
169
+ if (a.key === "allergens") this.f.allergens = this.f.allergens.filter((i) => i !== a.id); else this.f[a.key] = 0;
170
+ this.saveFilters(); this.reset(); this.loadMore();
171
+ } });
172
+ x += w + 1;
173
+ }
174
+ const top = r.y + 4;
175
+ const H = r.h - 4;
176
+ const paneW = r.w >= 96 ? Math.max(40, Math.min(60, Math.floor(r.w * 0.42))) : 0;
177
+ const lw = r.w - paneW - (paneW ? 1 : 0);
178
+ g.fill(r.x, top, lw, H, T.panel);
179
+ if (!this.items.length) {
180
+ g.textC(r.x, lw, top + Math.floor(H / 2) - 1, this.loading ? "Листаю кулинарную книгу…" : this.error ? `⚠ ${this.error}` : "Ничего не нашлось", { fg: this.error ? T.red : T.dim, bg: T.panel });
181
+ if (this.loading) loader(g, r.x + Math.floor(lw / 2) - 6, top + Math.floor(H / 2) + 1, app.frame);
182
+ } else {
183
+ this.list.draw(g, app, r.x, top, lw - 1, H, this.items, (it, i, y, sel) => {
184
+ const bg = sel ? (this.focus === "list" ? T.selHi : T.sel) : T.panel;
185
+ g.fill(r.x, y, lw - 1, 2, bg);
186
+ if (sel) { g.text(r.x, y, "▌", { fg: T.lime, bg }); g.text(r.x, y + 1, "▌", { fg: T.lime, bg }); }
187
+ g.textW(r.x + 2, y, decode(it.name), lw - 6, { fg: sel ? T.white : T.text, bold: sel, bg });
188
+ const meta = [it.cooking_time?.name && `◷ ${it.cooking_time.name}`, it.complexity?.name && `◆ ${it.complexity.name.toLowerCase()}`, it.portions && `${it.portions} ${plural(it.portions, "порция", "порции", "порций")}`, it.nutritional?.calories && `${Math.round(it.nutritional.calories)} ккал/100 г`].filter(Boolean).join(" ");
189
+ g.textW(r.x + 2, y + 1, meta, lw - 6, { fg: T.dim, bg });
190
+ }, { rowH: 2, onPick: (it) => this.open(it) });
191
+ }
192
+ if (paneW) this.drawPane(g, { x: r.x + lw + 1, y: top, w: paneW - 1, h: H }, this.items[this.list.sel]);
193
+ }
194
+
195
+ drawPane(g, r, it) {
196
+ g.fill(r.x, r.y, r.w, r.h, T.panel);
197
+ g.vline(r.x - 1, r.y, r.h, T.line);
198
+ if (!it) return;
199
+ const app = this.app;
200
+ const ph = Math.min(Math.floor(r.h * 0.42), Math.round(r.w * 0.34));
201
+ const img = image(it.image, r.w, ph, { mode: "cover", trim: false });
202
+ if (img && !img.error) g.photo(r.x, r.y, img);
203
+ else g.photoPlaceholder(r.x, r.y, r.w, ph, app.frame, "готовлю фото…");
204
+ let y = r.y + ph + 1;
205
+ const x = r.x + 1, w = r.w - 2;
206
+ y += g.para(x, y, w, 2, decode(it.name), { fg: T.white, bold: true, bg: T.panel });
207
+ let cx = x;
208
+ for (const [label, col] of [[it.cooking_time?.name, T.lime], [it.complexity?.name?.toLowerCase(), T.yellow], [it.cooking_method?.name?.toLowerCase(), T.blue]]) {
209
+ if (!label || cx + strWidth(label) + 2 > x + w) continue;
210
+ g.pill(cx, y, label, { fg: col, bg: mix(col, T.panel, 0.82) });
211
+ cx += strWidth(label) + 3;
212
+ }
213
+ y += 2;
214
+ if (it.description) y += g.para(x, y, w, 4, decode(it.description).replace(/<[^>]+>/g, ""), { fg: T.sub, bg: T.panel }) + 1;
215
+ if (it.allergens?.length && y < r.y + r.h - 2) {
216
+ g.textW(x, y++, "Аллергены: " + it.allergens.map((a) => a.name.toLowerCase()).join(", "), w, { fg: T.orange, bg: T.panel });
217
+ y++;
218
+ }
219
+ if (y < r.y + r.h - 1) g.text(x, y++, `ИНГРЕДИЕНТЫ · ${it.ingredients?.length ?? 0}`, { fg: T.dim, bold: true, bg: T.panel });
220
+ for (const ing of it.ingredients ?? []) {
221
+ if (y >= r.y + r.h) break;
222
+ g.text(x, y, "·", { fg: T.green, bg: T.panel });
223
+ g.textW(x + 2, y, ing.name, w - 16, { fg: T.text, bg: T.panel });
224
+ g.textR(x + w, y++, ing.quantity ?? "", { fg: T.dim, bg: T.panel });
225
+ }
226
+ }
227
+ }
228
+
229
+ const SKIP = /^(соль|перец|вода|специи)|по вкусу/i;
230
+
231
+ class RecipeView {
232
+ constructor(app, r) {
233
+ this.app = app;
234
+ this.r = r;
235
+ this.ings = (r.ingredients ?? []).map((i) => ({ ...i, checked: !!i.ids?.length && !SKIP.test(i.name) && !SKIP.test(i.quantity ?? "") }));
236
+ this.list = new ListView();
237
+ this.scroll = 0;
238
+ this.adding = null;
239
+ }
240
+
241
+ busy() { return !!this.adding; }
242
+
243
+ async addChecked() {
244
+ const todo = this.ings.filter((i) => i.checked && i.ids?.length);
245
+ if (!todo.length) return this.app.toast("Отметьте ингредиенты пробелом", T.orange);
246
+ this.adding = { done: 0, total: todo.length, ok: 0 };
247
+ await Promise.all(todo.map(async (ing) => {
248
+ try {
249
+ const p = normalize(await api.details(ing.ids[0]));
250
+ if (p?.id) { if (!store.qty(p.id)) store.add(p, qtyStep(p)); this.adding.ok++; ing.product = p; }
251
+ } catch {}
252
+ this.adding.done++;
253
+ this.app.invalidate();
254
+ }));
255
+ const { ok, total } = this.adding;
256
+ this.adding = null;
257
+ this.app.toast(`В корзине ${ok} из ${total} ${plural(total, "ингредиента", "ингредиентов", "ингредиентов")}`, ok ? T.lime : T.orange);
258
+ }
259
+
260
+ async openIngredient(ing) {
261
+ if (!ing?.ids?.length) return;
262
+ try {
263
+ const p = ing.product ?? normalize(await api.details(ing.ids[0]));
264
+ this.app.push(productView(this.app, p));
265
+ } catch (e) { this.app.toast(e.message, T.red); }
266
+ }
267
+
268
+ key(k) {
269
+ if (k.type !== "key") return false;
270
+ const h = k.hot ?? k.name;
271
+ if (this.narrow && this.showSteps && k.name === "escape") { this.showSteps = false; return true; }
272
+ if (this.narrow && this.showSteps && (k.name === "up" || k.name === "down")) { this.scroll = Math.max(0, this.scroll + (k.name === "up" ? -1 : 1)); return true; }
273
+ if (this.list.key(k, this.ings.length)) return true;
274
+ const ing = this.ings[this.list.sel];
275
+ if (k.name === "space" || h === "x") { if (ing) ing.checked = !ing.checked; return true; }
276
+ if (h === "a") { const all = this.ings.every((i) => i.checked); this.ings.forEach((i) => { i.checked = !all && !!i.ids?.length; }); return true; }
277
+ if (h === "+" || h === "=") { this.addChecked(); return true; }
278
+ if (k.name === "enter" || k.name === "right") { this.openIngredient(ing); return true; }
279
+ if (k.name === "pagedown") { this.scroll += 8; return true; }
280
+ if (k.name === "pageup") { this.scroll = Math.max(0, this.scroll - 8); return true; }
281
+ if (h === "o") { openUrl(this.r.url); return true; }
282
+ if (h === "s" || k.name === "tab") { this.showSteps = !this.showSteps; return true; }
283
+ return false;
284
+ }
285
+
286
+ hints() { return [["esc", "назад"], ["␣", "отметить"], ["a", "все"], ["+", "в корзину"], ["⏎", "товар"], [this.narrow ? "s" : "PgDn", "шаги"], ["o", "на сайте"]]; }
287
+
288
+ draw(g, r) {
289
+ const app = this.app;
290
+ const it = this.r;
291
+ g.fill(r.x, r.y, r.w, r.h, T.bg);
292
+ const wide = r.w >= 100;
293
+ this.narrow = !wide;
294
+ if (!wide && this.showSteps) return this.drawSteps(g, r, r.x + 2, r.w - 5);
295
+ const lw = wide ? Math.min(62, Math.floor(r.w * 0.42)) : r.w;
296
+ // Left column: photo, meta, ingredients
297
+ g.fill(r.x, r.y, lw, r.h, T.panel);
298
+ const ph = Math.min(Math.floor(r.h * 0.34), Math.round(lw * 0.3));
299
+ const img = image(it.image, lw, ph, { mode: "cover", trim: false });
300
+ if (img && !img.error) g.photo(r.x, r.y, img); else g.photoPlaceholder(r.x, r.y, lw, ph, app.frame, "готовлю фото…");
301
+ g.text(r.x + 1, r.y, " ← ", { fg: T.bg, bg: T.lime, bold: true });
302
+ app.hit(r.x + 1, r.y, 3, 1, { click: () => app.pop() });
303
+ let y = r.y + ph + 1;
304
+ const x = r.x + 2, w = lw - 4;
305
+ y += g.para(x, y, w, 2, decode(it.name), { fg: T.white, bold: true, bg: T.panel });
306
+ const meta = [it.cooking_time?.name && `◷ ${it.cooking_time.name}`, it.complexity?.name && `◆ ${it.complexity.name.toLowerCase()}`, it.portions && `⊙ ${it.portions} ${plural(it.portions, "порция", "порции", "порций")}`].filter(Boolean).join(" ");
307
+ g.textW(x, y++, meta, w, { fg: T.sub, bg: T.panel });
308
+ const nu = it.nutritional;
309
+ if (nu?.calories) {
310
+ g.spans(x, y++, [["на 100 г ", { fg: T.dim, bg: T.panel }], ["Б ", { fg: T.blue, bold: true, bg: T.panel }], [`${nu.proteins} `, { bg: T.panel, fg: T.text }], ["Ж ", { fg: T.yellow, bold: true, bg: T.panel }], [`${nu.fats} `, { bg: T.panel, fg: T.text }], ["У ", { fg: T.orange, bold: true, bg: T.panel }], [`${nu.carbs} `, { bg: T.panel, fg: T.text }], [`${Math.round(nu.calories)} ккал`, { fg: T.lime, bold: true, bg: T.panel }]], w);
311
+ }
312
+ y++;
313
+ const checked = this.ings.filter((i) => i.checked).length;
314
+ g.text(x, y, `ИНГРЕДИЕНТЫ`, { fg: T.dim, bold: true, bg: T.panel });
315
+ const btn = this.adding ? ` кладу ${this.adding.done}/${this.adding.total}… ` : ` + в корзину: ${checked} `;
316
+ g.textR(x + w, y, btn, { fg: T.bg, bg: this.adding ? T.yellow : T.lime, bold: true });
317
+ app.hit(x + w - strWidth(btn), y, strWidth(btn), 1, { click: () => this.addChecked() });
318
+ y += 1;
319
+ this.list.draw(g, app, r.x, y, lw - 1, r.y + r.h - y, this.ings, (ing, i, ry, sel) => {
320
+ const bg = sel ? T.selHi : T.panel;
321
+ g.fill(r.x, ry, lw - 1, 1, bg);
322
+ const inCart = ing.product && store.qty(ing.product.id);
323
+ g.text(x, ry, ing.checked ? "◼" : "◻", { fg: ing.checked ? T.lime : ing.ids?.length ? T.dim : T.mute, bg });
324
+ g.textW(x + 2, ry, ing.name, w - 18, { fg: ing.ids?.length ? (sel ? T.white : T.text) : T.dim, bold: sel, bg });
325
+ g.textR(x + w, ry, (inCart ? "✓ " : "") + (ing.quantity ?? ""), { fg: inCart ? T.lime : T.sub, bg });
326
+ app.hit(x, ry, 2, 1, { click: () => { ing.checked = !ing.checked; } });
327
+ }, { onPick: (ing) => this.openIngredient(ing) });
328
+ if (!wide) return;
329
+ this.drawSteps(g, r, r.x + lw + 2, r.w - lw - 5);
330
+ }
331
+
332
+ // Cooking steps: on the right on a wide screen, full screen on a narrow one
333
+ drawSteps(g, r, sx, sw) {
334
+ const app = this.app;
335
+ const it = this.r;
336
+ const lines = [];
337
+ if (it.description) { wrap(decode(it.description).replace(/<[^>]+>/g, ""), sw).forEach((l) => lines.push([l, { fg: T.sub, italic: true }])); lines.push(null); }
338
+ if (it.allergens?.length) { lines.push([`Аллергены: ${it.allergens.map((a) => a.name.toLowerCase()).join(", ")}`, { fg: T.orange }]); lines.push(null); }
339
+ for (const st of it.steps ?? []) {
340
+ const n = String(st.step_number);
341
+ const tl = wrap(decode(st.text ?? "").replace(/<[^>]+>/g, ""), sw - 4);
342
+ tl.forEach((l, i) => lines.push(i === 0 ? { n, l } : [` ${l}`, { fg: T.text }]));
343
+ lines.push(null);
344
+ }
345
+ const maxS = Math.max(0, lines.length - (r.h - 2));
346
+ this.scroll = Math.min(this.scroll, maxS);
347
+ g.text(sx, r.y, `ПРИГОТОВЛЕНИЕ · ${(it.steps ?? []).length} ${plural((it.steps ?? []).length, "шаг", "шага", "шагов")}`, { fg: T.lime, bold: true });
348
+ for (let i = 0; i < r.h - 2; i++) {
349
+ const ln = lines[this.scroll + i];
350
+ if (!ln) continue;
351
+ if (Array.isArray(ln)) g.text(sx, r.y + 2 + i, ln[0], ln[1]);
352
+ else { g.text(sx, r.y + 2 + i, ln.n.padStart(2).padEnd(3), { fg: T.bg, bg: T.green, bold: true }); g.text(sx + 4, r.y + 2 + i, ln.l, { fg: T.white }); }
353
+ }
354
+ g.scrollbar(r.x + r.w - 1, r.y + 2, r.h - 2, this.scroll, r.h - 2, lines.length);
355
+ app.hit(sx, r.y, sw + 2, r.h, { wheel: (d) => { this.scroll = Math.max(0, Math.min(maxS, this.scroll + d * 3)); } });
356
+ }
357
+ }
358
+