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/recipes.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// and a recipe page with the steps and an ingredient checklist that goes into the cart with one key.
|
|
3
3
|
|
|
4
4
|
import { T, mix } from "../ui/theme.js";
|
|
5
|
-
import { TextInput, ListView, Menu } from "../ui/widgets.js";
|
|
5
|
+
import { TextInput, ListView, Menu, ChipBar } from "../ui/widgets.js";
|
|
6
6
|
import { image } from "../image.js";
|
|
7
7
|
import * as api from "../api.js";
|
|
8
8
|
import { normalize, qtyStep, plural, decode } from "../product.js";
|
|
@@ -36,6 +36,7 @@ class RecipesView {
|
|
|
36
36
|
this.input = new TextInput({ placeholder: "блюдо или продукт: сырники, тыква, курица…" });
|
|
37
37
|
this.input.onChange = (v) => { clearTimeout(this.timer); this.timer = setTimeout(() => this.setQuery(v), 350); };
|
|
38
38
|
this.list = new ListView();
|
|
39
|
+
this.bar = new ChipBar();
|
|
39
40
|
this.q = "";
|
|
40
41
|
this.f = loadRecipeFilters();
|
|
41
42
|
this.filterDefs = [];
|
|
@@ -49,6 +50,22 @@ class RecipesView {
|
|
|
49
50
|
|
|
50
51
|
saveFilters() { store.setPref("recipes", this.f); }
|
|
51
52
|
|
|
53
|
+
focusTop() { this.focus = "search"; }
|
|
54
|
+
|
|
55
|
+
chips() {
|
|
56
|
+
const act = this.activeFilters();
|
|
57
|
+
return [
|
|
58
|
+
{ label: act.length ? "фильтры ▾" : "фильтры: аллергены, время, сложность…", prefix: "f ", run: () => this.filterMenu() },
|
|
59
|
+
...act.map((a) => ({
|
|
60
|
+
label: `${a.label} ×`, fg: T.bg, bg: a.key === "allergens" ? T.orange : T.lime, bold: true,
|
|
61
|
+
run: () => {
|
|
62
|
+
if (a.key === "allergens") this.f.allergens = this.f.allergens.filter((i) => i !== a.id); else this.f[a.key] = 0;
|
|
63
|
+
this.saveFilters(); this.reset(); this.loadMore();
|
|
64
|
+
},
|
|
65
|
+
})),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
tick() {
|
|
53
70
|
if (this.hasMore && !this.loading && !this.error && this.page && this.list.sel >= this.items.length - 4) this.loadMore();
|
|
54
71
|
}
|
|
@@ -122,13 +139,20 @@ class RecipesView {
|
|
|
122
139
|
key(k) {
|
|
123
140
|
if (this.focus === "search") {
|
|
124
141
|
if (k.type === "paste") return this.input.key(k);
|
|
125
|
-
if (
|
|
142
|
+
if (k.name === "down" || k.name === "tab") { this.focus = "chips"; return true; }
|
|
143
|
+
if (k.name === "escape") { this.focus = "list"; return true; }
|
|
126
144
|
if (k.name === "enter") { clearTimeout(this.timer); this.setQuery(this.input.value); this.focus = "list"; return true; }
|
|
127
145
|
return this.input.key(k);
|
|
128
146
|
}
|
|
129
147
|
if (k.type !== "key") return false;
|
|
148
|
+
// Zones top to bottom: search → filter chips → list
|
|
149
|
+
if (this.focus === "chips") {
|
|
150
|
+
if (k.name === "up") { this.focus = "search"; return true; }
|
|
151
|
+
if (k.name === "down" || k.name === "escape") { this.focus = "list"; return true; }
|
|
152
|
+
if (this.bar.key(k, this.chips())) return true;
|
|
153
|
+
}
|
|
130
154
|
const h = k.hot ?? k.name;
|
|
131
|
-
if (k.name === "up" && this.list.sel === 0) { this.focus = "
|
|
155
|
+
if (k.name === "up" && this.list.sel === 0) { this.focus = "chips"; return true; }
|
|
132
156
|
if (this.list.key(k, this.items.length)) return true;
|
|
133
157
|
if (h === "/" || k.ch === ".") { this.focus = "search"; return true; }
|
|
134
158
|
if (h === "f") { this.filterMenu(); return true; }
|
|
@@ -139,38 +163,21 @@ class RecipesView {
|
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
hints() {
|
|
142
|
-
if (this.focus === "search") return [["⏎", "искать"], ["↓", "к
|
|
166
|
+
if (this.focus === "search") return [["⏎", "искать"], ["↓", "к фильтрам"]];
|
|
167
|
+
if (this.focus === "chips") return [["← →", "выбор"], ["⏎", "открыть или снять"], ["↑", "к поиску"], ["↓", "к списку"]];
|
|
143
168
|
return [["↑↓", "выбор"], ["⏎", "рецепт"], ["/", "поиск"], ["f", "фильтры"], ["o", "на сайте"], ["esc", "сбросить"]];
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
draw(g, r) {
|
|
147
172
|
const app = this.app;
|
|
148
173
|
g.fill(r.x, r.y, r.w, r.h, T.bg);
|
|
149
|
-
const focused = this.focus === "search";
|
|
150
|
-
g.box(r.x, r.y, r.w, 3, { fg: focused ? T.lineHi : T.line, bg: T.panel, title: "
|
|
174
|
+
const focused = this.focus === "search" && !app.headerFocus;
|
|
175
|
+
g.box(r.x, r.y, r.w, 3, { fg: focused ? T.lineHi : T.line, bg: T.panel, title: "Поиск рецептов", titleFg: focused ? T.lime : T.dim, right: `${this.total ?? "…"} ${plural(this.total ?? 0, "рецепт", "рецепта", "рецептов")}` });
|
|
151
176
|
g.text(r.x + 2, r.y + 1, "⌕", { fg: T.lime, bold: true, bg: T.panel });
|
|
152
177
|
this.input.draw(g, r.x + 4, r.y + 1, r.w - 8, { focused, bg: T.panel, blink: app.frame % 10 < 7 || !focused });
|
|
153
178
|
app.hit(r.x, r.y, r.w, 3, { click: () => { this.focus = "search"; } });
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
const fy = r.y + 3;
|
|
157
|
-
x = g.text(x, fy, "f ", { fg: T.dim });
|
|
158
|
-
const act = this.activeFilters();
|
|
159
|
-
if (!act.length) {
|
|
160
|
-
const label = "фильтры: аллергены, время, сложность…";
|
|
161
|
-
g.pill(x, fy, label, { fg: T.sub, bg: T.raise });
|
|
162
|
-
app.hit(x, fy, strWidth(label) + 2, 1, { click: () => this.filterMenu() });
|
|
163
|
-
}
|
|
164
|
-
for (const a of act) {
|
|
165
|
-
const w = strWidth(a.label) + 4;
|
|
166
|
-
if (x + w > r.x + r.w - 2) break;
|
|
167
|
-
g.pill(x, fy, a.label + " ×", { fg: T.bg, bg: a.key === "allergens" ? T.orange : T.lime, bold: true });
|
|
168
|
-
app.hit(x, fy, w, 1, { click: () => {
|
|
169
|
-
if (a.key === "allergens") this.f.allergens = this.f.allergens.filter((i) => i !== a.id); else this.f[a.key] = 0;
|
|
170
|
-
this.saveFilters(); this.reset(); this.loadMore();
|
|
171
|
-
} });
|
|
172
|
-
x += w + 1;
|
|
173
|
-
}
|
|
179
|
+
// The filters button and the active filters (Enter on one removes it)
|
|
180
|
+
this.bar.draw(g, app, r.x + 1, r.y + 3, r.x + r.w - 2, this.chips(), { focused: this.focus === "chips" && !app.headerFocus });
|
|
174
181
|
const top = r.y + 4;
|
|
175
182
|
const H = r.h - 4;
|
|
176
183
|
const paneW = r.w >= 96 ? Math.max(40, Math.min(60, Math.floor(r.w * 0.42))) : 0;
|
|
@@ -180,14 +187,31 @@ class RecipesView {
|
|
|
180
187
|
g.textC(r.x, lw, top + Math.floor(H / 2) - 1, this.loading ? "Листаю кулинарную книгу…" : this.error ? `⚠ ${this.error}` : "Ничего не нашлось", { fg: this.error ? T.red : T.dim, bg: T.panel });
|
|
181
188
|
if (this.loading) loader(g, r.x + Math.floor(lw / 2) - 6, top + Math.floor(H / 2) + 1, app.frame);
|
|
182
189
|
} else {
|
|
190
|
+
// Wide enough: one line per recipe with the details in aligned columns on the right;
|
|
191
|
+
// otherwise the details go on a second line
|
|
192
|
+
const COLS = [["time", 14], ["level", 11], ["portions", 11], ["kcal", 17]];
|
|
193
|
+
const metaW = COLS.reduce((s, [, w]) => s + w, 0);
|
|
194
|
+
const oneLine = lw - 6 - metaW >= 28;
|
|
195
|
+
const rowH = oneLine ? 1 : 2;
|
|
183
196
|
this.list.draw(g, app, r.x, top, lw - 1, H, this.items, (it, i, y, sel) => {
|
|
184
197
|
const bg = sel ? (this.focus === "list" ? T.selHi : T.sel) : T.panel;
|
|
185
|
-
g.fill(r.x, y, lw - 1,
|
|
186
|
-
|
|
198
|
+
g.fill(r.x, y, lw - 1, rowH, bg);
|
|
199
|
+
for (let j = 0; j < rowH; j++) if (sel) g.text(r.x, y + j, "▌", { fg: T.lime, bg });
|
|
200
|
+
const meta = {
|
|
201
|
+
time: it.cooking_time?.name && `◷ ${it.cooking_time.name}`,
|
|
202
|
+
level: it.complexity?.name && `◆ ${it.complexity.name.toLowerCase()}`,
|
|
203
|
+
portions: it.portions && `${it.portions} ${plural(it.portions, "порция", "порции", "порций")}`,
|
|
204
|
+
kcal: it.nutritional?.calories && `${Math.round(it.nutritional.calories)} ккал/100 г`,
|
|
205
|
+
};
|
|
206
|
+
if (oneLine) {
|
|
207
|
+
g.textW(r.x + 2, y, decode(it.name), lw - 6 - metaW, { fg: sel ? T.white : T.text, bold: sel, bg });
|
|
208
|
+
let cx = r.x + lw - 2 - metaW;
|
|
209
|
+
for (const [key, w] of COLS) { if (meta[key]) g.textW(cx, y, meta[key], w - 1, { fg: T.dim, bg }); cx += w; }
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
187
212
|
g.textW(r.x + 2, y, decode(it.name), lw - 6, { fg: sel ? T.white : T.text, bold: sel, bg });
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}, { rowH: 2, onPick: (it) => this.open(it) });
|
|
213
|
+
g.textW(r.x + 2, y + 1, Object.values(meta).filter(Boolean).join(" "), lw - 6, { fg: T.dim, bg });
|
|
214
|
+
}, { rowH, onPick: (it) => this.open(it) });
|
|
191
215
|
}
|
|
192
216
|
if (paneW) this.drawPane(g, { x: r.x + lw + 1, y: top, w: paneW - 1, h: H }, this.items[this.list.sel]);
|
|
193
217
|
}
|
|
@@ -269,8 +293,18 @@ class RecipeView {
|
|
|
269
293
|
if (k.type !== "key") return false;
|
|
270
294
|
const h = k.hot ?? k.name;
|
|
271
295
|
if (this.narrow && this.showSteps && k.name === "escape") { this.showSteps = false; return true; }
|
|
272
|
-
if (this.narrow && this.showSteps && (k.name === "up" || k.name === "down")) {
|
|
273
|
-
|
|
296
|
+
if (this.narrow && this.showSteps && (k.name === "up" || k.name === "down")) {
|
|
297
|
+
if (k.name === "up" && this.scroll <= 0) return false;
|
|
298
|
+
this.scroll = Math.max(0, this.scroll + (k.name === "up" ? -1 : 1));
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
// The "+ в корзину" button sits above the ingredients: ↑ from the first one reaches it
|
|
302
|
+
if (this.focus === "button") {
|
|
303
|
+
if (k.name === "down" || k.name === "escape") { this.focus = "list"; return true; }
|
|
304
|
+
if (k.name === "enter" || k.name === "space") { this.addChecked(); return true; }
|
|
305
|
+
if (k.name === "up") return false;
|
|
306
|
+
} else if (k.name === "up" && this.list.sel <= 0) { this.focus = "button"; return true; }
|
|
307
|
+
if (this.focus !== "button" && this.list.key(k, this.ings.length)) return true;
|
|
274
308
|
const ing = this.ings[this.list.sel];
|
|
275
309
|
if (k.name === "space" || h === "x") { if (ing) ing.checked = !ing.checked; return true; }
|
|
276
310
|
if (h === "a") { const all = this.ings.every((i) => i.checked); this.ings.forEach((i) => { i.checked = !all && !!i.ids?.length; }); return true; }
|
|
@@ -283,7 +317,10 @@ class RecipeView {
|
|
|
283
317
|
return false;
|
|
284
318
|
}
|
|
285
319
|
|
|
286
|
-
hints() {
|
|
320
|
+
hints() {
|
|
321
|
+
if (this.focus === "button") return [["⏎", "положить отмеченное в корзину"], ["↓", "к ингредиентам"], ["esc", "к ингредиентам"]];
|
|
322
|
+
return [["← esc", "назад"], ["␣", "отметить"], ["a", "все"], ["+", "в корзину"], ["⏎", "товар"], [this.narrow ? "s" : "PgDn", "шаги"], ["o", "на сайте"]];
|
|
323
|
+
}
|
|
287
324
|
|
|
288
325
|
draw(g, r) {
|
|
289
326
|
const app = this.app;
|
|
@@ -313,7 +350,9 @@ class RecipeView {
|
|
|
313
350
|
const checked = this.ings.filter((i) => i.checked).length;
|
|
314
351
|
g.text(x, y, `ИНГРЕДИЕНТЫ`, { fg: T.dim, bold: true, bg: T.panel });
|
|
315
352
|
const btn = this.adding ? ` кладу ${this.adding.done}/${this.adding.total}… ` : ` + в корзину: ${checked} `;
|
|
316
|
-
|
|
353
|
+
const onBtn = this.focus === "button" && !app.headerFocus;
|
|
354
|
+
g.textR(x + w, y, btn, { fg: T.bg, bg: this.adding ? T.yellow : T.lime, bold: true, underline: onBtn });
|
|
355
|
+
if (onBtn) g.textR(x + w - strWidth(btn) - 1, y, "▸", { fg: T.lime, bg: T.panel });
|
|
317
356
|
app.hit(x + w - strWidth(btn), y, strWidth(btn), 1, { click: () => this.addChecked() });
|
|
318
357
|
y += 1;
|
|
319
358
|
this.list.draw(g, app, r.x, y, lw - 1, r.y + r.h - y, this.ings, (ing, i, ry, sel) => {
|
package/src/views/shops.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// and a Braille-dot map of all loaded shops with crosshairs on the selected one; z zooms in.
|
|
3
3
|
|
|
4
4
|
import { T, mix } from "../ui/theme.js";
|
|
5
|
-
import { ListView, Menu, TextInput } from "../ui/widgets.js";
|
|
5
|
+
import { ListView, Menu, TextInput, ChipBar } from "../ui/widgets.js";
|
|
6
6
|
import { Braille } from "../ui/draw.js";
|
|
7
7
|
import * as api from "../api.js";
|
|
8
8
|
import { plural } from "../product.js";
|
|
@@ -75,6 +75,7 @@ class ShopsView {
|
|
|
75
75
|
constructor(app) {
|
|
76
76
|
this.app = app;
|
|
77
77
|
this.list = new ListView();
|
|
78
|
+
this.bar = new ChipBar();
|
|
78
79
|
// Place filters survive restarts; Moscow region is the default
|
|
79
80
|
const saved = store.pref("shops", null);
|
|
80
81
|
const isId = (v) => Number.isInteger(v) && v >= 0;
|
|
@@ -103,6 +104,18 @@ class ShopsView {
|
|
|
103
104
|
return [...v].sort((a, b) => isCourier(a) - isCourier(b));
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
focusTop() { this.focus = "search"; }
|
|
108
|
+
|
|
109
|
+
// Place filters as keyboard-walkable pills, and "all on the map" while pages remain
|
|
110
|
+
chips() {
|
|
111
|
+
const out = [["region", "r"], ["city", "g"], ["subway", "m"], ["feature", "f"]].map(([kind, key]) => {
|
|
112
|
+
const val = this.f[kind] ? this.names[kind] : kind === "region" && this.regionHint ? this.regionHint : "любой";
|
|
113
|
+
return { label: `${key} ${FILTER_NAMES[kind]}: ${val} ▾`, run: () => this.pick(kind), ...(this.f[kind] ? { fg: T.bg, bg: T.lime, bold: true } : {}) };
|
|
114
|
+
});
|
|
115
|
+
if (this.hasMore && this.items.length) out.push({ label: "L все на карту", run: () => this.loadAll() });
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
tick() {
|
|
107
120
|
if (this.hasMore && !this.loading && !this.error && this.page && this.list.sel >= this.visible.length - 5) this.loadMore();
|
|
108
121
|
}
|
|
@@ -223,11 +236,19 @@ class ShopsView {
|
|
|
223
236
|
key(k) {
|
|
224
237
|
if (this.focus === "search") {
|
|
225
238
|
if (k.type === "paste") return this.search.key(k);
|
|
226
|
-
if (
|
|
239
|
+
if (k.name === "down" || k.name === "tab") { this.focus = "chips"; return true; }
|
|
240
|
+
if (k.name === "enter" || k.name === "escape") { this.focus = "list"; if (k.name === "escape") this.search.set(""); return true; }
|
|
227
241
|
return this.search.key(k);
|
|
228
242
|
}
|
|
229
243
|
if (k.type !== "key") return false;
|
|
244
|
+
// Zones top to bottom: address search → place filters → list
|
|
245
|
+
if (this.focus === "chips") {
|
|
246
|
+
if (k.name === "up") { this.focus = "search"; return true; }
|
|
247
|
+
if (k.name === "down" || k.name === "escape") { this.focus = "list"; return true; }
|
|
248
|
+
if (this.bar.key(k, this.chips())) return true;
|
|
249
|
+
}
|
|
230
250
|
const vis = this.visible;
|
|
251
|
+
if (k.name === "up" && this.list.sel <= 0) { this.focus = "chips"; return true; }
|
|
231
252
|
if (this.list.key(k, vis.length)) return true;
|
|
232
253
|
const h = k.hot ?? k.name;
|
|
233
254
|
const s = vis[this.list.sel];
|
|
@@ -247,48 +268,39 @@ class ShopsView {
|
|
|
247
268
|
}
|
|
248
269
|
|
|
249
270
|
hints() {
|
|
250
|
-
if (this.focus === "search") return [["
|
|
271
|
+
if (this.focus === "search") return [["↓", "к фильтрам"], ["⏎", "к списку"], ["esc", "сбросить поиск"]];
|
|
272
|
+
if (this.focus === "chips") return [["← →", "выбор"], ["⏎", "выбрать"], ["↑", "к поиску"], ["↓", "к списку"]];
|
|
251
273
|
return [["↑↓", "магазин"], ["/", "поиск"], ...(this.app.g.w < 100 ? [["v", "карта"]] : []), ["z", "приблизить"], ["r g m f", "регион·город·метро·особ."], ["L", "все на карту"], ["p", "Я.Карты"], ["o", "на сайте"], ["y", "адрес"]];
|
|
252
274
|
}
|
|
253
275
|
|
|
254
276
|
draw(g, r) {
|
|
255
277
|
const app = this.app;
|
|
256
278
|
g.fill(r.x, r.y, r.w, r.h, T.bg);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
279
|
+
const vis = this.visible;
|
|
280
|
+
// Search box, like the catalog and recipes: a framed field with the "open now" count in its corner
|
|
281
|
+
const searching = this.focus === "search" && !app.headerFocus;
|
|
282
|
+
const open = vis.filter((s) => shopState(s)?.open).length;
|
|
283
|
+
const shops = vis.filter((s) => !isCourier(s)).length;
|
|
284
|
+
g.box(r.x, r.y, r.w, 3, {
|
|
285
|
+
fg: searching ? T.lineHi : T.line, bg: T.panel, title: "Поиск магазина", titleFg: searching ? T.lime : T.dim,
|
|
286
|
+
right: vis.length ? `● ${open} из ${shops} открыто` : "", rightFg: open ? T.lime : T.dim,
|
|
287
|
+
});
|
|
288
|
+
g.text(r.x + 2, r.y + 1, "⌕", { fg: T.lime, bold: true, bg: T.panel });
|
|
289
|
+
this.search.draw(g, r.x + 4, r.y + 1, r.w - 8, { focused: searching, bg: T.panel, blink: app.frame % 10 < 7 || !searching });
|
|
290
|
+
app.hit(r.x, r.y, r.w, 3, { click: () => { this.focus = "search"; } });
|
|
291
|
+
// Place filters and the loading progress under the box
|
|
292
|
+
const fy = r.y + 3;
|
|
293
|
+
this.bar.draw(g, app, r.x + 1, fy, r.x + r.w - 30, this.chips(), { focused: this.focus === "chips" && !app.headerFocus });
|
|
269
294
|
const progress = this.pages ? this.page / this.pages : 0;
|
|
270
295
|
const pw = 16;
|
|
271
296
|
const px = r.x + r.w - pw - 12;
|
|
272
|
-
g.gradBar(px,
|
|
273
|
-
g.text(px + pw + 1,
|
|
274
|
-
app.hit(px,
|
|
297
|
+
g.gradBar(px, fy, pw, progress, [T.greenD, T.lime]);
|
|
298
|
+
g.text(px + pw + 1, fy, `${this.items.length}/${this.total ?? "…"}`, { fg: T.dim });
|
|
299
|
+
app.hit(px, fy, pw + 10, 1, { click: () => this.loadAll() });
|
|
275
300
|
|
|
276
|
-
const top = r.y +
|
|
301
|
+
const top = r.y + 5, H = r.h - 5;
|
|
277
302
|
if (r.w < 100 && this.mapOnly) return this.drawMap(g, { x: r.x, y: top, w: r.w, h: H });
|
|
278
303
|
const lw = r.w >= 100 ? Math.max(44, Math.floor(r.w * 0.42)) : r.w;
|
|
279
|
-
const vis = this.visible;
|
|
280
|
-
// Search line and the "open now" summary
|
|
281
|
-
const sy = r.y + 1;
|
|
282
|
-
const searching = this.focus === "search";
|
|
283
|
-
g.fill(r.x, sy, lw, 1, searching ? T.raise : T.bg);
|
|
284
|
-
g.text(r.x + 1, sy, "⌕", { fg: searching || this.search.value ? T.lime : T.dim, bg: searching ? T.raise : T.bg });
|
|
285
|
-
this.search.draw(g, r.x + 3, sy, lw - 24, { focused: searching, bg: searching ? T.raise : T.bg, blink: app.frame % 10 < 7 });
|
|
286
|
-
app.hit(r.x, sy, lw - 20, 1, { click: () => { this.focus = "search"; } });
|
|
287
|
-
if (vis.length) {
|
|
288
|
-
const open = vis.filter((s) => shopState(s)?.open).length;
|
|
289
|
-
const shops = vis.filter((s) => !isCourier(s)).length;
|
|
290
|
-
g.textR(r.x + lw - 1, sy, `● ${open} из ${shops} открыто`, { fg: open ? T.lime : T.red, bg: searching ? T.raise : T.bg });
|
|
291
|
-
}
|
|
292
304
|
g.fill(r.x, top, lw, H, T.panel);
|
|
293
305
|
if (!vis.length) {
|
|
294
306
|
const msg = this.loading ? "Ищу магазины…" : this.error ? `⚠ ${this.error}` : this.items.length ? `По «${this.search.value}» ничего` : "Магазинов не нашлось";
|
|
@@ -298,20 +310,35 @@ class ShopsView {
|
|
|
298
310
|
else if (this.search.value && this.hasMore && !this.loading) g.textC(r.x, lw, top + Math.floor(H / 2) + 1, "L — загрузить все магазины и искать по ним", { fg: T.dim, bg: T.panel });
|
|
299
311
|
if (this.loading) loader(g, r.x + Math.floor(lw / 2) - 6, top + Math.floor(H / 2) + 1, app.frame);
|
|
300
312
|
} else {
|
|
313
|
+
// Wide enough (no map beside the list): one line per shop, place and hours in columns
|
|
314
|
+
const PLACE = 20, HOURS = 28, RATING = 7;
|
|
315
|
+
const oneLine = lw - 6 - PLACE - HOURS - RATING >= 26;
|
|
316
|
+
const rowH = oneLine ? 1 : 2;
|
|
301
317
|
this.list.draw(g, app, r.x, top, lw - 1, H, vis, (s, i, y, sel) => {
|
|
302
|
-
const bg = sel ? T.selHi : T.panel;
|
|
303
|
-
g.fill(r.x, y, lw - 1,
|
|
304
|
-
|
|
318
|
+
const bg = sel ? (this.focus === "list" && !app.headerFocus ? T.selHi : T.sel) : T.panel;
|
|
319
|
+
g.fill(r.x, y, lw - 1, rowH, bg);
|
|
320
|
+
for (let j = 0; j < rowH; j++) if (sel) g.text(r.x, y + j, "▌", { fg: T.lime, bg });
|
|
305
321
|
const st = shopState(s);
|
|
306
322
|
const courier = st?.courier;
|
|
307
323
|
g.text(r.x + 2, y, courier ? "◌" : st ? (st.open ? "●" : "○") : "·", { fg: courier ? T.mute : st?.open ? T.lime : T.red, bg });
|
|
308
324
|
let ax = r.x + 4;
|
|
309
325
|
if (courier) ax = g.text(ax, y, "склад ", { fg: T.violet, bg });
|
|
326
|
+
const place = s.subway?.name ? `Ⓜ ${s.subway.name}` : s.city?.name ?? "";
|
|
327
|
+
const hours = courier ? "только для курьеров" : st?.text ?? s.schedule ?? "";
|
|
328
|
+
const hoursFg = courier ? T.mute : st && !st.open ? mix(T.red, T.dim, 0.5) : T.dim;
|
|
329
|
+
const rating = s.rating && !courier ? `★ ${String(s.rating).replace(".", ",")}` : "";
|
|
330
|
+
if (oneLine) {
|
|
331
|
+
const rx = r.x + lw - 2 - PLACE - HOURS - RATING;
|
|
332
|
+
g.textW(ax, y, cleanAddress(s), rx - ax - 1, { fg: courier ? T.dim : sel ? T.white : T.text, bold: sel && !courier, bg });
|
|
333
|
+
g.textW(rx, y, place, PLACE - 1, { fg: T.dim, bg });
|
|
334
|
+
g.textW(rx + PLACE, y, hours, HOURS - 1, { fg: hoursFg, bg });
|
|
335
|
+
if (rating) g.textR(r.x + lw - 2, y, rating, { fg: T.yellow, bg });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
310
338
|
g.textW(ax, y, cleanAddress(s), r.x + lw - 10 - ax, { fg: courier ? T.dim : sel ? T.white : T.text, bold: sel && !courier, bg });
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}, { rowH: 2, onPick: () => { this.zoom = !this.zoom; } });
|
|
339
|
+
if (rating) g.textR(r.x + lw - 2, y, rating, { fg: T.yellow, bg });
|
|
340
|
+
g.textW(r.x + 4, y + 1, [place, hours].filter(Boolean).join(" · "), lw - 6, { fg: hoursFg, bg });
|
|
341
|
+
}, { rowH, onPick: () => { this.zoom = !this.zoom; } });
|
|
315
342
|
}
|
|
316
343
|
if (lw < r.w) this.drawMap(g, { x: r.x + lw + 1, y: top, w: r.w - lw - 1, h: H });
|
|
317
344
|
}
|