feat: admin product views column (always 0 until backend tracks it)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
@if (isColumnVisible('price')) { <th scope="col">{{ 'backoffice.price' | translate }}</th> }
|
@if (isColumnVisible('price')) { <th scope="col">{{ 'backoffice.price' | translate }}</th> }
|
||||||
@if (isColumnVisible('stock')) { <th scope="col">{{ 'adminProducts.stockStatus' | translate }}</th> }
|
@if (isColumnVisible('stock')) { <th scope="col">{{ 'adminProducts.stockStatus' | translate }}</th> }
|
||||||
@if (isColumnVisible('visibility')) { <th scope="col">{{ 'adminProducts.visibility' | translate }}</th> }
|
@if (isColumnVisible('visibility')) { <th scope="col">{{ 'adminProducts.visibility' | translate }}</th> }
|
||||||
|
@if (isColumnVisible('visits')) { <th scope="col">{{ 'adminProducts.views' | translate }}</th> }
|
||||||
<th scope="col">{{ 'adminProducts.healthColumn' | translate }}</th>
|
<th scope="col">{{ 'adminProducts.healthColumn' | translate }}</th>
|
||||||
<th scope="col">{{ 'adminProducts.actions' | translate }}</th>
|
<th scope="col">{{ 'adminProducts.actions' | translate }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -124,6 +125,7 @@
|
|||||||
</app-badge>
|
</app-badge>
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
|
@if (isColumnVisible('visits')) { <td>{{ product.visits }}</td> }
|
||||||
<td class="health-cell"><app-product-health-widget [items]="healthItems(product)" [completionPercent]="health(product).completionPercent" [compact]="true" /></td>
|
<td class="health-cell"><app-product-health-widget [items]="healthItems(product)" [completionPercent]="health(product).completionPercent" [compact]="true" /></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const VIEW_MODE_KEY = 'admin-products:view-mode';
|
|||||||
const DENSITY_KEY = 'admin-products:density';
|
const DENSITY_KEY = 'admin-products:density';
|
||||||
const COLUMNS_KEY = 'admin-products:visible-columns';
|
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];
|
export type AdminProductColumn = typeof ALL_PRODUCT_COLUMNS[number];
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export interface AdminProduct {
|
|||||||
specifications: AdminProductSpecification[];
|
specifications: AdminProductSpecification[];
|
||||||
attributes: AdminProductAttribute[];
|
attributes: AdminProductAttribute[];
|
||||||
variantAttributes: AdminProductVariantAttributeDef[];
|
variantAttributes: AdminProductVariantAttributeDef[];
|
||||||
|
visits: number;
|
||||||
variants: AdminProductVariant[];
|
variants: AdminProductVariant[];
|
||||||
relatedProductIds: string[];
|
relatedProductIds: string[];
|
||||||
translations: Record<string, AdminProductTranslation>;
|
translations: Record<string, AdminProductTranslation>;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class AdminProductsFormFactory {
|
|||||||
discount: 0,
|
discount: 0,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
|
visits: 0,
|
||||||
stockStatus: 'in_stock',
|
stockStatus: 'in_stock',
|
||||||
availability: 'in_stock',
|
availability: 'in_stock',
|
||||||
shortDescription: '',
|
shortDescription: '',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,6 +86,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
|
|||||||
sku: `${source.sku}-COPY`,
|
sku: `${source.sku}-COPY`,
|
||||||
slug: `${source.slug}-copy-${Date.now()}`,
|
slug: `${source.slug}-copy-${Date.now()}`,
|
||||||
name: `${source.name} Copy`,
|
name: `${source.name} Copy`,
|
||||||
|
visits: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -153,6 +154,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
|
|||||||
: 0,
|
: 0,
|
||||||
currency: product.price.currency,
|
currency: product.price.currency,
|
||||||
quantity: product.stockStatus === 'out_of_stock' ? 0 : product.stockStatus === 'low_stock' ? 3 : 25,
|
quantity: product.stockStatus === 'out_of_stock' ? 0 : product.stockStatus === 'low_stock' ? 3 : 25,
|
||||||
|
visits: 0,
|
||||||
stockStatus: product.stockStatus ?? 'in_stock',
|
stockStatus: product.stockStatus ?? 'in_stock',
|
||||||
availability: product.stockStatus ?? 'in_stock',
|
availability: product.stockStatus ?? 'in_stock',
|
||||||
shortDescription: product.subtitle ?? '',
|
shortDescription: product.subtitle ?? '',
|
||||||
|
|||||||
@@ -1645,6 +1645,7 @@ export const en: Translations = {
|
|||||||
visible: 'Visible',
|
visible: 'Visible',
|
||||||
hidden: 'Hidden',
|
hidden: 'Hidden',
|
||||||
stockStatus: 'Stock status',
|
stockStatus: 'Stock status',
|
||||||
|
views: 'Views',
|
||||||
allStock: 'All stock levels',
|
allStock: 'All stock levels',
|
||||||
inStock: 'In stock',
|
inStock: 'In stock',
|
||||||
lowStock: 'Low stock',
|
lowStock: 'Low stock',
|
||||||
@@ -1669,6 +1670,7 @@ export const en: Translations = {
|
|||||||
column_stock: 'Stock',
|
column_stock: 'Stock',
|
||||||
column_visibility: 'Visibility',
|
column_visibility: 'Visibility',
|
||||||
column_updated: 'Last updated',
|
column_updated: 'Last updated',
|
||||||
|
column_visits: 'Views',
|
||||||
create: 'Create product',
|
create: 'Create product',
|
||||||
edit: 'Edit product',
|
edit: 'Edit product',
|
||||||
duplicate: 'Duplicate',
|
duplicate: 'Duplicate',
|
||||||
|
|||||||
@@ -1640,6 +1640,7 @@ export const hy: Translations = {
|
|||||||
visible: 'Տեսանելի',
|
visible: 'Տեսանելի',
|
||||||
hidden: 'Թաքցված',
|
hidden: 'Թաքցված',
|
||||||
stockStatus: 'Պահեստի կարգավիճակ',
|
stockStatus: 'Պահեստի կարգավիճակ',
|
||||||
|
views: 'Դիտումներ',
|
||||||
allStock: 'Ցանկացած մնացորդ',
|
allStock: 'Ցանկացած մնացորդ',
|
||||||
inStock: 'Առկա է',
|
inStock: 'Առկա է',
|
||||||
lowStock: 'Քիչ է մնացել',
|
lowStock: 'Քիչ է մնացել',
|
||||||
@@ -1664,6 +1665,7 @@ export const hy: Translations = {
|
|||||||
column_stock: 'Մնացորդ',
|
column_stock: 'Մնացորդ',
|
||||||
column_visibility: 'Տեսանելիություն',
|
column_visibility: 'Տեսանելիություն',
|
||||||
column_updated: 'Վերջին փոփոխություն',
|
column_updated: 'Վերջին փոփոխություն',
|
||||||
|
column_visits: 'Դիտումներ',
|
||||||
create: 'Ստեղծել ապրանք',
|
create: 'Ստեղծել ապրանք',
|
||||||
edit: 'Խմբագրել ապրանքը',
|
edit: 'Խմբագրել ապրանքը',
|
||||||
duplicate: 'Կրկնօրինակել',
|
duplicate: 'Կրկնօրինակել',
|
||||||
|
|||||||
@@ -1640,6 +1640,7 @@ export const ru: Translations = {
|
|||||||
visible: 'Видимый',
|
visible: 'Видимый',
|
||||||
hidden: 'Скрытый',
|
hidden: 'Скрытый',
|
||||||
stockStatus: 'Статус наличия',
|
stockStatus: 'Статус наличия',
|
||||||
|
views: 'Просмотры',
|
||||||
allStock: 'Любой остаток',
|
allStock: 'Любой остаток',
|
||||||
inStock: 'В наличии',
|
inStock: 'В наличии',
|
||||||
lowStock: 'Мало на складе',
|
lowStock: 'Мало на складе',
|
||||||
@@ -1664,6 +1665,7 @@ export const ru: Translations = {
|
|||||||
column_stock: 'Остаток',
|
column_stock: 'Остаток',
|
||||||
column_visibility: 'Видимость',
|
column_visibility: 'Видимость',
|
||||||
column_updated: 'Последнее изменение',
|
column_updated: 'Последнее изменение',
|
||||||
|
column_visits: 'Просмотры',
|
||||||
create: 'Создать товар',
|
create: 'Создать товар',
|
||||||
edit: 'Редактировать товар',
|
edit: 'Редактировать товар',
|
||||||
duplicate: 'Дублировать',
|
duplicate: 'Дублировать',
|
||||||
|
|||||||
@@ -1653,6 +1653,7 @@ export interface Translations {
|
|||||||
visible: string;
|
visible: string;
|
||||||
hidden: string;
|
hidden: string;
|
||||||
stockStatus: string;
|
stockStatus: string;
|
||||||
|
views: string;
|
||||||
allStock: string;
|
allStock: string;
|
||||||
inStock: string;
|
inStock: string;
|
||||||
lowStock: string;
|
lowStock: string;
|
||||||
@@ -1677,6 +1678,7 @@ export interface Translations {
|
|||||||
column_stock: string;
|
column_stock: string;
|
||||||
column_visibility: string;
|
column_visibility: string;
|
||||||
column_updated: string;
|
column_updated: string;
|
||||||
|
column_visits: string;
|
||||||
create: string;
|
create: string;
|
||||||
edit: string;
|
edit: string;
|
||||||
duplicate: string;
|
duplicate: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user