vkusvill 0.0.0-dev.0 → 0.0.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -10
- package/README.ru.md +41 -10
- package/package.json +16 -6
- package/src/api.js +102 -26
- package/src/app.js +75 -7
- package/src/auth.js +457 -0
- package/src/cli.js +121 -6
- package/src/main.js +26 -8
- package/src/paths.js +20 -0
- package/src/product.js +5 -3
- package/src/secrets.js +133 -0
- package/src/store.js +1 -2
- package/src/sys.js +13 -1
- package/src/ui/product-ui.js +13 -1
- package/src/ui/widgets.js +101 -0
- package/src/views/browser.js +1 -1
- package/src/views/cart.js +37 -15
- package/src/views/catalog.js +96 -46
- package/src/views/overlays.js +9 -4
- package/src/views/product.js +8 -5
- package/src/views/profile.js +317 -34
- package/src/views/recipes.js +74 -35
- package/src/views/shops.js +66 -39
package/src/views/profile.js
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
|
-
// Profile: favourites and history are local; orders, favourite product and "6 discounts" need
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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.
|
|
100
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
}, { scrollbar: false, onPick: () => {
|
|
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 (
|
|
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) -
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ||
|
|
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.
|
|
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.
|
|
508
|
+
}, { rowH: 3, onPick: (o) => this.openOrder(o) });
|
|
226
509
|
}
|
|
227
510
|
}
|
|
228
511
|
|