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.
@@ -1,21 +1,28 @@
1
- // Profile: favourites and history are local; orders, favourite product and "6 discounts" need a VkusVill token.
1
+ // Profile: favourites and history are local; orders, favourite product and "6 discounts" need
2
+ // the user signed in to VkusVill. Signing in happens right here: register a client, paste its id,
3
+ // confirm in the browser.
2
4
 
3
5
  import { T } from "../ui/theme.js";
4
- import { ListView } from "../ui/widgets.js";
6
+ import { ListView, Prompt } from "../ui/widgets.js";
5
7
  import { ProductBrowser } from "./browser.js";
6
8
  import * as api from "../api.js";
9
+ import * as auth from "../auth.js";
7
10
  import { normalize, rub, plural, qtyStep } from "../product.js";
8
11
  import { store } from "../store.js";
12
+ import { openUrl, copy } from "../sys.js";
9
13
  import { strWidth } from "../term/width.js";
10
14
 
11
15
  const SECTIONS = [
16
+ { id: "account", label: "Войти", icon: "◉" }, // "Аккаунт" once signed in
12
17
  { id: "favs", label: "Избранное", icon: "♥" },
13
18
  { id: "history", label: "Недавно смотрели", icon: "◷" },
14
- { id: "orders", label: "Мои заказы", icon: "▤", auth: true },
19
+ { id: "orders", label: "Мои заказы", icon: "▤", auth: true, scope: "purchase_history.read" },
15
20
  { id: "lp", label: "Любимый продукт", icon: "★", auth: true },
16
21
  { id: "personal", label: "6 скидок для меня", icon: "%", auth: true },
17
22
  ];
18
23
 
24
+ const time = (t) => new Date(t).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
25
+
19
26
  export function profileView(app) {
20
27
  return new ProfileView(app);
21
28
  }
@@ -26,10 +33,26 @@ class ProfileView {
26
33
  this.nav = new ListView();
27
34
  this.focus = "nav";
28
35
  this.subs = {};
36
+ this.signing = null; // sign-in in progress: { ctl, url, paste }
37
+ this.authError = null;
38
+ this.accountSel = 0; // account page: 0 — sign in again, 1 — sign out
29
39
  }
30
40
 
31
41
  get section() { return SECTIONS[this.nav.sel]; }
32
42
 
43
+ // auth.json is shared with `vv login` in other terminals: re-read, but at most once a second
44
+ get auth() { return auth.cachedStatus(); }
45
+
46
+ label(s) { return s.id === "account" ? (this.auth.signedIn ? "Аккаунт" : "Войти") : s.label; }
47
+
48
+ locked(s = this.section) { return s.auth && (!this.auth.signedIn || this.missing(s)); }
49
+
50
+ // The section needs a right this client was not given
51
+ missing(s = this.section) {
52
+ const g = this.auth.granted;
53
+ return !!(s.scope && this.auth.signedIn && g && !g.includes(s.scope));
54
+ }
55
+
33
56
  sub(id) {
34
57
  if (this.subs[id]) return this.subs[id];
35
58
  const app = this.app;
@@ -52,12 +75,11 @@ class ProfileView {
52
75
  emptyText: "Любимый продукт ещё не выбран — это делается в приложении ВкусВилла",
53
76
  loader: async () => {
54
77
  const d = await api.favoriteProduct();
55
- const items = d.items ?? d.products ?? (d.id ? [d] : d.product ? [d.product] : []);
78
+ const items = Array.isArray(d) ? d : d.items ?? d.products ?? (d.id ? [d] : d.product ? [d.product] : []);
56
79
  return { items, total: items.length, hasMore: false };
57
80
  },
58
81
  });
59
- }
60
- else if (id === "orders") v = new OrdersView(app);
82
+ } else if (id === "orders") v = new OrdersView(app);
61
83
  return (this.subs[id] = v);
62
84
  }
63
85
 
@@ -68,24 +90,139 @@ class ProfileView {
68
90
  }
69
91
 
