· 4 years ago · Aug 09, 2021, 02:24 AM
1import { Cart, ShippingCouriers } from 'api';
2import { isEmpty, maxBy, minBy, startCase } from 'lodash';
3import React, {
4 useCallback,
5 useEffect,
6 useMemo,
7 useRef,
8 useState,
9} from 'react';
10import { Card, Col, Form, Row } from 'react-bootstrap';
11import { useTranslation } from 'react-i18next';
12import { useQuery, useMutation } from 'react-query';
13import api from '../../../api/api';
14import useCartStore from '../../../stores/cart';
15import { date } from '../../../utils/formatter';
16import { SCard, ShippingSelect } from './atoms';
17import CartItem from './CartItem';
18import { CurrencyFormat, number } from '../../../components/formatter';
19import { notifyErrorAPI } from '../../../components/notif';
20import dayjs from 'dayjs';
21import { OptionType } from '../../../components/forms/Select';
22import styled from '@emotion/styled';
23import { FaInfoCircle } from 'react-icons/fa';
24import { OverlayTrigger, Tooltip } from 'react-bootstrap';
25import { Global } from '@emotion/react';
26import classNames from 'classnames';
27
28enum DurationValue {
29 INSTANT,
30 NEXT_DAY,
31 REGULAR,
32}
33
34interface Duration extends OptionType {
35 value: DurationValue;
36 fee: string;
37 estReceivedDate?: string;
38 couriers: ShippingCouriers.ShippingCourier[];
39 info?: React.ReactElement;
40}
41
42export default function CartItemGroup({ group }: { group?: Cart.Group }) {
43 const { t } = useTranslation('cart_page');
44 return (
45 <SCard className="mb-4" data-cy="CartItemGroup-Card">
46 <Card.Header>
47 <Card.Title className="reg-14 reg-14-md">
48 {`${t('estimation_deliv')} ${date(group?.finishTime)} ${t('from')} `}
49 <span className="bold-14 bold-16-md">{group.source.name}</span>
50 </Card.Title>
51 </Card.Header>
52 <Card.Body>
53 <Shipping group={group} />
54 {group.cartItems.map((cartItem) => {
55 return <CartItem key={cartItem.id} item={cartItem} />;
56 })}
57 </Card.Body>
58 </SCard>
59 );
60}
61
62const getMinMax = <T, K extends keyof T>(values: T[], key: K): [any, any] => {
63 const min = minBy(values, key);
64 const max = maxBy(values, key);
65 return [min[key], max[key]];
66};
67
68const CourierOption = ({
69 innerProps,
70 data,
71 isSelected,
72}: {
73 innerProps: any;
74 data: Duration;
75 isSelected: boolean;
76}) => {
77 const { t } = useTranslation('cart_page');
78
79 return (
80 <Courier
81 {...innerProps}
82 className={classNames('px-3 py-2', !data.fee && 'disabled', isSelected && 'selected')}
83 >
84 <div>
85 <label className="med-14">
86 {data.label}
87 {data.info && (
88 <OverlayTrigger
89 placement="bottom"
90 overlay={
91 <Tooltip show id={`courier-info-${data.value}`}>
92 {data.info}
93 </Tooltip>
94 }
95 >
96 <FaInfoCircle color="var(--n-60)" className="ml-2" />
97 </OverlayTrigger>
98 )}
99 </label>
100 <span className="reg-14">{data.fee}</span>
101 </div>
102 <span className="reg-12">{`${t('estimated_date_of_receipt')} ${
103 data.estReceivedDate
104 }`}</span>
105 </Courier>
106 );
107};
108
109function Shipping({ group }: { group?: Cart.Group }) {
110 const { t } = useTranslation('cart_page');
111 const { cart, setCart } = useCartStore();
112 const [openCount, setOpenCount] = useState(0);
113 const [durationOptions, setDurationOptions] = useState<Duration[]>([]);
114 const [selectedDuration, setSelectedDuration] = useState<Duration>();
115 const [selectedCourier, setSelectedCourier] = useState<any>();
116 const isLoaded = useRef(false);
117 const { isLoading, error } = useQuery(
118 [`shipping-couriers-group-${group.id}`, { openCount }],
119 () =>
120 api.get<ShippingCouriers.Response>(
121 `/cart-groups/${group.id}/shipping-couriers`
122 ),
123 {
124 enabled: openCount > 0,
125 cacheTime: 30 * 1000, //30 sec
126 onSuccess: (response) => {
127 const couriers = (response?.data?.data || []).sort(
128 (a, b) => b.fee - a.fee
129 );
130 const durations: Duration[] = [];
131
132 const instantCouriers = couriers.filter(
133 (courier) => courier.speedUnit === 'hours'
134 );
135 const instantEst = dayjs(group.finishTime).format('D MMM');
136 const instant: Duration = {
137 value: DurationValue.INSTANT,
138 label: `${t('instant_duration', { value: 3 })}`,
139 fee: '',
140 estReceivedDate: instantEst,
141 couriers: instantCouriers,
142 info: (
143 <ul className="pl-1 my-1 ml-3 text-left">
144 <li>{t('instant_courier_info_1')}</li>
145 <li>{t('instant_courier_info_2')}</li>
146 <li>{t('instant_courier_info_3')}</li>
147 </ul>
148 ),
149 };
150 if (instantCouriers.length) {
151 const [instantDuration, instantFee] = getDurationValue(
152 instantCouriers
153 );
154 instant.label = `${t('instant_duration', {
155 value: instantDuration,
156 })}`;
157 instant.fee = instantFee;
158 }
159 durations.push(instant);
160
161 const nextDayCouriers = couriers.filter(
162 (courier) => courier.speedUnit === 'days' && courier.speed === 1
163 );
164 const nextDayEst = dayjs(group.finishTime)
165 .add(1, 'day')
166 .format('D MMM');
167 const nextDay: Duration = {
168 value: DurationValue.NEXT_DAY,
169 label: `${t('next_day_duration')}`,
170 estReceivedDate: nextDayEst,
171 fee: '',
172 couriers: nextDayCouriers,
173 };
174 if (nextDayCouriers.length) {
175 const [, nextDayFee] = getDurationValue(nextDayCouriers);
176 nextDay.fee = nextDayFee;
177 }
178 durations.push(nextDay);
179
180 const regularCouriers = couriers.filter(
181 (courier) => courier.speedUnit === 'days' && courier.speed > 1
182 );
183 const regular: Duration = {
184 value: DurationValue.REGULAR,
185 label: `${t('regular')}`,
186 fee: '',
187 estReceivedDate: '',
188 couriers: regularCouriers,
189 };
190 if (regularCouriers.length) {
191 const [regularDuration, regularFee, regularEst] = getDurationValue(
192 regularCouriers
193 );
194 regular.label = `${t('regular_duration', {
195 value: regularDuration,
196 })}`;
197 regular.fee = regularFee;
198 regular.estReceivedDate = regularEst;
199 }
200 durations.push(regular);
201 setDurationOptions(durations);
202 },
203 }
204 );
205 const selectCourM = useMutation(
206 (shippingCourierId: number | string) =>
207 api.put(`/cart-groups/${group.id}/shipping-couriers`, {
208 shippingCourierId,
209 }),
210 {
211 mutationKey: 'cart-set-shipping-courier',
212 onSuccess,
213 onError: notifyErrorAPI,
214 }
215 );
216
217 function onSuccess(response) {
218 setCart(response.data?.data);
219 }
220
221 const getDurationValue = useCallback(
222 (
223 shippingCouriers: ShippingCouriers.ShippingCourier[]
224 ): [string, string, string] => {
225 const [minDuration, maxDuration] = getMinMax(shippingCouriers, 'speed');
226 let duration = `${minDuration}`;
227 if (minDuration < maxDuration) {
228 duration += ` - ${maxDuration}`;
229 }
230 const [minFee, maxFee] = getMinMax(shippingCouriers, 'fee');
231 let fee = `Rp ${number(minFee)}`;
232 if (minFee < maxFee) {
233 fee = `${t('start')} Rp ${number(minFee)}`;
234 }
235 const minEst = dayjs(group.finishTime).add(minDuration, 'day');
236 const maxEst = dayjs(group.finishTime).add(maxDuration, 'day');
237 let estimation = dayjs(maxEst).format('D MMM');
238 if (minEst < maxEst) {
239 estimation = `${dayjs(minEst).format('D')} - ${estimation}`;
240 }
241 return [duration, fee, estimation];
242 },
243 [group.finishTime, t]
244 );
245
246 const courierTransform = useCallback(
247 (shippingCourier: ShippingCouriers.ShippingCourier) => {
248 const { id, name, service, fee } = shippingCourier;
249 return {
250 value: id,
251 label: `${name} ${startCase(service)}`,
252 fee: `Rp ${number(fee)}`,
253 estReceivedDate: selectedDuration?.estReceivedDate,
254 };
255 },
256 [selectedDuration]
257 );
258
259 useEffect(() => {
260 if (!isEmpty(group.shippingCourier) && !isLoaded.current) {
261 setOpenCount(1);
262 setSelectedCourier(courierTransform(group.shippingCourier));
263 if (durationOptions.length) {
264 const { speedUnit, speed } = group.shippingCourier;
265 let value =
266 speedUnit === 'hours'
267 ? DurationValue.INSTANT
268 : DurationValue.NEXT_DAY;
269 if (speedUnit === 'days' && speed > 1) {
270 value = DurationValue.REGULAR;
271 }
272 const duration = durationOptions.find(
273 (option) => option.value === value
274 );
275 setSelectedDuration(duration);
276 isLoaded.current = true;
277 }
278 }
279 }, [courierTransform, durationOptions, group.shippingCourier, t]);
280
281 useEffect(() => {
282 if (isEmpty(group.shippingCourier)) {
283 setDurationOptions([]);
284 setSelectedDuration(null);
285 setSelectedCourier(null);
286 }
287 }, [group.shippingCourier]);
288
289 const courierOptions = useMemo(() => {
290 if (!selectedDuration) {
291 return [];
292 }
293 return selectedDuration.couriers.map((courier) =>
294 courierTransform(courier)
295 );
296 }, [courierTransform, selectedDuration]);
297
298 const selectDuration = (duration: Duration) => {
299 if (duration.fee) {
300 setSelectedDuration(duration);
301 const courier = [...duration.couriers].pop();
302 setSelectedCourier(courierTransform(courier));
303 selectCourM.mutate(courier.id);
304 }
305 };
306
307 return (
308 <Row>
309 <Col>
310 <Global
311 styles={{
312 '#courier-info-0 .tooltip-inner': {
313 maxWidth: 300,
314 },
315 }}
316 />
317 <ShippingSelect
318 className="shipping-select test-select-shipping"
319 options={isLoading || error ? [] : durationOptions}
320 onChange={selectDuration}
321 isLoading={isLoading}
322 isDisabled={isLoading || isEmpty(cart.shippingAddress)}
323 onMenuOpen={() => setOpenCount((openCount) => openCount + 1)}
324 value={selectedDuration}
325 placeholder={
326 isEmpty(cart.shippingAddress)
327 ? t('add_or_select_address')
328 : t('choose_delivery_duration')
329 }
330 label={<div className="reg-12 reg-14-md">{t('duration')}</div>}
331 labelSize={3}
332 // @ts-ignore
333 noOptionsMessage={() => error?.response?.data?.messages?.[0]}
334 components={{ Option: (props) => CourierOption({
335 ...props,
336 isSelected: props.data?.value === selectedDuration?.value,
337 }) }}
338 />
339 {selectedDuration && (
340 <ShippingSelect
341 className="shipping-select test-select-shipping"
342 options={isLoading || error ? [] : courierOptions}
343 onChange={(opt) => {
344 isLoaded.current = false;
345 selectCourM.mutate(opt.value);
346 }}
347 isLoading={isLoading}
348 isDisabled={isLoading || isEmpty(cart.shippingAddress)}
349 value={selectedCourier}
350 label={
351 <div className="reg-12 reg-14-md">{t('selected_courier')}</div>
352 }
353 labelSize={3}
354 // @ts-ignore
355 noOptionsMessage={() => error?.response?.data?.messages?.[0]}
356 components={{ Option: (props) => CourierOption({
357 ...props,
358 isSelected: props.data?.value === selectedCourier?.value,
359 }) }}
360 />
361 )}
362 <Form.Row className="mb-2">
363 <Form.Label column sm={3} xs={4} className="reg-12 reg-14-md">
364 {t('shipping_fee')}
365 </Form.Label>
366 <Col
367 sm={9}
368 xs={8}
369 className="pl-3 pt-1 d-flex flex-column flex-md-row justify-content-between"
370 >
371 <CurrencyFormat
372 className="med-12 med-14-md"
373 value={
374 isEmpty(cart.shippingAddress)
375 ? 0
376 : group?.shippingCourier?.fee || 0
377 }
378 />
379 {selectedDuration && (
380 <span>
381 {t('estimated_date_of_receipt')}
382 <span className="med-12 med-14-md pl-1">
383 {selectedDuration.estReceivedDate}
384 </span>
385 </span>
386 )}
387 </Col>
388 </Form.Row>
389 </Col>
390 </Row>
391 );
392}
393
394const Courier = styled.div`
395 cursor: pointer;
396
397 &:hover, &.selected {
398 background-color: var(--b-10);
399 }
400
401 &.disabled {
402 cursor: not-allowed;
403 opacity: 0.5;
404 }
405
406 > div {
407 display: flex;
408 justify-content: space-between;
409
410 @media (max-width: 576px) {
411 flex-direction: column;
412 justify-content: flex-start;
413
414 label {
415 margin-bottom: 0;
416 }
417 }
418
419 label,
420 span {
421 color: var(--n-90);
422 }
423 }
424
425 > span {
426 color: var(--n-70);
427 }
428`;
429