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,308 @@
1
+ // Product page: large photo with a gallery, price in a pixel font, energy donut of protein/fat/carbs,
2
+ // description, highlighted ingredients, all properties. [ and ] flip through neighbouring products of the list.
3
+
4
+ import { T } from "../ui/theme.js";
5
+ import { Braille } from "../ui/draw.js";
6
+ import { drawRich } from "../ui/widgets.js";
7
+ import { flagPills, drawPills, compositionLines, perKg } from "../ui/product-ui.js";
8
+ import { image } from "../image.js";
9
+ import * as api from "../api.js";
10
+ import { normalize, rub, compactNum, qtyText, qtyStep } from "../product.js";
11
+ import { store } from "../store.js";
12
+ import { openUrl } from "../sys.js";
13
+ import { wrap, strWidth } from "../term/width.js";
14
+
15
+ const SKIP_PROPS = /Состав|ценность|Информация на этикетке/;
16
+
17
+ export function productView(app, p, ctx = {}) {
18
+ return new ProductView(app, p, ctx);
19
+ }
20
+
21
+ class ProductView {
22
+ constructor(app, p, { list = [], index = -1 } = {}) {
23
+ this.app = app;
24
+ this.p = p;
25
+ this.list = list;
26
+ this.index = index;
27
+ this.photo = 0;
28
+ this.scroll = 0;
29
+ this.loading = false;
30
+ }
31
+
32
+ busy() { return this.loading; }
33
+
34
+ onShow() { this.fetch(); }
35
+
36
+ // Search results carry only a few properties; the details call brings the rest.
37
+ // Only the latest request may touch the page: [ and ] can fire several in a row.
38
+ async fetch() {
39
+ const id = this.p.id;
40
+ const req = (this.req = (this.req ?? 0) + 1);
41
+ this.err = null;
42
+ if (this.p.props?.length > 4) { this.loading = false; return; }
43
+ this.loading = true;
44
+ try {
45
+ const d = normalize(await api.details(id));
46
+ if (req === this.req) this.p = { ...this.p, ...d, flags: d.comp ? d.flags : this.p.flags, comp: d.comp || this.p.comp };
47
+ } catch (e) {
48
+ if (req === this.req) this.err = e.message;
49
+ } finally {
50
+ if (req === this.req) this.loading = false;
51
+ this.app.invalidate();
52
+ }
53
+ }
54
+
55
+ go(delta) {
56
+ const i = this.index + delta;
57
+ if (i < 0 || i >= this.list.length) return;
58
+ this.index = i;
59
+ this.p = this.list[i];
60
+ this.photo = 0;
61
+ this.scroll = 0;
62
+ store.viewed(this.p);
63
+ this.fetch();
64
+ }
65
+
66
+ cart(sign) {
67
+ const p = this.p;
68
+ store.add(p, sign * qtyStep(p));
69
+ const q = store.qty(p.id);
70
+ this.app.toast(q ? `В корзине ${qtyText(p, q)}` : "Убрано из корзины", sign > 0 ? T.lime : T.orange);
71
+ }
72
+
73
+ analogs() {
74
+ this.app.views.analogs(this.app, this.p);
75
+ }
76
+
77
+ toggleFav() {
78
+ this.app.toast(store.toggleFav(this.p) ? "♥ В избранном" : "Убрано из избранного", T.pink);
79
+ }
80
+
81
+ openSite() {
82
+ if (openUrl(this.p.url)) this.app.toast("Открываю на сайте ВкусВилла");
83
+ }
84
+
85
+ key(k) {
86
+ if (k.type !== "key") return false;
87
+ const h = k.hot ?? k.name;
88
+ const imgs = this.p.images?.length || 1;
89
+ switch (true) {
90
+ case k.name === "left": this.photo = (this.photo - 1 + imgs) % imgs; return true;
91
+ case k.name === "right": this.photo = (this.photo + 1) % imgs; return true;
92
+ case k.name === "up" || h === "k": this.scroll = Math.max(0, this.scroll - 1); return true;
93
+ case k.name === "down" || h === "j": this.scroll++; return true;
94
+ case k.name === "pageup": this.scroll = Math.max(0, this.scroll - 10); return true;
95
+ case k.name === "pagedown" || k.name === "space": this.scroll += 10; return true;
96
+ case k.name === "home": this.scroll = 0; return true;
97
+ case h === "[" || h === "{": this.go(-1); return true;
98
+ case h === "]" || h === "}": this.go(1); return true;
99
+ case h === "+" || h === "=" || k.name === "enter": this.cart(1); return true;
100
+ case h === "-" || h === "_": this.cart(-1); return true;
101
+ case h === "*" || h === "l": this.toggleFav(); return true;
102
+ case h === "a": this.analogs(); return true;
103
+ case h === "o": this.openSite(); return true;
104
+ case h === "x": this.app.toggleCompare(this.p); return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ hints() {
110
+ return [
111
+ ["esc", "назад"], ["← →", "фото"], ["+ −", "корзина"],
112
+ ...(this.list.length > 1 ? [["[ ]", "соседний"]] : []),
113
+ ["a", "аналоги"], ["*", "избранное"], ["o", "на сайте"], ["x", "сравнить"],
114
+ ];
115
+ }
116
+
117
+ draw(g, r) {
118
+ const p = this.p;
119
+ const app = this.app;
120
+ g.fill(r.x, r.y, r.w, r.h, T.bg);
121
+ // Breadcrumbs
122
+ const cx = g.text(r.x + 1, r.y, "← ", { fg: T.lime });
123
+ app.hit(r.x, r.y, 4, 1, { click: () => app.pop() });
124
+ g.textW(cx, r.y, (p.category ?? []).join(" › ") || "Товар", r.w - 34, { fg: T.dim });
125
+ let rx = r.x + r.w - 2;
126
+ if (this.list.length > 1 && this.index >= 0) rx = g.textR(rx, r.y, `[ ${this.index + 1} из ${this.list.length} ]`, { fg: T.dim }) - 2;
127
+ if (this.loading) g.textR(rx, r.y, "⠿ уточняю…", { fg: T.lime });
128
+ const top = r.y + 1;
129
+ const H = r.h - 1;
130
+ const wide = r.w >= 100;
131
+ const lw = wide ? Math.min(64, Math.floor(r.w * 0.44)) : r.w;
132
+ // Left column: photo, price, cart
133
+ g.fill(r.x, top, lw, H, T.panel);
134
+ let y = top + 1;
135
+ const photoH = wide ? Math.max(6, Math.min(Math.floor(H * 0.55), Math.round((lw - 4) * 0.42))) : Math.min(10, Math.floor(H * 0.3));
136
+ const url = p.images?.[this.photo] ?? p.thumb;
137
+ const img = image(url, lw - 4, photoH);
138
+ if (img && !img.error) {
139
+ g.photo(r.x + 2 + Math.floor((lw - 4 - img.w) / 2), y + Math.floor((photoH - Math.ceil(img.h / 2)) / 2), img);
140
+ } else g.photoPlaceholder(r.x + 2, y, lw - 4, photoH, app.frame, img?.error ? "фото не загрузилось" : "проявляю фото…");
141
+ app.hit(r.x, y, lw, photoH, { click: () => { this.photo = (this.photo + 1) % (p.images?.length || 1); } });
142
+ y += photoH;
143
+ const n = p.images?.length ?? 0;
144
+ if (n > 1) {
145
+ const dots = Array.from({ length: n }, (_, i) => (i === this.photo ? "●" : "○")).join(" ");
146
+ g.textC(r.x, lw, y, dots, { fg: T.lime, bg: T.panel });
147
+ }
148
+ y += 2;
149
+ if (!wide) {
150
+ y -= 1;
151
+ let cx = g.text(r.x + 2, y, rub(p.price) + (p.unit === "кг" ? "/кг" : ""), { fg: p.old ? T.orange : T.lime, bold: true, bg: T.panel });
152
+ if (p.old) cx = g.text(cx + 1, y, rub(p.old), { fg: T.dim, strike: true, bg: T.panel });
153
+ const q = store.qty(p.id);
154
+ g.textR(r.x + r.w - 2, y, q ? `в корзине ${qtyText(p, q)} · + −` : "+ — в корзину", { fg: q ? T.lime : T.dim, bg: T.panel });
155
+ return this.drawInfo(g, { x: r.x, y: y + 1, w: r.w, h: top + H - y - 1 });
156
+ }
157
+ // Price
158
+ const pw = g.bigPrice(r.x + 3, y, p.price, p.old ? T.orange : T.lime);
159
+ let tx = r.x + 3 + pw + 2;
160
+ if (p.unit === "кг") g.text(tx, y + 4, "за кг", { fg: T.dim, bg: T.panel });
161
+ if (p.old) {
162
+ g.text(tx, y + 1, rub(p.old), { fg: T.dim, strike: true, bg: T.panel });
163
+ g.pill(tx, y + 3, `−${p.off}%`, { fg: T.bg, bg: T.orange, bold: true });
164
+ }
165
+ y += 6;
166
+ if (p.offInfo) g.textW(r.x + 3, y++, p.offInfo, lw - 6, { fg: T.orange, italic: true, bg: T.panel });
167
+ const pk = perKg(p);
168
+ 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 });
169
+ y++;
170
+ // Cart
171
+ const q = store.qty(p.id);
172
+ if (q) {
173
+ let bx = r.x + 3;
174
+ const minus = " − ", plus = " + ";
175
+ g.text(bx, y, minus, { fg: T.bg, bg: T.orange, bold: true });
176
+ app.hit(bx, y, 3, 1, { click: () => this.cart(-1) });
177
+ bx += 4;
178
+ bx = g.text(bx, y, ` ${qtyText(p, q)} `, { fg: T.white, bg: T.raise, bold: true }) + 1;
179
+ g.text(bx, y, plus, { fg: T.bg, bg: T.lime, bold: true });
180
+ app.hit(bx, y, 3, 1, { click: () => this.cart(1) });
181
+ g.text(bx + 4, y, `= ${rub(p.price * q)}`, { fg: T.lime, bold: true, bg: T.panel });
182
+ } else {
183
+ const label = " ✚ В корзину ";
184
+ g.text(r.x + 3, y, label, { fg: T.bg, bg: T.lime, bold: true });
185
+ app.hit(r.x + 3, y, strWidth(label), 1, { click: () => this.cart(1) });
186
+ }
187
+ y += 2;
188
+ // Buttons
189
+ let bx = r.x + 3;
190
+ const fav = store.isFav(p.id);
191
+ const buttons = [
192
+ [fav ? "♥ в избранном" : "♡ в избранное", fav ? T.pink : T.sub, () => this.toggleFav()],
193
+ ["≈ аналоги", T.sub, () => this.analogs()],
194
+ ["↗ на сайте", T.sub, () => this.openSite()],
195
+ [app.compare.has(p.id) ? "⇄ в сравнении" : "⇄ сравнить", app.compare.has(p.id) ? T.violet : T.sub, () => app.toggleCompare(p)],
196
+ ];
197
+ for (const [label, fg, fn] of buttons) {
198
+ const bw = strWidth(label) + 2;
199
+ if (bx + bw > r.x + lw - 1) { bx = r.x + 3; y++; }
200
+ g.pill(bx, y, label, { fg, bg: T.raise });
201
+ app.hit(bx, y, bw, 1, { click: fn });
202
+ bx += bw + 1;
203
+ }
204
+ this.drawInfo(g, { x: r.x + lw + 1, y: top, w: r.w - lw - 1, h: H });
205
+ }
206
+
207
+ // Right column: scrollable text
208
+ drawInfo(g, r) {
209
+ const p = this.p;
210
+ const app = this.app;
211
+ const w = r.w - 4;
212
+ const lines = []; // (g, x, y) draw functions, one per line
213
+ const blank = () => lines.push(null);
214
+ const text = (s, st) => wrap(s, w).forEach((l) => lines.push((g, x, y) => g.text(x, y, l, st)));
215
+ const head = (s) => { blank(); lines.push((g, x, y) => { g.text(x, y, s, { fg: T.lime, bold: true }); }); };
216
+
217
+ wrap(p.title, w).forEach((l) => lines.push((g, x, y) => g.text(x, y, l, { fg: T.white, bold: true })));
218
+ if (p.pack) lines.push((g, x, y) => g.text(x, y, p.pack, { fg: T.dim }));
219
+ blank();
220
+ if (p.rating) lines.push((g, x, y) => {
221
+ let cx = g.stars(x, y, p.rating);
222
+ cx = g.text(cx + 1, y, p.rating.toFixed(1).replace(".", ","), { fg: T.yellow, bold: true });
223
+ g.text(cx + 1, y, `${compactNum(p.votes)} оценок`, { fg: T.dim });
224
+ });
225
+ const pills = flagPills(p);
226
+ if (pills.length) { blank(); lines.push((g, x, y) => drawPills(g, x, y, w, pills)); }
227
+
228
+ if (p.kcal != null || p.p != null) {
229
+ head("ПИЩЕВАЯ ЦЕННОСТЬ · на 100 г");
230
+ blank();
231
+ const donut = energyDonut(p);
232
+ const rows = donut ? donut.h : 0;
233
+ const legend = [
234
+ ["Белки", p.p, T.blue, 4], ["Жиры", p.f, T.yellow, 9], ["Углеводы", p.c, T.orange, 4],
235
+ ];
236
+ const esum = legend.reduce((s, [, v, , k]) => s + (v ?? 0) * k, 0);
237
+ for (let i = 0; i < Math.max(rows, 6); i++) {
238
+ lines.push((g, x, y) => {
239
+ if (donut && i < rows) donut.draw(g, x, y, i);
240
+ const lx = x + (donut ? donut.w + 3 : 0);
241
+ if (i === 0) g.spans(lx, y, [[p.kcal != null ? `${Math.round(p.kcal)}` : "—", { fg: T.lime, bold: true }], [" ккал", { fg: T.sub }]]);
242
+ const li = i - 2;
243
+ if (li >= 0 && li < 3) {
244
+ const [name, v, col, kk] = legend[li];
245
+ const share = esum ? Math.round(((v ?? 0) * kk * 100) / esum) : 0;
246
+ g.text(lx, y, "■ ", { fg: col });
247
+ g.text(lx + 2, y, name, { fg: T.text });
248
+ g.text(lx + 12, y, v == null ? "—" : `${String(v).replace(".", ",")} г`, { fg: T.white, bold: true });
249
+ g.text(lx + 21, y, `${share}%`, { fg: T.dim });
250
+ }
251
+ if (i === 5 && p.sugars != null) g.text(lx, y, `в т. ч. сахара ${String(p.sugars).replace(".", ",")} г`, { fg: T.dim });
252
+ });
253
+ }
254
+ }
255
+
256
+ if (p.description) { head("ОПИСАНИЕ"); text(p.description, { fg: T.sub }); }
257
+ if (p.comp) {
258
+ head("СОСТАВ");
259
+ compositionLines(p.comp, w).forEach((ln) => lines.push((g, x, y) => drawRich(g, x, y, w, 1, [ln], { fg: T.sub })));
260
+ if (p.additiveList?.length) {
261
+ blank();
262
+ text(`Добавки: ${p.additiveList.join(", ")}`, { fg: T.orange });
263
+ }
264
+ }
265
+ for (const pr of p.props ?? []) {
266
+ if (SKIP_PROPS.test(pr.name) || !pr.value) continue;
267
+ head(pr.name.toUpperCase());
268
+ text(pr.value.replace(/\n+/g, " · "), { fg: T.sub });
269
+ }
270
+ if (this.loading && !p.props?.length) { blank(); lines.push((g, x, y) => g.text(x, y, "загружаю подробности…", { fg: T.dim, italic: true })); }
271
+ if (this.err && !p.props?.length) { blank(); text(`⚠ Подробности не загрузились: ${this.err}`, { fg: T.red }); }
272
+
273
+ // Scrolling
274
+ const maxScroll = Math.max(0, lines.length - r.h + 1);
275
+ this.scroll = Math.min(this.scroll, maxScroll);
276
+ g.s.pushClip(r.x, r.y, r.w, r.h);
277
+ for (let i = 0; i < r.h - 1; i++) {
278
+ const fn = lines[this.scroll + i];
279
+ if (fn) fn(g, r.x + 2, r.y + 1 + i);
280
+ }
281
+ g.s.popClip();
282
+ g.scrollbar(r.x + r.w - 1, r.y + 1, r.h - 1, this.scroll, r.h - 1, lines.length);
283
+ app.hit(r.x, r.y, r.w, r.h, { wheel: (d) => { this.scroll = Math.max(0, Math.min(maxScroll, this.scroll + d * 3)); }, passive: true });
284
+ }
285
+ }
286
+
287
+ // Energy donut: share of calories from protein, fat and carbs
288
+ export function energyDonut(p, cw = 12, ch = 6) {
289
+ const e = [(p.p ?? 0) * 4, (p.f ?? 0) * 9, (p.c ?? 0) * 4];
290
+ const sum = e[0] + e[1] + e[2];
291
+ if (!sum) return null;
292
+ const cols = [T.blue, T.yellow, T.orange];
293
+ const b = new Braille(cw, ch);
294
+ const cxp = b.pw / 2, cyp = b.ph / 2;
295
+ const R = Math.min(cxp, cyp) - 0.5, r0 = R * 0.55;
296
+ for (let py = 0; py < b.ph; py++) for (let px = 0; px < b.pw; px++) {
297
+ const dx = px + 0.5 - cxp, dy = py + 0.5 - cyp;
298
+ const d = Math.hypot(dx, dy);
299
+ if (d > R || d < r0) continue;
300
+ let a = Math.atan2(dx, -dy) / (2 * Math.PI);
301
+ if (a < 0) a += 1;
302
+ const t = a * sum;
303
+ const k = t < e[0] ? 0 : t < e[0] + e[1] ? 1 : 2;
304
+ b.set(px, py, cols[k]);
305
+ }
306
+ return { w: cw, h: ch, draw: (g, x, y, row) => b.drawRow(g, x, y, row) };
307
+ }
308
+
@@ -0,0 +1,228 @@
1
+ // Profile: favourites and history are local; orders, favourite product and "6 discounts" need a VkusVill token.
2
+
3
+ import { T } from "../ui/theme.js";
4
+ import { ListView } from "../ui/widgets.js";
5
+ import { ProductBrowser } from "./browser.js";
6
+ import * as api from "../api.js";
7
+ import { normalize, rub, plural, qtyStep } from "../product.js";
8
+ import { store } from "../store.js";
9
+ import { strWidth } from "../term/width.js";
10
+
11
+ const SECTIONS = [
12
+ { id: "favs", label: "Избранное", icon: "♥" },
13
+ { id: "history", label: "Недавно смотрели", icon: "◷" },
14
+ { id: "orders", label: "Мои заказы", icon: "▤", auth: true },
15
+ { id: "lp", label: "Любимый продукт", icon: "★", auth: true },
16
+ { id: "personal", label: "6 скидок для меня", icon: "%", auth: true },
17
+ ];
18
+
19
+ export function profileView(app) {
20
+ return new ProfileView(app);
21
+ }
22
+
23
+ class ProfileView {
24
+ constructor(app) {
25
+ this.app = app;
26
+ this.nav = new ListView();
27
+ this.focus = "nav";
28
+ this.subs = {};
29
+ }
30
+
31
+ get section() { return SECTIONS[this.nav.sel]; }
32
+
33
+ sub(id) {
34
+ if (this.subs[id]) return this.subs[id];
35
+ const app = this.app;
36
+ const local = (title, arr, empty) => new ProductBrowser(app, {
37
+ title, serverSort: false, autoLoad: false, emptyText: empty,
38
+ loader: async () => ({ items: arr(), total: arr().length, hasMore: false }),
39
+ });
40
+ let v;
41
+ if (id === "favs") v = local("Избранное", () => store.favs, "Пусто. Нажмите * на любом товаре");
42
+ else if (id === "history") v = local("Недавно смотрели", () => store.history, "Вы ещё ничего не открывали");
43
+ else if (id === "personal") {
44
+ v = new ProductBrowser(app, {
45
+ title: "6 скидок для меня", serverSort: false, autoLoad: false,
46
+ emptyText: "ВкусВилл пока не подобрал персональных скидок",
47
+ loader: async () => { const d = await api.personalDiscounts(); const items = d.items ?? d.products ?? []; return { items, total: items.length, hasMore: false }; },
48
+ });
49
+ } else if (id === "lp") {
50
+ v = new ProductBrowser(app, {
51
+ title: "Любимый продукт", serverSort: false, autoLoad: false,
52
+ emptyText: "Любимый продукт ещё не выбран — это делается в приложении ВкусВилла",
53
+ loader: async () => {
54
+ const d = await api.favoriteProduct();
55
+ const items = d.items ?? d.products ?? (d.id ? [d] : d.product ? [d.product] : []);
56
+ return { items, total: items.length, hasMore: false };
57
+ },
58
+ });
59
+ }
60
+ else if (id === "orders") v = new OrdersView(app);
61
+ return (this.subs[id] = v);
62
+ }
63
+
64
+ onShow() {
65
+ // Re-read local lists every time the view is shown
66
+ for (const id of ["favs", "history"]) if (this.subs[id]) this.subs[id].reload();
67
+ this.activate();
68
+ }
69
+
70
+ activate() {
71
+ const s = this.section;
72
+ if (s.auth && !api.TOKEN) return;
73
+ this.sub(s.id).onShow?.();
74
+ }
75
+
76
+ busy() { return this.subs[this.section.id]?.busy?.(); }
77
+
78
+ tick() {
79
+ const s = this.section;
80
+ if (!(s.auth && !api.TOKEN)) this.subs[s.id]?.tick?.();
81
+ }
82
+
83
+ key(k) {
84
+ if (this.focus === "content") {
85
+ const s = this.section;
86
+ const locked = s.auth && !api.TOKEN;
87
+ // The section gets the key first; ← and esc that it doesn't use go back to the section list
88
+ if (!locked && this.sub(s.id).key?.(k)) return true;
89
+ if (k.type === "key" && (k.name === "escape" || k.name === "left")) { this.focus = "nav"; return true; }
90
+ return false;
91
+ }
92
+ if (k.type !== "key") return false;
93
+ if (this.nav.key(k, SECTIONS.length)) { this.activate(); return true; }
94
+ if (k.name === "enter" || k.name === "right") { this.focus = "content"; this.activate(); return true; }
95
+ return false;
96
+ }
97
+
98
+ hints() {
99
+ if (this.focus === "content" && !(this.section.auth && !api.TOKEN)) return [["esc", "к разделам"], ...(this.sub(this.section.id).hints?.() ?? [])];
100
+ return [["↑↓", "раздел"], ["⏎ →", "открыть"]];
101
+ }
102
+
103
+ draw(g, r) {
104
+ const app = this.app;
105
+ g.fill(r.x, r.y, r.w, r.h, T.bg);
106
+ const nw = 28;
107
+ g.fill(r.x, r.y, nw, r.h, T.panel2);
108
+ // Leaf avatar
109
+ g.text(r.x + 2, r.y + 1, "◆ ВкусВилл", { fg: T.lime, bold: true, bg: T.panel2 });
110
+ g.text(r.x + 2, r.y + 2, api.TOKEN ? "вход выполнен (токен)" : "без входа", { fg: api.TOKEN ? T.green : T.dim, bg: T.panel2 });
111
+ g.hline(r.x, r.y + 3, nw, T.line);
112
+ this.nav.draw(g, app, r.x, r.y + 4, nw, SECTIONS.length, SECTIONS, (s, i, y, sel) => {
113
+ const bg = sel ? (this.focus === "nav" ? T.selHi : T.sel) : T.panel2;
114
+ g.fill(r.x, y, nw, 1, bg);
115
+ if (sel) g.text(r.x, y, "▌", { fg: T.lime, bg });
116
+ g.text(r.x + 2, y, s.icon, { fg: s.id === "favs" ? T.pink : T.lime, bg });
117
+ g.text(r.x + 4, y, s.label, { fg: sel ? T.white : T.text, bold: sel, bg });
118
+ const n = s.id === "favs" ? store.favs.length : s.id === "history" ? store.history.length : null;
119
+ if (n) g.textR(r.x + nw - 2, y, String(n), { fg: T.dim, bg });
120
+ if (s.auth && !api.TOKEN) g.textR(r.x + nw - 2, y, "⊘", { fg: T.mute, bg });
121
+ }, { scrollbar: false, onPick: () => { this.focus = "content"; this.activate(); } });
122
+ // Counters
123
+ let y = r.y + 5 + SECTIONS.length;
124
+ g.hline(r.x, y++, nw, T.line);
125
+ for (const [label, v] of [["в корзине", `${store.count} · ${rub(store.total)}`], ["избранное", store.favs.length], ["запросов в истории", store.queries.length]]) {
126
+ g.text(r.x + 2, ++y, label, { fg: T.dim, bg: T.panel2 });
127
+ g.textR(r.x + nw - 2, y, String(v), { fg: T.sub, bg: T.panel2 });
128
+ }
129
+ const cr = { x: r.x + nw + 1, y: r.y, w: r.w - nw - 1, h: r.h };
130
+ const s = this.section;
131
+ app.hit(cr.x, cr.y, cr.w, cr.h, { click: () => { this.focus = "content"; }, passive: false });
132
+ if (s.auth && !api.TOKEN) return this.drawLocked(g, cr, s);
133
+ this.sub(s.id).draw(g, cr);
134
+ if (this.focus !== "content") g.tint(cr.x, cr.y, 1, cr.h, T.bg);
135
+ }
136
+
137
+ drawLocked(g, r, s) {
138
+ g.fill(r.x, r.y, r.w, r.h, T.panel);
139
+ const art = [
140
+ " ╭───────╮ ",
141
+ " │ ╭───╮ │ ",
142
+ " │ │ │ │ ",
143
+ " ╭─┴─┴───┴─┴─╮ ",
144
+ " │ ◉ │ ",
145
+ " │ ┃ │ ",
146
+ " ╰───────────╯ ",
147
+ ];
148
+ const ax = r.x + Math.floor((r.w - 15) / 2);
149
+ let y = r.y + Math.max(1, Math.floor(r.h / 2) - 9);
150
+ art.forEach((l, i) => g.text(ax, y + i, l, { fg: i >= 3 ? T.lime : T.green, bg: T.panel }));
151
+ y += art.length + 1;
152
+ const c = (t, st) => g.textC(r.x, r.w, y++, t, { bg: T.panel, ...st });
153
+ c(`«${s.label}» — только после входа`, { fg: T.white, bold: true });
154
+ y++;
155
+ c("ВкусВилл отдаёт заказы, любимый продукт и персональные скидки", { fg: T.sub });
156
+ c("только авторизованным клиентам (OAuth 2.0 на mcp.vkusvill.ru).", { fg: T.sub });
157
+ y++;
158
+ c("Если у вас есть токен доступа, передайте его при запуске:", { fg: T.dim });
159
+ y++;
160
+ c(" VV_TOKEN=… npx vkusvill-cli ", { fg: T.bg, bg: T.lime, bold: true });
161
+ y++;
162
+ c("Каталог, акции, рецепты, магазины и корзина работают без входа.", { fg: T.dim });
163
+ }
164
+ }
165
+
166
+ class OrdersView {
167
+ constructor(app) {
168
+ this.app = app;
169
+ this.list = new ListView();
170
+ this.items = [];
171
+ this.page = 0;
172
+ this.loading = false;
173
+ }
174
+ busy() { return this.loading; }
175
+ onShow() { if (!this.page && !this.loading) this.load(); }
176
+
177
+ // Ten orders per page; the next page loads when the selection nears the end
178
+ tick() {
179
+ if (this.hasMore && !this.loading && !this.error && this.page && this.list.sel >= this.items.length - 3) this.load();
180
+ }
181
+
182
+ async load() {
183
+ this.loading = true;
184
+ this.error = null;
185
+ try {
186
+ const d = await api.orders(this.page + 1);
187
+ const items = d.items ?? d.orders ?? [];
188
+ this.items.push(...items);
189
+ this.page++;
190
+ this.hasMore = d.meta?.has_more ?? items.length >= 10;
191
+ } catch (e) { this.error = e.message; }
192
+ this.loading = false;
193
+ this.app.invalidate();
194
+ }
195
+ async repeat(o) {
196
+ const prods = o?.products ?? [];
197
+ if (!prods.length) return;
198
+ this.app.toast(`Повторяю заказ: ${prods.length} ${plural(prods.length, "товар", "товара", "товаров")}…`);
199
+ let ok = 0;
200
+ await Promise.all(prods.map(async (pr) => {
201
+ try { const p = normalize(await api.details(pr.id)); store.add(p, (pr.q ?? pr.quantity ?? qtyStep(p))); ok++; } catch {}
202
+ }));
203
+ this.app.toast(`Добавлено в корзину: ${ok} из ${prods.length}`);
204
+ }
205
+ key(k) {
206
+ if (k.type !== "key") return false;
207
+ if (this.list.key(k, this.items.length)) return true;
208
+ if (k.name === "enter" || (k.hot ?? k.name) === "+") { this.repeat(this.items[this.list.sel]); return true; }
209
+ if ((k.hot ?? k.name) === "R" && this.error) { this.page = 0; this.items = []; this.load(); return true; }
210
+ return false;
211
+ }
212
+ hints() { return [["↑↓", "заказ"], ["⏎", "повторить заказ"]]; }
213
+ draw(g, r) {
214
+ g.fill(r.x, r.y, r.w, r.h, T.panel);
215
+ g.text(r.x + 2, r.y, "Заказы за 30 дней", { fg: T.lime, bold: true, bg: T.panel });
216
+ if (!this.items.length) { g.textC(r.x, r.w, r.y + Math.floor(r.h / 2), this.loading ? "Загружаю заказы…" : this.error ? `⚠ ${this.error}` : "Заказов нет", { fg: this.error ? T.red : T.dim, bg: T.panel }); return; }
217
+ this.list.draw(g, this.app, r.x, r.y + 2, r.w - 1, r.h - 2, this.items, (o, i, y, sel) => {
218
+ const bg = sel ? T.selHi : T.panel;
219
+ g.fill(r.x, y, r.w - 1, 3, bg);
220
+ const date = o.created_at ? new Date(o.created_at * (o.created_at < 1e12 ? 1000 : 1)).toLocaleDateString("ru-RU", { day: "numeric", month: "long" }) : "";
221
+ g.spans(r.x + 2, y, [[`№ ${o.id_general ?? o.id}`, { fg: T.white, bold: true, bg }], [` ${date} · ${o.service ?? ""}`, { fg: T.sub, bg }]]);
222
+ if (o.status_name) g.pill(r.x + r.w - 16 - strWidth(o.status_name), y, o.status_name, { fg: T.bg, bg: T.green });
223
+ g.textR(r.x + r.w - 3, y, rub(o.price), { fg: T.lime, bold: true, bg });
224
+ g.textW(r.x + 2, y + 1, (o.products ?? []).map((p) => p.name).join(" · "), r.w - 6, { fg: T.dim, bg });
225
+ }, { rowH: 3, onPick: (o) => this.repeat(o) });
226
+ }
227
+ }
228
+