70
92
  activate() {
71
- const s = this.section;
72
- if (s.auth && !api.TOKEN) return;
73
- this.sub(s.id).onShow?.();
93
+ if (this.section.id !== "account" && !this.locked()) this.sub(this.section.id).onShow?.();
74
94
  }
75
95
 
76
- busy() { return this.subs[this.section.id]?.busy?.(); }
96
+ busy() { return !!this.signing || this.subs[this.section.id]?.busy?.(); }
97
+ get animated() { return !!this.signing; }
77
98
 
78
99
  tick() {
79
- const s = this.section;
80
- if (!(s.auth && !api.TOKEN)) this.subs[s.id]?.tick?.();
100
+ // A section loads whenever it becomes visible: chosen by mouse or keys, or unlocked by a sign-in
101
+ // that happened elsewhere (`vv login` in another terminal)
102
+ const key = `${this.section.id}:${this.locked()}`;
103
+ if (key !== this.activeKey) { this.activeKey = key; this.activate(); }
104
+ if (this.section.id !== "account" && !this.locked()) this.subs[this.section.id]?.tick?.();
81
105
  }
82
106
 
107
+ focusTop() { this.focus = "nav"; }
108
+
109
+ // Entry point from the header button and the palette: straight to signing in
110
+ openSignIn() {
111
+ this.nav.sel = 0;
112
+ this.focus = "nav";
113
+ this.signIn();
114
+ }
115
+
116
+ // ——— Signing in ———
117
+
118
+ askClient() {
119
+ this.app.overlay(new Prompt({
120
+ title: "Данные клиента",
121
+ note: ["Данные пришли письмом после регистрации персонального агента.", "Секрет — только если ВкусВилл его выдал."],
122
+ fields: [
123
+ { label: "client_id", placeholder: "из письма ВкусВилла", value: this.auth.clientId ?? "" },
124
+ { label: "client_secret", placeholder: "необязательно", hidden: true, optional: true },
125
+ ],
126
+ onSubmit: ([clientId, clientSecret]) => {
127
+ try { auth.setClient({ clientId, clientSecret }); } catch (e) { return e.message; }
128
+ this.signIn();
129
+ return null;
130
+ },
131
+ }));
132
+ }
133
+
134
+ signIn({ allScopes = false } = {}) {
135
+ if (this.signing) return;
136
+ if (!this.auth.configured) return this.askClient();
137
+ // After rights were added on the site, ask for everything again
138
+ if (allScopes) auth.setClient({ clientId: this.auth.clientId, scopes: null });
139
+ const ctl = new AbortController();
140
+ const signing = { ctl, url: null, paste: null, notice: null };
141
+ this.signing = signing;
142
+ this.authError = null;
143
+ auth.login({
144
+ signal: ctl.signal,
145
+ notice: (text) => { signing.notice = text; this.app.invalidate(); },
146
+ open: (url) => { signing.url = url; openUrl(url); this.app.invalidate(); },
147
+ paste: (signal) => new Promise((resolve, reject) => {
148
+ signing.paste = resolve;
149
+ signal.addEventListener("abort", () => reject(new Error("aborted")));
150
+ }),
151
+ }).then(
152
+ (rec) => {
153
+ this.app.toast(`✓ Вход выполнен · до ${time(rec.expiresAt)}`);
154
+ // Personal sections load afresh with the new token
155
+ for (const id of ["orders", "lp", "personal"]) delete this.subs[id];
156
+ this.activate();
157
+ },
158
+ (e) => { if (e.code !== "access_denied") this.authError = e.message; },
159
+ ).finally(() => {
160
+ if (this.signing === signing) this.signing = null;
161
+ this.app.invalidate();
162
+ });
163
+ }
164
+
165
+ pasteLink() {
166
+ const signing = this.signing;
167
+ if (!signing?.paste) return;
168
+ this.app.overlay(new Prompt({
169
+ title: "Ссылка после входа",
170
+ note: ["Скопируйте адрес из строки браузера после подтверждения входа."],
171
+ fields: [{ label: "адрес или код", placeholder: "http://…?code=…" }],
172
+ onSubmit: ([text]) => { if (!text) return "Вставьте адрес"; signing.paste(text); return null; },
173
+ }));
174
+ }
175
+
176
+ async signOut() {
177
+ if (this.signingOut) return;
178
+ this.signingOut = true;
179
+ let r;
180
+ try { r = await auth.logout(); } finally { this.signingOut = false; }
181
+ for (const id of ["orders", "lp", "personal"]) delete this.subs[id];
182
+ if (r) this.app.toast(r.revoked ? "Вышли: токены отозваны" : "Вышли: токены удалены с этого компьютера", T.orange);
183
+ }
184
+
185
+ // ——— Keys ———
186
+
83
187
  key(k) {
188
+ const h = k.hot ?? k.name;
189
+ if (this.signing && k.type === "key") {
190
+ if (k.name === "escape") { this.signing.ctl.abort(); return true; }
191
+ if (h === "v") { this.pasteLink(); return true; }
192
+ if (h === "o" && this.signing.url) { openUrl(this.signing.url); return true; }
193
+ if (h === "y" && this.signing.url) { copy(this.signing.url).then(() => this.app.toast("Ссылка скопирована")); return true; }
194
+ // Everything else waits for the browser, except switching tabs
195
+ return !(k.name === "tab" || k.name === "backtab" || /^[1-6]$/.test(k.ch ?? ""));
196
+ }
197
+ if (k.type === "key" && h === "L" && this.auth.signedIn && !this.auth.viaEnv) { this.signOut(); return true; }
198
+ // Sign-in is one key away from the section list: no need to step into the content first
199
+ const s0 = this.section;
200
+ const wantsSignIn = (s0.id === "account" && !this.auth.signedIn) || this.locked(s0);
201
+ if (k.type === "key" && k.name === "enter" && wantsSignIn) { this.signIn({ allScopes: this.missing(s0) }); return true; }
202
+ if (k.type === "key" && s0.id === "account") {
203
+ // Signed in, the account page has two buttons: ↑↓ choose, Enter presses
204
+ if (this.focus === "content" && this.auth.signedIn && !this.auth.viaEnv) {
205
+ if (k.name === "down" && this.accountSel === 0) { this.accountSel = 1; return true; }
206
+ if (k.name === "up" && this.accountSel === 1) { this.accountSel = 0; return true; }
207
+ if (k.name === "enter") { if (this.accountSel === 1) this.signOut(); else this.signIn({ allScopes: true }); return true; }
208
+ }
209
+ if (h === "c") { this.askClient(); return true; }
210
+ if (h === "o" && !this.auth.configured) { openUrl(auth.REGISTRATION_URL); return true; }
211
+ if (k.name === "enter" && this.auth.signedIn) { this.signIn({ allScopes: true }); return true; }
212
+ }
84
213
  if (this.focus === "content") {
85
214
  const s = this.section;
86
- const locked = s.auth && !api.TOKEN;
215
+ if (s.id === "account") {
216
+ if (k.type === "key" && (k.name === "escape" || k.name === "left")) { this.focus = "nav"; return true; }
217
+ return false;
218
+ }
219
+ if (this.locked(s) && k.type === "key") {
220
+ if (k.name === "enter") { this.signIn({ allScopes: this.missing(s) }); return true; }
221
+ if (h === "c") { this.askClient(); return true; }
222
+ if (h === "o") { openUrl(auth.REGISTRATION_URL); return true; }
223
+ }
87
224
  // 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;
225
+ if (!this.locked(s) && this.sub(s.id).key?.(k)) return true;
89
226
  if (k.type === "key" && (k.name === "escape" || k.name === "left")) { this.focus = "nav"; return true; }
90
227
  return false;
91
228
  }
@@ -96,29 +233,53 @@ class ProfileView {
96
233
  }
97
234
 
98
235
  hints() {
99
- if (this.focus === "content" && !(this.section.auth && !api.TOKEN)) return [["esc", "к разделам"], ...(this.sub(this.section.id).hints?.() ?? [])];
100
- return [["↑↓", "раздел"], ["⏎ →", "открыть"]];
236
+ if (this.signing) return [["esc", "отменить вход"], ["v", "вставить ссылку"], ["o", "открыть снова"], ["y", "скопировать ссылку"]];
237
+ const out = [];
238
+ const a = this.auth;
239
+ if (this.section.id === "account") {
240
+ if (!a.signedIn) out.push(["⏎", "войти"], ...(a.configured ? [["c", "сменить client_id"]] : [["o", "регистрация клиента"]]));
241
+ else out.push(["⏎", "войти заново с новыми правами"], ["c", "сменить client_id"]);
242
+ out.push(["↑↓", "раздел"]);
243
+ } else if (this.locked()) {
244
+ out.push(["⏎", "войти"], ["↑↓", "раздел"]);
245
+ } else if (this.focus === "content") out.push(["esc", "к разделам"], ...(this.sub(this.section.id).hints?.() ?? []));
246
+ else out.push(["↑↓", "раздел"], ["⏎ →", "открыть"]);
247
+ if (this.auth.signedIn && !this.auth.viaEnv) out.push(["L", "выйти"]);
248
+ return out;
101
249
  }
102
250
 
251
+ // ——— Drawing ———
252
+
103
253
  draw(g, r) {
104
254
  const app = this.app;
255
+ const a = this.auth;
105
256
  g.fill(r.x, r.y, r.w, r.h, T.bg);
106
257
  const nw = 28;
107
258
  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 });
