Compare commits
10 Commits
6231128288
...
6cc5d43a10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc5d43a10 | ||
|
|
b04e3a67f5 | ||
|
|
0b08802996 | ||
|
|
1af337f005 | ||
|
|
3feb806caa | ||
|
|
ec8ed8f6a8 | ||
|
|
8937aea57c | ||
|
|
c0bce7feac | ||
|
|
9f784406d7 | ||
|
|
461cd8421c |
@@ -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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -17,6 +17,24 @@ export interface AuthError {
|
||||
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. */
|
||||
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
|
||||
switch (status) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { catchError, switchMap, tap, throwError } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
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 { Ed25519KeypairService } from './ed25519-keypair.service';
|
||||
import { SessionService } from './session.service';
|
||||
@@ -109,7 +109,9 @@ export class AuthService {
|
||||
|
||||
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
|
||||
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) {
|
||||
return { code: fallbackCode, message: error.message };
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { PageConfig } from '../../shared/models/config';
|
||||
import { PageRenderModel } from '../page-renderer/page-renderer.model';
|
||||
import { SectionRendererService } from '../section-renderer/section-renderer.service';
|
||||
import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SectionEngineService {
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
constructor(private readonly sectionRenderer: SectionRendererService) {}
|
||||
|
||||
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 {
|
||||
if (typeof layout === 'string') {
|
||||
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'] {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
(selected)="productSelected.emit(product)"
|
||||
(addToCart)="onAddToCart(product, $event.event)"
|
||||
(preview)="productPreview.emit($event)"
|
||||
(quickViewPlaceholder)="quickView.emit($event)"
|
||||
(favoriteToggled)="favoriteToggled.emit(product)"
|
||||
(compareToggled)="compareToggled.emit(product)"
|
||||
(shareRequested)="shareRequested.emit(product)"
|
||||
|
||||
@@ -28,6 +28,7 @@ export class CatalogProductGridComponent {
|
||||
@Output() productSelected = new EventEmitter<Product>();
|
||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||
@Output() productPreview = new EventEmitter<number>();
|
||||
@Output() quickView = new EventEmitter<number>();
|
||||
@Output() favoriteToggled = new EventEmitter<Product>();
|
||||
@Output() compareToggled = new EventEmitter<Product>();
|
||||
@Output() shareRequested = new EventEmitter<Product>();
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
(productSelected)="productSelected.emit($event)"
|
||||
(addToCart)="addToCart.emit($event)"
|
||||
(productPreview)="productPreview.emit($event)"
|
||||
(quickView)="quickView.emit($event)"
|
||||
(favoriteToggled)="favoriteToggled.emit($event)"
|
||||
(compareToggled)="compareToggled.emit($event)"
|
||||
(shareRequested)="shareRequested.emit($event)" />
|
||||
|
||||
@@ -40,6 +40,7 @@ export class CatalogSearchResultsComponent {
|
||||
@Output() productSelected = new EventEmitter<Product>();
|
||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||
@Output() productPreview = new EventEmitter<number>();
|
||||
@Output() quickView = new EventEmitter<number>();
|
||||
@Output() favoriteToggled = new EventEmitter<Product>();
|
||||
@Output() compareToggled = new EventEmitter<Product>();
|
||||
@Output() shareRequested = new EventEmitter<Product>();
|
||||
|
||||
@@ -229,6 +229,7 @@
|
||||
(productSelected)="selectProduct($event)"
|
||||
(addToCart)="addToCart($event)"
|
||||
(productPreview)="previewProduct($event)"
|
||||
(quickView)="openQuickView($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(compareToggled)="onCompareToggled($event)"
|
||||
(shareRequested)="onShareRequested($event)" />
|
||||
@@ -295,3 +296,9 @@
|
||||
}
|
||||
}
|
||||
</main>
|
||||
|
||||
<app-quick-view-dialog
|
||||
[product]="quickViewProduct()"
|
||||
[loading]="quickViewLoading()"
|
||||
(closed)="closeQuickView()"
|
||||
(addToCart)="addQuickViewToCart($event)" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Product } from '../../../../core/products/models/product-domain.model';
|
||||
import { ConfigService } from '../../../../core/config/config.service';
|
||||
import { FeatureConfigService } from '../../../../core/config/feature-config.service';
|
||||
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
||||
import { ProductFacade } from '../../../../facades/platform/product.facade';
|
||||
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
||||
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
||||
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 { ButtonComponent } from '../../../../shared/ui/button/button.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 { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||
@@ -56,7 +58,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
|
||||
EmptyStateComponent,
|
||||
SkeletonComponent,
|
||||
ButtonComponent,
|
||||
IconComponent
|
||||
IconComponent,
|
||||
QuickViewDialogComponent
|
||||
],
|
||||
templateUrl: './catalog-container.component.html',
|
||||
styleUrls: ['./catalog-container.component.scss'],
|
||||
@@ -68,6 +71,7 @@ export class CatalogContainerComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly configService = inject(ConfigService);
|
||||
private readonly categoryFacade = inject(CategoryFacade);
|
||||
private readonly productFacade = inject(ProductFacade);
|
||||
private readonly searchFacade = inject(SearchFacade);
|
||||
private readonly featureConfig = inject(FeatureConfigService);
|
||||
private readonly cartService = inject(CartService);
|
||||
@@ -287,6 +291,31 @@ export class CatalogContainerComponent {
|
||||
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 {
|
||||
this.enterCategory(this.state().category?.id ?? null);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
||||
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 { ProductActionsComponent } from '../components/product-actions/product-actions.component';
|
||||
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
|
||||
@@ -70,6 +77,11 @@ export class ProductDetailsContainerComponent {
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
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 userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
||||
@@ -229,6 +241,8 @@ export class ProductDetailsContainerComponent {
|
||||
this.route.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(params => this.loadProduct(Number(params.get('id'))));
|
||||
|
||||
this.destroyRef.onDestroy(() => this.seoService.resetToDefaults());
|
||||
}
|
||||
|
||||
loadProduct(productId: number): void {
|
||||
@@ -261,6 +275,7 @@ export class ProductDetailsContainerComponent {
|
||||
}
|
||||
|
||||
this.product.set(product);
|
||||
this.seoService.setItemMeta(product);
|
||||
if (this.userExperienceConfig().recentlyViewed.enabled) {
|
||||
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
|
||||
}
|
||||
@@ -303,10 +318,10 @@ export class ProductDetailsContainerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
addToCart(): void {
|
||||
addToCart(): Promise<void> {
|
||||
const current = this.product();
|
||||
if (!current) return;
|
||||
this.cartService.addItem(current.itemID, 1, {
|
||||
if (!current) return Promise.resolve();
|
||||
return this.cartService.addItem(current.itemID, 1, {
|
||||
colour: this.selectedColour() ?? undefined,
|
||||
size: this.selectedSize() ?? undefined,
|
||||
price: this.effectivePrice(),
|
||||
@@ -314,9 +329,9 @@ export class ProductDetailsContainerComponent {
|
||||
});
|
||||
}
|
||||
|
||||
buyNow(): void {
|
||||
this.addToCart();
|
||||
this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
|
||||
async buyNow(): Promise<void> {
|
||||
await this.addToCart();
|
||||
void this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
|
||||
}
|
||||
|
||||
toggleWishlist(): void {
|
||||
@@ -353,8 +368,36 @@ export class ProductDetailsContainerComponent {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -124,6 +124,7 @@ export const en: Translations = {
|
||||
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
||||
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.',
|
||||
paymentDescriptionFallback: 'Purchase on Marketplace',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Log in to checkout',
|
||||
@@ -272,6 +273,7 @@ export const en: Translations = {
|
||||
compare: 'Compare',
|
||||
share: 'Share',
|
||||
quickView: 'Quick view',
|
||||
quickViewDetails: 'View full details',
|
||||
stockHigh: 'In stock',
|
||||
stockMedium: 'Limited stock',
|
||||
stockLow: 'Almost gone',
|
||||
@@ -320,6 +322,7 @@ export const en: Translations = {
|
||||
compare: 'Compare',
|
||||
share: 'Share',
|
||||
notifyMe: 'Notify me',
|
||||
notifyMeConfirmed: 'We\'ll let you know when this is back in stock.',
|
||||
zoom: 'Zoom',
|
||||
fullscreen: 'Fullscreen',
|
||||
pdfDocument: 'Product PDF document',
|
||||
|
||||
@@ -124,6 +124,7 @@ export const hy: Translations = {
|
||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
||||
emailInvalid: 'Սխալ email ձևաչափ',
|
||||
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
|
||||
paymentDescriptionFallback: 'Գնում Մարկետփլեյսում',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
||||
@@ -272,6 +273,7 @@ export const hy: Translations = {
|
||||
compare: 'Համեմատել',
|
||||
share: 'Կիսվել',
|
||||
quickView: 'Արագ դիտում',
|
||||
quickViewDetails: 'Տեսնել ամբողջությամբ',
|
||||
stockHigh: 'Առկա է',
|
||||
stockMedium: 'Սահմանափակ քանակ',
|
||||
stockLow: 'Գրեթե սպառված է',
|
||||
@@ -320,6 +322,7 @@ export const hy: Translations = {
|
||||
compare: 'Համեմատել',
|
||||
share: 'Կիսվել',
|
||||
notifyMe: 'Ծանուցել ինձ',
|
||||
notifyMeConfirmed: 'Մենք կտեղեկացնենք ձեզ, երբ ապրանքը կրկին հասանելի լինի։',
|
||||
zoom: 'Մեծացնել',
|
||||
fullscreen: 'Ամբողջ էկրան',
|
||||
pdfDocument: 'Ապրանքի PDF փաստաթուղթ',
|
||||
|
||||
@@ -124,6 +124,7 @@ export const ru: Translations = {
|
||||
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
||||
emailInvalid: 'Некорректный формат email',
|
||||
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
|
||||
paymentDescriptionFallback: 'Покупка на Маркетплейсе',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Войдите для оформления',
|
||||
@@ -272,6 +273,7 @@ export const ru: Translations = {
|
||||
compare: 'Сравнить',
|
||||
share: 'Поделиться',
|
||||
quickView: 'Быстрый просмотр',
|
||||
quickViewDetails: 'Смотреть полностью',
|
||||
stockHigh: 'В наличии',
|
||||
stockMedium: 'Ограниченно',
|
||||
stockLow: 'Почти распродано',
|
||||
@@ -320,6 +322,7 @@ export const ru: Translations = {
|
||||
compare: 'Сравнить',
|
||||
share: 'Поделиться',
|
||||
notifyMe: 'Сообщить о наличии',
|
||||
notifyMeConfirmed: 'Мы сообщим вам, когда товар снова появится в наличии.',
|
||||
zoom: 'Увеличить',
|
||||
fullscreen: 'Полный экран',
|
||||
pdfDocument: 'PDF документ товара',
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface Translations {
|
||||
emailNeedsDomain: string;
|
||||
emailInvalid: string;
|
||||
telegramIdMissing: string;
|
||||
paymentDescriptionFallback: string;
|
||||
emailPlaceholder: string;
|
||||
phonePlaceholder: string;
|
||||
loginRequired: string;
|
||||
@@ -270,6 +271,7 @@ export interface Translations {
|
||||
compare: string;
|
||||
share: string;
|
||||
quickView: string;
|
||||
quickViewDetails: string;
|
||||
stockHigh: string;
|
||||
stockMedium: string;
|
||||
stockLow: string;
|
||||
@@ -318,6 +320,7 @@ export interface Translations {
|
||||
compare: string;
|
||||
share: string;
|
||||
notifyMe: string;
|
||||
notifyMeConfirmed: string;
|
||||
zoom: string;
|
||||
fullscreen: string;
|
||||
pdfDocument: string;
|
||||
|
||||
@@ -615,7 +615,7 @@ export class CartComponent implements OnDestroy {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
return 'Покупка на Маркетплейсе';
|
||||
return this.i18n.t('cart.paymentDescriptionFallback');
|
||||
}
|
||||
|
||||
private generateOrderId(): string {
|
||||
|
||||
@@ -694,6 +694,17 @@ export class ApiService {
|
||||
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[]> {
|
||||
let params = new HttpParams().set('count', count.toString());
|
||||
if (categoryID) {
|
||||
|
||||
@@ -203,9 +203,16 @@ export class CartService {
|
||||
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
|
||||
if (this.addingItems.has(itemID)) return;
|
||||
if (this.addingItems.has(itemID)) return Promise.resolve();
|
||||
|
||||
const currentItems = this.cartItems();
|
||||
const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant));
|
||||
@@ -213,10 +220,13 @@ export class CartService {
|
||||
if (existingItem) {
|
||||
// Item exists, increase quantity
|
||||
this.updateQuantity(itemID, existingItem.quantity + quantity, variant);
|
||||
} else {
|
||||
// Get item details from API and add to cart
|
||||
this.addingItems.add(itemID);
|
||||
import('./api.service').then(({ ApiService }) => {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Get item details from API and add to cart
|
||||
this.addingItems.add(itemID);
|
||||
return import('./api.service').then(({ ApiService }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
this.injector.get(ApiService).getItem(itemID).subscribe({
|
||||
next: (item) => {
|
||||
const cartItem = this.normalizeCartItem({
|
||||
@@ -229,17 +239,19 @@ export class CartService {
|
||||
});
|
||||
this.cartItems.set([...this.cartItems(), cartItem]);
|
||||
this.addingItems.delete(itemID);
|
||||
resolve();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error adding to cart:', err);
|
||||
this.addingItems.delete(itemID);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('Error loading API service:', err);
|
||||
this.addingItems.delete(itemID);
|
||||
});
|
||||
}
|
||||
})
|
||||
).catch((err) => {
|
||||
console.error('Error loading API service:', err);
|
||||
this.addingItems.delete(itemID);
|
||||
});
|
||||
}
|
||||
|
||||
updateQuantity(itemID: number, quantity: number, variant?: CartVariant): void {
|
||||
|
||||
@@ -4,6 +4,13 @@ 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';
|
||||
import { LanguageService } from './language.service';
|
||||
|
||||
const OG_LOCALE_MAP: Record<string, string> = {
|
||||
ru: 'ru_RU',
|
||||
en: 'en_US',
|
||||
hy: 'hy_AM',
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -14,6 +21,7 @@ export class SeoService {
|
||||
private doc = inject(DOCUMENT);
|
||||
private readonly uiRuntime = inject(UiRuntimeFacade);
|
||||
private readonly configService = inject(ConfigService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
constructor() {
|
||||
// Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with
|
||||
@@ -38,6 +46,10 @@ export class SeoService {
|
||||
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.
|
||||
*/
|
||||
@@ -59,7 +71,7 @@ export class SeoService {
|
||||
{ property: 'og:image', content: imageUrl },
|
||||
{ property: 'og:url', content: itemUrl },
|
||||
{ property: 'og:site_name', content: this.siteName },
|
||||
{ property: 'og:locale', content: 'ru_RU' },
|
||||
{ property: 'og:locale', content: this.ogLocale },
|
||||
|
||||
// Product-specific OG tags
|
||||
{ property: 'product:price:amount', content: price.toFixed(2) },
|
||||
@@ -74,6 +86,22 @@ export class SeoService {
|
||||
// Standard meta
|
||||
{ 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:url', content: this.siteUrl },
|
||||
{ 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:title', content: defaultTitle },
|
||||
@@ -127,6 +155,30 @@ export class SeoService {
|
||||
// Remove product-specific tags
|
||||
this.meta.removeTag("property='product:price:amount'");
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,21 @@ import { ThemeConfig } from '../../shared/models/config';
|
||||
import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
|
||||
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
|
||||
? theme.spacing.scale
|
||||
: [0.25, 0.5, 1, 1.5, 2];
|
||||
@@ -49,5 +63,5 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab
|
||||
vars[`--shadow-${key}`] = value;
|
||||
}
|
||||
|
||||
return vars;
|
||||
return effectiveMode === 'dark' ? { ...vars, ...DARK_MODE_OVERRIDES } : vars;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ export class ThemeEngineService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
private systemDarkQuery?: MediaQueryList;
|
||||
private systemDarkListener?: (event: MediaQueryListEvent) => void;
|
||||
|
||||
initialize(): void {
|
||||
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
||||
next: (bootstrap) => this.applyTheme(bootstrap.theme),
|
||||
@@ -20,7 +23,20 @@ export class ThemeEngineService {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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-mode', theme.mode);
|
||||
root.setAttribute('data-theme-mode', effectiveMode);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +352,19 @@
|
||||
"en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</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": [
|
||||
|
||||
Reference in New Issue
Block a user