feat(seo): tenant-driven meta tags, sitemap/robots, reduced-motion, docs
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Completes the rest of Sprint 28 on top of the earlier admin-scoped
a11y/skeleton pass (576f260):

- SeoService.resetToDefaults() now reads real bootstrap.seo.default /
  branding instead of hardcoded placeholder text + a broken
  /og-image.jpg reference; auto-reapplies via an effect() whenever
  bootstrap (re)loads, same pattern as UiRuntimeFacade.
- New public/sitemap.xml (static baseline, documented per-tenant-dynamic
  limitation) + public/robots.txt Sitemap directive and admin/editor
  Disallow rules.
- Global prefers-reduced-motion override in styles.scss covering every
  existing hover-transform/fade-in/shimmer animation in one place.
- New adminProducts/adminUsers/adminMonitoring/adminAnalytics
  empty-state i18n keys (en/ru/hy) for this sprint's skeleton/empty-state
  consistency fixes.
- docs/KNOWN-ISSUES.md: logged a newly-found, much larger pre-existing
  gap (~178 missing adminXxx.* i18n keys across the whole admin
  backoffice) - deferred to Sprint 29's translation validation, not
  fixed here.
- docs/BACKEND.md: new item 17 (sitemap generation gap).
- docs/ADMIN.md, docs/SPRINT-PLAN.md: rewritten Sprint 28 sections to
  describe the full, combined scope (both commits) instead of the
  earlier admin-only framing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-16 00:19:41 +04:00
parent 576f2600a5
commit 173ceb8081
12 changed files with 333 additions and 33 deletions

View File

@@ -862,4 +862,21 @@ export const en: Translations = {
emptyTitle: 'No files yet',
emptyDescription: 'Upload an image or document to get started.',
},
adminProducts: {
emptyTitle: 'No products found',
emptyDescription: 'Try adjusting your filters, or create a new product.',
},
adminUsers: {
emptyTitle: 'No users found',
emptyDescription: 'Invite a teammate to get started.',
invitationsEmptyTitle: 'No pending invitations',
},
adminMonitoring: {
eventsEmptyTitle: 'No events found',
eventsEmptyDescription: 'Try a different category filter or search term.',
},
adminAnalytics: {
topProductsEmptyTitle: 'No product sales in this period',
topProductsEmptyDescription: 'Try a wider date range.',
},
};

View File

@@ -857,4 +857,21 @@ export const hy: Translations = {
emptyTitle: 'Ֆայլեր դեռ չկան',
emptyDescription: 'Վերբեռնեք պատկեր կամ փաստաթուղթ սկսելու համար։',
},
adminProducts: {
emptyTitle: 'Ապրանքներ չեն գտնվել',
emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։',
},
adminUsers: {
emptyTitle: 'Օգտատերեր չեն գտնվել',
emptyDescription: 'Հրավիրեք գործընկերոջ՝ սկսելու համար։',
invitationsEmptyTitle: 'Սպասող հրավերներ չկան',
},
adminMonitoring: {
eventsEmptyTitle: 'Իրադարձություններ չեն գտնվել',
eventsEmptyDescription: 'Փորձեք այլ կատեգորիայի ֆիլտր կամ որոնման բառ։',
},
adminAnalytics: {
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։',
},
};

View File

@@ -857,4 +857,21 @@ export const ru: Translations = {
emptyTitle: 'Пока нет файлов',
emptyDescription: 'Загрузите изображение или документ, чтобы начать.',
},
adminProducts: {
emptyTitle: 'Товары не найдены',
emptyDescription: 'Измените фильтры или создайте новый товар.',
},
adminUsers: {
emptyTitle: 'Пользователи не найдены',
emptyDescription: 'Пригласите коллегу, чтобы начать.',
invitationsEmptyTitle: 'Нет ожидающих приглашений',
},
adminMonitoring: {
eventsEmptyTitle: 'События не найдены',
eventsEmptyDescription: 'Попробуйте изменить фильтр категории или поисковый запрос.',
},
adminAnalytics: {
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.',
},
};

View File