259
+ g.text(r.x + 2, r.y + 1, "Профиль", { fg: T.lime, bold: true, bg: T.panel2 });
260
+ // Fits the 24-cell column: rights as "n из m", the expiry only when it will not renew itself
261
+ const all = Object.keys(auth.SCOPE_LABELS).filter((s) => s !== "offline_access").length;
262
+ const rights = a.granted ? a.granted.filter((s) => s in auth.SCOPE_LABELS && s !== "offline_access").length : null;
263
+ const who = a.viaEnv ? "вход по VV_TOKEN"
264
+ : a.signedIn ? `${a.refreshable ? "вход" : `вход до ${time(a.expiresAt)}`}${rights != null ? ` · права ${rights} из ${all}` : ""}`
265
+ : a.configured ? "вход истёк" : "без входа";
266
+ g.text(r.x + 2, r.y + 2, who, { fg: a.signedIn ? T.green : a.configured ? T.orange : T.dim, bg: T.panel2 });
111
267
  g.hline(r.x, r.y + 3, nw, T.line);
112
268
  this.nav.draw(g, app, r.x, r.y + 4, nw, SECTIONS.length, SECTIONS, (s, i, y, sel) => {
113
269
  const bg = sel ? (this.focus === "nav" ? T.selHi : T.sel) : T.panel2;
114
270
  g.fill(r.x, y, nw, 1, bg);
115
271
  if (sel) g.text(r.x, y, "▌", { fg: T.lime, bg });
116
272
  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 });
