feat(seo): tenant-driven meta tags, sitemap/robots, reduced-motion, docs
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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:
@@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -857,4 +857,21 @@ export const hy: Translations = {
|
||||
emptyTitle: 'Ֆայլեր դեռ չկան',
|
||||
emptyDescription: 'Վերբեռնեք պատկեր կամ փաստաթուղթ սկսելու համար։',
|
||||
},
|
||||
adminProducts: {
|
||||
emptyTitle: 'Ապրանքներ չեն գտնվել',
|
||||
emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։',
|
||||
},
|
||||
adminUsers: {
|
||||
emptyTitle: 'Օգտատերեր չեն գտնվել',
|
||||
emptyDescription: 'Հրավիրեք գործընկերոջ՝ սկսելու համար։',
|
||||
invitationsEmptyTitle: 'Սպասող հրավերներ չկան',
|
||||
},
|
||||
adminMonitoring: {
|
||||
eventsEmptyTitle: 'Իրադարձություններ չեն գտնվել',
|
||||
eventsEmptyDescription: 'Փորձեք այլ կատեգորիայի ֆիլտր կամ որոնման բառ։',
|
||||
},
|
||||
adminAnalytics: {
|
||||
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
|
||||
topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -857,4 +857,21 @@ export const ru: Translations = {
|
||||
emptyTitle: 'Пока нет файлов',
|
||||
emptyDescription: 'Загрузите изображение или документ, чтобы начать.',
|
||||
},
|
||||
adminProducts: {
|
||||
emptyTitle: 'Товары не найдены',
|
||||
emptyDescription: 'Измените фильтры или создайте новый товар.',
|
||||
},
|
||||
adminUsers: {
|
||||
emptyTitle: 'Пользователи не найдены',
|
||||
emptyDescription: 'Пригласите коллегу, чтобы начать.',
|
||||
invitationsEmptyTitle: 'Нет ожидающих приглашений',
|
||||
},
|
||||
adminMonitoring: {
|
||||
eventsEmptyTitle: 'События не найдены',
|
||||
eventsEmptyDescription: 'Попробуйте изменить фильтр категории или поисковый запрос.',
|
||||
},
|
||||
adminAnalytics: {
|
||||
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
|
||||
topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user