feat: add favorite functionality for goods; implement favorite button and service integration

This commit is contained in:
2026-05-20 11:55:30 +03:30
parent 2e1ad77946
commit c5e1fab09b
12 changed files with 185 additions and 58 deletions
+1
View File
@@ -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 {}
@@ -0,0 +1,9 @@
<p-button
type="button"
[icon]="`pi pi-${isFavorite ? 'star-fill' : 'star'}`"
class="shrink-0"
[size]="size"
[loading]="loading()"
[severity]="isFavorite ? 'warn' : 'secondary'"
(click)="toggleFavorite(id, isFavorite)"
></p-button>
@@ -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<unknown>,
goodId: string,
isFavorite: boolean,
) {
this.loading.set(true);
request$
.pipe(
finalize(() => {
this.loading.set(false);
}),
)
.subscribe(() => {
this.store.toggleGoodFavorite(goodId, isFavorite);
});
}
}
@@ -7,7 +7,7 @@
}
} @else {
@for (good of visibleGoods(); track good.id) {
<div class="flex-1 bg-surface-card rounded-lg p-1 shadow-xs flex flex-col" (click)="addProduct(good)">
<div class="flex-1 bg-surface-card rounded-lg p-1 shadow-xs flex flex-col">
<img
[src]="good.image_url || goodPlaceholder"
loading="lazy"
@@ -21,9 +21,9 @@
<small class="text-xs text-muted-color">*</small>
}
</span>
<div class="flex items-center justify-between mt-1 w-full px-2 my-2 shrink-0">
<!-- <span [appPriceMask]="good.salePrice" class="text-base font-bold"></span> -->
<button pButton type="button" icon="pi pi-plus" class="w-full!">انتخاب</button>
<div class="flex items-center justify-between mt-1 w-full px-2 my-2 shrink-0 gap-1">
<button pButton type="button" label="انتخاب" class="grow w-full" (click)="addProduct(good)"></button>
<pos-good-favorite-cta [isFavorite]="good.is_favorite" [id]="good.id" />
</div>
</div>
</div>
@@ -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<IGoodResponse>();
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<string[]>([]);
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);
}
@@ -7,7 +7,7 @@
}
} @else {
@for (good of visibleGoods(); track good.id) {
<div class="flex items-center bg-surface-card rounded-lg p-1 shadow-xs gap-2">
<div class="flex items-center bg-surface-card rounded-lg py-1 px-3 shadow-xs gap-2">
<div class="grow flex items-center gap-2">
<img
[src]="good.image_url || goodPlaceholder"
@@ -16,7 +16,7 @@
class="w-12 h-12 object-cover rounded-md"
/>
<div class="flex flex-col gap-2">
<span class="text-lg font-bold">
<span class="text-base font-bold">
{{ good.name }}
@if (!good.is_default_guild_good) {
<small class="text-xs text-muted-color">*</small>
@@ -27,8 +27,9 @@
</span>
</div>
</div>
<div class="shrink-0 flex items-center justify-between gap-3">
<button pButton type="button" icon="pi pi-plus" size="small" (click)="addGood(good)">انتخاب</button>
<div class="shrink-0 flex items-center justify-between gap-1">
<pos-good-favorite-cta [isFavorite]="good.is_favorite" [id]="good.id" size="small" />
<button pButton type="button" label="انتخاب" class="w-28" size="small" (click)="addGood(good)"></button>
</div>
</div>
}
@@ -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<IGoodResponse>();
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);
}
@@ -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)}`,
};
@@ -1,3 +1,4 @@
export * from './favorite';
const baseUrl = '/api/v1/pos';
export const POS_API_ROUTES = {
@@ -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<IPosProfileResponse> {
return this.http.post<IPosProfileRawResponse>(this.apiRoutes.favorite(good_id), {});
}
detach(good_id: string): Observable<IPosInfoResponse> {
return this.http.delete<IPosInfoRawResponse>(this.apiRoutes.favorite(good_id));
}
}
@@ -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<IPosLandingState>) {
this.state$.set({ ...INITIAL_POS_STATE, ...data });
}
getGoods() {
private async getGoods() {
this.setState({ getGoodsLoading: true });
this.service.getGoods(this.state$().searchQuery).subscribe({
next: (res) => {
try {
const res = await firstValueFrom(this.service.getGoods(this.state$().searchQuery));
this.setState({ goods: res.data, getGoodsLoading: false });
},
error: () => {
} 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) => {
try {
const res = await firstValueFrom(this.service.getGoodCategories());
this.setState({
goodCategories: res,
goodCategories: res.data,
getGoodCategoriesLoading: false,
activeGoodCategory: res[0]?.id,
});
},
error: () => {
} 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() {
+2 -2
View File
@@ -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,