feat(site): свежие скриншоты ALL MARKET на витринных данных
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Пересняты скриншоты публичного сайта в интерфейсе ALL MARKET на витринной организации «Магазин у дома» (200 товаров, ~30 дней продаж, чистые данные без e2e/test-мусора). Светлая тема, 1440×900 @2x, WebP + srcset, alt по-русски. - screenshots.mjs: стенд + витринный владелец, 14 экранов (дашборд/каталог/ карточка/приёмки/остатки/инвентаризация/деньги/отчёты/контрагенты), скрытие баннеров стенда/«что нового»/MoM-дельты, резолв AsyncSelect-подписей - optimize-screenshots.mjs: PNG→WebP (1x 1440w / 2x 2880w) через canvas, все ≤300КБ - pos-mockup/pos-mockup.html: светлая касса ALL MARKET «Магазин у дома» (продажа+оплата) - index.astro: hero-дашборд + каталог + новый блок «Касса»; pos.astro: продажа+оплата - ShowcaseSeeder: стартовый завоз всех товаров (нет отрицательных остатков) - deploy/showcase-seed.sh: RESET=1 пересоздаёт витрину с нуля - исходные PNG не коммитятся (.gitignore), доставка — только WebP Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
@ -25,6 +25,37 @@ j() { python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get(sys.argv[1]
|
|||
|
||||
echo "→ стенд: $BASE организация: «$ORGNAME» владелец: $EMAIL"
|
||||
|
||||
# 0. (опц.) RESET=1 — архивировать прежнюю витринную орг. через SuperAdmin, чтобы
|
||||
# пересоздать её с нуля (signup реактивирует владельца с новой организацией).
|
||||
# Только стенд: мастер-пароль быстрого входа работает лишь при QuickLogin и не-прод Issuer.
|
||||
if [[ "${RESET:-0}" == "1" ]]; then
|
||||
SA_EMAIL="${SUPERADMIN_EMAIL:-admin@food-market.local}"
|
||||
SA_PASS="${SUPERADMIN_PASSWORD:-__QUICK_LOGIN_PWLESS__}"
|
||||
echo "→ RESET: архивирую прежнюю «$ORGNAME» (SuperAdmin $SA_EMAIL)…"
|
||||
SATOKEN=$(curl -sk -m 30 -X POST "$BASE/connect/token" -H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode grant_type=password --data-urlencode "username=$SA_EMAIL" --data-urlencode "password=$SA_PASS" \
|
||||
--data-urlencode client_id=food-market-web --data-urlencode 'scope=openid profile email roles api' | j access_token)
|
||||
if [[ -n "$SATOKEN" ]]; then
|
||||
# orgId прежнего владельца (если он ещё существует и активен).
|
||||
OLDTOKEN=$(curl -sk -m 30 -X POST "$BASE/connect/token" -H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode grant_type=password --data-urlencode "username=$EMAIL" --data-urlencode "password=$PASSWORD" \
|
||||
--data-urlencode client_id=food-market-web --data-urlencode 'scope=openid profile email roles api' | j access_token)
|
||||
if [[ -n "$OLDTOKEN" ]]; then
|
||||
OLDORG=$(curl -sk -m 30 "$BASE/api/me" -H "Authorization: Bearer $OLDTOKEN" | j orgId)
|
||||
if [[ -n "$OLDORG" ]]; then
|
||||
code=$(curl -sk -m 30 -o /dev/null -w '%{http_code}' -X POST "$BASE/api/super-admin/organizations/$OLDORG/archive" \
|
||||
-H "Authorization: Bearer $SATOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"confirmationName\":\"$ORGNAME\"}")
|
||||
echo " archive $OLDORG → HTTP $code"
|
||||
fi
|
||||
else
|
||||
echo " прежний владелец не логинится (орг уже архивна?) — пропускаю."
|
||||
fi
|
||||
else
|
||||
echo " ⚠ не удалось получить SuperAdmin-токен — пропускаю RESET."
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. Регистрация организации (идемпотентно: если e-mail занят — просто логинимся).
|
||||
echo "→ signup…"
|
||||
signup=$(curl -sk -m 60 -X POST "$BASE/api/auth/signup" \
|
||||
|
|
|
|||
|
|
@ -1,26 +1,42 @@
|
|||
# Обновление скриншотов публичного сайта (ALL MARKET)
|
||||
|
||||
Владелец 08.10: «скриншоты нужно ещё обновить». Сейчас
|
||||
Владелец 08.10: «скриншоты нужно ещё обновить». Старые
|
||||
`src/food-market.public/public/screenshots/*.png` — от 26.04, старый интерфейс со старым
|
||||
логотипом Food Market. Нужны свежие скриншоты интерфейса ALL MARKET на правдоподобных
|
||||
витринных данных (без e2e/test-мусора).
|
||||
логотипом Food Market. Переснято в интерфейсе ALL MARKET на витринной организации
|
||||
«Магазин у дома» (чистые данные, без e2e/test-мусора).
|
||||
|
||||
## План / статус
|
||||
1. [ ] Витринная организация на стенде «Магазин у дома» (простой режим): ~200 реальных
|
||||
товаров (цены/штрихкоды/группы), 2–3 поставщика, неделя продаж, приёмки/списания,
|
||||
инвентаризация, счета (касса/Kaspi/эквайринг), сотрудники с обычными именами.
|
||||
Повторяемый скрипт — `deploy/showcase-seed.*`.
|
||||
2. [ ] Переснять `scripts/screenshots.mjs` в интерфейсе ALL MARKET (1440×900 @2x, светлая тема,
|
||||
без тостов/отладки, без чужих данных): дашборд, каталог, карточка товара, приёмка,
|
||||
остатки, инвентаризация, деньги/смены, отчёты, контрагенты.
|
||||
3. [ ] Касса: свежие скрины новой светлой кассы (продажа/оплата). Мобильное iOS — если есть
|
||||
`~/.fm-watchdog/brand/ios-screens/`.
|
||||
4. [ ] Подставить на сайт, оптимизировать (webp/png ≤300КБ, srcset), alt по-русски. OG — проверить.
|
||||
5. [ ] Выкладку публичного сайта добавить в `~/deploy-stage.sh` (через локальное зеркало образов).
|
||||
6. [ ] Выложить, проверить test.food-market.kz, галерея `docs/site-screenshots/index.html`.
|
||||
1. [x] Витринная организация на стенде «Магазин у дома» (простой режим): 200 реальных
|
||||
товаров (без Article-маркеров, реальные EAN-13, группы), 3 поставщика, ~30 дней
|
||||
продаж (856 чеков), 6 приёмок (стартовый завоз всех товаров + 5 текущих), 2 списания,
|
||||
завершённая инвентаризация, счета (касса/Kaspi/эквайринг), сотрудники с обычными именами.
|
||||
Сидер `ShowcaseSeeder` + эндпоинт `POST /api/admin/seed-demo?showcase=1`.
|
||||
Повторяемый скрипт `deploy/showcase-seed.sh` (signup + seed; `RESET=1` пересоздаёт с нуля).
|
||||
2. [x] Пересняты `scripts/screenshots.mjs` в ALL MARKET (1440×900 @2x, светлая тема, баннеры
|
||||
стенда/«что нового»/MoM-дельта скрыты): дашборд, каталог, карточка товара, приёмки (список
|
||||
+ документ), приёмка по фото, остатки, инвентаризация, деньги/счета, отчёты (продажи,
|
||||
прибыль), контрагенты. Логин — витринный владелец (без super-admin override).
|
||||
3. [x] Касса: новая светлая касса (продажа + оплата) — макет
|
||||
`scripts/pos-mockup/pos-mockup.html` (ALL MARKET, «Магазин у дома», кассир Виктория Ким),
|
||||
рендер в screenshots.mjs → `pos-sale`/`pos-pay`. На pos.astro и на главной.
|
||||
Мобильное iOS — НЕ использовано: в `~/.fm-watchdog/brand/ios-screens/` бейдж «Демо-магазин»
|
||||
и на home-экране мусор «Касса (e2e) …», каталог не продуктовый — противоречит требованию
|
||||
«без Демо/e2e». Блок «Склад в телефоне» отложен до чистых iOS-скринов.
|
||||
4. [x] На сайте: `index.astro` (дашборд-hero + каталог + новый блок «Касса»), `pos.astro`
|
||||
(продажа+оплата). WebP + srcset (1x 1440w / 2x 2880w), все ≤300 КБ (max 205 КБ),
|
||||
alt по-русски. Исходные PNG не коммитятся (`.gitignore`), доставка — только WebP.
|
||||
OG — уже ALL MARKET (проверено `og/home.png`).
|
||||
5. [x] Выкладка публичного сайта добавлена в `~/deploy-stage.sh` (функция deploy_public, локальное
|
||||
зеркало образов). Основа — `~/.fm-watchdog/deploy-public-test.sh`.
|
||||
6. [~] Выложить, проверить test.food-market.kz, галерея `docs/site-screenshots/index.html` — ниже.
|
||||
|
||||
## Что НЕ трогаем
|
||||
Прод (food-market.kz, admin.food-market.kz), shared-postgres, global.json, gateway nginx, sklad.
|
||||
|
||||
## Журнал
|
||||
- 08.10 — старт: план, обследование текущих скриншотов и страниц сайта.
|
||||
- 08.10 — ShowcaseSeeder + эндпоинт + deploy/showcase-seed.sh; деплой API на стенд.
|
||||
- 08.10 — витринная орг. «Магазин у дома» (200 товаров/856 продаж/счета/сотрудники), чистые данные.
|
||||
- 08.10 — screenshots.mjs переписан на стенд+витрину (14 экранов) + optimize-screenshots.mjs (WebP).
|
||||
- 08.10 — фикс сидера: стартовый завоз всех товаров → нет отрицательных остатков; RESET пересоздаёт орг.
|
||||
- 08.10 — сайт: index.astro + pos.astro на новые WebP, POS-мокап ALL MARKET.
|
||||
|
|
|
|||
|
|
@ -215,26 +215,27 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
|||
// Накопитель движений (bulk в конце, один пересчёт агрегата Stocks).
|
||||
var movements = new List<StockMovement>();
|
||||
|
||||
// ── Приёмки: 6 поставок за последние 30 дней ─────────────────────────
|
||||
var supplyCount = 6;
|
||||
// ── Приёмки ──────────────────────────────────────────────────────────
|
||||
// Первая — стартовый завоз ВСЕХ товаров (чтобы ни один остаток не ушёл
|
||||
// в минус после продаж), затем 5 текущих поставок по случайным позициям.
|
||||
var supplies = new List<Supply>();
|
||||
for (int i = 0; i < supplyCount; i++)
|
||||
int supplyNo = 1;
|
||||
|
||||
void AddSupply(DateTime date, IReadOnlyList<Product> picked, Func<int> qtyFn)
|
||||
{
|
||||
var date = now.AddDays(-28 + i * 4 - _rng.NextDouble());
|
||||
var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(30, 60)).ToList();
|
||||
var supply = new Supply
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Number = $"П-{yearNow}-{i + 1:D6}",
|
||||
Number = $"П-{yearNow}-{supplyNo++:D6}",
|
||||
Date = date, Status = SupplyStatus.Posted, PostedAt = date,
|
||||
SupplierId = suppliers[i % suppliers.Count].Id,
|
||||
SupplierId = suppliers[(supplyNo) % suppliers.Count].Id,
|
||||
StoreId = mainStore.Id, CurrencyId = currency.Id,
|
||||
};
|
||||
decimal total = 0;
|
||||
int order = 0;
|
||||
foreach (var p in picked)
|
||||
{
|
||||
var qty = _rng.Next(20, 80);
|
||||
var qty = qtyFn();
|
||||
var price = Math.Round(p.ReferencePrice!.Value * 0.6m, 2);
|
||||
var lt = qty * price;
|
||||
supply.Lines.Add(new SupplyLine
|
||||
|
|
@ -257,6 +258,16 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
|
|||
supplies.Add(supply);
|
||||
}
|
||||
|
||||
// Стартовый завоз: все товары, 120–240 ед. — покроет месяц продаж.
|
||||
AddSupply(now.AddDays(-33), products, () => _rng.Next(120, 240));
|
||||
// 5 текущих поставок за последние 28 дней.
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var date = now.AddDays(-24 + i * 5 - _rng.NextDouble());
|
||||
var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(30, 60)).ToList();
|
||||
AddSupply(date, picked, () => _rng.Next(40, 120));
|
||||
}
|
||||
|
||||
// ── Продажи: ~30 дней, 18-38 чеков в день ────────────────────────────
|
||||
var sales = new List<RetailSale>();
|
||||
int saleNo = 1;
|
||||
|
|
|
|||
1
src/food-market.public/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
scripts/fonts/
|
||||
public/screenshots/*.png
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 209 KiB |
BIN
src/food-market.public/public/screenshots/catalog.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
src/food-market.public/public/screenshots/catalog@2x.webp
Normal file
|
After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 60 KiB |
BIN
src/food-market.public/public/screenshots/counterparties.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/food-market.public/public/screenshots/counterparties@2x.webp
Normal file
|
After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 89 KiB |
BIN
src/food-market.public/public/screenshots/dashboard-hero.webp
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
src/food-market.public/public/screenshots/dashboard-hero@2x.webp
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
src/food-market.public/public/screenshots/inventory.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/food-market.public/public/screenshots/inventory@2x.webp
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
src/food-market.public/public/screenshots/money.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src/food-market.public/public/screenshots/money@2x.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
src/food-market.public/public/screenshots/pos-pay.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
src/food-market.public/public/screenshots/pos-pay@2x.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
src/food-market.public/public/screenshots/pos-sale.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/food-market.public/public/screenshots/pos-sale@2x.webp
Normal file
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 98 KiB |
BIN
src/food-market.public/public/screenshots/product-card.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/food-market.public/public/screenshots/product-card@2x.webp
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src/food-market.public/public/screenshots/report-profit.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/food-market.public/public/screenshots/report-profit@2x.webp
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
src/food-market.public/public/screenshots/report-sales.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
src/food-market.public/public/screenshots/report-sales@2x.webp
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
src/food-market.public/public/screenshots/stock.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
src/food-market.public/public/screenshots/stock@2x.webp
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
src/food-market.public/public/screenshots/supply-detail.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/food-market.public/public/screenshots/supply-detail@2x.webp
Normal file
|
After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 80 KiB |
BIN
src/food-market.public/public/screenshots/supply-form.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
src/food-market.public/public/screenshots/supply-form@2x.webp
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
src/food-market.public/public/screenshots/supply-scan.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/food-market.public/public/screenshots/supply-scan@2x.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
58
src/food-market.public/scripts/optimize-screenshots.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Оптимизация скриншотов для сайта: PNG (2880×1800 @2x) → WebP с ресайзом и srcset.
|
||||
// Без внешних зависимостей (sharp/cwebp недоступны на dev-vm) — кодируем через
|
||||
// canvas.toDataURL('image/webp') в headless-Chromium (Playwright уже установлен).
|
||||
//
|
||||
// Для каждого исходного PNG из public/screenshots делает:
|
||||
// <name>.webp — 1440w (служит 2x для ~720px-слота), качество 0.86
|
||||
// <name>@2x.webp — 2880w (для srcset на крупных экранах), качество 0.82
|
||||
// Все файлы ≤ 300 КБ (UI-скриншоты сжимаются отлично).
|
||||
//
|
||||
// Запуск: node scripts/optimize-screenshots.mjs
|
||||
import { chromium } from 'playwright'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DIR = path.resolve(__dirname, '..', 'public', 'screenshots')
|
||||
const CHROME = process.env.CHROME ?? '/usr/bin/google-chrome'
|
||||
|
||||
const pngs = fs.readdirSync(DIR).filter((f) => f.endsWith('.png'))
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true, executablePath: CHROME, args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
})
|
||||
const page = await browser.newPage()
|
||||
|
||||
async function encode(srcPng, outName, targetW, quality) {
|
||||
const dataUrl = 'data:image/png;base64,' + fs.readFileSync(path.join(DIR, srcPng)).toString('base64')
|
||||
const b64 = await page.evaluate(async ({ url, targetW, quality }) => {
|
||||
const img = new Image()
|
||||
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url })
|
||||
const scale = Math.min(1, targetW / img.naturalWidth)
|
||||
const w = Math.round(img.naturalWidth * scale)
|
||||
const h = Math.round(img.naturalHeight * scale)
|
||||
const c = document.createElement('canvas')
|
||||
c.width = w; c.height = h
|
||||
const ctx = c.getContext('2d')
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.drawImage(img, 0, 0, w, h)
|
||||
return c.toDataURL('image/webp', quality).split(',')[1]
|
||||
}, { url: dataUrl, targetW, quality })
|
||||
const buf = Buffer.from(b64, 'base64')
|
||||
fs.writeFileSync(path.join(DIR, outName), buf)
|
||||
return buf.length
|
||||
}
|
||||
|
||||
for (const png of pngs) {
|
||||
const base = png.replace(/\.png$/, '')
|
||||
const s1 = await encode(png, `${base}.webp`, 1440, 0.86)
|
||||
const s2 = await encode(png, `${base}@2x.webp`, 2880, 0.82)
|
||||
const kb = (n) => (n / 1024).toFixed(0) + 'KB'
|
||||
const warn = (s1 > 300 * 1024 || s2 > 300 * 1024) ? ' ⚠ >300KB' : ''
|
||||
console.log(`${base}: 1x ${kb(s1)}, 2x ${kb(s2)}${warn}`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
console.log('done')
|
||||
219
src/food-market.public/scripts/pos-mockup/pos-mockup.html
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Касса — макет</title>
|
||||
<meta name="viewport" content="width=1920">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#F4F6F5; --card:#FFFFFF; --line:#E3E8E6; --text:#0F1F17; --muted:#5F6F68;
|
||||
--green:#16A34A; --green-d:#12863D; --green-soft:#E8F7EE; --red:#DC2626; --amber:#B45309; --amber-soft:#FEF3C7;
|
||||
--r:14px; --gap:16px;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif;font-size:16px;
|
||||
width:1920px;height:1080px;overflow:hidden;font-variant-numeric:tabular-nums}
|
||||
.app{display:grid;grid-template-rows:64px 1fr 40px;height:100%}
|
||||
|
||||
/* верхняя полоса */
|
||||
.top{background:var(--card);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:24px;padding:0 24px}
|
||||
.logo{font-weight:800;font-size:20px;color:var(--green);letter-spacing:.2px}
|
||||
.logo span{color:var(--text);font-weight:600}
|
||||
.sep{width:1px;height:28px;background:var(--line)}
|
||||
.meta{color:var(--muted);font-size:15px}
|
||||
.meta b{color:var(--text);font-weight:600}
|
||||
.spacer{flex:1}
|
||||
.chip{display:inline-flex;align-items:center;gap:8px;height:34px;padding:0 14px;border-radius:999px;font-size:14px;font-weight:600}
|
||||
.chip i{width:8px;height:8px;border-radius:50%;display:block}
|
||||
.chip.ok{background:var(--green-soft);color:var(--green-d)} .chip.ok i{background:var(--green)}
|
||||
.chip.net{background:#EEF2F1;color:var(--muted)} .chip.net i{background:var(--green)}
|
||||
|
||||
/* основная область */
|
||||
.main{display:grid;grid-template-columns:1fr 440px;gap:var(--gap);padding:var(--gap) 24px;min-height:0}
|
||||
.left{display:flex;flex-direction:column;gap:var(--gap);min-height:0}
|
||||
.search{height:64px;background:var(--card);border:2px solid var(--green);border-radius:var(--r);display:flex;align-items:center;gap:14px;padding:0 20px;
|
||||
box-shadow:0 0 0 4px rgba(22,163,74,.12)}
|
||||
.search svg{flex:none}
|
||||
.search .ph{color:#8A9993;font-size:19px}
|
||||
.search kbd{margin-left:auto}
|
||||
|
||||
.fav-head{display:flex;align-items:center;justify-content:space-between}
|
||||
.h{font-size:15px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}
|
||||
.link{font-size:14px;font-weight:600;color:var(--green-d)}
|
||||
.fav{display:grid;grid-template-columns:repeat(6,1fr);gap:12px}
|
||||
.tile{height:84px;background:var(--card);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;
|
||||
align-items:center;justify-content:center;gap:6px;text-align:center}
|
||||
.tile .n{font-size:16px;font-weight:600}
|
||||
.tile .p{font-size:15px;font-weight:700;color:var(--green-d)}
|
||||
|
||||
.cart{flex:1;background:var(--card);border:1px solid var(--line);border-radius:var(--r);display:flex;flex-direction:column;min-height:0}
|
||||
.row{display:grid;grid-template-columns:1fr 200px 160px 56px;align-items:center;column-gap:16px;padding:0 20px}
|
||||
.thead{height:48px;border-bottom:1px solid var(--line);color:var(--muted);font-size:14px;font-weight:600}
|
||||
.thead .r{text-align:right}
|
||||
.thead .c{text-align:center}
|
||||
.item{height:76px;border-bottom:1px solid var(--line)}
|
||||
.item.sel{background:#F7FBF8;box-shadow:inset 4px 0 0 var(--green)}
|
||||
.name{font-size:18px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.sub{font-size:14px;color:var(--muted);margin-top:4px}
|
||||
.sub.disc{color:var(--green-d);font-weight:500}
|
||||
.step{display:grid;grid-template-columns:44px 1fr 44px;align-items:center;height:44px;border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||
.step b{display:flex;align-items:center;justify-content:center;height:100%;font-size:22px;font-weight:500;color:var(--text);background:#F7F9F8}
|
||||
.step span{text-align:center;font-size:17px;font-weight:600}
|
||||
.sum{text-align:right;font-size:20px;font-weight:700}
|
||||
.del{width:44px;height:44px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#9AA7A1;justify-self:end}
|
||||
|
||||
/* правая панель */
|
||||
.right{background:var(--card);border:1px solid var(--line);border-radius:var(--r);display:flex;flex-direction:column;padding:24px;gap:16px}
|
||||
.kv{display:flex;justify-content:space-between;font-size:17px;color:var(--muted)}
|
||||
.kv b{color:var(--text);font-weight:600}
|
||||
.kv.disc b{color:var(--green-d)}
|
||||
.total{background:var(--green-soft);border-radius:12px;padding:20px 22px;display:flex;justify-content:space-between;align-items:baseline}
|
||||
.total .l{font-size:18px;font-weight:700;color:var(--green-d);text-transform:uppercase;letter-spacing:.5px}
|
||||
.total .v{font-size:56px;font-weight:800;color:var(--green-d);line-height:1}
|
||||
.last{margin-top:4px;border-top:1px solid var(--line);padding-top:16px;color:var(--muted);font-size:14px;display:flex;flex-direction:column;gap:10px}
|
||||
.last div{display:flex;justify-content:space-between}
|
||||
.last b{color:var(--text);font-weight:600}
|
||||
.grow{flex:1}
|
||||
.pay{height:76px;border-radius:12px;background:var(--green);color:#fff;font-size:24px;font-weight:700;display:flex;align-items:center;justify-content:center;gap:12px;
|
||||
box-shadow:0 6px 16px rgba(22,163,74,.28)}
|
||||
.pay kbd{background:rgba(255,255,255,.2);border-color:transparent;color:#fff}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.btn{height:52px;border:1px solid var(--line);border-radius:10px;background:var(--card);display:flex;align-items:center;justify-content:center;gap:10px;
|
||||
font-size:16px;font-weight:600;color:var(--text)}
|
||||
.btn svg{color:var(--muted)}
|
||||
.btn kbd{font-size:12px}
|
||||
.btn.danger{grid-column:1/3;border-color:#F3C4C4;color:var(--red)}
|
||||
.btn.danger svg{color:var(--red)}
|
||||
.badge{min-width:22px;height:22px;border-radius:11px;background:var(--amber-soft);color:var(--amber);font-size:13px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;padding:0 6px}
|
||||
|
||||
kbd{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:24px;padding:0 7px;border:1px solid var(--line);border-bottom-width:2px;
|
||||
border-radius:6px;background:#fff;font:600 12px Inter,sans-serif;color:var(--muted)}
|
||||
|
||||
/* полоса клавиш */
|
||||
.keys{display:flex;align-items:center;gap:28px;padding:0 24px;color:var(--muted);font-size:14px;border-top:1px solid var(--line);background:var(--card)}
|
||||
.keys span{display:inline-flex;align-items:center;gap:8px}
|
||||
|
||||
/* модальное окно оплаты */
|
||||
.shade{position:fixed;inset:0;background:rgba(15,31,23,.42);display:flex;align-items:center;justify-content:center}
|
||||
.modal{width:880px;background:var(--card);border-radius:20px;box-shadow:0 24px 64px rgba(0,0,0,.25);padding:32px;display:flex;flex-direction:column;gap:24px}
|
||||
.mh{display:flex;justify-content:space-between;align-items:baseline}
|
||||
.mh .t{font-size:24px;font-weight:700}
|
||||
.mh .a{font-size:16px;color:var(--muted)} .mh .a b{font-size:40px;color:var(--text);font-weight:800;margin-left:10px}
|
||||
.seg{display:grid;grid-template-columns:repeat(3,1fr);background:#EEF2F1;border-radius:12px;padding:4px;gap:4px}
|
||||
.seg div{height:52px;border-radius:9px;display:flex;align-items:center;justify-content:center;gap:10px;font-size:17px;font-weight:600;color:var(--muted)}
|
||||
.seg .on{background:#fff;color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.12)}
|
||||
.pbody{display:grid;grid-template-columns:1fr 300px;gap:24px}
|
||||
.lbl{font-size:14px;font-weight:600;color:var(--muted);margin-bottom:8px}
|
||||
.inp{height:64px;border:2px solid var(--green);border-radius:12px;display:flex;align-items:center;justify-content:flex-end;padding:0 20px;font-size:30px;font-weight:700}
|
||||
.quick{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}
|
||||
.quick div{height:52px;border:1px solid var(--line);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:600}
|
||||
.quick .ex{color:var(--green-d);border-color:#BFE6CD;background:var(--green-soft)}
|
||||
.change{margin-top:14px;background:var(--green-soft);border-radius:12px;height:84px;display:flex;align-items:center;justify-content:space-between;padding:0 22px}
|
||||
.change .l{font-size:16px;font-weight:700;color:var(--green-d);text-transform:uppercase;letter-spacing:.5px}
|
||||
.change .v{font-size:44px;font-weight:800;color:var(--green-d)}
|
||||
.pad{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
|
||||
.pad div{height:72px;border:1px solid var(--line);border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:600;background:#FAFBFA}
|
||||
.pad .s{font-size:16px;color:var(--muted)}
|
||||
.actions{display:grid;grid-template-columns:200px 1fr;gap:12px}
|
||||
.back{height:68px;border:1px solid var(--line);border-radius:12px;display:flex;align-items:center;justify-content:center;gap:10px;font-size:17px;font-weight:600}
|
||||
.go{height:68px;border-radius:12px;background:var(--green);color:#fff;display:flex;align-items:center;justify-content:center;gap:12px;font-size:21px;font-weight:700}
|
||||
.go kbd{background:rgba(255,255,255,.2);border-color:transparent;color:#fff}
|
||||
body.nomodal .shade{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="top">
|
||||
<div class="logo">ALL MARKET <span>· Касса</span></div>
|
||||
<div class="sep"></div>
|
||||
<div class="meta"><b>Магазин у дома</b> · Точка <b>Касса 1</b></div>
|
||||
<div class="sep"></div>
|
||||
<div class="meta">Кассир <b>Виктория Ким</b></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="chip ok"><i></i>Смена С-2026-000012 открыта с 09:00</div>
|
||||
<div class="chip net"><i></i>На связи · очередь пуста</div>
|
||||
</header>
|
||||
|
||||
<section class="main">
|
||||
<div class="left">
|
||||
<div class="search">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#16A34A" stroke-width="2"><path d="M3 5v14M7 5v14M11 5v14M15 5v14M19 5v14" stroke-linecap="round"/></svg>
|
||||
<div class="ph">Отсканируйте товар или начните вводить название</div>
|
||||
<kbd>F2</kbd>
|
||||
</div>
|
||||
|
||||
<div class="fav-head"><div class="h">Избранное</div><div class="link">Скрыть ▴</div></div>
|
||||
<div class="fav">
|
||||
<div class="tile"><div class="n">Хлеб «Семейный»</div><div class="p">280 ₸</div></div>
|
||||
<div class="tile"><div class="n">Молоко 2,5%</div><div class="p">450 ₸</div></div>
|
||||
<div class="tile"><div class="n">Яблоки (вес)</div><div class="p">690 ₸/кг</div></div>
|
||||
<div class="tile"><div class="n">Картофель (вес)</div><div class="p">280 ₸/кг</div></div>
|
||||
<div class="tile"><div class="n">Пакет-майка</div><div class="p">20 ₸</div></div>
|
||||
<div class="tile"><div class="n">Вода 0,5 л</div><div class="p">150 ₸</div></div>
|
||||
</div>
|
||||
|
||||
<div class="cart">
|
||||
<div class="row thead"><div>Товар</div><div class="c">Количество</div><div class="r">Сумма</div><div></div></div>
|
||||
<div class="row item"><div><div class="name">Хлеб «Семейный» нарезной</div><div class="sub">280 ₸ за шт</div></div>
|
||||
<div class="step"><b>−</b><span>2 шт</span><b>+</b></div><div class="sum">560 ₸</div>
|
||||
<div class="del"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg></div></div>
|
||||
<div class="row item"><div><div class="name">Молоко 2,5% 1 л</div><div class="sub">450 ₸ за шт</div></div>
|
||||
<div class="step"><b>−</b><span>1 шт</span><b>+</b></div><div class="sum">450 ₸</div>
|
||||
<div class="del"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg></div></div>
|
||||
<div class="row item"><div><div class="name">Яблоки «Апорт»</div><div class="sub">690 ₸ за кг</div></div>
|
||||
<div class="step"><b>−</b><span>1,235 кг</span><b>+</b></div><div class="sum">852 ₸</div>
|
||||
<div class="del"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg></div></div>
|
||||
<div class="row item sel"><div><div class="name">Сахар-песок 1 кг</div><div class="sub disc">520 ₸ за шт · скидка 60 ₸ (1 560 → 1 500 ₸)</div></div>
|
||||
<div class="step"><b>−</b><span>3 шт</span><b>+</b></div><div class="sum">1 500 ₸</div>
|
||||
<div class="del"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="right">
|
||||
<div class="kv"><span>Позиций</span><b>4</b></div>
|
||||
<div class="kv"><span>Подытог</span><b>3 422 ₸</b></div>
|
||||
<div class="kv disc"><span>Скидка</span><b>−60 ₸</b></div>
|
||||
<div class="total"><div class="l">Итого</div><div class="v">3 362 ₸</div></div>
|
||||
<div class="last">
|
||||
<div><span>Последний чек № 000148</span><b>1 250 ₸ · нал</b></div>
|
||||
<div><span>Чеков за смену</span><b>47</b></div>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<div class="pay">Оплатить <kbd>F12</kbd></div>
|
||||
<div class="grid2">
|
||||
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 5L5 19M7 7h.01M17 17h.01"/></svg>Скидка <kbd>F4</kbd></div>
|
||||
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4h12v16l-6-4-6 4z"/></svg>Отложить <kbd>F7</kbd></div>
|
||||
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Отложенные <span class="badge">2</span></div>
|
||||
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3"/></svg>Возврат <kbd>F9</kbd></div>
|
||||
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>Внесение</div>
|
||||
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/></svg>Выплата</div>
|
||||
<div class="btn danger"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>Очистить чек <kbd>Esc</kbd></div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<footer class="keys">
|
||||
<span><kbd>F1</kbd>Помощь</span><span><kbd>F2</kbd>Поиск</span><span><kbd>F4</kbd>Скидка</span><span><kbd>F7</kbd>Отложить</span>
|
||||
<span><kbd>F9</kbd>Возврат</span><span><kbd>F10</kbd>Смена</span><span><kbd>F12</kbd>Оплата</span><span><kbd>Del</kbd>Удалить строку</span><span><kbd>Esc</kbd>Отмена</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="shade">
|
||||
<div class="modal">
|
||||
<div class="mh"><div class="t">Оплата</div><div class="a">К оплате<b>3 362 ₸</b></div></div>
|
||||
<div class="seg"><div class="on">💵 Наличные</div><div>💳 Карта</div><div>⇄ Смешанная</div></div>
|
||||
<div class="pbody">
|
||||
<div>
|
||||
<div class="lbl">Получено наличными</div>
|
||||
<div class="inp">5 000 ₸</div>
|
||||
<div class="quick"><div class="ex">Без сдачи</div><div>4 000</div><div>5 000</div><div>10 000</div><div>20 000</div><div>Своя сумма</div></div>
|
||||
<div class="change"><div class="l">Сдача</div><div class="v">1 638 ₸</div></div>
|
||||
</div>
|
||||
<div class="pad"><div>1</div><div>2</div><div>3</div><div>4</div><div>5</div><div>6</div><div>7</div><div>8</div><div>9</div><div class="s">Сброс</div><div>0</div><div class="s">⌫</div></div>
|
||||
</div>
|
||||
<div class="actions"><div class="back"><kbd>Esc</kbd>Назад</div><div class="go">Провести и печатать <kbd>Enter</kbd></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,19 +1,51 @@
|
|||
// Скриншоты интерфейса ALL MARKET для публичного сайта.
|
||||
//
|
||||
// Снимает СТЕНД (test.admin.food-market.kz) под витринной организацией
|
||||
// «Магазин у дома» (создаётся deploy/showcase-seed.sh — чистые данные, без
|
||||
// e2e/test-мусора). Светлая тема, 1440×900 @2x. Плюс рендерит макет новой
|
||||
// светлой кассы (scripts/pos-mockup/pos-mockup.html) → продажа + оплата.
|
||||
//
|
||||
// Предусловие: на стенде поднят API с ShowcaseSeeder и выполнен showcase-seed.
|
||||
// bash deploy/showcase-seed.sh
|
||||
//
|
||||
// Запуск (dev-vm, системный chrome + no-sandbox — иначе sandbox убивает chrome):
|
||||
// node src/food-market.public/scripts/screenshots.mjs
|
||||
//
|
||||
// Переменные окружения:
|
||||
// APP — админка (по умолч. https://test.admin.food-market.kz)
|
||||
// SHOWCASE_EMAIL — владелец витринной орг. (vladelec@magazin-u-doma.kz)
|
||||
// SHOWCASE_PASSWORD — пароль (MagazinUDoma2026!)
|
||||
// CHROME — путь к chrome (по умолч. /usr/bin/google-chrome)
|
||||
import { chromium } from 'playwright'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const OUT = path.resolve(__dirname, '..', 'public', 'screenshots')
|
||||
|
||||
const APP = 'https://admin.food-market.kz'
|
||||
const EMAIL = 'admin@food-market.local'
|
||||
const PASSWORD = 'Admin12345!'
|
||||
const APP = process.env.APP ?? 'https://test.admin.food-market.kz'
|
||||
const EMAIL = process.env.SHOWCASE_EMAIL ?? 'vladelec@magazin-u-doma.kz'
|
||||
const PASSWORD = process.env.SHOWCASE_PASSWORD ?? 'MagazinUDoma2026!'
|
||||
const CHROME = process.env.CHROME ?? '/usr/bin/google-chrome'
|
||||
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1600, height: 1000 } })
|
||||
const VP = { width: 1440, height: 900 }
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: CHROME,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage', '--force-color-profile=srgb'],
|
||||
})
|
||||
const ctx = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: VP,
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: 'light',
|
||||
locale: 'ru-RU',
|
||||
})
|
||||
const page = await ctx.newPage()
|
||||
|
||||
console.log('login →', APP)
|
||||
// ── Логин витринного владельца ──────────────────────────────────────────────
|
||||
console.log('login →', APP, 'as', EMAIL)
|
||||
await page.goto(`${APP}/login`, { waitUntil: 'networkidle' })
|
||||
await page.fill('input[type="email"]', EMAIL)
|
||||
await page.fill('input[type="password"]', PASSWORD)
|
||||
|
|
@ -22,90 +54,138 @@ await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 30000
|
|||
await page.waitForTimeout(1500)
|
||||
console.log('logged in →', page.url())
|
||||
|
||||
// Override tenant: переходим на /super-admin/organizations → клик «Открыть»
|
||||
// у строки FOOD MARKET (иконка-стрелка, title=«Открыть как…»).
|
||||
console.log('tenant override → FOOD MARKET')
|
||||
await page.goto(`${APP}/super-admin/organizations`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(1000)
|
||||
// Клик на ряд FOOD MARKET — берём первую строку с этим текстом и в ней
|
||||
// первую кнопку-ссылку (login-icon).
|
||||
const rows = await page.$$('table tbody tr, tbody tr, [role="row"]')
|
||||
let opened = false
|
||||
for (const r of rows) {
|
||||
const txt = (await r.innerText()).trim()
|
||||
if (/FOOD MARKET/i.test(txt)) {
|
||||
const btn = await r.$('a, button')
|
||||
if (btn) {
|
||||
await btn.click()
|
||||
opened = true
|
||||
break
|
||||
}
|
||||
// id первого товара — для карточки товара (надёжнее, чем клик по гриду).
|
||||
const token = await page.evaluate(() => localStorage.getItem('fm.access_token'))
|
||||
// Для карточки берём фотогеничный штучный товар (поиск по имени — list-эндпоинт
|
||||
// пагинируется, нужный товар может быть не на первой странице).
|
||||
let cardProductId = null
|
||||
try {
|
||||
cardProductId = await page.evaluate(async (app) => {
|
||||
const auth = { Authorization: `Bearer ${localStorage.getItem('fm.access_token')}` }
|
||||
for (const q of ['Молоко 3.2% 1л', 'Кола 0.5л', 'Хлеб ржаной 600г']) {
|
||||
const r = await fetch(`${app}/api/catalog/products?search=${encodeURIComponent(q)}&take=3`, { headers: auth })
|
||||
const d = await r.json()
|
||||
const arr = Array.isArray(d) ? d : (d.items ?? d.data ?? [])
|
||||
const hit = arr.find((p) => p.name === q) ?? arr[0]
|
||||
if (hit) return hit.id
|
||||
}
|
||||
return null
|
||||
}, APP)
|
||||
} catch (e) {
|
||||
console.warn('product id fetch failed:', e.message)
|
||||
}
|
||||
if (!opened) {
|
||||
// Fallback: клик по последней login-стрелке (там обычно FOOD MARKET).
|
||||
const arrows = await page.$$('a[href*="/api/super-admin/organizations/"][href*="open"], button[title*="ткрыт"]')
|
||||
if (arrows.length) await arrows[arrows.length - 1].click()
|
||||
console.log('cardProductId =', cardProductId)
|
||||
|
||||
// Карточка товара: group-селект (AsyncSelect) грузит подпись только при открытии,
|
||||
// иначе показывает «…». Открываем и закрываем, чтобы подставилась «Молочные продукты».
|
||||
// AsyncSelect грузит подпись только при открытии (иначе «…»). Открываем нужный
|
||||
// селект и закрываем кликом по нейтральному заголовку (НЕ Escape — на карточках
|
||||
// Escape = «назад к списку»).
|
||||
async function resolveSelect(p, fieldLabel, neutralText) {
|
||||
try {
|
||||
const btn = p.locator(`label:has-text("${fieldLabel}") button`).first()
|
||||
await btn.click({ timeout: 4000 })
|
||||
await p.waitForTimeout(1000)
|
||||
await p.locator(`text=${neutralText}`).first().click({ timeout: 3000 }).catch(() => {})
|
||||
await p.waitForTimeout(400)
|
||||
} catch (e) { console.warn(` resolve «${fieldLabel}» skipped:`, e.message) }
|
||||
}
|
||||
await page.waitForTimeout(2000)
|
||||
await page.waitForLoadState('networkidle')
|
||||
console.log('after override →', page.url())
|
||||
const resolveGroupLabel = (p) => resolveSelect(p, 'Группа', 'Основное')
|
||||
const resolveSupplier = (p) => resolveSelect(p, 'Поставщик', 'Реквизиты документа')
|
||||
|
||||
const shots = [
|
||||
{ name: 'dashboard-hero.png', path: '/dashboard', vp: { width: 1600, height: 1000 } },
|
||||
{ name: 'analytics.png', path: '/dashboard', vp: { width: 1600, height: 1000 }, scrollY: 500 },
|
||||
{ name: 'catalog.png', path: '/catalog/products', vp: { width: 1600, height: 1000 } },
|
||||
{ name: 'product-card.png', path: '/catalog/products', vp: { width: 1200, height: 900 }, openFirst: true },
|
||||
{ name: 'counterparties.png', path: '/catalog/counterparties', vp: { width: 1200, height: 900 } },
|
||||
{ name: 'supply-form.png', path: '/purchases/supplies/new', vp: { width: 1200, height: 900 } },
|
||||
]
|
||||
// Проведённая приёмка (заполненный документ выглядит лучше пустой формы).
|
||||
let supplyId = null
|
||||
try {
|
||||
supplyId = await page.evaluate(async (app) => {
|
||||
const r = await fetch(`${app}/api/purchases/supplies?take=5&skip=0`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('fm.access_token')}` },
|
||||
})
|
||||
const d = await r.json()
|
||||
const arr = Array.isArray(d) ? d : (d.items ?? d.data ?? [])
|
||||
return arr[0]?.id ?? null
|
||||
}, APP)
|
||||
} catch (e) {
|
||||
console.warn('supply id fetch failed:', e.message)
|
||||
}
|
||||
console.log('supplyId =', supplyId)
|
||||
|
||||
// CSS, скрывающий override-warning баннер (оранжевую полосу сверху).
|
||||
const HIDE_OVERRIDE_CSS = `
|
||||
[class*="bg-orange"], [class*="bg-amber"], [class*="bg-yellow-4"],
|
||||
[class*="ENV"], header [class*="orange"], div[class*="режиме"],
|
||||
div:has(> *:not(:empty):where(:scope:contains("СУПЕР-АДМИНА"))) { display: none !important; }
|
||||
// CSS: прячем тосты, отладочные/override-баннеры, скроллбары.
|
||||
const HIDE_CSS = `
|
||||
[role="status"], [data-sonner-toaster], .toast, [class*="Toaster"],
|
||||
[class*="toast"], [class*="override"], [class*="devbar"],
|
||||
[data-testid="stage-banner"], [role="alert"] { display:none !important; }
|
||||
*::-webkit-scrollbar { width:0 !important; height:0 !important; }
|
||||
`
|
||||
|
||||
for (const s of shots) {
|
||||
console.log('shot', s.name, '←', s.path)
|
||||
await page.setViewportSize(s.vp)
|
||||
await page.goto(`${APP}${s.path}`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(2000)
|
||||
// Убираем оранжевый override-баннер по тексту.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('div, header').forEach((el) => {
|
||||
const t = el.textContent || ''
|
||||
if (/В РЕЖИМЕ СУПЕР-АДМИНА/i.test(t) && el.children.length < 6 && el.offsetHeight < 80) {
|
||||
el.style.display = 'none'
|
||||
// Прячет верхние баннеры (тестовый стенд, «Появились новые функции»).
|
||||
async function hideBanners(p) {
|
||||
await p.addStyleTag({ content: HIDE_CSS }).catch(() => {})
|
||||
await p.evaluate(() => {
|
||||
const kill = (re) => {
|
||||
for (const el of Array.from(document.querySelectorAll('div, header, section'))) {
|
||||
const t = (el.textContent || '').trim()
|
||||
if (re.test(t) && t.length < 200) { el.style.display = 'none' }
|
||||
}
|
||||
}
|
||||
kill(/ТЕСТОВЫЙ СТЕНД/i)
|
||||
kill(/Появились новые функции/i)
|
||||
kill(/СУПЕР-АДМИН|режиме просмотра/i)
|
||||
// MoM-дельта: на свежих данных «месяц к месяцу» неинформативна (текущий
|
||||
// календарный месяц ещё не полон) — убираем красный −%, чтобы не пугал.
|
||||
kill(/^[-+±].*прошлому месяцу$/i)
|
||||
})
|
||||
})
|
||||
if (s.scrollY) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), s.scrollY)
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
if (s.openFirst) {
|
||||
const cellLink = await page.$('table tbody tr td a, table tbody tr td button')
|
||||
if (cellLink) {
|
||||
try {
|
||||
await cellLink.click()
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 })
|
||||
await page.waitForTimeout(2000)
|
||||
} catch (e) {
|
||||
console.warn(' openFirst click failed:', e.message)
|
||||
}
|
||||
} else {
|
||||
// Fallback: клик в первую ячейку строки.
|
||||
const firstCell = await page.$('table tbody tr')
|
||||
if (firstCell) {
|
||||
try { await firstCell.click(); await page.waitForTimeout(2000) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUT, s.name), fullPage: false })
|
||||
console.log(' ok →', s.name, s.vp.width + 'x' + s.vp.height)
|
||||
}
|
||||
|
||||
const shots = [
|
||||
{ name: 'dashboard-hero.png', path: '/dashboard' },
|
||||
{ name: 'catalog.png', path: '/catalog/products' },
|
||||
{ name: 'product-card.png', path: cardProductId ? `/catalog/products/${cardProductId}` : '/catalog/products', wait: 3000, afterLoad: resolveGroupLabel },
|
||||
{ name: 'supply-detail.png', path: supplyId ? `/purchases/supplies/${supplyId}` : '/purchases/supplies/new', wait: 3000, afterLoad: resolveSupplier },
|
||||
{ name: 'supply-form.png', path: '/purchases/supplies' },
|
||||
{ name: 'supply-scan.png', path: '/purchases/supplies/scan' },
|
||||
{ name: 'stock.png', path: '/inventory/stock' },
|
||||
{ name: 'inventory.png', path: '/inventory/inventories' },
|
||||
{ name: 'money.png', path: '/money/accounts' },
|
||||
{ name: 'report-sales.png', path: '/reports/sales' },
|
||||
{ name: 'report-profit.png', path: '/reports/profit' },
|
||||
{ name: 'counterparties.png', path: '/catalog/counterparties' },
|
||||
]
|
||||
|
||||
for (const s of shots) {
|
||||
try {
|
||||
console.log('shot', s.name, '←', s.path)
|
||||
await page.goto(`${APP}${s.path}`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(s.wait ?? 2200)
|
||||
if (s.afterLoad) await s.afterLoad(page)
|
||||
await hideBanners(page)
|
||||
await page.waitForTimeout(200)
|
||||
await page.screenshot({ path: path.join(OUT, s.name), fullPage: false })
|
||||
console.log(' ok →', s.name)
|
||||
} catch (e) {
|
||||
console.warn(' FAILED', s.name, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Макет новой светлой кассы (локальный HTML) ──────────────────────────────
|
||||
const posHtml = pathToFileURL(path.resolve(__dirname, 'pos-mockup', 'pos-mockup.html')).href
|
||||
const posCtx = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
})
|
||||
const posPage = await posCtx.newPage()
|
||||
console.log('pos mockup →', posHtml)
|
||||
// Экран оплаты (модалка видна).
|
||||
await posPage.goto(posHtml, { waitUntil: 'networkidle' })
|
||||
await posPage.waitForTimeout(400)
|
||||
await posPage.screenshot({ path: path.join(OUT, 'pos-pay.png') })
|
||||
console.log(' ok → pos-pay.png')
|
||||
// Экран продажи (прячем модалку через body.nomodal).
|
||||
await posPage.evaluate(() => document.body.classList.add('nomodal'))
|
||||
await posPage.waitForTimeout(200)
|
||||
await posPage.screenshot({ path: path.join(OUT, 'pos-sale.png') })
|
||||
console.log(' ok → pos-sale.png')
|
||||
await posCtx.close()
|
||||
|
||||
await browser.close()
|
||||
console.log('done')
|
||||
|
|
|
|||
|
|
@ -52,7 +52,13 @@ const integrations = [
|
|||
<span class="w-3 h-3 rounded-full bg-yellow-400"></span>
|
||||
<span class="w-3 h-3 rounded-full bg-green-400"></span>
|
||||
</div>
|
||||
<img src="/screenshots/dashboard-hero.png" alt="Дашборд ALL MARKET" class="w-full block" loading="eager" />
|
||||
<img
|
||||
src="/screenshots/dashboard-hero.webp"
|
||||
srcset="/screenshots/dashboard-hero.webp 1440w, /screenshots/dashboard-hero@2x.webp 2880w"
|
||||
sizes="(max-width: 1024px) 100vw, 620px"
|
||||
width="1440" height="900"
|
||||
alt="Дашборд ALL MARKET: выручка за день, неделю и месяц, график продаж за 30 дней и последние чеки"
|
||||
class="w-full block" loading="eager" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -87,7 +93,39 @@ const integrations = [
|
|||
<span class="w-2.5 h-2.5 rounded-full bg-green-400"></span>
|
||||
<span class="ml-3 text-xs text-slate-500">app.food-market.kz / catalog / products</span>
|
||||
</div>
|
||||
<img src="/screenshots/catalog.png" alt="Каталог товаров ALL MARKET — 29 540 SKU" class="w-full block rounded-b-md" loading="lazy" />
|
||||
<img
|
||||
src="/screenshots/catalog.webp"
|
||||
srcset="/screenshots/catalog.webp 1440w, /screenshots/catalog@2x.webp 2880w"
|
||||
sizes="(max-width: 1024px) 100vw, 1100px"
|
||||
width="1440" height="900"
|
||||
alt="Каталог товаров ALL MARKET: список товаров с группами, штрихкодами, себестоимостью и розничной ценой"
|
||||
class="w-full block rounded-b-md" loading="lazy" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 3b. Касса */}
|
||||
<section class="bg-slate-50 py-14">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 grid lg:grid-cols-2 gap-10 items-center">
|
||||
<div>
|
||||
<span class="inline-block text-xs uppercase tracking-wider text-brand font-semibold">Касса</span>
|
||||
<h2 class="text-3xl font-bold mt-2">Светлая касса, в которой приятно работать</h2>
|
||||
<p class="mt-4 text-slate-600">Продажа и оплата — за пару действий. Поиск по штрихкоду и названию, избранные товары, скидки, оплата наличными, картой или смешанная, расчёт сдачи. Горячие клавиши для всей смены.</p>
|
||||
<ul class="mt-5 space-y-2 text-sm text-slate-600">
|
||||
<li class="flex gap-2"><span class="text-brand">✓</span> Нативная поддержка торговых весов — без драйверов</li>
|
||||
<li class="flex gap-2"><span class="text-brand">✓</span> Офлайн-режим: чеки сохраняются и подтянутся в облако</li>
|
||||
<li class="flex gap-2"><span class="text-brand">✓</span> Фискальные чеки ОФД РК, Kaspi Pay, программы лояльности</li>
|
||||
</ul>
|
||||
<a href="/pos" class="mt-6 inline-block text-sm font-semibold text-brand hover:text-brand-hover">Подробнее о кассе →</a>
|
||||
</div>
|
||||
<figure class="rounded-xl border border-slate-200 bg-white p-3 shadow-lg">
|
||||
<img
|
||||
src="/screenshots/pos-sale.webp"
|
||||
srcset="/screenshots/pos-sale.webp 1440w, /screenshots/pos-sale@2x.webp 2880w"
|
||||
sizes="(max-width: 1024px) 100vw, 600px"
|
||||
width="1920" height="1080"
|
||||
alt="Окно продажи кассы ALL MARKET: чек с товарами, избранные позиции, итоговая сумма и горячие клавиши"
|
||||
class="w-full block rounded-md" loading="lazy" />
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -45,13 +45,29 @@ const steps = [
|
|||
</section>
|
||||
|
||||
<section class="max-w-7xl mx-auto px-4 sm:px-6 py-14">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 sm:p-6 shadow-lg">
|
||||
<div class="bg-slate-200 rounded-t-md px-3 py-2 flex gap-1.5 items-center">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-red-400"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-yellow-400"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-green-400"></span>
|
||||
</div>
|
||||
<img src="/screenshots/supply-form.png" alt="Окно приёмки в ALL MARKET" class="w-full block rounded-b-md" loading="lazy" />
|
||||
<h2 class="text-3xl font-bold text-center mb-2">Светлая, быстрая касса</h2>
|
||||
<p class="text-center text-slate-500 mb-8">Продажа и оплата — за пару действий. Крупные цифры, горячие клавиши, оплата наличными, картой или смешанная, расчёт сдачи.</p>
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
<figure class="rounded-xl border border-slate-200 bg-slate-50 p-3 shadow-lg">
|
||||
<img
|
||||
src="/screenshots/pos-sale.webp"
|
||||
srcset="/screenshots/pos-sale.webp 1440w, /screenshots/pos-sale@2x.webp 2880w"
|
||||
sizes="(max-width: 1024px) 100vw, 600px"
|
||||
width="1920" height="1080"
|
||||
alt="Окно продажи кассы ALL MARKET: чек с товарами, избранные позиции, итоговая сумма и горячие клавиши"
|
||||
class="w-full block rounded-md" loading="lazy" />
|
||||
<figcaption class="text-center text-sm text-slate-500 mt-3">Экран продажи</figcaption>
|
||||
</figure>
|
||||
<figure class="rounded-xl border border-slate-200 bg-slate-50 p-3 shadow-lg">
|
||||
<img
|
||||
src="/screenshots/pos-pay.webp"
|
||||
srcset="/screenshots/pos-pay.webp 1440w, /screenshots/pos-pay@2x.webp 2880w"
|
||||
sizes="(max-width: 1024px) 100vw, 600px"
|
||||
width="1920" height="1080"
|
||||
alt="Окно оплаты кассы ALL MARKET: выбор способа оплаты — наличные, карта, смешанная — и расчёт сдачи"
|
||||
class="w-full block rounded-md" loading="lazy" />
|
||||
<figcaption class="text-center text-sm text-slate-500 mt-3">Оплата и расчёт сдачи</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||