+
{table}
)
}
return (
+ // overflow-x-auto на узких экранах позволяет горизонтально скроллить
+ // широкие таблицы (например Products), а на md+ контент укладывается
+ // в полную ширину контейнера.
{table}
diff --git a/src/food-market.web/src/main.tsx b/src/food-market.web/src/main.tsx
index 2dd3234..657cbec 100644
--- a/src/food-market.web/src/main.tsx
+++ b/src/food-market.web/src/main.tsx
@@ -9,3 +9,13 @@ createRoot(document.getElementById('root')!).render(
,
)
+
+// PWA: регистрируем service worker. Only prod (на dev sw.js обновляется
+// hot-reload'ом vite'a и мешает). Не падаем если регистрация не сработала
+// (например в Safari Private mode).
+if ('serviceWorker' in navigator && import.meta.env.PROD) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js')
+ .catch((err) => console.warn('[PWA] service worker registration failed:', err))
+ })
+}
diff --git a/tests/e2e/scenarios/stage-ui-s9-loyalty.spec.ts b/tests/e2e/scenarios/stage-ui-s9-loyalty.spec.ts
new file mode 100644
index 0000000..0864e27
--- /dev/null
+++ b/tests/e2e/scenarios/stage-ui-s9-loyalty.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * Sprint 9 пункты 1-2 — Loyalty + Promotions stage smoke.
+ * - GET endpoints отвечают
+ * - UI страницы /loyalty/programs, /loyalty/cards, /promotions рендерятся
+ * - Promocode применяется к чеку через API.
+ */
+import { test, expect, request as apiRequest } from '@playwright/test'
+import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
+
+const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
+
+test.describe('S9 Loyalty + Promotions', () => {
+ test('S9-1.api programs/cards/promotions endpoints доступны', async () => {
+ const sess = await apiSignup('s9a')
+ const ctx = await apiRequest.newContext({
+ baseURL: BASE, ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
+ })
+ expect((await ctx.get('/api/loyalty/programs')).status()).toBe(200)
+ expect((await ctx.get('/api/loyalty/cards')).status()).toBe(200)
+ expect((await ctx.get('/api/promotions')).status()).toBe(200)
+ await ctx.dispose()
+ })
+
+ test('S9-1.ui /loyalty/programs рендерится без console-errors', async ({ page }) => {
+ const sess = await apiSignup('s9b')
+ const errs = watchPage(page)
+ await attachSession(page, sess, '/loyalty/programs')
+ await page.evaluate(() => localStorage.setItem('fm.lang', 'ru'))
+ await page.reload()
+ await page.waitForLoadState('networkidle')
+ await expect(page.getByText('Программы лояльности').first()).toBeVisible({ timeout: 8_000 })
+ expectNoErrors(errs, 'loyalty programs page')
+ })
+
+ test('S9-2.ui /promotions рендерится без console-errors', async ({ page }) => {
+ const sess = await apiSignup('s9c')
+ const errs = watchPage(page)
+ await attachSession(page, sess, '/promotions')
+ await page.evaluate(() => localStorage.setItem('fm.lang', 'ru'))
+ await page.reload()
+ await page.waitForLoadState('networkidle')
+ await expect(page.getByText('Акции и промокоды').first()).toBeVisible({ timeout: 8_000 })
+ expectNoErrors(errs, 'promotions page')
+ })
+
+ test('S9-2.api промокод STAGE10 применяется к чеку', async () => {
+ test.setTimeout(60_000)
+ const sess = await apiSignup('s9d')
+ const ctx = await apiRequest.newContext({
+ baseURL: BASE, ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
+ })
+ // Создаём промокод STAGE10 = 10%
+ const promoResp = await ctx.post('/api/promotions', {
+ data: {
+ name: 'Stage 10', description: null, code: 'STAGE10',
+ type: 1, value: 10, scope: 1, minSaleAmount: 0,
+ startsAt: new Date(Date.now() - 86400000).toISOString(),
+ endsAt: null, isActive: true,
+ productGroupIds: [], productIds: [],
+ },
+ })
+ expect([200, 201]).toContain(promoResp.status())
+
+ // Сидим товар + остаток
+ type Paged
= { items: T[] }
+ const units = await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json() as Paged<{ id: string; code: string }>
+ const groups = await (await ctx.get('/api/catalog/product-groups')).json() as Paged<{ id: string }>
+ const pts = await (await ctx.get('/api/catalog/price-types')).json() as Paged<{ id: string; isRetail: boolean }>
+ const curs = await (await ctx.get('/api/catalog/currencies')).json() as Paged<{ id: string; code: string }>
+ const stores = await (await ctx.get('/api/catalog/stores')).json() as Paged<{ id: string; isMain: boolean }>
+ const rp = await (await ctx.get('/api/catalog/retail-points')).json() as Paged<{ id: string }>
+
+ const prodResp = await ctx.post('/api/catalog/products', {
+ data: {
+ name: 'S9 prod', article: `S9-${Date.now()}`,
+ unitOfMeasureId: units.items.find(u => u.code === '796')!.id,
+ vat: 12, vatEnabled: true,
+ productGroupId: groups.items[0].id, packaging: 1,
+ prices: [{ priceTypeId: pts.items.find(p => p.isRetail)!.id, amount: 100, currencyId: curs.items.find(c => c.code === 'KZT')!.id }],
+ barcodes: [{ code: `8000000${Date.now().toString().slice(-6)}`.slice(0, 13), type: 1, isPrimary: true }],
+ },
+ })
+ expect([200, 201]).toContain(prodResp.status())
+ const prod = await prodResp.json() as { id: string }
+
+ const supRes = await ctx.post('/api/catalog/counterparties', { data: { name: 'sup', type: 2 } })
+ const sup = await supRes.json() as { id: string }
+ const supplyRes = await ctx.post('/api/purchases/supplies', {
+ data: {
+ date: new Date().toISOString(),
+ supplierId: sup.id,
+ storeId: stores.items.find(s => s.isMain)!.id,
+ currencyId: curs.items.find(c => c.code === 'KZT')!.id,
+ lines: [{ productId: prod.id, quantity: 10, unitPrice: 50 }],
+ },
+ })
+ const supply = await supplyRes.json() as { id: string }
+ await ctx.post(`/api/purchases/supplies/${supply.id}/post`)
+
+ const saleResp = await ctx.post('/api/sales/retail', {
+ data: {
+ date: new Date().toISOString(),
+ storeId: stores.items.find(s => s.isMain)!.id,
+ retailPointId: rp.items[0]?.id,
+ currencyId: curs.items.find(c => c.code === 'KZT')!.id,
+ payment: 0, isReturn: false,
+ lines: [{ productId: prod.id, quantity: 5, unitPrice: 100, discount: 0, vatPercent: 12 }],
+ subtotal: 500, discountTotal: 0, total: 500,
+ paidCash: 500, paidCard: 0,
+ promotionCode: 'STAGE10',
+ },
+ })
+ expect([200, 201]).toContain(saleResp.status())
+ const sale = await saleResp.json() as { total: number; promotionDiscount: number; promotionCode: string }
+ expect(sale.promotionDiscount).toBe(50) // 10% от 500
+ expect(sale.total).toBe(450)
+ expect(sale.promotionCode).toBe('STAGE10')
+
+ await ctx.dispose()
+ })
+})
diff --git a/tests/e2e/scenarios/stage-ui-s9-mobile-audit.spec.ts b/tests/e2e/scenarios/stage-ui-s9-mobile-audit.spec.ts
new file mode 100644
index 0000000..b383875
--- /dev/null
+++ b/tests/e2e/scenarios/stage-ui-s9-mobile-audit.spec.ts
@@ -0,0 +1,74 @@
+/**
+ * Sprint 9 пункт 3 — mobile-аудит. Прогоняем ключевые страницы в двух
+ * viewport-ах (mobile 375x667 и tablet 768x1024), снимаем screenshot,
+ * фиксируем horizontal overflow и нечитаемый текст.
+ *
+ * Эта спец-aудит — не failure-driven (страницы могут показывать таблицы с
+ * h-scroll, это допустимо), а snapshot-driven: складываем картинки в
+ * reports/mobile/, в спринте смотрим что важно починить.
+ */
+import { test, expect } from '@playwright/test'
+import { apiSignup, attachSession, watchPage } from '../lib/ui.js'
+import { promises as fs } from 'node:fs'
+
+const MOBILE = { width: 375, height: 667 }
+const TABLET = { width: 768, height: 1024 }
+
+const PAGES = [
+ '/dashboard',
+ '/catalog/products',
+ '/catalog/counterparties',
+ '/inventory/stock',
+ '/purchases/supplies',
+ '/sales/retail',
+ '/loyalty/programs',
+ '/loyalty/cards',
+ '/promotions',
+ '/reports/sales',
+] as const
+
+test.describe('S9 mobile audit', () => {
+ test.describe.configure({ mode: 'serial' })
+
+ test('S9-3 audit все viewports', async ({ browser, request: rq }) => {
+ test.setTimeout(180_000)
+ await fs.mkdir('reports/mobile', { recursive: true })
+ const sess = await apiSignup('s9mo')
+ // Seed demo чтобы списки были с данными.
+ await rq.post(`${process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'}/api/admin/seed-demo`, {
+ headers: { Authorization: `Bearer ${sess.accessToken}` },
+ data: {},
+ }).catch(() => {})
+
+ for (const viewport of [MOBILE, TABLET]) {
+ const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport })
+ const page = await ctx.newPage()
+ const errs = watchPage(page)
+ await attachSession(page, sess, '/dashboard')
+ await page.evaluate(() => localStorage.setItem('fm.lang', 'ru'))
+ const overflowReport: string[] = []
+ for (const path of PAGES) {
+ await page.goto(path, { waitUntil: 'domcontentloaded' }).catch(() => {})
+ await page.waitForLoadState('networkidle').catch(() => {})
+ // Скриншот
+ const fname = `${viewport.width}-${path.replace(/\//g, '_')}.png`
+ await page.screenshot({ path: `reports/mobile/${fname}`, fullPage: false })
+ // horizontal overflow?
+ const sw = await page.evaluate(() => document.documentElement.scrollWidth)
+ if (sw > viewport.width + 2) {
+ overflowReport.push(`${viewport.width}x ${path}: scrollWidth=${sw} > ${viewport.width}`)
+ }
+ }
+ // На mobile (375) допускаем horizontal scroll внутри таблиц, но не у body.
+ // body имеет flex layout, должен быть точно <= viewport.
+ // Запишем report для логирования; не падаем — аудит, не fail.
+ if (overflowReport.length) {
+ console.warn(`[mobile-audit ${viewport.width}px] overflow на: ${overflowReport.join(', ')}`)
+ }
+ await page.close()
+ await ctx.close()
+ }
+
+ expect(true).toBeTruthy() // marker — тест прошёл, screenshots в reports/mobile/
+ })
+})