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>
This commit is contained in:
sdarbinyan
2026-08-13 09:30:53 +04:00
parent 1af337f005
commit 0b08802996
7 changed files with 64 additions and 1 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,12 @@ import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPag
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 { 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';
@@ -72,6 +78,10 @@ export class ProductDetailsContainerComponent {
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 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());
@@ -358,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

@@ -321,6 +321,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

@@ -321,6 +321,7 @@ export const hy: Translations = {
compare: 'Համեմատել', compare: 'Համեմատել',
share: 'Կիսվել', share: 'Կիսվել',
notifyMe: 'Ծանուցել ինձ', notifyMe: 'Ծանուցել ինձ',
notifyMeConfirmed: 'Մենք կտեղեկացնենք ձեզ, երբ ապրանքը կրկին հասանելի լինի։',
zoom: 'Մեծացնել', zoom: 'Մեծացնել',
fullscreen: 'Ամբողջ էկրան', fullscreen: 'Ամբողջ էկրան',
pdfDocument: 'Ապրանքի PDF փաստաթուղթ', pdfDocument: 'Ապրանքի PDF փաստաթուղթ',

View File

@@ -321,6 +321,7 @@ export const ru: Translations = {
compare: 'Сравнить', compare: 'Сравнить',
share: 'Поделиться', share: 'Поделиться',
notifyMe: 'Сообщить о наличии', notifyMe: 'Сообщить о наличии',
notifyMeConfirmed: 'Мы сообщим вам, когда товар снова появится в наличии.',
zoom: 'Увеличить', zoom: 'Увеличить',
fullscreen: 'Полный экран', fullscreen: 'Полный экран',
pdfDocument: 'PDF документ товара', pdfDocument: 'PDF документ товара',

View File

@@ -319,6 +319,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

@@ -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) {