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>
This commit is contained in:
nns 2026-10-08 22:19:46 +05:00
parent 9ddbffc04a
commit f7f92261ca
43 changed files with 583 additions and 113 deletions

View file

@ -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" \

View file

@ -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.

View file

@ -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;

View file

@ -1 +1,2 @@
scripts/fonts/
public/screenshots/*.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View 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')

View 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>

View file

@ -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'
}
})
})
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 {}
// Прячет верхние баннеры (тестовый стенд, «Появились новые функции»).
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' }
}
}
}
await page.screenshot({ path: path.join(OUT, s.name), fullPage: false })
console.log(' ok →', s.name, s.vp.width + 'x' + s.vp.height)
kill(/ТЕСТОВЫЙ СТЕНД/i)
kill(/Появились новые функции/i)
kill(/СУПЕР-АДМИН|режиме просмотра/i)
// MoM-дельта: на свежих данных «месяц к месяцу» неинформативна (текущий
// календарный месяц ещё не полон) — убираем красный −%, чтобы не пугал.
kill(/^[-+±].*прошлому месяцу$/i)
})
}
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')

View file

@ -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>

View file

@ -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>