Skip to content

Commit a910c12

Browse files
authored
Merge pull request #210 from BinaryStudioAcademy/feature/leave-auction
Feature/leave auction
2 parents 746d369 + 8eb3266 commit a910c12

File tree

35 files changed

+378
-23
lines changed

35 files changed

+378
-23
lines changed

packages/backend/src/api/routes/product.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,25 @@ export const initProductRoutes = (
6060
wrap((req: Request) => productService.getFavoriteIds(req.userId)),
6161
);
6262

63+
router.get(
64+
apiPath(path, ProductApiRoutes.AUCTION_PERMISSIONS),
65+
authMiddleware,
66+
wrap((req: Request) =>
67+
productService.getAuctionPermissions(
68+
req.userId,
69+
<string>req.query.productId,
70+
),
71+
),
72+
);
73+
74+
router.post(
75+
apiPath(path, ProductApiRoutes.AUCTION_LEAVE),
76+
authMiddleware,
77+
wrap((req: Request) =>
78+
productService.leaveAuction(req.userId, <string>req.query.productId),
79+
),
80+
);
81+
6382
/**
6483
* @openapi
6584
* /products/{type}:
@@ -93,7 +112,7 @@ export const initProductRoutes = (
93112

94113
router.get(
95114
apiPath(path, ProductApiRoutes.ID),
96-
wrap((req) => productService.getById(req)),
115+
wrap((req: Request) => productService.getById(req.params.id)),
97116
);
98117

99118
router.put(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { AuctionPermissionsResponse } from '@vse-bude/shared';
2+
3+
export const auctionPermissionsMapper = (
4+
isAbleToLeave: boolean,
5+
): AuctionPermissionsResponse => ({
6+
isAbleToLeaveAuction: isAbleToLeave,
7+
});

packages/backend/src/mapper/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export * from './response';
2+
export * from './auction-permissions';
3+
export * from './product';
+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { Product } from '@prisma/client';
2+
3+
export const productMapper = (productData: Product, currentPrice: number) => ({
4+
...productData,
5+
currentPrice,
6+
});

packages/backend/src/repositories/bid.ts

+18
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,22 @@ export class BidRepository {
1717
},
1818
});
1919
}
20+
21+
async getByUserAndProduct(userId: string, productId: string) {
22+
return this._dbClient.bid.findMany({
23+
where: {
24+
bidderId: userId,
25+
productId: productId,
26+
},
27+
});
28+
}
29+
30+
async deleteAllByProductAndUser(userId: string, productId: string) {
31+
return this._dbClient.bid.deleteMany({
32+
where: {
33+
bidderId: userId,
34+
productId: productId,
35+
},
36+
});
37+
}
2038
}

packages/backend/src/services/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const initServices = (repositories: Repositories) => {
5050
productService: new ProductService(
5151
repositories.productRepository,
5252
verifyService,
53+
repositories.bidRepository,
5354
),
5455
newsService: new NewsService(repositories.newsRepository),
5556
healthService: new HealthService(repositories.healthRepository),

packages/backend/src/services/product.ts

+54-13
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,55 @@
11
import type { ProductRepository } from '@repositories';
22
import type { ProductQuery } from '@types';
3-
import { ProductNotFoundError } from '@errors';
3+
import { AuctionEndedError, ProductNotFoundError } from '@errors';
44
import type { Request } from 'express';
5-
import { getUserIdFromRequest } from '@helpers';
5+
import { getUserIdFromRequest, toUtc } from '@helpers';
66
import type {
77
AddProductToFavorites,
8+
AuctionPermissionsResponse,
89
BuyProduct,
910
DeleteProductFromFavorites,
1011
} from '@vse-bude/shared';
12+
import type { Bid } from '@prisma/client';
1113
import { ProductStatus } from '@prisma/client';
1214
import type { VerifyService } from '@services';
15+
import type { BidRepository } from '@repositories';
16+
import { productMapper } from '@mappers';
17+
import { auctionPermissionsMapper } from '../mapper/auction-permissions';
1318

1419
export class ProductService {
1520
private _productRepository: ProductRepository;
1621

22+
private _bidRepository: BidRepository;
23+
1724
private _verifyService: VerifyService;
1825

1926
constructor(
20-
categoryRepository: ProductRepository,
27+
productRepository: ProductRepository,
2128
verifyService: VerifyService,
29+
bidRepository: BidRepository,
2230
) {
23-
this._productRepository = categoryRepository;
31+
this._productRepository = productRepository;
2432
this._verifyService = verifyService;
33+
this._bidRepository = bidRepository;
2534
}
2635

2736
public getAll(query: ProductQuery) {
2837
return this._productRepository.getAll(query);
2938
}
3039

31-
public async getById(req: Request) {
32-
const { id } = req.params;
33-
const product = await this._productRepository.getById(id);
40+
public async getById(productId: string) {
41+
const product = await this._productRepository.getById(productId);
3442
if (!product) {
3543
throw new ProductNotFoundError();
3644
}
37-
38-
product.category.title = req.t(`categories.${product.category.title}`);
45+
// TODO: fix translation after localization refactor
46+
// product.category.title = req.t(`categories.${product.category.title}`);
3947

4048
const currentPrice = await this._productRepository.getCurrentPrice(
4149
product.id,
4250
);
4351

44-
return {
45-
...product,
46-
currentPrice: currentPrice,
47-
};
52+
return productMapper(product, +currentPrice);
4853
}
4954

5055
public async incrementViews(id: string, req: Request) {
@@ -71,6 +76,42 @@ export class ProductService {
7176
return this._productRepository.getFavorite(userId);
7277
}
7378

79+
public async getAuctionPermissions(
80+
userId: string,
81+
productId: string,
82+
): Promise<AuctionPermissionsResponse> {
83+
const product = await this._productRepository.getById(productId);
84+
if (!product) {
85+
throw new ProductNotFoundError();
86+
}
87+
88+
if (toUtc(product.endDate) < toUtc()) {
89+
throw new AuctionEndedError();
90+
}
91+
92+
const bids: Bid[] = await this._bidRepository.getByUserAndProduct(
93+
userId,
94+
productId,
95+
);
96+
97+
return auctionPermissionsMapper(!!bids.length);
98+
}
99+
100+
public async leaveAuction(userId: string, productId: string) {
101+
const product = await this._productRepository.getById(productId);
102+
if (!product) {
103+
throw new ProductNotFoundError();
104+
}
105+
106+
if (toUtc(product.endDate) < toUtc()) {
107+
throw new AuctionEndedError();
108+
}
109+
110+
await this._bidRepository.deleteAllByProductAndUser(userId, productId);
111+
112+
return this.getById(productId);
113+
}
114+
74115
public async addToFavorites({ userId, productId }: AddProductToFavorites) {
75116
const isInFavorite = await this._productRepository.isInFavorite(
76117
userId,

packages/frontend/common/types/store/store.ts

+2
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import type { CategoryState } from 'store/category';
33
import type { ProductState } from 'store/product';
44
import type { ProfileState } from 'store/profile/reducer';
55
import type { FavoriteProductState } from 'store/favorite-product';
6+
import type { ProductAuctionState } from 'store/product-auction';
67

78
export interface RootState {
89
auth: AuthState;
910
profile: ProfileState;
1011
category: CategoryState;
1112
product: ProductState;
1213
favoriteProduct: FavoriteProductState;
14+
auction: ProductAuctionState;
1315
}

packages/frontend/components/item/item-info-auction/component.tsx

+51
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@ import { useTranslation } from 'next-i18next';
55
import type { SubmitHandler } from 'react-hook-form';
66
import { useForm } from 'react-hook-form';
77
import { joiResolver } from '@hookform/resolvers/joi';
8+
import { useAppDispatch, useTypedSelector } from '@hooks';
9+
import { useState } from 'react';
810
import { CountDownTimer } from '../countdown-timer/component';
911
import { ItemTitle, ItemInfo, ItemPrice } from '../item-info';
1012
import { minBidValidation } from '../validation';
13+
import { ConfirmationModal } from '../../modal/confirm/component';
14+
import {
15+
auctionLeaveAction,
16+
auctionPermissions,
17+
} from '../../../store/product-auction';
1118
import * as styles from './styles';
1219

1320
interface ItemInfoAuctionProps {
@@ -23,11 +30,20 @@ export const ItemInfoAuction = ({
2330
onBid,
2431
onChangeIsFavorite,
2532
}: ItemInfoAuctionProps) => {
33+
const [confirmModalVisible, setModalVisible] = useState(false);
34+
2635
const { t } = useTranslation('item');
2736

37+
const dispatch = useAppDispatch();
38+
2839
const targetDate = new Date(item.endDate);
2940
const minBidAmount = +item.currentPrice + +item.minimalBid + 1;
3041

42+
const {
43+
permissions: { isAbleToLeaveAuction },
44+
} = useTypedSelector((state) => state.auction);
45+
const { user } = useTypedSelector((state) => state.auth);
46+
3147
const {
3248
register,
3349
handleSubmit,
@@ -40,6 +56,23 @@ export const ItemInfoAuction = ({
4056
onBid(data);
4157
};
4258

59+
const onCancel = () => {
60+
setModalVisible(false);
61+
};
62+
63+
const confirmLeave = () => {
64+
setModalVisible(true);
65+
};
66+
67+
const onLeaveAuction = async () => {
68+
const reqData = {
69+
productId: item.id,
70+
};
71+
await dispatch(auctionLeaveAction(reqData));
72+
await dispatch(auctionPermissions(reqData));
73+
onCancel();
74+
};
75+
4376
return (
4477
<div css={styles.wrapper}>
4578
<div css={styles.priceTimerWrapper}>
@@ -79,6 +112,24 @@ export const ItemInfoAuction = ({
79112
></FavoriteButton>
80113
</div>
81114
</form>
115+
{!!isAbleToLeaveAuction && user && (
116+
<div css={styles.leaveAuctionBlock}>
117+
<Button
118+
onClick={confirmLeave}
119+
variant="danger"
120+
tooltip={t('leave.tooltip')}
121+
>
122+
{t('leave.btnText')}
123+
</Button>
124+
</div>
125+
)}
126+
{!!isAbleToLeaveAuction && confirmModalVisible && (
127+
<ConfirmationModal
128+
onClose={onCancel}
129+
onConfirm={onLeaveAuction}
130+
text={t('leave.confirmText')}
131+
/>
132+
)}
82133
</div>
83134
);
84135
};

packages/frontend/components/item/item-info-auction/styles.ts

+6
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ export const priceWrapper = ({
118118
}
119119
`;
120120

121+
export const leaveAuctionBlock = css`
122+
display: flex;
123+
margin-top: 10px;
124+
justify-content: end;
125+
`;
126+
121127
export const buttons = css`
122128
display: flex;
123129
align-items: center;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Button, Modal } from '@primitives';
2+
import { useTranslation } from 'next-i18next';
3+
import { actionBtns, header, mainText } from './styles';
4+
5+
export interface ConfirmationModalProps {
6+
onClose: () => void;
7+
onConfirm: () => void;
8+
text: string;
9+
}
10+
11+
export const ConfirmationModal = ({
12+
text,
13+
onClose,
14+
onConfirm,
15+
}: ConfirmationModalProps) => {
16+
const { t } = useTranslation('common');
17+
18+
return (
19+
<Modal visible={true}>
20+
<div css={header}>{t('modal.confirm.header')}</div>
21+
<div css={mainText}>{text}</div>
22+
<div css={actionBtns}>
23+
<Button onClick={onConfirm} variant="danger" size="small">
24+
{t('modal.confirm.confirm')}
25+
</Button>
26+
<Button onClick={onClose} size="small">
27+
{t('modal.confirm.cancel')}
28+
</Button>
29+
</div>
30+
</Modal>
31+
);
32+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { css } from '@emotion/react';
2+
import type { Theme } from '../../../theme';
3+
4+
export const header = ({ fontSizes }: Theme) => css`
5+
font-size: ${fontSizes.h4};
6+
`;
7+
8+
export const mainText = ({ fontSizes, fontWeights, colors }: Theme) => css`
9+
text-align: center;
10+
font-weight: ${fontWeights.h4};
11+
font-size: ${fontSizes.h6};
12+
color: ${colors.extraDark};
13+
padding: 20px 0;
14+
border-top: 1px solid ${colors.textLight};
15+
`;
16+
17+
export const actionBtns = css`
18+
display: flex;
19+
justify-content: center;
20+
& button:first-child {
21+
margin-right: 10px;
22+
}
23+
`;

packages/frontend/components/primitives/button/styles.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { css } from '@emotion/react';
2-
import type { Theme } from '@emotion/react';
2+
import type { Theme } from 'theme';
33
import { resetButton } from 'theme';
44

55
export const button = ({
@@ -35,6 +35,11 @@ export const button = ({
3535
}
3636
}
3737
38+
&[data-variant='danger'] {
39+
background: ${colors.danger};
40+
color: ${colors.white};
41+
}
42+
3843
&[data-variant='outlined'] {
3944
border: 1px solid ${colors.secondaryLight};
4045
background: white;

packages/frontend/components/primitives/button/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export interface ButtonProps
44
'size' | 'className' | 'style' | 'type'
55
> {
66
type?: 'button' | 'submit' | 'reset';
7-
variant?: 'filled' | 'outlined';
7+
variant?: 'filled' | 'outlined' | 'danger';
88
size?: 'big' | 'small' | 'flexible';
99
width?: string;
1010
tooltip?: string;

0 commit comments

Comments
 (0)