From 0d1d468307599adc8a06713fee3846227e4cf5a3 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 16:18:20 +0400 Subject: [PATCH] feat: admin product views column (always 0 until backend tracks it) Co-Authored-By: Claude Sonnet 5 --- BACKEND-API-REFERENCE.md | 10 ++- .../admin-products-list.component.html | 2 + .../products/facade/admin-products.facade.ts | 2 +- .../products/models/admin-product.model.ts | 1 + .../services/admin-products-form.factory.ts | 1 + .../admin-products-local.gateway.spec.ts | 64 +++++++++++++++++++ .../services/admin-products-local.gateway.ts | 2 + src/app/i18n/en.ts | 2 + src/app/i18n/hy.ts | 2 + src/app/i18n/ru.ts | 2 + src/app/i18n/translations.ts | 2 + 11 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/app/features/admin/products/services/admin-products-local.gateway.spec.ts diff --git a/BACKEND-API-REFERENCE.md b/BACKEND-API-REFERENCE.md index 6993914..e8b735c 100644 --- a/BACKEND-API-REFERENCE.md +++ b/BACKEND-API-REFERENCE.md @@ -539,7 +539,15 @@ Either way, the *authoritative* amount charged (`createCartPayment`'s `amount`, **Ask:** nothing new beyond what §10/§11 already ask for — once a real `AdminOrdersApiGateway` is bound, this feature starts working with no additional frontend change. Flagging here only so nobody spends time debugging "why doesn't the notification ever fire" against the mock. -### 12.9 Trending search terms +### 12.9 Admin product view counts + +**Gap:** Admin Products (§8) runs on a fully separate mock domain from the storefront's live catalog — `AdminProduct.visits` is a new field added to support a "Views" column in Admin Products, but the mock gateway always defaults it to `0` because there is no real tracking source available to the admin domain today. This is unrelated to the storefront's `Item.visits` field (§6, `/items/{id}`), which is live-wired but never displayed anywhere in the UI. + +**Ask:** two options, not mutually exclusive: +1. Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response. +2. Bridge `AdminProduct.visits` to the storefront's already-live `Item.visits` by product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure. + +### 12.10 Trending search terms **Gap:** `SearchTrendingService.loadTrending()` is a stub returning `of(null)` - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug. diff --git a/src/app/features/admin/products/components/admin-products-list.component.html b/src/app/features/admin/products/components/admin-products-list.component.html index 36f16a2..3083438 100644 --- a/src/app/features/admin/products/components/admin-products-list.component.html +++ b/src/app/features/admin/products/components/admin-products-list.component.html @@ -98,6 +98,7 @@ @if (isColumnVisible('price')) { {{ 'backoffice.price' | translate }} } @if (isColumnVisible('stock')) { {{ 'adminProducts.stockStatus' | translate }} } @if (isColumnVisible('visibility')) { {{ 'adminProducts.visibility' | translate }} } + @if (isColumnVisible('visits')) { {{ 'adminProducts.views' | translate }} } {{ 'adminProducts.healthColumn' | translate }} {{ 'adminProducts.actions' | translate }} @@ -124,6 +125,7 @@ } + @if (isColumnVisible('visits')) { {{ product.visits }} } {{ 'adminProducts.edit' | translate }} diff --git a/src/app/features/admin/products/facade/admin-products.facade.ts b/src/app/features/admin/products/facade/admin-products.facade.ts index abffd3c..2eaa9eb 100644 --- a/src/app/features/admin/products/facade/admin-products.facade.ts +++ b/src/app/features/admin/products/facade/admin-products.facade.ts @@ -36,7 +36,7 @@ const VIEW_MODE_KEY = 'admin-products:view-mode'; const DENSITY_KEY = 'admin-products:density'; const COLUMNS_KEY = 'admin-products:visible-columns'; -export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated'] as const; +export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated', 'visits'] as const; export type AdminProductColumn = typeof ALL_PRODUCT_COLUMNS[number]; @Injectable({ providedIn: 'root' }) diff --git a/src/app/features/admin/products/models/admin-product.model.ts b/src/app/features/admin/products/models/admin-product.model.ts index d2a49d5..314c943 100644 --- a/src/app/features/admin/products/models/admin-product.model.ts +++ b/src/app/features/admin/products/models/admin-product.model.ts @@ -101,6 +101,7 @@ export interface AdminProduct { specifications: AdminProductSpecification[]; attributes: AdminProductAttribute[]; variantAttributes: AdminProductVariantAttributeDef[]; + visits: number; variants: AdminProductVariant[]; relatedProductIds: string[]; translations: Record; diff --git a/src/app/features/admin/products/services/admin-products-form.factory.ts b/src/app/features/admin/products/services/admin-products-form.factory.ts index 0301238..a32d254 100644 --- a/src/app/features/admin/products/services/admin-products-form.factory.ts +++ b/src/app/features/admin/products/services/admin-products-form.factory.ts @@ -20,6 +20,7 @@ export class AdminProductsFormFactory { discount: 0, currency: 'RUB', quantity: 0, + visits: 0, stockStatus: 'in_stock', availability: 'in_stock', shortDescription: '', diff --git a/src/app/features/admin/products/services/admin-products-local.gateway.spec.ts b/src/app/features/admin/products/services/admin-products-local.gateway.spec.ts new file mode 100644 index 0000000..7b00592 --- /dev/null +++ b/src/app/features/admin/products/services/admin-products-local.gateway.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { AdminProductsLocalGateway } from './admin-products-local.gateway'; +import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service'; +import { AdminCategoriesLocalGateway } from '../../categories/services/admin-categories-local.gateway'; +import { ProductCardConfig } from '../../../../shared/models/ui'; +import { AdminProductListFilters } from '../models/admin-product.model'; + +function makeProductCard(id: string): ProductCardConfig { + return { + id, + sku: `SKU-${id}`, + title: `Product ${id}`, + imageUrl: '/image.png', + price: { amount: 1000, currency: 'RUB' }, + stockStatus: 'in_stock', + }; +} + +function makeFilters(): AdminProductListFilters { + return { + search: '', + categoryId: null, + visibility: 'all', + stock: 'all', + includeArchived: true, + sort: 'title', + page: 1, + pageSize: 50, + }; +} + +describe('AdminProductsLocalGateway visits field', () => { + let gateway: AdminProductsLocalGateway; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: BackofficeDataService, useValue: { loadProducts: () => of([makeProductCard('p1'), makeProductCard('p2')]) } }, + { provide: AdminCategoriesLocalGateway, useValue: { loadCategories: () => of([{ id: 'cat-001', title: 'Category' }]) } }, + ], + }); + gateway = TestBed.inject(AdminProductsLocalGateway); + }); + + it('defaults visits to 0 on every loaded product', (done) => { + gateway.loadProducts(makeFilters()).subscribe(result => { + expect(result.items.length).toBe(2); + expect(result.items.every(product => product.visits === 0)).toBe(true); + done(); + }); + }); + + it('resets visits to 0 on a duplicated product', (done) => { + gateway.loadProducts(makeFilters()).subscribe(result => { + const source = result.items[0]; + gateway.duplicateProduct(source.id).subscribe(duplicated => { + expect(duplicated).not.toBeNull(); + expect(duplicated!.visits).toBe(0); + done(); + }); + }); + }); +}); diff --git a/src/app/features/admin/products/services/admin-products-local.gateway.ts b/src/app/features/admin/products/services/admin-products-local.gateway.ts index 659578e..6dfe0a8 100644 --- a/src/app/features/admin/products/services/admin-products-local.gateway.ts +++ b/src/app/features/admin/products/services/admin-products-local.gateway.ts @@ -86,6 +86,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { sku: `${source.sku}-COPY`, slug: `${source.slug}-copy-${Date.now()}`, name: `${source.name} Copy`, + visits: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -153,6 +154,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { : 0, currency: product.price.currency, quantity: product.stockStatus === 'out_of_stock' ? 0 : product.stockStatus === 'low_stock' ? 3 : 25, + visits: 0, stockStatus: product.stockStatus ?? 'in_stock', availability: product.stockStatus ?? 'in_stock', shortDescription: product.subtitle ?? '', diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index d543aac..76ab6b0 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1645,6 +1645,7 @@ export const en: Translations = { visible: 'Visible', hidden: 'Hidden', stockStatus: 'Stock status', + views: 'Views', allStock: 'All stock levels', inStock: 'In stock', lowStock: 'Low stock', @@ -1669,6 +1670,7 @@ export const en: Translations = { column_stock: 'Stock', column_visibility: 'Visibility', column_updated: 'Last updated', + column_visits: 'Views', create: 'Create product', edit: 'Edit product', duplicate: 'Duplicate', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 68ff444..bfc3278 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1640,6 +1640,7 @@ export const hy: Translations = { visible: 'Տեսանելի', hidden: 'Թաքցված', stockStatus: 'Պահեստի կարգավիճակ', + views: 'Դիտումներ', allStock: 'Ցանկացած մնացորդ', inStock: 'Առկա է', lowStock: 'Քիչ է մնացել', @@ -1664,6 +1665,7 @@ export const hy: Translations = { column_stock: 'Մնացորդ', column_visibility: 'Տեսանելիություն', column_updated: 'Վերջին փոփոխություն', + column_visits: 'Դիտումներ', create: 'Ստեղծել ապրանք', edit: 'Խմբագրել ապրանքը', duplicate: 'Կրկնօրինակել', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index a2963a9..6a4fe23 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1640,6 +1640,7 @@ export const ru: Translations = { visible: 'Видимый', hidden: 'Скрытый', stockStatus: 'Статус наличия', + views: 'Просмотры', allStock: 'Любой остаток', inStock: 'В наличии', lowStock: 'Мало на складе', @@ -1664,6 +1665,7 @@ export const ru: Translations = { column_stock: 'Остаток', column_visibility: 'Видимость', column_updated: 'Последнее изменение', + column_visits: 'Просмотры', create: 'Создать товар', edit: 'Редактировать товар', duplicate: 'Дублировать', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 4227d80..cf51603 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1653,6 +1653,7 @@ export interface Translations { visible: string; hidden: string; stockStatus: string; + views: string; allStock: string; inStock: string; lowStock: string; @@ -1677,6 +1678,7 @@ export interface Translations { column_stock: string; column_visibility: string; column_updated: string; + column_visits: string; create: string; edit: string; duplicate: string;