10 Commits

Author SHA1 Message Date
sdarbinyan
6cc5d43a10 feat: build and wire the Quick View modal
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
The Quick View button (search results page only) emitted
quickViewPlaceholder with zero listeners anywhere up the chain - the
button did nothing. Built a minimal QuickViewDialogComponent (image,
name, price incl. discount, short description, Add to Cart, link to
full product page) and wired the event through product-grid ->
search-results -> catalog-container, which fetches the product via
ProductFacade and opens the dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:45:03 +04:00
sdarbinyan
b04e3a67f5 feat: wire up dark mode selector with a real dark palette
theme.mode wrote data-theme-mode to the DOM but no CSS ever reacted to
it, and mapThemeConfigToCssVariables() never looked at mode at all -
selecting Dark had zero visible effect.

Added dark-mode neutral overrides (background/text/border axis only -
brand colors stay as configured) using the palette provided by the
user (colorhunt.co/palette/091413285a48408a71b0e4cc):
  --bg-primary: #091413   --bg-secondary: #285a48
  --text-primary: #b0e4cc --text-secondary: #408a71
  --border-color: #285a48

ThemeEngineService now resolves 'system' mode via
prefers-color-scheme and re-renders live on OS theme changes, and
sets data-theme-mode to the *effective* resolved mode instead of the
raw setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:35:09 +04:00
sdarbinyan
0b08802996 feat: real back-in-stock subscription for Notify Me (API + localStorage fallback)
notifyMe() just called toggleWishlist() - no actual subscription
mechanism existed. Now calls a new subscribeToRestock() API method
(POST /items/{id}/notify-me, not yet built server-side - see
BACKEND-API-REFERENCE.md §12.5) and falls back to a local-only record
in localStorage on failure, so the request isn't silently dropped
while the backend catches up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:30:53 +04:00
sdarbinyan
1af337f005 fix: footer Contacts link resolved to nothing, add placeholder static page
The /contacts route had no bootstrap.staticPages entry at all (the old
hardcoded contacts page was intentionally removed per app.routes.ts's
comment, in favor of the generic staticPath -> CMS resolver). Added a
placeholder entry (route/title/html per locale) so the link resolves
instead of 404ing. Placeholder text explicitly says the real contact
details go through the admin panel - per user decision, real content
belongs to whoever runs a given marketplace, not something to fabricate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:26:32 +04:00
sdarbinyan
3feb806caa feat: add JSON-LD structured data for product and site pages
JSON-LD was absent entirely (sitemap generation is separate backend
work, out of scope here). Added Product schema (name/description/
image/offers with price+availability) on item pages via setItemMeta(),
and a site-wide Organization schema via resetToDefaults(), both
injected as a single #seo-json-ld <script type=application/ld+json>
tag that gets replaced on navigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:16:37 +04:00
sdarbinyan
ec8ed8f6a8 fix: Site Layout builder setting saved but never read by page rendering
bootstrap.layout.type (the 'Site Layout' selector in the theme editor)
was only ever consulted by a validator checking it against the known
list - nothing used it to actually pick a layout. SectionEngineService
already resolves a per-page layout.type with a hardcoded 'default'
fallback; that fallback now reads the site-wide setting first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:14:56 +04:00
sdarbinyan
8937aea57c fix: checkout payment-description fallback hardcoded Russian regardless of locale
getPaymentDescription()'s final fallback ('Покупка на Маркетплейсе')
ignored the active language. Moved to a translated key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:09:12 +04:00
sdarbinyan
c0bce7feac fix: Buy Now raced ahead of addToCart, per-product SEO tags never applied
CartService.addItem() fired an async dynamic import + API call for
new items but returned void immediately - buyNow() navigated to /cart
before the item was actually added, landing the user on an empty or
stale cart. addItem() now returns a Promise that resolves once the
cart signal actually contains the item; buyNow() awaits it before
navigating.

Also wired SeoService.setItemMeta()/resetToDefaults() into the product
detail page - built and working, but never called anywhere, so every
product page rendered the site-wide default OG/Twitter tags instead of
per-product ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:06:46 +04:00
sdarbinyan
9f784406d7 fix: og:locale hardcoded ru_RU regardless of active locale
Both setItemMeta() and resetToDefaults() hardcoded 'ru_RU'. Added a
LanguageService-driven mapping (ru/en/hy -> ru_RU/en_US/hy_AM).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:02:18 +04:00
sdarbinyan
461cd8421c fix: toAuthErrorShape() ignored backend error.code, session-expired screen unreachable
Known bug per BACKEND-API-REFERENCE.md §5: the client has dedicated
session-expired/invalid-signature recovery screens fully built, but
the mapper only ever derived the error code from HTTP status, never
the response body - so a real 401 with error.code: 'TOKEN_EXPIRED'
rendered the generic 'Unauthorized' screen instead.

Frontend half of the fix: prefer error.code from the body when present
(mapped via authErrorCodeFromBackendCode), fall back to status-derived
code otherwise. Stays dormant until the backend actually sends the
code, per the doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:00:39 +04:00
25 changed files with 453 additions and 32 deletions

View File