273
+ const signInItem = s.id === "account" && !this.auth.signedIn;
274
+ g.text(r.x + 4, y, this.label(s), { fg: signInItem ? T.lime : sel ? T.white : T.text, bold: sel || signInItem, bg });
118
275
  const n = s.id === "favs" ? store.favs.length : s.id === "history" ? store.history.length : null;
119
276
  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(); } });
277
+ if (this.locked(s)) g.textR(r.x + nw - 2, y, "⊘", { fg: T.mute, bg });
278
+ }, { scrollbar: false, onPick: (s) => {
279
+ if (s.id === "account" && !this.auth.signedIn || this.locked(s)) return this.signIn({ allScopes: this.missing(s) });
280
+ this.focus = "content";
281
+ this.activate();
282
+ } });
122
283
  // Counters
123
284
  let y = r.y + 5 + SECTIONS.length;
124
285
  g.hline(r.x, y++, nw, T.line);
@@ -129,12 +290,16 @@ class ProfileView {
129
290
  const cr = { x: r.x + nw + 1, y: r.y, w: r.w - nw - 1, h: r.h };
130
291
  const s = this.section;
131
292
  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);
293
+ if (this.signing) return this.drawSigning(g, cr);
294
+ if (s.id === "account") return a.signedIn ? this.drawAccount(g, cr) : this.drawLocked(g, cr, s);
295
+ if (this.locked(s)) return this.drawLocked(g, cr, s);
133
296
  this.sub(s.id).draw(g, cr);
