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,251 @@
1
+ // Product list with a detail pane on the right: the shared base for catalog, deals, analogs and favourites.
2
+ // Loads pages as you scroll; filters and sorts what is already loaded in place.
3
+
4
+ import { T } from "../ui/theme.js";
5
+ import { ListView, Menu } from "../ui/widgets.js";
6
+ import { productRow, productPane, perKg } from "../ui/product-ui.js";
7
+ import { normalize, qtyText, qtyStep } from "../product.js";
8
+ import { store } from "../store.js";
9
+ import { productView } from "./product.js";
10
+ import { openUrl } from "../sys.js";
11
+
12
+ export const FILTERS = [
13
+ { id: "noSugar", label: "Без сахара в составе", test: (p) => p.comp && !p.flags.sugar },
14
+ { id: "noGluten", label: "Без глютена", test: (p) => p.comp && !p.flags.gluten },
15
+ { id: "noLactose", label: "Без лактозы", test: (p) => p.comp && !p.flags.lactose },
16
+ { id: "noAdd", label: "Без пищевых добавок", test: (p) => p.comp && !p.flags.additives },
17
+ { id: "noPalm", label: "Без пальмового масла", test: (p) => !p.flags.palm },
18
+ { id: "sale", label: "Только со скидкой", test: (p) => !!p.old },
19
+ { id: "top", label: "Рейтинг 4,8 и выше", test: (p) => p.rating >= 4.8 },
20
+ { id: "protein", label: "Белка 10 г и больше на 100 г", test: (p) => (p.p ?? 0) >= 10 },
21
+ { id: "light", label: "До 150 ккал на 100 г", test: (p) => p.kcal != null && p.kcal <= 150 },
22
+ ];
23
+
24
+ export const LOCAL_SORTS = [
25
+ { id: "perkg", label: "Дешевле за кг", fn: (a, b) => (perKg(a) ?? a.price) - (perKg(b) ?? b.price) },
26
+ { id: "protein", label: "Больше белка на рубль", fn: (a, b) => protPerRub(b) - protPerRub(a) },
27
+ { id: "kcal", label: "Меньше калорий", fn: (a, b) => (a.kcal ?? 1e9) - (b.kcal ?? 1e9) },
28
+ { id: "off", label: "Самая большая скидка", fn: (a, b) => (b.off ?? 0) - (a.off ?? 0) },
29
+ { id: "votes", label: "Больше всего оценок", fn: (a, b) => b.votes - a.votes },
30
+ ];
31
+ const protPerRub = (p) => (p.p && p.grams ? (p.p * p.grams) / 100 / p.price : p.p && p.unit === "кг" ? (p.p * 10) / p.price : 0);
32
+
33
+ export const SERVER_SORTS = [
34
+ { id: "popularity", label: "Популярные" },
35
+ { id: "rating", label: "С высоким рейтингом" },
36
+ { id: "price_asc", label: "Сначала дешёвые" },
37
+ { id: "price_desc", label: "Сначала дорогие" },
38
+ { id: "new", label: "Новинки" },
39
+ ];
40
+
41
+ export class ProductBrowser {
42
+ constructor(app, { title, loader, headerH = 0, drawHeader, emptyText = "Ничего не нашлось", serverSort = true, keyHook, hintsExtra = [], autoLoad = true, prefKey = null } = {}) {
43
+ this.app = app;
44
+ this.title = title;
45
+ this.loader = loader;
46
+ this.headerH = headerH;
47
+ this.drawHeaderFn = drawHeader;
48
+ this.emptyText = emptyText;
49
+ this.serverSort = serverSort;
50
+ this.keyHook = keyHook;
51
+ this.hintsExtra = hintsExtra;
52
+ this.autoLoad = autoLoad;
53
+ this.list = new ListView();
54
+ this.prefKey = prefKey;
55
+ // Sort and filters survive restarts for named browsers (catalog, deals)
56
+ const saved = prefKey ? store.pref(`browser.${prefKey}`, {}) : {};
57
+ this.filters = new Set((Array.isArray(saved.filters) ? saved.filters : []).filter((id) => FILTERS.some((f) => f.id === id)));
58
+ this.sort = SERVER_SORTS.some((s) => s.id === saved.sort) ? saved.sort : "popularity";
59
+ this.localSort = LOCAL_SORTS.some((s) => s.id === saved.localSort) ? saved.localSort : null;
60
+ this.reset();
61
+ }
62
+
63
+ reset() {
64
+ this.items = [];
65
+ this.page = 0;
66
+ this.total = null;
67
+ this.hasMore = true;
68
+ this.loading = false;
69
+ this.error = null;
70
+ this.req = (this.req ?? 0) + 1;
71
+ this.autoLoads = 0;
72
+ this.list.sel = 0;
73
+ this.list.top = 0;
74
+ this.loaded = false;
75
+ }
76
+
77
+ busy() { return this.loading; }
78
+
79
+ reload() { this.reset(); this.loadMore(); }
80
+
81
+ onShow() { if (!this.loaded && !this.loading) this.loadMore(); }
82
+
83
+ async loadMore() {
84
+ if (this.loading || !this.hasMore) return;
85
+ this.loading = true;
86
+ this.error = null;
87
+ const req = this.req;
88
+ try {
89
+ const res = await this.loader(this.page + 1, { sort: this.sort });
90
+ if (req !== this.req) return;
91
+ const seen = new Set(this.items.map((p) => p.id));
92
+ for (const it of res.items ?? []) { const p = normalize(it); if (!seen.has(p.id)) { this.items.push(p); seen.add(p.id); } }
93
+ this.page++;
94
+ this.total = res.total ?? this.items.length;
95
+ this.hasMore = !!res.hasMore;
96
+ this.facets = res.facets ?? this.facets;
97
+ this.loaded = true;
98
+ } catch (e) {
99
+ if (req !== this.req) return;
100
+ this.error = e.message;
101
+ this.hasMore = false;
102
+ } finally {
103
+ if (req === this.req) this.loading = false;
104
+ this.app.invalidate();
105
+ }
106
+ }
107
+
108
+ get visible() {
109
+ let v = this.items;
110
+ if (this.filters.size) v = v.filter((p) => [...this.filters].every((id) => FILTERS.find((f) => f.id === id).test(p)));
111
+ if (this.localSort) { const s = LOCAL_SORTS.find((x) => x.id === this.localSort); v = [...v].sort(s.fn); }
112
+ return v;
113
+ }
114
+
115
+ // Load more when the selection nears the end of the list or filters left too few items
116
+ tick() {
117
+ if (!this.autoLoad || !this.hasMore || this.loading || this.error || !this.items.length) return;
118
+ const n = this.visible.length;
119
+ const nearEnd = this.list.sel >= n - 6;
120
+ const sparse = n < this.list.height && this.autoLoads < 10;
121
+ if (nearEnd || sparse) { this.autoLoads++; this.loadMore(); }
122
+ }
123
+
124
+ get current() { return this.visible[this.list.sel]; }
125
+
126
+ open(p) {
127
+ if (!p) return;
128
+ store.viewed(p);
129
+ this.app.push(productView(this.app, p, { list: this.visible, index: this.visible.indexOf(p) }));
130
+ }
131
+
132
+ persist() {
133
+ if (this.prefKey) store.setPref(`browser.${this.prefKey}`, { sort: this.sort, localSort: this.localSort, filters: [...this.filters] });
134
+ }
135
+
136
+ sortMenu() {
137
+ const items = [];
138
+ if (this.serverSort) {
139
+ items.push({ label: "ВКУСВИЛЛ СОРТИРУЕТ", section: true });
140
+ for (const s of SERVER_SORTS) items.push({ label: s.label, value: { server: s.id }, checked: !this.localSort && this.sort === s.id });
141
+ items.push({ label: "ПО ЗАГРУЖЕННЫМ ТОВАРАМ", section: true });
142
+ }
143
+ for (const s of LOCAL_SORTS) items.push({ label: s.label, value: { local: s.id }, checked: this.localSort === s.id });
144
+ if (!this.serverSort) items.unshift({ label: "Как у ВкусВилла", value: { local: null }, checked: !this.localSort });
145
+ this.app.overlay(new Menu({
146
+ title: "Сортировка", items,
147
+ onPick: (it) => {
148
+ if (!it.value) return;
149
+ if (it.value.server) { this.localSort = null; if (this.sort !== it.value.server) { this.sort = it.value.server; this.reload(); } }
150
+ else { this.localSort = it.value.local; this.list.sel = 0; }
151
+ this.persist();
152
+ },
153
+ }));
154
+ }
155
+
156
+ filterMenu() {
157
+ this.app.overlay(new Menu({
158
+ title: "Фильтры по составу и цене", multi: true,
159
+ items: FILTERS.map((f) => ({ label: f.label, value: f.id, checked: this.filters.has(f.id) })),
160
+ onPick: (it) => { if (it.checked) this.filters.add(it.value); else this.filters.delete(it.value); this.list.sel = 0; this.autoLoads = 0; this.persist(); },
161
+ }));
162
+ }
163
+
164
+ sortLabel() {
165
+ if (this.localSort) return LOCAL_SORTS.find((s) => s.id === this.localSort).label.toLowerCase();
166
+ return SERVER_SORTS.find((s) => s.id === this.sort)?.label.toLowerCase() ?? "";
167
+ }
168
+
169
+ addToCart(p, sign = 1) {
170
+ if (!p) return;
171
+ const before = store.qty(p.id);
172
+ store.add(p, sign * qtyStep(p));
173
+ const q = store.qty(p.id);
174
+ if (sign > 0) this.app.toast(before ? `В корзине ${qtyText(p, q)} · ${p.title}` : `✚ ${p.title}`);
175
+ else this.app.toast(q ? `В корзине ${qtyText(p, q)}` : `Убрано: ${p.title}`, T.orange);
176
+ }
177
+
178
+ key(k) {
179
+ if (this.keyHook?.(k)) return true;
180
+ if (k.type !== "key") return false;
181
+ const vis = this.visible;
182
+ if (this.list.key(k, vis.length)) return true;
183
+ const p = vis[this.list.sel];
184
+ const h = k.hot ?? k.name;
185
+ if (k.name === "enter" || k.name === "right") { this.open(p); return true; }
186
+ if (h === "+" || h === "=") { this.addToCart(p, 1); return true; }
187
+ if (h === "-" || h === "_") { this.addToCart(p, -1); return true; }
188
+ if (h === "*" || h === "l") { if (p) this.app.toast(store.toggleFav(p) ? `♥ В избранном: ${p.title}` : "Убрано из избранного", T.pink); return true; }
189
+ if (h === "s") { this.sortMenu(); return true; }
190
+ if (h === "f") { this.filterMenu(); return true; }
191
+ if (h === "o" && p) { openUrl(p.url); this.app.toast("Открываю на сайте ВкусВилла"); return true; }
192
+ if (h === "x" && p) { this.app.toggleCompare(p); return true; }
193
+ if (h === "X") { this.app.openCompare(); return true; }
194
+ if (h === "R") { this.reload(); return true; }
195
+ return false;
196
+ }
197
+
198
+ hints() {
199
+ if (this.error && !this.items.length) return [["R", "попробовать снова"], ...this.hintsExtra];
200
+ return [["↑↓", "выбор"], ["⏎", "товар"], ["+ −", "корзина"], ...this.hintsExtra, ["s", "сорт"], ["f", "фильтры"], ["*", "избранное"], ["x", "сравнить"]];
201
+ }
202
+
203
+ draw(g, r) {
204
+ const app = this.app;
205
+ g.fill(r.x, r.y, r.w, r.h, T.bg);
206
+ let y = r.y;
207
+ if (this.drawHeaderFn) { this.drawHeaderFn(g, { x: r.x, y, w: r.w, h: this.headerH }); y += this.headerH; }
208
+ const h = r.y + r.h - y;
209
+ const showPane = r.w >= 96;
210
+ const paneW = showPane ? Math.max(38, Math.min(58, Math.floor(r.w * 0.38))) : 0;
211
+ const listW = r.w - paneW - (showPane ? 1 : 0);
212
+ const vis = this.visible;
213
+ // List header row
214
+ const lx = r.x + 1, lw = listW - 2;
215
+ g.fill(r.x, y, listW, h, T.panel);
216
+ const count = this.total != null ? `${vis.length}${this.filters.size ? ` из ${this.items.length}` : ""}${this.total > this.items.length ? ` · всего ${this.total}` : ""}` : "";
217
+ g.spans(lx + 1, y, [[this.title ?? "", { fg: T.lime, bold: true, bg: T.panel }], [count ? ` ${count}` : "", { fg: T.dim, bg: T.panel }]], lw - 30);
218
+ const meta = `↕ ${this.sortLabel()}${this.filters.size ? ` ⚑ ${this.filters.size}` : ""}`;
219
+ g.textR(lx + lw, y, meta, { fg: this.filters.size || this.localSort ? T.yellow : T.dim, bg: T.panel });
220
+ app.hit(lx + lw - meta.length, y, meta.length, 1, { click: () => this.sortMenu() });
221
+ g.hline(r.x, y + 1, listW, T.line);
222
+ const ly = y + 2, lh = h - 2;
223
+ if (!vis.length) {
224
+ const msg = this.loading ? "Загружаю…" : this.error ? `⚠ ${this.error}` : this.items.length ? "Все загруженные товары отсеяны фильтрами" : this.emptyText;
225
+ g.textC(r.x, listW, ly + Math.floor(lh / 2) - 1, msg, { fg: this.error ? T.red : T.dim, bg: T.panel });
226
+ if (this.error) g.textC(r.x, listW, ly + Math.floor(lh / 2) + 1, "R — попробовать снова", { fg: T.dim, bg: T.panel });
227
+ else if (this.loading) loader(g, r.x + Math.floor(listW / 2) - 6, ly + Math.floor(lh / 2) + 1, app.frame);
228
+ } else {
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) ? "⇄" : "" });
231
+ }, { onPick: (p) => this.open(p) });
232
+ if (this.loading) g.textC(r.x, listW, r.y + r.h - 1, ` ${"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[app.frame % 10]} подгружаю… `, { fg: T.lime, bg: T.panel });
233
+ }
234
+ if (showPane) {
235
+ const px = r.x + listW + 1;
236
+ g.vline(r.x + listW, y, h, T.line, "│");
237
+ productPane(g, app, vis[this.list.sel], px, y, paneW - 1, h);
238
+ app.hit(px, y, paneW, h, { click: (ev) => { if (ev.double) this.open(vis[this.list.sel]); } });
239
+ }
240
+ }
241
+ }
242
+
243
+ // Running "caterpillar" loading indicator
244
+ export function loader(g, x, y, frame) {
245
+ const n = 12;
246
+ for (let i = 0; i < n; i++) {
247
+ const d = Math.abs(((frame % (n * 2)) - n) - (i - n / 2));
248
+ g.text(x + i, y, "▬", { fg: d < 1.5 ? T.lime : d < 3 ? T.green : T.mute });
249
+ }
250
+ }
251
+
@@ -0,0 +1,306 @@
1
+ // Cart: lines with quantity steppers, the total in large digits, savings, calories and macros of the whole cart,
2
+ // a breakdown by category. Checkout is a VkusVill link plus a QR code right in the terminal.
3
+
4
+ import { T, mix, ramp } from "../ui/theme.js";
5
+ import { ListView, shadow } from "../ui/widgets.js";
6
+ import { G } from "../ui/draw.js";
7
+ import * as api from "../api.js";
8
+ import { normalize, rub, qtyText, qtyStep, lineTotal, lineGrams, plural } from "../product.js";
9
+ import { store, CART_MAX } from "../store.js";
10
+ import { productView } from "./product.js";
11
+ import { openUrl, copy } from "../sys.js";
12
+ import { strWidth, truncate } from "../term/width.js";
13
+
14
+ const STALE = 6 * 3600e3;
15
+
16
+ const CAT_COLORS = [T.green, T.lime, T.blue, T.orange, T.violet, T.yellow, T.pink, T.mint];
17
+
18
+ export function cartView(app) {
19
+ return new CartView(app);
20
+ }
21
+
22
+ class CartView {
23
+ constructor(app) {
24
+ this.app = app;
25
+ this.list = new ListView();
26
+ this.confirmClear = false;
27
+ }
28
+
29
+ get lines() { return store.cart; }
30
+
31
+ key(k) {
32
+ if (k.type !== "key") return false;
33
+ const lines = this.lines;
34
+ if (this.list.key(k, lines.length)) { this.confirmClear = false; return true; }
35
+ const line = lines[this.list.sel];
36
+ const h = k.hot ?? k.name;
37
+ if (line && (h === "+" || h === "=" || k.name === "right")) { store.add(line.p, qtyStep(line.p)); return true; }
38
+ if (line && (h === "-" || h === "_" || k.name === "left")) { store.add(line.p, -qtyStep(line.p)); return true; }
39
+ if (line && (k.name === "delete" || k.name === "backspace" || h === "d")) { store.remove(line.p.id); this.app.toast(`Убрано: ${line.p.title}`, T.orange); return true; }
40
+ if (line && k.name === "enter") { this.openLine(this.list.sel); return true; }
41
+ if (h === "o") { this.checkout(); return true; }
42
+ if (h === "D") {
43
+ if (!lines.length) return true;
44
+ if (this.confirmClear) { store.clearCart(); this.confirmClear = false; this.app.toast("Корзина очищена", T.orange); }
45
+ else { this.confirmClear = true; this.app.toast("Ещё раз Shift+D — очистить корзину", T.red); }
46
+ return true;
47
+ }
48
+ if (h === "u") { this.refresh(); return true; }
49
+ return false;
50
+ }
51
+
52
+ hints() {
53
+ if (!this.lines.length) return [["1", "в каталог"], ["2", "акции"], ["3", "рецепты"]];
54
+ return [["↑↓", "выбор"], ["← →", "кол-во"], ["d", "убрать"], ["⏎", "товар"], ["o", "оформить"], ["u", "обновить цены"], ["D", "очистить"]];
55
+ }
56
+
57
+ // A cart line opens with its neighbours, so [ and ] page through the cart
58
+ openLine(i) {
59
+ const lines = this.lines;
60
+ if (lines[i]) this.app.push(productView(this.app, lines[i].p, { list: lines.map((l) => l.p), index: i }));
61
+ }
62
+
63
+ // Prices in the cart are snapshots: lines older than STALE are re-checked quietly in the background when the cart opens
64
+ onShow() {
65
+ if (!this.refreshing && store.stale(STALE).length) this.refresh({ quiet: true });
66
+ }
67
+
68
+ busy() { return this.refreshing; }
69
+
70
+ async refresh({ quiet = false } = {}) {
71
+ if (this.refreshing) return;
72
+ const lines = quiet ? store.stale(STALE) : [...store.cart];
73
+ if (!lines.length) return;
74
+ this.refreshing = true;
75
+ if (!quiet) this.app.toast("Сверяю цены…");
76
+ let changed = 0, failed = 0;
77
+ await Promise.all(lines.map(async (l) => {
78
+ try {
79
+ const p = normalize(await api.call("vkusvill_product_details", { id: l.p.id }, { prio: "lo", fresh: true }));
80
+ if (store.refresh(l.p.id, p)) changed++;
81
+ } catch { failed++; }
82
+ }));
83
+ this.refreshing = false;
84
+ store.changed();
85
+ if (changed) this.app.toast(`Цены изменились у ${changed} ${plural(changed, "товара", "товаров", "товаров")}`, T.orange);
86
+ else if (!quiet) this.app.toast(failed ? `Не удалось сверить ${failed}` : "Цены актуальны", failed ? T.orange : T.lime);
87
+ }
88
+
89
+ checkout() {
90
+ if (!store.cart.length) return this.app.toast("Корзина пуста", T.orange);
91
+ this.app.overlay(new Checkout(this.app));
92
+ }
93
+
94
+ draw(g, r) {
95
+ const app = this.app;
96
+ g.fill(r.x, r.y, r.w, r.h, T.bg);
97
+ const lines = this.lines;
98
+ if (!lines.length) return this.drawEmpty(g, r);
99
+ const sideW = r.w >= 100 ? Math.min(52, Math.floor(r.w * 0.38)) : 0;
100
+ const lw = r.w - sideW - (sideW ? 1 : 0);
101
+ g.fill(r.x, r.y, lw, r.h, T.panel);
102
+ const n = lines.length;
103
+ 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
+ g.hline(r.x, r.y + 1, lw, T.line);
105
+ // Table: product | − qty + | sum
106
+ const rowH = 2;
107
+ 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;
109
+ 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 }); }
111
+ 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 });
116
+ // Stepper
117
+ const sx = r.x + lw - right;
118
+ g.text(sx, y, " − ", { fg: T.white, bg: sel ? T.orange : T.raise, bold: true });
119
+ app.hit(sx, y, 3, 1, { click: () => store.add(p, -qtyStep(p)) });
120
+ const qt = qtyText(p, l.q);
121
+ g.textC(sx + 3, 10, y, qt, { fg: T.white, bold: true, bg });
122
+ g.text(sx + 13, y, " + ", { fg: T.bg, bg: sel ? T.lime : T.green, bold: true });
123
+ 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) });
127
+ }, { rowH, onPick: (l, i) => this.openLine(i) });
128
+ if (sideW) this.drawSummary(g, { x: r.x + lw + 1, y: r.y, w: sideW - 1, h: r.h });
129
+ else {
130
+ const y = r.y + r.h - 1;
131
+ g.fill(r.x, y, r.w, 1, T.panel2);
132
+ 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
+ const label = " o ОФОРМИТЬ ▶ ";
134
+ g.textR(r.x + r.w - 1, y, label, { fg: T.bg, bg: T.lime, bold: true });
135
+ app.hit(r.x + r.w - 1 - strWidth(label), y, strWidth(label), 1, { click: () => this.checkout() });
136
+ }
137
+ }
138
+
139
+ drawSummary(g, r) {
140
+ const app = this.app;
141
+ g.fill(r.x, r.y, r.w, r.h, T.panel2);
142
+ let y = r.y + 1;
143
+ const x = r.x + 2, w = r.w - 4;
144
+ g.text(x, y++, "ИТОГО", { fg: T.dim, bold: true, bg: T.panel2 });
145
+ g.bigPrice(x, y, store.total, T.lime);
146
+ y += 6;
147
+ const sav = store.savings;
148
+ if (sav > 0) g.spans(x, y++, [["экономия по акциям ", { fg: T.sub, bg: T.panel2 }], [rub(sav), { fg: T.orange, bold: true, bg: T.panel2 }]]);
149
+ y++;
150
+ // Checkout button
151
+ const label = " ОФОРМИТЬ ВО ВКУСВИЛЛЕ ▶ ";
152
+ const bw = Math.min(w, strWidth(label));
153
+ const pulse = mix(T.green, T.lime, (Math.sin(app.frame / 4) + 1) / 4);
154
+ g.fill(x, y, bw, 3, pulse);
155
+ g.textC(x, bw, y + 1, label.trim(), { fg: T.bg, bg: pulse, bold: true });
156
+ app.hit(x, y, bw, 3, { click: () => this.checkout() });
157
+ this.animated = true;
158
+ y += 4;
159
+ g.textW(x, y++, "o — ссылка и QR-код для телефона", w, { fg: T.dim, bg: T.panel2 });
160
+ y++;
161
+ // Calories and macros of the cart
162
+ const tot = { p: 0, f: 0, c: 0, kcal: 0, g: 0 };
163
+ let known = 0;
164
+ for (const l of store.cart) {
165
+ const gr = lineGrams(l.p, l.q);
166
+ if (!gr || l.p.kcal == null) continue;
167
+ known++;
168
+ tot.g += gr;
169
+ for (const k of ["p", "f", "c", "kcal"]) tot[k] += ((l.p[k] ?? 0) * gr) / 100;
170
+ }
171
+ if (known && y + 6 < r.y + r.h) {
172
+ g.text(x, y++, `В КОРЗИНЕ · ${(tot.g / 1000).toFixed(1).replace(".", ",")} кг продуктов`, { fg: T.dim, bold: true, bg: T.panel2 });
173
+ const k = Math.round(tot.kcal).toLocaleString("ru-RU").replace(/\s/g, " ");
174
+ g.spans(x, y++, [[k, { fg: T.lime, bold: true, bg: T.panel2 }], [" ккал", { fg: T.sub, bg: T.panel2 }], [` ≈ ${Math.max(1, Math.round(tot.kcal / 2000))} ${plural(Math.max(1, Math.round(tot.kcal / 2000)), "день", "дня", "дней")} по 2000`, { fg: T.dim, bg: T.panel2 }]], w);
175
+ const maxv = Math.max(tot.p, tot.f, tot.c, 1);
176
+ for (const [name, v, col] of [["белки", tot.p, T.blue], ["жиры", tot.f, T.yellow], ["углев.", tot.c, T.orange]]) {
177
+ g.text(x, y, name, { fg: T.sub, bg: T.panel2 });
178
+ g.bar(x + 8, y, w - 17, v / maxv, col, T.raise);
179
+ g.textR(x + w, y++, `${Math.round(v)} г`, { fg: T.text, bg: T.panel2 });
180
+ }
181
+ if (known < store.cart.length) g.text(x, y++, `без данных: ${store.cart.length - known}`, { fg: T.mute, bg: T.panel2 });
182
+ y++;
183
+ }
184
+ // By category
185
+ const byCat = new Map();
186
+ for (const l of store.cart) {
187
+ const c = l.p.category?.at(-1) ?? l.p.category?.[0] ?? "Другое";
188
+ byCat.set(c, (byCat.get(c) ?? 0) + lineTotal(l.p, l.q));
189
+ }
190
+ const cats = [...byCat.entries()].sort((a, b) => b[1] - a[1]);
191
+ if (cats.length && y + 3 < r.y + r.h) {
192
+ g.text(x, y++, "КУДА УХОДЯТ ДЕНЬГИ", { fg: T.dim, bold: true, bg: T.panel2 });
193
+ // Solid bar of shares
194
+ let bx = 0;
195
+ const total = store.total || 1;
196
+ cats.forEach(([, v], i) => {
197
+ const cw = i === cats.length - 1 ? w - bx : Math.round((v / total) * w);
198
+ g.text(x + bx, y, " ".repeat(Math.max(0, cw)), { bg: CAT_COLORS[i % CAT_COLORS.length] });
199
+ bx += cw;
200
+ });
201
+ y += 2;
202
+ for (const [i, [name, v]] of cats.entries()) {
203
+ if (y >= r.y + r.h - 1) break;
204
+ g.text(x, y, "■", { fg: CAT_COLORS[i % CAT_COLORS.length], bg: T.panel2 });
205
+ g.textW(x + 2, y, name, w - 14, { fg: T.sub, bg: T.panel2 });
206
+ g.textR(x + w, y++, `${Math.round((v / total) * 100)}% ${rub(v)}`, { fg: T.text, bg: T.panel2 });
207
+ }
208
+ }
209
+ }
210
+
211
+ drawEmpty(g, r) {
212
+ g.fill(r.x, r.y, r.w, r.h, T.bg);
213
+ // Empty trolley in box-drawing art
214
+ const art = [
215
+ " ┌─────────────────────┐",
216
+ "───┤ ░░░░░░░░░░░░░░░░░░░ │",
217
+ " │ ░░░░░░░░░░░░░░░░░░░ │",
218
+ " │ ░░░░░░░░░░░░░░░░░░░ │",
219
+ " └──┬───────────────┬──┘",
220
+ " ◯ ◯",
221
+ ];
222
+ const ax = r.x + Math.floor((r.w - 26) / 2);
223
+ const ay = r.y + Math.floor(r.h / 2) - 6;
224
+ art.forEach((l, i) => g.text(ax, ay + i, l, { fg: i === 5 ? T.lime : T.green }));
225
+ g.textC(r.x, r.w, ay + 8, "Корзина пуста", { fg: T.white, bold: true });
226
+ g.textC(r.x, r.w, ay + 9, "найдите что-нибудь в каталоге (1) или загляните в акции (2)", { fg: T.dim });
227
+ g.textC(r.x, r.w, ay + 10, "в рецептах (3) можно положить в корзину все ингредиенты разом", { fg: T.dim });
228
+ this.animated = false;
229
+ }
230
+ }
231
+
232
+ // Checkout: VkusVill link + QR code for the phone
233
+ class Checkout {
234
+ constructor(app) {
235
+ this.app = app;
236
+ this.link = null;
237
+ this.error = null;
238
+ this.animated = true;
239
+ this.make();
240
+ }
241
+
242
+ async make() {
243
+ try {
244
+ const products = store.cart.map((l) => ({ id: l.p.id, q: Math.round(l.q * 100) / 100 }));
245
+ const d = await api.cartLink(products);
246
+ this.link = d.link ?? d.url ?? null;
247
+ if (!this.link) throw new Error("ВкусВилл не вернул ссылку");
248
+ } catch (e) {
249
+ this.error = e.message;
250
+ }
251
+ this.animated = false;
252
+ this.app.invalidate();
253
+ }
254
+
255
+ key(k, app) {
256
+ const h = k.hot ?? k.name;
257
+ if (k.name === "escape" || h === "q") return app.closeOverlay(this);
258
+ if (!this.link) return;
259
+ if (h === "o" || k.name === "enter") { openUrl(this.link); app.toast("Открываю корзину ВкусВилла"); }
260
+ if (h === "y" || h === "c") copy(this.link).then(() => app.toast("Ссылка скопирована"));
261
+ }
262
+
263
+ hints() { return this.link ? [["o", "открыть в браузере"], ["y", "скопировать"], ["esc", "закрыть"]] : [["esc", "закрыть"]]; }
264
+
265
+ draw(g, app) {
266
+ const qs = this.link ? G.qrSize(this.link) : { w: 33, h: 17 };
267
+ const w = Math.min(g.w - 2, Math.max(qs.w + 6, 64));
268
+ const compact = qs.h + 10 > g.h - 2;
269
+ const h = Math.min(g.h - 2, compact ? qs.h + 4 : qs.h + 10);
270
+ const x = Math.floor((g.w - w) / 2), y = Math.max(1, Math.floor((g.h - h) / 2));
271
+ 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" });
273
+ app.hit(x, y, w, h, { click: () => {} });
274
+ if (this.error) {
275
+ g.textC(x, w, y + Math.floor(h / 2) - 1, "⚠ Не получилось создать корзину", { fg: T.red, bold: true, bg: T.panel2 });
276
+ g.textC(x, w, y + Math.floor(h / 2), this.error, { fg: T.sub, bg: T.panel2 });
277
+ return;
278
+ }
279
+ if (!this.link) {
280
+ g.textC(x, w, y + Math.floor(h / 2) - 1, "Собираю корзину…", { fg: T.sub, bg: T.panel2 });
281
+ const n = 20;
282
+ for (let i = 0; i < n; i++) g.text(x + Math.floor((w - n) / 2) + i, y + Math.floor(h / 2) + 1, " ", { bg: ramp([T.greenD, T.lime, T.greenD], ((i + app.frame) % n) / n) });
283
+ return;
284
+ }
285
+ const qx = x + Math.floor((w - qs.w) / 2);
286
+ if (compact) {
287
+ if (qs.h + 4 <= h) g.qr(qx, y + 1, this.link);
288
+ else g.textC(x, w, y + 3, "окно мало для QR-кода — растяните терминал", { fg: T.dim, bg: T.panel2 });
289
+ g.textC(x, w, y + h - 3, truncate(this.link, w - 4), { fg: T.lime, underline: true, bg: T.panel2 });
290
+ g.textC(x, w, y + h - 2, `${rub(store.total)} · камера телефона → приложение ВкусВилла`, { fg: T.dim, bg: T.panel2 });
291
+ return;
292
+ }
293
+ g.qr(qx, y + 2, this.link);
294
+ g.textC(x, w, y + qs.h + 3, "наведите камеру телефона — откроется приложение ВкусВилла", { fg: T.sub, bg: T.panel2 });
295
+ const ly = y + h - 4;
296
+ g.textC(x, w, ly, truncate(this.link, w - 4), { fg: T.lime, underline: true, bg: T.panel2 });
297
+ app.hit(x + 2, ly, w - 4, 1, { click: () => openUrl(this.link) });
298
+ g.textC(x, w, ly + 1, `${store.count} ${plural(store.count, "позиция", "позиции", "позиций")} · ${rub(store.total)} · оплата и доставка — у ВкусВилла`, { fg: T.dim, bg: T.panel2 });
299
+ let bx = x + Math.floor((w - 40) / 2);
300
+ const btn = (label, fn, prim) => { g.pill(bx, ly + 2, label, { fg: prim ? T.bg : T.sub, bg: prim ? T.lime : T.raise, bold: prim }); app.hit(bx, ly + 2, strWidth(label) + 2, 1, { click: fn }); bx += strWidth(label) + 3; };
301
+ btn("o открыть", () => openUrl(this.link), true);
302
+ btn("y скопировать", () => copy(this.link).then(() => app.toast("Ссылка скопирована")));
303
+ btn("esc", () => app.closeOverlay(this));
304
+ }
305
+ }
306
+