@@ -512,3 +512,13 @@ Separately, `createCartPayment()` (payment-gateway charge creation) still sends
} }
``` ```
`actor` must be derived server-side from the authenticated caller, never trusted from the request body. `actor` must be derived server-side from the authenticated caller, never trusted from the request body.
### 12.5 Back-in-stock ("Notify Me") subscription
**Gap:** the "Notify Me" button on out-of-stock products had no real subscription mechanism at all - it just toggled wishlist. Client fix already shipped: `notifyMe()` now calls `POST /items/{id}/notify-me` and, if that fails (today it always will - the endpoint doesn't exist), falls back to a local-only record in `localStorage['restockSubscriptions']` so the request isn't silently dropped while waiting on the backend. The shopper sees the same confirmation either way.
**Ask:** implement `POST /items/{id}/notify-me`, plus whatever mechanism actually sends the notification once the item restocks (Telegram message, most likely, given the rest of the auth stack). Request body sent today:
```json
{ "telegramUserId": "8823771" }
```
`telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call.

View File

@@ -17,6 +17,24 @@ export interface AuthError {
status?: number; status?: number;
} }
/**
* Maps the backend error envelope's `error.code` (see
* BACKEND-API-REFERENCE.md §5) to the client's AuthErrorCode screens.
* Only codes with a dedicated screen are mapped; anything else falls back
* to the HTTP-status-derived code via authErrorCodeFromStatus.
*/
const BACKEND_ERROR_CODE_MAP: Record<string, AuthErrorCode> = {
TOKEN_EXPIRED: 'session-expired',
INVALID_SIGNATURE: 'invalid-signature',
UNAUTHENTICATED: 'unauthorized',
FORBIDDEN: 'forbidden',
SERVICE_UNAVAILABLE: 'backend-unavailable',
};
export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined {
return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined;
}
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */ /** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
export function authErrorCodeFromStatus(status: number): AuthErrorCode { export function authErrorCodeFromStatus(status: number): AuthErrorCode {
switch (status) { switch (status) {

View File

@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { catchError, switchMap, tap, throwError } from 'rxjs'; import { catchError, switchMap, tap, throwError } from 'rxjs';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AuthTokenPair } from '../models/auth-api.model'; import { AuthTokenPair } from '../models/auth-api.model';
import { AuthError, authErrorCodeFromStatus } from '../models/auth-error.model'; import { AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from '../models/auth-error.model';
import { AuthApiService } from './auth-api.service'; import { AuthApiService } from './auth-api.service';
import { Ed25519KeypairService } from './ed25519-keypair.service'; import { Ed25519KeypairService } from './ed25519-keypair.service';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
@@ -109,7 +109,9 @@ export class AuthService {
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError { private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
if (error instanceof HttpErrorResponse) { if (error instanceof HttpErrorResponse) {
return { code: authErrorCodeFromStatus(error.status), message: error.message, status: error.status }; const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.error?.code;
const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status);
return { code, message: error.message, status: error.status };
} }
if (error instanceof Error) { if (error instanceof Error) {
return { code: fallbackCode, message: error.message }; return { code: fallbackCode, message: error.message };

View File

@@ -1,12 +1,15 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { PageConfig } from '../../shared/models/config'; import { PageConfig } from '../../shared/models/config';
import { PageRenderModel } from '../page-renderer/page-renderer.model'; import { PageRenderModel } from '../page-renderer/page-renderer.model';
import { SectionRendererService } from '../section-renderer/section-renderer.service'; import { SectionRendererService } from '../section-renderer/section-renderer.service';
import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config'; import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config';
import { SectionConfig } from '../../shared/models/config'; import { SectionConfig } from '../../shared/models/config';
import { ConfigService } from '../../core/config/config.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SectionEngineService { export class SectionEngineService {
private readonly configService = inject(ConfigService);
constructor(private readonly sectionRenderer: SectionRendererService) {} constructor(private readonly sectionRenderer: SectionRendererService) {}
toPageRenderModel(page: PageConfig): PageRenderModel { toPageRenderModel(page: PageConfig): PageRenderModel {
@@ -27,12 +30,23 @@ export class SectionEngineService {
}; };
} }
/**
* Falls back to the site-wide builder setting (bootstrap.layout.type,
* "Site Layout" in the theme editor) when a page has no layout of its
* own - previously that global setting was saved but never read by
* rendering at all, so it had no visible effect.
*/
private resolveLayoutType(layout: PageConfig['layout']): string { private resolveLayoutType(layout: PageConfig['layout']): string {
if (typeof layout === 'string') { if (typeof layout === 'string') {
return layout; return layout;
} }
return (layout as PlatformLayoutConfig)?.type ?? 'default'; const pageLayoutType = (layout as PlatformLayoutConfig)?.type;
if (pageLayoutType) {
return pageLayoutType;
}
return this.configService.getBootstrapSnapshot()?.layout?.type ?? 'default';
} }
private normalizeSectionsByLayout(sections: PageConfig['sections'], layoutType: string): PageConfig['sections'] { private normalizeSectionsByLayout(sections: PageConfig['sections'], layoutType: string): PageConfig['sections'] {

View File

@@ -20,6 +20,7 @@
(selected)="productSelected.emit(product)" (selected)="productSelected.emit(product)"
(addToCart)="onAddToCart(product, $event.event)" (addToCart)="onAddToCart(product, $event.event)"
(preview)="productPreview.emit($event)" (preview)="productPreview.emit($event)"
(quickViewPlaceholder)="quickView.emit($event)"
(favoriteToggled)="favoriteToggled.emit(product)" (favoriteToggled)="favoriteToggled.emit(product)"
(compareToggled)="compareToggled.emit(product)" (compareToggled)="compareToggled.emit(product)"
(shareRequested)="shareRequested.emit(product)" (shareRequested)="shareRequested.emit(product)"

View File

@@ -28,6 +28,7 @@ export class CatalogProductGridComponent {
@Output() productSelected = new EventEmitter<Product>(); @Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter<number>(); @Output() productPreview = new EventEmitter<number>();
@Output() quickView = new EventEmitter<number>();
@Output() favoriteToggled = new EventEmitter<Product>(); @Output() favoriteToggled = new EventEmitter<Product>();
@Output() compareToggled = new EventEmitter<Product>(); @Output() compareToggled = new EventEmitter<Product>();
@Output() shareRequested = new EventEmitter<Product>(); @Output() shareRequested = new EventEmitter<Product>();

View File

@@ -28,6 +28,7 @@
(productSelected)="productSelected.emit($event)" (productSelected)="productSelected.emit($event)"
(addToCart)="addToCart.emit($event)" (addToCart)="addToCart.emit($event)"
(productPreview)="productPreview.emit($event)" (productPreview)="productPreview.emit($event)"
(quickView)="quickView.emit($event)"
(favoriteToggled)="favoriteToggled.emit($event)" (favoriteToggled)="favoriteToggled.emit($event)"
(compareToggled)="compareToggled.emit($event)" (compareToggled)="compareToggled.emit($event)"
(shareRequested)="shareRequested.emit($event)" /> (shareRequested)="shareRequested.emit($event)" />

View File

@@ -40,6 +40,7 @@ export class CatalogSearchResultsComponent {
@Output() productSelected = new EventEmitter<Product>(); @Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter<number>(); @Output() productPreview = new EventEmitter<number>();
@Output() quickView = new EventEmitter<number>();
@Output() favoriteToggled = new EventEmitter<Product>(); @Output() favoriteToggled = new EventEmitter<Product>();
@Output() compareToggled = new EventEmitter<Product>(); @Output() compareToggled = new EventEmitter<Product>();
@Output() shareRequested = new EventEmitter<Product>(); @Output() shareRequested = new EventEmitter<Product>();

View File

@@ -229,6 +229,7 @@
(productSelected)="selectProduct($event)" (productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)" (addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)" (productPreview)="previewProduct($event)"
(quickView)="openQuickView($event)"
(favoriteToggled)="onFavoriteToggled($event)" (favoriteToggled)="onFavoriteToggled($event)"
(compareToggled)="onCompareToggled($event)" (compareToggled)="onCompareToggled($event)"
(shareRequested)="onShareRequested($event)" /> (shareRequested)="onShareRequested($event)" />
@@ -295,3 +296,9 @@
} }
} }
</main> </main>
<app-quick-view-dialog
[product]="quickViewProduct()"
[loading]="quickViewLoading()"
(closed)="closeQuickView()"
(addToCart)="addQuickViewToCart($event)" />

View File

@@ -10,6 +10,7 @@ import { Product } from '../../../../core/products/models/product-domain.model';
import { ConfigService } from '../../../../core/config/config.service'; import { ConfigService } from '../../../../core/config/config.service';
import { FeatureConfigService } from '../../../../core/config/feature-config.service'; import { FeatureConfigService } from '../../../../core/config/feature-config.service';
import { CategoryFacade } from '../../../../facades/platform/category.facade'; import { CategoryFacade } from '../../../../facades/platform/category.facade';
import { ProductFacade } from '../../../../facades/platform/product.facade';
import { SearchFacade } from '../../../../facades/platform/search.facade'; import { SearchFacade } from '../../../../facades/platform/search.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade'; import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { CartService } from '../../../../services'; import { CartService } from '../../../../services';
@@ -32,6 +33,7 @@ import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-sta
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { IconComponent } from '../../../../shared/ui/icon/icon.component'; import { IconComponent } from '../../../../shared/ui/icon/icon.component';
import { QuickViewDialogComponent } from '../../product/components/quick-view-dialog/quick-view-dialog.component';
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model'; import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
import { ProductShareService } from '../../user-experience/services/product-share.service'; import { ProductShareService } from '../../user-experience/services/product-share.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service'; import { UserNotificationService } from '../../user-experience/services/user-notification.service';
@@ -56,7 +58,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
EmptyStateComponent, EmptyStateComponent,
SkeletonComponent, SkeletonComponent,
ButtonComponent, ButtonComponent,
IconComponent IconComponent,
QuickViewDialogComponent
], ],
templateUrl: './catalog-container.component.html', templateUrl: './catalog-container.component.html',
styleUrls: ['./catalog-container.component.scss'], styleUrls: ['./catalog-container.component.scss'],
@@ -68,6 +71,7 @@ export class CatalogContainerComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private readonly categoryFacade = inject(CategoryFacade); private readonly categoryFacade = inject(CategoryFacade);
private readonly productFacade = inject(ProductFacade);
private readonly searchFacade = inject(SearchFacade); private readonly searchFacade = inject(SearchFacade);
private readonly featureConfig = inject(FeatureConfigService); private readonly featureConfig = inject(FeatureConfigService);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
@@ -287,6 +291,31 @@ export class CatalogContainerComponent {
this.prefetchService.prefetchItem(productId); this.prefetchService.prefetchItem(productId);
} }
readonly quickViewProduct = signal<Product | null>(null);
readonly quickViewLoading = signal(false);
openQuickView(productId: number): void {
this.quickViewProduct.set(null);
this.quickViewLoading.set(true);
this.productFacade.getProduct(productId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: product => {
this.quickViewProduct.set(product);
this.quickViewLoading.set(false);
},
error: () => this.quickViewLoading.set(false)
});
}
closeQuickView(): void {
this.quickViewProduct.set(null);
this.quickViewLoading.set(false);
}
addQuickViewToCart(product: Product): void {
this.cartService.addItem(product.itemID);
this.closeQuickView();
}
retry(): void { retry(): void {
this.enterCategory(this.state().category?.id ?? null); this.enterCategory(this.state().category?.id ?? null);
} }

View File

@@ -0,0 +1,34 @@
<app-dialog
[open]="!!product || loading"
[titleText]="'catalog.quickView' | translate"
size="md"
(closed)="closed.emit()"
>
@if (loading) {
<div class="quick-view__loading" role="status" aria-live="polite">{{ 'common.loading' | translate }}</div>
} @else if (product) {
<div class="quick-view">
<img class="quick-view__image" [src]="mainImage" [alt]="product.name" />
<div class="quick-view__body">
<h3 class="quick-view__title">{{ product.name }}</h3>
<p class="quick-view__price">
@if (hasDiscount) {
<span class="quick-view__price-original">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
<span class="quick-view__price-final">{{ discountedPrice | number:'1.2-2' }} {{ product.currency }}</span>
} @else {
<span class="quick-view__price-final">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
}
</p>
@if (product.simpleDescription) {
<p class="quick-view__description">{{ product.simpleDescription }}</p>
}
<div class="quick-view__actions">
<app-button variant="primary" (click)="addToCart.emit(product)">{{ 'carousel.addToCart' | translate }}</app-button>
<a class="quick-view__link" [routerLink]="('/product/' + product.itemID) | langRoute" (click)="closed.emit()">
{{ 'catalog.quickViewDetails' | translate }}
</a>
</div>
</div>
</div>
}
</app-dialog>

View File

@@ -0,0 +1,73 @@
.quick-view {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
@media (min-width: 560px) {
grid-template-columns: 200px 1fr;
}
}
.quick-view__image {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: var(--radius-md, 8px);
background: var(--bg-secondary, #f3f3f3);
}
.quick-view__body {
display: flex;
flex-direction: column;
gap: 8px;
}
.quick-view__title {
margin: 0;
font-size: var(--font-size-lg, 1.125rem);
color: var(--text-primary);
}
.quick-view__price {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
}
.quick-view__price-original {
text-decoration: line-through;
color: var(--text-secondary);
font-size: var(--font-size-sm, 0.875rem);
}
.quick-view__price-final {
font-weight: var(--font-weight-bold, 700);
font-size: var(--font-size-lg, 1.125rem);
color: var(--text-primary);
}
.quick-view__description {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm, 0.875rem);
}
.quick-view__actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.quick-view__link {
text-align: center;
color: var(--primary-color);
font-size: var(--font-size-sm, 0.875rem);
}
.quick-view__loading {
padding: 40px 0;
text-align: center;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Product } from '../../../../../core/products/models/product-domain.model';
import { DialogComponent } from '../../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
import { getDiscountedPrice, getMainImage } from '../../../../../utils/item.utils';
@Component({
selector: 'app-quick-view-dialog',
standalone: true,
imports: [DialogComponent, ButtonComponent, TranslatePipe, LangRoutePipe, DecimalPipe, RouterLink],
templateUrl: './quick-view-dialog.component.html',
styleUrl: './quick-view-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuickViewDialogComponent {
@Input() product: Product | null = null;
@Input() loading = false;
@Output() closed = new EventEmitter<void>();
@Output() addToCart = new EventEmitter<Product>();
get mainImage(): string {
return this.product ? getMainImage(this.product) : '';
}
get discountedPrice(): number {
return this.product ? getDiscountedPrice(this.product) : 0;
}
get hasDiscount(): boolean {
return !!this.product?.discount && this.product.discount > 0;
}
}

View File

@@ -16,6 +16,13 @@ import { LanguageService } from '../../../../services/language.service';
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config'; import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils'; import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductShareService } from '../../user-experience/services/product-share.service'; import { ProductShareService } from '../../user-experience/services/product-share.service';
import { SeoService } from '../../../../services/seo.service';
import { ApiService } from '../../../../services/api.service';
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
import { AuthService } from '../../../../services/auth.service';
const RESTOCK_SUBSCRIPTIONS_KEY = 'restockSubscriptions';
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component'; import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
import { ProductActionsComponent } from '../components/product-actions/product-actions.component'; import { ProductActionsComponent } from '../components/product-actions/product-actions.component';
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component'; import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
@@ -70,6 +77,11 @@ export class ProductDetailsContainerComponent {
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly shareService = inject(ProductShareService); private readonly shareService = inject(ProductShareService);
private readonly seoService = inject(SeoService);
private readonly apiService = inject(ApiService);
private readonly storage = inject(LocalStorageService);
private readonly notifications = inject(UserNotificationService);
private readonly authService = inject(AuthService);
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig()); readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig()); readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
@@ -229,6 +241,8 @@ export class ProductDetailsContainerComponent {
this.route.paramMap this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(params => this.loadProduct(Number(params.get('id')))); .subscribe(params => this.loadProduct(Number(params.get('id'))));
this.destroyRef.onDestroy(() => this.seoService.resetToDefaults());
} }
loadProduct(productId: number): void { loadProduct(productId: number): void {
@@ -261,6 +275,7 @@ export class ProductDetailsContainerComponent {
} }
this.product.set(product); this.product.set(product);
this.seoService.setItemMeta(product);
if (this.userExperienceConfig().recentlyViewed.enabled) { if (this.userExperienceConfig().recentlyViewed.enabled) {
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems); this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
} }
@@ -303,10 +318,10 @@ export class ProductDetailsContainerComponent {
} }
} }
addToCart(): void { addToCart(): Promise<void> {
const current = this.product(); const current = this.product();
if (!current) return; if (!current) return Promise.resolve();
this.cartService.addItem(current.itemID, 1, { return this.cartService.addItem(current.itemID, 1, {
colour: this.selectedColour() ?? undefined, colour: this.selectedColour() ?? undefined,
size: this.selectedSize() ?? undefined, size: this.selectedSize() ?? undefined,
price: this.effectivePrice(), price: this.effectivePrice(),
@@ -314,9 +329,9 @@ export class ProductDetailsContainerComponent {
}); });
} }
buyNow(): void { async buyNow(): Promise<void> {
this.addToCart(); await this.addToCart();
this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]); void this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
} }
toggleWishlist(): void { toggleWishlist(): void {
@@ -353,8 +368,36 @@ export class ProductDetailsContainerComponent {
await this.shareService.shareProduct(current, url); await this.shareService.shareProduct(current, url);
} }
/**
* Back-in-stock subscription. Tries the backend endpoint first (not built
* yet - see BACKEND-API-REFERENCE.md §12); falls back to a local-only
* record on any failure so the request isn't silently lost while the
* backend catches up. Either way the shopper sees the same confirmation.
*/
notifyMe(): void { notifyMe(): void {
this.toggleWishlist(); const current = this.product();
if (!current) {
return;
}
const telegramUserId = this.authService.session()?.userId != null
? String(this.authService.session()!.userId)
: null;
this.apiService.subscribeToRestock(current.itemID, { telegramUserId }).subscribe({
next: () => this.notifications.show(this.translate.t('productDetails.notifyMeConfirmed'), 'success'),
error: () => {
this.saveRestockSubscriptionLocally(current.itemID);
this.notifications.show(this.translate.t('productDetails.notifyMeConfirmed'), 'success');
}
});
}
private saveRestockSubscriptionLocally(itemID: number): void {
const existing = this.storage.getJSON<number[]>(RESTOCK_SUBSCRIPTIONS_KEY) ?? [];
if (!existing.includes(itemID)) {
this.storage.setJSON(RESTOCK_SUBSCRIPTIONS_KEY, [...existing, itemID]);
}
} }
addRelatedToCart(payload: { product: Product; event: Event }): void { addRelatedToCart(payload: { product: Product; event: Event }): void {

View File

@@ -124,6 +124,7 @@ export const en: Translations = {
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)', emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
emailInvalid: 'Invalid email format', emailInvalid: 'Invalid email format',
telegramIdMissing: 'We could not identify your Telegram account, so we could not save your contact details. Your payment was still successful.', telegramIdMissing: 'We could not identify your Telegram account, so we could not save your contact details. Your payment was still successful.',
paymentDescriptionFallback: 'Purchase on Marketplace',
emailPlaceholder: 'you@example.com', emailPlaceholder: 'you@example.com',
phonePlaceholder: '+7 (___) ___-__-__', phonePlaceholder: '+7 (___) ___-__-__',
loginRequired: 'Log in to checkout', loginRequired: 'Log in to checkout',
@@ -272,6 +273,7 @@ export const en: Translations = {
compare: 'Compare', compare: 'Compare',
share: 'Share', share: 'Share',
quickView: 'Quick view', quickView: 'Quick view',
quickViewDetails: 'View full details',
stockHigh: 'In stock', stockHigh: 'In stock',
stockMedium: 'Limited stock', stockMedium: 'Limited stock',
stockLow: 'Almost gone', stockLow: 'Almost gone',
@@ -320,6 +322,7 @@ export const en: Translations = {
compare: 'Compare', compare: 'Compare',
share: 'Share', share: 'Share',
notifyMe: 'Notify me', notifyMe: 'Notify me',
notifyMeConfirmed: 'We\'ll let you know when this is back in stock.',
zoom: 'Zoom', zoom: 'Zoom',
fullscreen: 'Fullscreen', fullscreen: 'Fullscreen',
pdfDocument: 'Product PDF document', pdfDocument: 'Product PDF document',

View File

@@ -124,6 +124,7 @@ export const hy: Translations = {
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)', emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
emailInvalid: 'Սխալ email ձևաչափ', emailInvalid: 'Սխալ email ձևաչափ',
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։', telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
paymentDescriptionFallback: 'Գնում Մարկետփլեյսում',
emailPlaceholder: 'you@example.com', emailPlaceholder: 'you@example.com',
phonePlaceholder: '+7 (___) ___-__-__', phonePlaceholder: '+7 (___) ___-__-__',
loginRequired: 'Մուտք գործեք ձևակերպելու համար', loginRequired: 'Մուտք գործեք ձևակերպելու համար',
@@ -272,6 +273,7 @@ export const hy: Translations = {
compare: 'Համեմատել', compare: 'Համեմատել',
share: 'Կիսվել', share: 'Կիսվել',
quickView: 'Արագ դիտում', quickView: 'Արագ դիտում',
quickViewDetails: 'Տեսնել ամբողջությամբ',
stockHigh: 'Առկա է', stockHigh: 'Առկա է',
stockMedium: 'Սահմանափակ քանակ', stockMedium: 'Սահմանափակ քանակ',
stockLow: 'Գրեթե սպառված է', stockLow: 'Գրեթե սպառված է',
@@ -320,6 +322,7 @@ export const hy: Translations = {
compare: 'Համեմատել', compare: 'Համեմատել',
share: 'Կիսվել', share: 'Կիսվել',
notifyMe: 'Ծանուցել ինձ', notifyMe: 'Ծանուցել ինձ',
notifyMeConfirmed: 'Մենք կտեղեկացնենք ձեզ, երբ ապրանքը կրկին հասանելի լինի։',
zoom: 'Մեծացնել', zoom: 'Մեծացնել',
fullscreen: 'Ամբողջ էկրան', fullscreen: 'Ամբողջ էկրան',
pdfDocument: 'Ապրանքի PDF փաստաթուղթ', pdfDocument: 'Ապրանքի PDF փաստաթուղթ',

View File

@@ -124,6 +124,7 @@ export const ru: Translations = {
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)', emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
emailInvalid: 'Некорректный формат email', emailInvalid: 'Некорректный формат email',
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.', telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
paymentDescriptionFallback: 'Покупка на Маркетплейсе',
emailPlaceholder: 'you@example.com', emailPlaceholder: 'you@example.com',
phonePlaceholder: '+7 (___) ___-__-__', phonePlaceholder: '+7 (___) ___-__-__',
loginRequired: 'Войдите для оформления', loginRequired: 'Войдите для оформления',
@@ -272,6 +273,7 @@ export const ru: Translations = {
compare: 'Сравнить', compare: 'Сравнить',
share: 'Поделиться', share: 'Поделиться',
quickView: 'Быстрый просмотр', quickView: 'Быстрый просмотр',
quickViewDetails: 'Смотреть полностью',
stockHigh: 'В наличии', stockHigh: 'В наличии',
stockMedium: 'Ограниченно', stockMedium: 'Ограниченно',
stockLow: 'Почти распродано', stockLow: 'Почти распродано',
@@ -320,6 +322,7 @@ export const ru: Translations = {
compare: 'Сравнить', compare: 'Сравнить',
share: 'Поделиться', share: 'Поделиться',
notifyMe: 'Сообщить о наличии', notifyMe: 'Сообщить о наличии',
notifyMeConfirmed: 'Мы сообщим вам, когда товар снова появится в наличии.',
zoom: 'Увеличить', zoom: 'Увеличить',
fullscreen: 'Полный экран', fullscreen: 'Полный экран',
pdfDocument: 'PDF документ товара', pdfDocument: 'PDF документ товара',

View File

@@ -122,6 +122,7 @@ export interface Translations {
emailNeedsDomain: string; emailNeedsDomain: string;
emailInvalid: string; emailInvalid: string;
telegramIdMissing: string; telegramIdMissing: string;
paymentDescriptionFallback: string;
emailPlaceholder: string; emailPlaceholder: string;
phonePlaceholder: string; phonePlaceholder: string;
loginRequired: string; loginRequired: string;
@@ -270,6 +271,7 @@ export interface Translations {
compare: string; compare: string;
share: string; share: string;
quickView: string; quickView: string;
quickViewDetails: string;
stockHigh: string; stockHigh: string;
stockMedium: string; stockMedium: string;
stockLow: string; stockLow: string;
@@ -318,6 +320,7 @@ export interface Translations {
compare: string; compare: string;
share: string; share: string;
notifyMe: string; notifyMe: string;
notifyMeConfirmed: string;
zoom: string; zoom: string;
fullscreen: string; fullscreen: string;
pdfDocument: string; pdfDocument: string;

View File

@@ -615,7 +615,7 @@ export class CartComponent implements OnDestroy {
return hostname; return hostname;
} }
return 'Покупка на Маркетплейсе'; return this.i18n.t('cart.paymentDescriptionFallback');
} }
private generateOrderId(): string { private generateOrderId(): string {

View File

@@ -694,6 +694,17 @@ export class ApiService {
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData); return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
} }
/**
* Back-in-stock subscription. No backend endpoint exists for this yet
* (tracked in BACKEND-API-REFERENCE.md §12) - callers should fall back to
* a local-only record (see CartService/UX facade patterns) when this 404s
* or the request otherwise fails, rather than surfacing an error to the
* shopper for something this minor.
*/
subscribeToRestock(itemID: number, contact: { telegramUserId: string | null; email?: string }): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/items/${itemID}/notify-me`, contact);
}
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> { getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
let params = new HttpParams().set('count', count.toString()); let params = new HttpParams().set('count', count.toString());
if (categoryID) { if (categoryID) {

View File

@@ -203,9 +203,16 @@ export class CartService {
return this.cartItems().findIndex(item => this.isSameCartLine(item, itemID, variant)); return this.cartItems().findIndex(item => this.isSameCartLine(item, itemID, variant));
} }
addItem(itemID: number, quantity: number = 1, variant?: CartVariant): void { /**
* Resolves once the item is actually present in the cart signal - not
* merely once the call was fired. New (not-yet-in-cart) items fetch their
* details from the API first, so callers that navigate straight after
* (e.g. Buy Now -> /cart) must await this rather than treat it as fired
* synchronously.
*/
addItem(itemID: number, quantity: number = 1, variant?: CartVariant): Promise<void> {
// Prevent duplicate API calls for same item // Prevent duplicate API calls for same item
if (this.addingItems.has(itemID)) return; if (this.addingItems.has(itemID)) return Promise.resolve();
const currentItems = this.cartItems(); const currentItems = this.cartItems();
const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant)); const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant));
@@ -213,10 +220,13 @@ export class CartService {
if (existingItem) { if (existingItem) {
// Item exists, increase quantity // Item exists, increase quantity
this.updateQuantity(itemID, existingItem.quantity + quantity, variant); this.updateQuantity(itemID, existingItem.quantity + quantity, variant);
} else { return Promise.resolve();
}
// Get item details from API and add to cart // Get item details from API and add to cart
this.addingItems.add(itemID); this.addingItems.add(itemID);
import('./api.service').then(({ ApiService }) => { return import('./api.service').then(({ ApiService }) =>
new Promise<void>((resolve) => {
this.injector.get(ApiService).getItem(itemID).subscribe({ this.injector.get(ApiService).getItem(itemID).subscribe({
next: (item) => { next: (item) => {
const cartItem = this.normalizeCartItem({ const cartItem = this.normalizeCartItem({
@@ -229,18 +239,20 @@ export class CartService {
}); });
this.cartItems.set([...this.cartItems(), cartItem]); this.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID); this.addingItems.delete(itemID);
resolve();
}, },
error: (err) => { error: (err) => {
console.error('Error adding to cart:', err); console.error('Error adding to cart:', err);
this.addingItems.delete(itemID); this.addingItems.delete(itemID);
resolve();
} }
}); });
}).catch((err) => { })
).catch((err) => {
console.error('Error loading API service:', err); console.error('Error loading API service:', err);
this.addingItems.delete(itemID); this.addingItems.delete(itemID);
}); });
} }
}
updateQuantity(itemID: number, quantity: number, variant?: CartVariant): void { updateQuantity(itemID: number, quantity: number, variant?: CartVariant): void {
if (quantity <= 0) { if (quantity <= 0) {

View File

@@ -4,6 +4,13 @@ import { Item } from '../models';
import { getDiscountedPrice, getMainImage } from '../utils/item.utils'; import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
import { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade'; import { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade';
import { ConfigService } from '../core/config/config.service'; import { ConfigService } from '../core/config/config.service';
import { LanguageService } from './language.service';
const OG_LOCALE_MAP: Record<string, string> = {
ru: 'ru_RU',
en: 'en_US',
hy: 'hy_AM',
};
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -14,6 +21,7 @@ export class SeoService {
private doc = inject(DOCUMENT); private doc = inject(DOCUMENT);
private readonly uiRuntime = inject(UiRuntimeFacade); private readonly uiRuntime = inject(UiRuntimeFacade);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private readonly languageService = inject(LanguageService);
constructor() { constructor() {
// Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with // Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with
@@ -38,6 +46,10 @@ export class SeoService {
return this.uiRuntime.marketplaceDisplayName() || 'Marketplace'; return this.uiRuntime.marketplaceDisplayName() || 'Marketplace';
} }
private get ogLocale(): string {
return OG_LOCALE_MAP[this.languageService.currentLanguage()] ?? 'en_US';
}
/** /**
* Set Open Graph & Twitter Card meta tags for a product/item page. * Set Open Graph & Twitter Card meta tags for a product/item page.
*/ */
@@ -59,7 +71,7 @@ export class SeoService {
{ property: 'og:image', content: imageUrl }, { property: 'og:image', content: imageUrl },
{ property: 'og:url', content: itemUrl }, { property: 'og:url', content: itemUrl },
{ property: 'og:site_name', content: this.siteName }, { property: 'og:site_name', content: this.siteName },
{ property: 'og:locale', content: 'ru_RU' }, { property: 'og:locale', content: this.ogLocale },
// Product-specific OG tags // Product-specific OG tags
{ property: 'product:price:amount', content: price.toFixed(2) }, { property: 'product:price:amount', content: price.toFixed(2) },
@@ -74,6 +86,22 @@ export class SeoService {
// Standard meta // Standard meta
{ name: 'description', content: description }, { name: 'description', content: description },
]); ]);
this.setJsonLd({
'@context': 'https://schema.org',
'@type': 'Product',
name: item.name,
description,
image: imageUrl,
url: itemUrl,
offers: {
'@type': 'Offer',
price: price.toFixed(2),
priceCurrency: item.currency || 'RUB',
availability: (item.quantity ?? 0) > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
url: itemUrl,
},
});
} }
/** /**
@@ -111,7 +139,7 @@ export class SeoService {
{ property: 'og:image', content: defaultImage }, { property: 'og:image', content: defaultImage },
{ property: 'og:url', content: this.siteUrl }, { property: 'og:url', content: this.siteUrl },
{ property: 'og:site_name', content: this.siteName }, { property: 'og:site_name', content: this.siteName },
{ property: 'og:locale', content: 'ru_RU' }, { property: 'og:locale', content: this.ogLocale },
{ name: 'twitter:card', content: 'summary_large_image' }, { name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: defaultTitle }, { name: 'twitter:title', content: defaultTitle },
@@ -127,6 +155,30 @@ export class SeoService {
// Remove product-specific tags // Remove product-specific tags
this.meta.removeTag("property='product:price:amount'"); this.meta.removeTag("property='product:price:amount'");
this.meta.removeTag("property='product:price:currency'"); this.meta.removeTag("property='product:price:currency'");
this.setJsonLd({
'@context': 'https://schema.org',
'@type': 'Organization',
name: this.siteName,
url: this.siteUrl,
...(defaultImage ? { logo: defaultImage } : {}),
});
}
/** Replace (or remove, when data is null) the page's JSON-LD structured-data script tag. */
private setJsonLd(data: Record<string, unknown> | null): void {
const existing = this.doc.getElementById('seo-json-ld');
existing?.remove();
if (!data) {
return;
}
const script = this.doc.createElement('script');
script.id = 'seo-json-ld';
script.type = 'application/ld+json';
script.text = JSON.stringify(data);
this.doc.head.appendChild(script);
} }
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void { private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {

View File

@@ -2,7 +2,21 @@ import { ThemeConfig } from '../../shared/models/config';
import { ThemeCssVariables } from '../tokens/theme-css-variable.model'; import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map'; import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map';
export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariables { /**
* Dark-mode neutral/surface overrides. Brand colors (primary/secondary/
* accent/success/warning/danger/info) are intentionally left as configured -
* only the background/text/border axis flips for dark mode, same as most
* dark-theme implementations.
*/
const DARK_MODE_OVERRIDES: ThemeCssVariables = {
[THEME_VARIABLE_MAP.backgroundPrimary]: '#091413',
[THEME_VARIABLE_MAP.backgroundSecondary]: '#285a48',
[THEME_VARIABLE_MAP.textPrimary]: '#b0e4cc',
[THEME_VARIABLE_MAP.textSecondary]: '#408a71',
[THEME_VARIABLE_MAP.border]: '#285a48',
};
export function mapThemeConfigToCssVariables(theme: ThemeConfig, effectiveMode: 'light' | 'dark' = 'light'): ThemeCssVariables {
const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0 const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0
? theme.spacing.scale ? theme.spacing.scale
: [0.25, 0.5, 1, 1.5, 2]; : [0.25, 0.5, 1, 1.5, 2];
@@ -49,5 +63,5 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab
vars[`--shadow-${key}`] = value; vars[`--shadow-${key}`] = value;
} }
return vars; return effectiveMode === 'dark' ? { ...vars, ...DARK_MODE_OVERRIDES } : vars;
} }

View File

@@ -10,6 +10,9 @@ export class ThemeEngineService {
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private systemDarkQuery?: MediaQueryList;
private systemDarkListener?: (event: MediaQueryListEvent) => void;
initialize(): void { initialize(): void {
this.configService.loadBootstrap().pipe(take(1)).subscribe({ this.configService.loadBootstrap().pipe(take(1)).subscribe({
next: (bootstrap) => this.applyTheme(bootstrap.theme), next: (bootstrap) => this.applyTheme(bootstrap.theme),
@@ -20,7 +23,20 @@ export class ThemeEngineService {
} }
applyTheme(theme: ThemeConfig): void { applyTheme(theme: ThemeConfig): void {
const variables = mapThemeConfigToCssVariables(theme); this.teardownSystemModeListener();
const effectiveMode = this.resolveEffectiveMode(theme.mode);
this.render(theme, effectiveMode);
if (theme.mode === 'system' && typeof window !== 'undefined' && window.matchMedia) {
this.systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.systemDarkListener = () => this.render(theme, this.resolveEffectiveMode('system'));
this.systemDarkQuery.addEventListener('change', this.systemDarkListener);
}
}
private render(theme: ThemeConfig, effectiveMode: 'light' | 'dark'): void {
const variables = mapThemeConfigToCssVariables(theme, effectiveMode);
const root = this.document.documentElement; const root = this.document.documentElement;
for (const [name, value] of Object.entries(variables)) { for (const [name, value] of Object.entries(variables)) {
@@ -28,7 +44,27 @@ export class ThemeEngineService {
} }
root.setAttribute('data-theme-id', theme.themeId); root.setAttribute('data-theme-id', theme.themeId);
root.setAttribute('data-theme-mode', theme.mode); root.setAttribute('data-theme-mode', effectiveMode);
root.setAttribute('data-icon-set', theme.iconSet); root.setAttribute('data-icon-set', theme.iconSet);
} }
private resolveEffectiveMode(mode: ThemeConfig['mode']): 'light' | 'dark' {
if (mode === 'dark') {
return 'dark';
}
if (mode === 'light') {
return 'light';
}
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
private teardownSystemModeListener(): void {
if (this.systemDarkQuery && this.systemDarkListener) {
this.systemDarkQuery.removeEventListener('change', this.systemDarkListener);
}
this.systemDarkQuery = undefined;
this.systemDarkListener = undefined;
}
} }

View File

@@ -352,6 +352,19 @@
"en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</p>", "en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</p>",
"hy": "<h2>Օգտագործման պայմաններ</h2><p>Հարթակի օգտագործումը կարգավորվում է հրապարակային առաջարկի պայմաններով։</p>" "hy": "<h2>Օգտագործման պայմաններ</h2><p>Հարթակի օգտագործումը կարգավորվում է հրապարակային առաջարկի պայմաններով։</p>"
} }
},
"contacts": {
"route": "/contacts",
"title": {
"ru": "Контакты",
"en": "Contacts",
"hy": "Կապ"
},
"html": {
"ru": "<h2>Контакты</h2><p>Здесь будет размещена контактная информация продавца — адрес, телефон, email и часы работы. Заполняется администратором маркетплейса.</p>",
"en": "<h2>Contacts</h2><p>Seller contact details (address, phone, email, business hours) go here. Fill this in from the admin panel.</p>",
"hy": "<h2>Կապ</h2><p>Այստեղ կտեղադրվի վաճառողի կոնտակտային տեղեկատվությունը՝ հասցե, հեռախոս, էլ. փոստ և աշխատանքային ժամեր։ Լրացվում է կայքի ադմինիստրատորի կողմից։</p>"
}
} }
}, },
"pages": [ "pages": [