134
297
  if (this.focus !== "content") g.tint(cr.x, cr.y, 1, cr.h, T.bg);
135
298
  }
136
299
 
137
300
  drawLocked(g, r, s) {
301
+ const app = this.app;
302
+ const a = this.auth;
138
303
  g.fill(r.x, r.y, r.w, r.h, T.panel);
139
304
  const art = [
140
305
  " ╭───────╮ ",
@@ -146,21 +311,118 @@ class ProfileView {
146
311
  " ╰───────────╯ ",
147
312
  ];
148
313
  const ax = r.x + Math.floor((r.w - 15) / 2);
149
- let y = r.y + Math.max(1, Math.floor(r.h / 2) - 9);
314
+ let y = r.y + Math.max(1, Math.floor(r.h / 2) - 11);
150
315
  art.forEach((l, i) => g.text(ax, y + i, l, { fg: i >= 3 ? T.lime : T.green, bg: T.panel }));
151
316
  y += art.length + 1;
152
317
  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 });
318
+ const button = (label, fn) => {
319
+ const w = strWidth(label) + 2;
320
+ const bx = r.x + Math.floor((r.w - w) / 2);
321
+ g.pill(bx, y, label, { fg: T.bg, bg: T.lime, bold: true });
322
+ app.hit(bx, y, w, 1, { click: fn });
323
+ y++;
324
+ };
325
+ if (this.missing(s)) {
326
+ c(`«${s.label}» — этому клиенту не выдано «${auth.scopeLabel(s.scope)}»`, { fg: T.white, bold: true });
327
+ y++;
328
+ // Rights as a left-aligned list, like the registration steps
329
+ const have = a.granted.filter((x) => x !== "offline_access").map((x) => `✓ ${auth.scopeLabel(x)}`);
330
+ const lx = r.x + Math.max(2, Math.floor((r.w - Math.max(15, ...have.map(strWidth))) / 2));
331
+ g.text(lx, y++, "Выданные права:", { fg: T.dim, bg: T.panel });
332
+ for (const line of have) g.text(lx, y++, line, { fg: T.sub, bg: T.panel });
333
+ y++;
334
+ c("Отметьте это действие в настройках клиента на vkusvill.ru и войдите заново:", { fg: T.sub });
335
+ y++;
336
+ button("⏎ войти с новыми правами", () => this.signIn({ allScopes: true }));
337
+ return;
338
+ }
339
+ c(s.id === "account" ? "Вход во ВкусВилл — для заказов, любимого продукта и личных скидок" : `«${s.label}» — после входа`, { fg: T.white, bold: true });
154
340
  y++;
155
- c("ВкусВилл отдаёт заказы, любимый продукт и персональные скидки", { fg: T.sub });
156
- c("только авторизованным клиентам (OAuth 2.0 на mcp.vkusvill.ru).", { fg: T.sub });
341
+ if (this.authError) { c(`⚠ ${this.authError}`, { fg: T.red }); y++; }
342
+ if (a.configured) {
343
+ c(a.clientId ? `Клиент ${a.clientId} · ${a.signedIn ? "" : "вход истёк"}`.replace(/ · $/, "") : "", { fg: T.dim });
344
+ y++;
345
+ button("⏎ войти", () => this.signIn());
346
+ y++;
347
+ c("c — сменить client_id", { fg: T.dim });
348
+ return;
349
+ }
350
+ c("ВкусВилл пускает к личным данным только через вашего персонального агента:", { fg: T.sub });
157
351
  y++;