@@ -860,4 +860,25 @@ export interface Translations {
emptyTitle: string;
emptyDescription: string;
};
// Sprint 28: only the new empty-state copy this sprint's skeleton/empty-
// state consistency fix introduces - NOT the full adminProducts/
// adminUsers/adminMonitoring/adminAnalytics translation surface, which is
// still missing entirely (see docs/KNOWN-ISSUES.md, deferred to Sprint 29).
adminProducts: {
emptyTitle: string;
emptyDescription: string;
};
adminUsers: {
emptyTitle: string;
emptyDescription: string;
invitationsEmptyTitle: string;
};
adminMonitoring: {
eventsEmptyTitle: string;
eventsEmptyDescription: string;
};
adminAnalytics: {
topProductsEmptyTitle: string;
topProductsEmptyDescription: string;
};
}

View File

@@ -1,8 +1,9 @@
import { Injectable, inject, DOCUMENT } from '@angular/core';
import { Injectable, inject, DOCUMENT, effect } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { Item } from '../models';
import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
import { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade';
import { ConfigService } from '../core/config/config.service';
@Injectable({
providedIn: 'root'
@@ -12,6 +13,22 @@ export class SeoService {
private title = inject(Title);
private doc = inject(DOCUMENT);
private readonly uiRuntime = inject(UiRuntimeFacade);
private readonly configService = inject(ConfigService);
constructor() {
// Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with
// the tenant's own bootstrap.seo.default + branding config whenever it
// (re)loads - the static defaults in index.html are only a pre-JS /
// no-JS-crawler fallback and are otherwise stuck on the hardcoded
// "Marketplace" placeholder tenant. Item-detail pages override these via
// setItemMeta() and restore them via resetToDefaults() on navigating away.
effect(() => {
this.configService.bootstrapRevision();
if (this.configService.getBootstrapSnapshot()) {
this.resetToDefaults();
}
});
}
private get siteUrl(): string {
return this.doc?.location?.origin ?? '';
@@ -60,14 +77,32 @@ export class SeoService {
}
/**
* Reset meta tags back to defaults (call on navigation away from item page).
* Reset meta tags back to the tenant's own site-wide defaults (call on
* navigation away from an item page, and also applied automatically
* whenever the bootstrap config (re)loads - see constructor).
*
* Reads `bootstrap.seo.default` (title/description/canonicalUrl/robots/
* metaTags - editable in the Project Editor's General/Branding sections,
* previously authored but never actually applied anywhere) and
* `bootstrap.branding` (name/logo, for the OG/Twitter image) so the runtime
* tags reflect the real tenant instead of a hardcoded "Marketplace"
* placeholder. Falls back to generic copy only if bootstrap hasn't loaded
* yet or a field is unset.
*/
resetToDefaults(): void {
const defaultTitle = `${this.siteName} — Marketplace`;
const defaultDescription = 'Modern marketplace for buying digital goods. Wide selection, convenient search, fast delivery.';
const defaultImage = `${this.siteUrl}/og-image.jpg`;
const bootstrap = this.configService.getBootstrapSnapshot();
const seoDefault = bootstrap?.seo?.default;
const branding = bootstrap?.branding;
const defaultTitle = seoDefault?.title || `${this.siteName} — Marketplace`;
const defaultDescription = seoDefault?.description
|| 'Modern marketplace for buying digital goods. Wide selection, convenient search, fast delivery.';
const defaultImage = this.resolveUrl(branding?.appIconUrl || branding?.logoUrl || '')
|| `${this.siteUrl}/icons/icon-512x512.png`;
const canonicalUrl = seoDefault?.canonicalUrl ? this.resolveUrl(seoDefault.canonicalUrl) : this.siteUrl;
this.title.setTitle(defaultTitle);
this.setCanonical(canonicalUrl);
this.setOrUpdate([
{ property: 'og:type', content: 'website' },
@@ -84,12 +119,14 @@ export class SeoService {
{ name: 'twitter:image', content: defaultImage },
{ name: 'description', content: defaultDescription },
...(seoDefault?.robots ? [{ name: 'robots', content: seoDefault.robots }] : []),
...(seoDefault?.metaTags ?? []),
]);
// Remove product-specific tags
this.meta.removeTag("property='product:price:amount'");
this.meta.removeTag("property='product:price:currency'");
this.removeCanonical();
}
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {

View File

@@ -140,6 +140,19 @@ a, button, input, textarea, select {
outline-offset: 2px;
}
/* Respect OS-level reduced-motion preference globally: neutralizes hover
transforms, card-entrance/skeleton-shimmer animations, and smooth-scroll
everywhere in one place rather than requiring every component to opt in
individually (a few already did this locally, e.g. shared/ui/skeleton). */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Utility Classes */
.page-container {
max-width: 1280px;