From c5e1fab09bb9ba413eebd7ee654205efe8c53387 Mon Sep 17 00:00:00 2001 From: ahasani194 Date: Wed, 20 May 2026 11:55:30 +0330 Subject: [PATCH] feat: add favorite functionality for goods; implement favorite button and service integration --- src/app/domains/pos/models/good.io.ts | 1 + .../components/favorite-CTA.component.html | 9 ++ .../components/favorite-CTA.component.ts | 55 +++++++++ .../components/grid-view.component.html | 8 +- .../landing/components/grid-view.component.ts | 14 ++- .../components/list-view.component.html | 9 +- .../landing/components/list-view.component.ts | 11 +- .../landing/constants/apiRoutes/favorite.ts | 5 + .../landing/constants/apiRoutes/index.ts | 1 + .../landing/services/favorite.service.ts | 21 ++++ .../pos/modules/landing/store/main.store.ts | 105 +++++++++++------- src/environments/environment.tis.ts | 4 +- 12 files changed, 185 insertions(+), 58 deletions(-) create mode 100644 src/app/domains/pos/modules/landing/components/favorite-CTA.component.html create mode 100644 src/app/domains/pos/modules/landing/components/favorite-CTA.component.ts create mode 100644 src/app/domains/pos/modules/landing/constants/apiRoutes/favorite.ts create mode 100644 src/app/domains/pos/modules/landing/services/favorite.service.ts diff --git a/src/app/domains/pos/models/good.io.ts b/src/app/domains/pos/models/good.io.ts index 83c53c3..f4c82d9 100644 --- a/src/app/domains/pos/models/good.io.ts +++ b/src/app/domains/pos/models/good.io.ts @@ -17,6 +17,7 @@ export interface IGoodRawResponse { is_default_guild_good: boolean; created_at: string; updated_at: string; + is_favorite: boolean; } export interface IGoodResponse extends IGoodRawResponse {} diff --git a/src/app/domains/pos/modules/landing/components/favorite-CTA.component.html b/src/app/domains/pos/modules/landing/components/favorite-CTA.component.html new file mode 100644 index 0000000..be4c90f --- /dev/null +++ b/src/app/domains/pos/modules/landing/components/favorite-CTA.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/domains/pos/modules/landing/components/favorite-CTA.component.ts b/src/app/domains/pos/modules/landing/components/favorite-CTA.component.ts new file mode 100644 index 0000000..9d35b71 --- /dev/null +++ b/src/app/domains/pos/modules/landing/components/favorite-CTA.component.ts @@ -0,0 +1,55 @@ +import { Component, inject, Input, signal } from '@angular/core'; +import { Button } from 'primeng/button'; +import { finalize, Observable } from 'rxjs'; +import { PosGoodFavoriteService } from '../services/favorite.service'; +import { PosLandingStore } from '../store/main.store'; + +@Component({ + selector: 'pos-good-favorite-cta', + templateUrl: 'favorite-CTA.component.html', + imports: [Button], +}) +export class FavoriteCTAComponent { + private readonly favoriteService = inject(PosGoodFavoriteService); + private readonly store = inject(PosLandingStore); + + @Input({ required: true }) id!: string; + @Input({ required: true }) isFavorite!: boolean; + @Input() size?: 'small' | 'large'; + + loading = signal(false); + + toggleFavorite(goodId: string, currentState: boolean) { + if (this.loading()) return; + if (currentState) { + this.detachFavorite(goodId); + } else { + this.attachFavorite(goodId); + } + } + + attachFavorite(goodId: string) { + this.handleFavoriteRequest(this.favoriteService.attach(goodId), goodId, true); + } + + detachFavorite(goodId: string) { + this.handleFavoriteRequest(this.favoriteService.detach(goodId), goodId, false); + } + + private handleFavoriteRequest( + request$: Observable, + goodId: string, + isFavorite: boolean, + ) { + this.loading.set(true); + request$ + .pipe( + finalize(() => { + this.loading.set(false); + }), + ) + .subscribe(() => { + this.store.toggleGoodFavorite(goodId, isFavorite); + }); + } +} diff --git a/src/app/domains/pos/modules/landing/components/grid-view.component.html b/src/app/domains/pos/modules/landing/components/grid-view.component.html index 69516e0..c84024b 100644 --- a/src/app/domains/pos/modules/landing/components/grid-view.component.html +++ b/src/app/domains/pos/modules/landing/components/grid-view.component.html @@ -7,7 +7,7 @@ } } @else { @for (good of visibleGoods(); track good.id) { -
+
* } -
- - +
+ +
diff --git a/src/app/domains/pos/modules/landing/components/grid-view.component.ts b/src/app/domains/pos/modules/landing/components/grid-view.component.ts index f81f620..568bc9e 100644 --- a/src/app/domains/pos/modules/landing/components/grid-view.component.ts +++ b/src/app/domains/pos/modules/landing/components/grid-view.component.ts @@ -13,26 +13,30 @@ import { ButtonDirective } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import images from 'src/assets/images'; import { PosLandingStore } from '../store/main.store'; +import { FavoriteCTAComponent } from './favorite-CTA.component'; @Component({ selector: 'pos-good-grid-view', templateUrl: './grid-view.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonDirective, Skeleton], + imports: [ButtonDirective, Skeleton, FavoriteCTAComponent], }) export class PosGoodsGridViewComponent { - private readonly store = inject(PosLandingStore); @Output() onAdd = new EventEmitter(); - goods = computed(() => this.store.filteredGoods()); + private readonly store = inject(PosLandingStore); private readonly pageSize = 80; readonly renderLimit = signal(this.pageSize); + + goodPlaceholder = images.placeholders.default; + + onFavItemsLoading = signal([]); + + goods = computed(() => this.store.filteredGoods()); visibleGoods = computed(() => this.goods()?.slice(0, this.renderLimit())); hasMore = computed(() => (this.goods()?.length || 0) > (this.visibleGoods()?.length || 0)); loading = computed(() => this.store.getGoodsLoading()); - goodPlaceholder = images.placeholders.default; - addProduct(good: IGoodResponse) { this.onAdd.emit(good); } diff --git a/src/app/domains/pos/modules/landing/components/list-view.component.html b/src/app/domains/pos/modules/landing/components/list-view.component.html index c92df9d..dc390f0 100644 --- a/src/app/domains/pos/modules/landing/components/list-view.component.html +++ b/src/app/domains/pos/modules/landing/components/list-view.component.html @@ -7,7 +7,7 @@ } } @else { @for (good of visibleGoods(); track good.id) { -
+
- + {{ good.name }} @if (!good.is_default_guild_good) { * @@ -27,8 +27,9 @@
-
- +
+ +
} diff --git a/src/app/domains/pos/modules/landing/components/list-view.component.ts b/src/app/domains/pos/modules/landing/components/list-view.component.ts index 6473798..33d45b8 100644 --- a/src/app/domains/pos/modules/landing/components/list-view.component.ts +++ b/src/app/domains/pos/modules/landing/components/list-view.component.ts @@ -13,26 +13,29 @@ import { ButtonDirective } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import images from 'src/assets/images'; import { PosLandingStore } from '../store/main.store'; +import { FavoriteCTAComponent } from './favorite-CTA.component'; @Component({ selector: 'pos-goods-list-view', templateUrl: './list-view.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonDirective, Skeleton], + imports: [ButtonDirective, Skeleton, FavoriteCTAComponent], }) export class PosGoodsListViewComponent { private readonly store = inject(PosLandingStore); + @Output() onAdd = new EventEmitter(); - goods = computed(() => this.store.filteredGoods()); + goodPlaceholder = images.placeholders.default; + private readonly pageSize = 120; readonly renderLimit = signal(this.pageSize); + + goods = computed(() => this.store.filteredGoods()); visibleGoods = computed(() => this.goods()?.slice(0, this.renderLimit())); hasMore = computed(() => (this.goods()?.length || 0) > (this.visibleGoods()?.length || 0)); loading = computed(() => this.store.getGoodsLoading()); - goodPlaceholder = images.placeholders.default; - addGood(good: IGoodResponse) { this.onAdd.emit(good); } diff --git a/src/app/domains/pos/modules/landing/constants/apiRoutes/favorite.ts b/src/app/domains/pos/modules/landing/constants/apiRoutes/favorite.ts new file mode 100644 index 0000000..654c7a6 --- /dev/null +++ b/src/app/domains/pos/modules/landing/constants/apiRoutes/favorite.ts @@ -0,0 +1,5 @@ +const baseUrl = (good_id: string) => `/api/v1/pos/goods/${good_id}/favorite`; + +export const POS_GOOD_FAVORITE_API_ROUTES = { + favorite: (good_id: string) => `${baseUrl(good_id)}`, +}; diff --git a/src/app/domains/pos/modules/landing/constants/apiRoutes/index.ts b/src/app/domains/pos/modules/landing/constants/apiRoutes/index.ts index 3add49b..f6ff62a 100644 --- a/src/app/domains/pos/modules/landing/constants/apiRoutes/index.ts +++ b/src/app/domains/pos/modules/landing/constants/apiRoutes/index.ts @@ -1,3 +1,4 @@ +export * from './favorite'; const baseUrl = '/api/v1/pos'; export const POS_API_ROUTES = { diff --git a/src/app/domains/pos/modules/landing/services/favorite.service.ts b/src/app/domains/pos/modules/landing/services/favorite.service.ts new file mode 100644 index 0000000..f473b67 --- /dev/null +++ b/src/app/domains/pos/modules/landing/services/favorite.service.ts @@ -0,0 +1,21 @@ +import { IPosInfoRawResponse, IPosInfoResponse } from '@/domains/pos/models/pos.io'; +import { IPosProfileRawResponse, IPosProfileResponse } from '@/domains/pos/models/profile.io'; +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { POS_GOOD_FAVORITE_API_ROUTES } from '../constants'; + +@Injectable({ providedIn: 'root' }) +export class PosGoodFavoriteService { + constructor(private http: HttpClient) {} + + private apiRoutes = POS_GOOD_FAVORITE_API_ROUTES; + + attach(good_id: string): Observable { + return this.http.post(this.apiRoutes.favorite(good_id), {}); + } + + detach(good_id: string): Observable { + return this.http.delete(this.apiRoutes.favorite(good_id)); + } +} diff --git a/src/app/domains/pos/modules/landing/store/main.store.ts b/src/app/domains/pos/modules/landing/store/main.store.ts index 19b9d32..058893e 100644 --- a/src/app/domains/pos/modules/landing/store/main.store.ts +++ b/src/app/domains/pos/modules/landing/store/main.store.ts @@ -5,12 +5,13 @@ import { IGoodCategoryResponse, IGoodResponse } from '@/domains/pos/models/good. import { CustomerType } from '@/shared/localEnum/constants/customerTypes'; import { createUUID, JALALI_DATE_FORMATS, nowJalali } from '@/utils'; import { computed, inject, Injectable, signal } from '@angular/core'; -import { catchError, finalize, map, throwError } from 'rxjs'; +import { catchError, finalize, firstValueFrom, map, throwError } from 'rxjs'; import { ICustomer, IPayment, IPosOrderRequest, IPosOrderResponse } from '../models'; import { IPosInOrderGood, IPosOrderItem } from '../models/types'; import { PosService } from '../services/main.service'; export type TViewType = 'list' | 'grid'; +const FAVORITE_CATEGORY_ID = 'favorite'; interface IPosLandingState { getGoodsLoading: boolean; @@ -74,6 +75,9 @@ export class PosLandingStore { if (!this.activeGoodCategory()) { return this.state$().goods; } + if (this.activeGoodCategory() === FAVORITE_CATEGORY_ID) { + return this.state$().goods?.filter((good) => good.is_favorite); + } return this.state$().goods?.filter((s) => s.category.id === this.state$().activeGoodCategory); }); readonly filteredGoods = computed(() => @@ -115,55 +119,38 @@ export class PosLandingStore { this.state$.set({ ...INITIAL_POS_STATE }); } - initial() { - this.getGoods(); - this.getGoodCategories(); + async initial() { + await Promise.all([this.getGoods(), this.getGoodCategories()]); + + this.prepareGoodCategories(); } fillInitial(data: Partial) { this.state$.set({ ...INITIAL_POS_STATE, ...data }); } - getGoods() { + private async getGoods() { this.setState({ getGoodsLoading: true }); - this.service.getGoods(this.state$().searchQuery).subscribe({ - next: (res) => { - this.setState({ goods: res.data, getGoodsLoading: false }); - }, - error: () => { - this.setState({ getGoodsLoading: false }); - }, - }); + try { + const res = await firstValueFrom(this.service.getGoods(this.state$().searchQuery)); + this.setState({ goods: res.data, getGoodsLoading: false }); + } catch { + this.setState({ getGoodsLoading: false }); + } } - getGoodCategories() { + private async getGoodCategories() { this.setState({ getGoodCategoriesLoading: true }); - this.service - .getGoodCategories() - .pipe( - map((res) => { - return [ - { - id: '', - name: 'همه', - goods_count: res.data.reduce((acc, curr) => acc + curr.goods_count, 0), - }, - ...res.data, - ]; - }), - ) - .subscribe({ - next: (res) => { - this.setState({ - goodCategories: res, - getGoodCategoriesLoading: false, - activeGoodCategory: res[0]?.id, - }); - }, - error: () => { - this.setState({ getGoodCategoriesLoading: false }); - }, + try { + const res = await firstValueFrom(this.service.getGoodCategories()); + + this.setState({ + goodCategories: res.data, + getGoodCategoriesLoading: false, }); + } catch { + this.setState({ getGoodCategoriesLoading: false }); + } } changeActiveCategory(categoryId: string) { @@ -210,6 +197,46 @@ export class PosLandingStore { this.setState({ searchQuery }); } + toggleGoodFavorite(goodId: string, newState: boolean) { + const updatedGoods = this.state$().goods?.map((good) => { + if (good.id === goodId) { + return { ...good, is_favorite: newState }; + } + return good; + }); + this.setState({ goods: updatedGoods || this.state$().goods }); + this.prepareGoodCategories(); + } + + private prepareGoodCategories() { + const rawCategories = + this.goodCategories()?.filter(({ id }) => id !== '' && id !== FAVORITE_CATEGORY_ID) || []; + const favoriteGoodsCount = this.goods()?.filter((good) => good.is_favorite).length || 0; + const goodsCount = this.goods()?.length || 0; + const preparedGoodCategories = rawCategories; + + if (favoriteGoodsCount > 0) { + preparedGoodCategories.unshift({ + id: FAVORITE_CATEGORY_ID, + name: 'علاقه‌مندی‌ها', + goods_count: favoriteGoodsCount, + }); + } + preparedGoodCategories.unshift({ + id: '', + name: 'همه', + goods_count: goodsCount, + }); + + this.setState({ + goodCategories: preparedGoodCategories, + }); + + if (!this.activeGoodCategory()) { + this.setState({ activeGoodCategory: '' }); + } + } + filterGoods() {} resetInOrderGoods() { diff --git a/src/environments/environment.tis.ts b/src/environments/environment.tis.ts index 0ac90f5..8873f90 100644 --- a/src/environments/environment.tis.ts +++ b/src/environments/environment.tis.ts @@ -1,8 +1,8 @@ // TIS tenant environment configuration export const environment = { production: true, - apiBaseUrl: 'https://psp-api.shift-am.ir', - // apiBaseUrl: 'http://192.168.128.73:5002', + // apiBaseUrl: 'https://psp-api.shift-am.ir', + apiBaseUrl: 'http://192.168.128.73:5002', host: 'localhost', port: 5000, enableLogging: false,