158
- c("Если у вас есть токен доступа, передайте его при запуске:", { fg: T.dim });
352
+ // Steps share one left edge
353
+ const steps = [
354
+ ["1. Зарегистрируйте его на vkusvill.ru/oauth/registration/", "o — открыть", T.text],
355
+ ["2. Отметьте «У моего сервиса нет публичного URI»", "", T.text],
356
+ [` ВкусВилл подставит ${auth.DEFAULT_REDIRECT} — его ловит приложение`, "", T.dim],
357
+ ["3. client_id придёт на почту — введите его здесь", "", T.text],
358
+ ];
359
+ const sw = Math.max(...steps.map(([t, k]) => strWidth(t) + (k ? strWidth(k) + 3 : 0)));
360
+ const sx = r.x + Math.max(2, Math.floor((r.w - sw) / 2));
361
+ for (const [t, k, fg] of steps) {
362
+ const ex = g.text(sx, y, t, { fg, bg: T.panel });
363
+ if (k) g.text(ex + 3, y, k, { fg: T.lime, bg: T.panel });
364
+ y++;
365
+ }
159
366
  y++;
160
- c(" VV_TOKEN=… npx vkusvill-cli ", { fg: T.bg, bg: T.lime, bold: true });
367
+ button("⏎ войти", () => this.askClient());
161
368
  y++;
162
369
  c("Каталог, акции, рецепты, магазины и корзина работают без входа.", { fg: T.dim });
163
370
  }
371
+
372
+ drawAccount(g, r) {
373
+ const app = this.app;
374
+ const a = this.auth;
375
+ g.fill(r.x, r.y, r.w, r.h, T.panel);
376
+ const x = r.x + 4;
377
+ let y = r.y + 2;
378
+ g.text(x, y++, "Аккаунт ВкусВилла", { fg: T.white, bold: true, bg: T.panel });
379
+ y++;
380
+ if (a.viaEnv) {
381
+ g.text(x, y++, "Вход по готовому токену из VV_TOKEN.", { fg: T.sub, bg: T.panel });
382
+ return;
383
+ }
384
+ const row = (k, v, fg = T.text) => { g.text(x, y, k, { fg: T.dim, bg: T.panel }); g.text(x + 12, y++, v, { fg, bg: T.panel }); };
385
+ row("клиент", a.clientId ?? "—");
386
+ row("вход", a.refreshable ? "продлевается сам" : `до ${time(a.expiresAt)}, потом войти заново`, a.refreshable ? T.green : T.orange);
387
+ y++;
388
+ g.text(x, y++, "Права", { fg: T.dim, bg: T.panel });
389
+ const granted = a.granted;
390
+ for (const sc of Object.keys(auth.SCOPE_LABELS).filter((s) => s !== "offline_access")) {
391
+ const has = !granted || granted.includes(sc);
392
+ g.text(x, y, has ? "✓" : "·", { fg: has ? T.lime : T.mute, bg: T.panel });
393
+ g.text(x + 2, y++, auth.scopeLabel(sc) + (has ? "" : " — не выдано"), { fg: has ? T.text : T.dim, bg: T.panel });
394
+ }
395
+ y++;
396
+ if (granted && Object.keys(auth.SCOPE_LABELS).some((s) => s !== "offline_access" && !granted.includes(s))) {
397
+ g.text(x, y++, "Отметили на vkusvill.ru новые действия? Войдите заново:", { fg: T.sub, bg: T.panel });
398
+ y++;
399
+ }
400
+ const btn = (label, fn, prim) => {
401
+ // The pill's own padding sits left of the text column
402
+ g.pill(x - 1, y, label, { fg: prim ? T.bg : T.sub, bg: prim ? T.lime : T.raise, bold: prim });
403
+ app.hit(x - 1, y, strWidth(label) + 2, 1, { click: fn });
404
+ y += 2;
405
+ };
406
+ const sel = this.focus === "content" && !app.headerFocus ? this.accountSel ?? 0 : -1;
407
+ btn(`${sel === 0 ? "▸ " : ""}войти заново`, () => this.signIn({ allScopes: true }), sel === 0 || sel === -1);
408
+ btn(`${sel === 1 ? "▸ " : ""}выйти`, () => this.signOut(), sel === 1);
409
+ }
410
+
411
+ drawSigning(g, r) {
412
+ const app = this.app;
413
+ g.fill(r.x, r.y, r.w, r.h, T.panel);
414
+ let y = r.y + Math.max(1, Math.floor(r.h / 2) - 5);
415
+ const c = (t, st) => g.textC(r.x, r.w, y++, t, { bg: T.panel, ...st });
416
+ c(`${"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[app.frame % 10]} Жду подтверждения входа в браузере`, { fg: T.lime, bold: true });
417
+ y++;
418
+ c("Страница ВкусВилла открылась в браузере. После подтверждения всё продолжится само.", { fg: T.sub });
419
+ if (this.signing.notice) c(`${this.signing.notice} — v`, { fg: T.orange });
420
+ else c("Если браузер ушёл на адрес, который не открылся, — v и вставьте его.", { fg: T.dim });
421
+ y++;
422
+ if (this.signing.url) c(this.signing.url, { fg: T.mute });
423
+ y++;
424
+ c("esc — отменить", { fg: T.dim });
425
+ }
164
426
  }
165
427
 
166
428
  class OrdersView {
@@ -205,24 +467,45 @@ class OrdersView {
205
467
  key(k) {
206
468
  if (k.type !== "key") return false;
207
469
  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; }
470
+ if (k.name === "enter" || k.name === "right") { this.openOrder(this.items[this.list.sel]); return true; }
471
+ if ((k.hot ?? k.name) === "+") { this.repeat(this.items[this.list.sel]); return true; }
209
472
  if ((k.hot ?? k.name) === "R" && this.error) { this.page = 0; this.items = []; this.load(); return true; }
210
473
  return false;
211
474
  }
212
- hints() { return [["↑↓", "заказ"], ["⏎", "повторить заказ"]]; }
475
+ hints() { return [["↑↓", "заказ"], ["⏎", "состав"], ["+", "повторить заказ"]]; }
476
+
477
+ // An order opens as a regular product list: photos, prices, single items to the cart
478
+ openOrder(o) {
479
+ const prods = o?.products ?? [];
480
+ if (!prods.length) return;
481
+ const b = new ProductBrowser(this.app, {
482
+ title: `Заказ № ${o.id_general ?? o.id}`,
483
+ serverSort: false, autoLoad: false,
484
+ emptyText: "Товаров этого заказа больше нет в каталоге",
485
+ hintsExtra: [["P", "повторить весь заказ"]],
486
+ keyHook: (k) => ((k.hot ?? k.name) === "P" ? (this.repeat(o), true) : false),
487
+ loader: async () => {
488
+ const items = (await Promise.all(prods.map((p) => api.details(p.id).catch(() => null)))).filter(Boolean);
489
+ return { items, total: items.length, hasMore: false };
490
+ },
491
+ });
492
+ const base = b.hints.bind(b);
493
+ b.hints = () => [["← esc", "к заказам"], ...base()];
494
+ this.app.push(b);
495
+ }
213
496
  draw(g, r) {
214
497
  g.fill(r.x, r.y, r.w, r.h, T.panel);
215
498
  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; }
499
+ if (!this.items.length) { g.textC(r.x, r.w, r.y + Math.floor(r.h / 2), this.error ? `⚠ ${this.error}` : this.loading || !this.page ? "Загружаю заказы…" : "Заказов нет", { fg: this.error ? T.red : T.dim, bg: T.panel }); return; }
217
500
  this.list.draw(g, this.app, r.x, r.y + 2, r.w - 1, r.h - 2, this.items, (o, i, y, sel) => {
218
501
  const bg = sel ? T.selHi : T.panel;
219
502
  g.fill(r.x, y, r.w - 1, 3, bg);
220
503
  const date = o.created_at ? new Date(o.created_at * (o.created_at < 1e12 ? 1000 : 1)).toLocaleDateString("ru-RU", { day: "numeric", month: "long" }) : "";
221
504
  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
505
  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 });
506
+ g.textR(r.x + r.w - 3, y, rub(typeof o.price === "object" ? o.price?.price : o.price), { fg: T.lime, bold: true, bg });
224
507
  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) });
508
+ }, { rowH: 3, onPick: (o) => this.openOrder(o) });
226
509
  }
227
510
  }
228
511