Skip to content

Commit 209a144

Browse files
authored
Feature: Handle abandoned carts gracefully (#862)
1 parent 5e08a8c commit 209a144

File tree

40 files changed

+1904
-3327
lines changed

40 files changed

+1904
-3327
lines changed

backend/app/DomainObjects/Status/OrderStatus.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ enum OrderStatus
1212
case CANCELLED;
1313
case COMPLETED;
1414
case AWAITING_OFFLINE_PAYMENT;
15+
case ABANDONED;
1516

1617
public static function getHumanReadableStatus(string $status): string
1718
{
@@ -20,6 +21,7 @@ public static function getHumanReadableStatus(string $status): string
2021
self::CANCELLED->name => __('Cancelled'),
2122
self::COMPLETED->name => __('Completed'),
2223
self::AWAITING_OFFLINE_PAYMENT->name => __('Awaiting offline payment'),
24+
self::ABANDONED->name => __('Abandoned'),
2325
};
2426
}
2527
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace HiEvents\Http\Actions\Orders\Public;
4+
5+
use HiEvents\Exceptions\ResourceConflictException;
6+
use HiEvents\Exceptions\UnauthorizedException;
7+
use HiEvents\Http\Actions\BaseAction;
8+
use HiEvents\Resources\Order\OrderResourcePublic;
9+
use HiEvents\Services\Application\Handlers\Order\Public\AbandonOrderPublicHandler;
10+
use Illuminate\Http\JsonResponse;
11+
use Symfony\Component\HttpFoundation\Response;
12+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
13+
14+
class AbandonOrderActionPublic extends BaseAction
15+
{
16+
public function __construct(
17+
private readonly AbandonOrderPublicHandler $abandonOrderPublicHandler,
18+
)
19+
{
20+
}
21+
22+
public function __invoke(int $eventId, string $orderShortId): JsonResponse
23+
{
24+
try {
25+
$order = $this->abandonOrderPublicHandler->handle($orderShortId);
26+
27+
return $this->resourceResponse(
28+
resource: OrderResourcePublic::class,
29+
data: $order,
30+
);
31+
} catch (ResourceNotFoundException $e) {
32+
return $this->errorResponse($e->getMessage(), Response::HTTP_NOT_FOUND);
33+
} catch (ResourceConflictException $e) {
34+
return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT);
35+
} catch (UnauthorizedException $e) {
36+
return $this->errorResponse($e->getMessage(), Response::HTTP_UNAUTHORIZED);
37+
}
38+
}
39+
}

backend/app/Repository/Eloquent/OrderRepository.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
2525
$where = [
2626
[OrderDomainObjectAbstract::EVENT_ID, '=', $eventId],
2727
[OrderDomainObjectAbstract::STATUS, '!=', OrderStatus::RESERVED->name],
28+
[OrderDomainObjectAbstract::STATUS, '!=', OrderStatus::ABANDONED->name],
2829
];
2930

3031
if ($params->query) {
@@ -64,6 +65,7 @@ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsD
6465
{
6566
$where = [
6667
['orders.status', '!=', OrderStatus::RESERVED->name],
68+
['orders.status', '!=', OrderStatus::ABANDONED->name],
6769
];
6870

6971
if ($params->query) {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
namespace HiEvents\Services\Application\Handlers\Order\Public;
4+
5+
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
6+
use HiEvents\DomainObjects\OrderDomainObject;
7+
use HiEvents\DomainObjects\Status\OrderStatus;
8+
use HiEvents\Exceptions\ResourceConflictException;
9+
use HiEvents\Exceptions\UnauthorizedException;
10+
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
11+
use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService;
12+
use Illuminate\Log\Logger;
13+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
14+
15+
class AbandonOrderPublicHandler
16+
{
17+
public function __construct(
18+
private readonly OrderRepositoryInterface $orderRepository,
19+
private readonly CheckoutSessionManagementService $sessionService,
20+
private readonly Logger $logger,
21+
)
22+
{
23+
}
24+
25+
/**
26+
* @throws ResourceConflictException
27+
*/
28+
public function handle(string $orderShortId): OrderDomainObject
29+
{
30+
$order = $this->orderRepository->findByShortId($orderShortId);
31+
32+
if (!$order) {
33+
throw new ResourceNotFoundException(__('Order not found'));
34+
}
35+
36+
if ($order->getStatus() !== OrderStatus::RESERVED->name) {
37+
throw new ResourceConflictException(__('Order is not in a valid status to be abandoned'));
38+
}
39+
40+
if ($order->isReservedOrderExpired()) {
41+
throw new ResourceConflictException(__('Order has already expired'));
42+
}
43+
44+
$this->verifySessionId($order->getSessionId());
45+
46+
$this->orderRepository->updateFromArray($order->getId(), [
47+
OrderDomainObjectAbstract::STATUS => OrderStatus::ABANDONED->name,
48+
]);
49+
50+
$this->logger->info('Order abandoned by customer', [
51+
'order_id' => $order->getId(),
52+
'order_short_id' => $orderShortId,
53+
'event_id' => $order->getEventId(),
54+
]);
55+
56+
return $this->orderRepository->findById($order->getId());
57+
}
58+
59+
private function verifySessionId(string $orderSessionId): void
60+
{
61+
if (!$this->sessionService->verifySession($orderSessionId)) {
62+
throw new UnauthorizedException(
63+
__('Sorry, we could not verify your session. Please restart your order.')
64+
);
65+
}
66+
}
67+
}

backend/routes/api.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
use HiEvents\Http\Actions\Orders\Payment\RefundOrderAction;
8888
use HiEvents\Http\Actions\Orders\Payment\Stripe\CreatePaymentIntentActionPublic;
8989
use HiEvents\Http\Actions\Orders\Payment\Stripe\GetPaymentIntentActionPublic;
90+
use HiEvents\Http\Actions\Orders\Public\AbandonOrderActionPublic;
9091
use HiEvents\Http\Actions\Orders\Public\CompleteOrderActionPublic;
9192
use HiEvents\Http\Actions\Orders\Public\CreateOrderActionPublic;
9293
use HiEvents\Http\Actions\Orders\Public\DownloadOrderInvoicePublicAction;
@@ -382,6 +383,7 @@ function (Router $router): void {
382383
$router->post('/events/{event_id}/order', CreateOrderActionPublic::class);
383384
$router->put('/events/{event_id}/order/{order_short_id}', CompleteOrderActionPublic::class);
384385
$router->get('/events/{event_id}/order/{order_short_id}', GetOrderActionPublic::class);
386+
$router->post('/events/{event_id}/order/{order_short_id}/abandon', AbandonOrderActionPublic::class);
385387
$router->post('/events/{event_id}/order/{order_short_id}/await-offline-payment', TransitionOrderToOfflinePaymentPublicAction::class);
386388
$router->get('/events/{event_id}/order/{order_short_id}/invoice', DownloadOrderInvoicePublicAction::class);
387389

frontend/src/api/order.client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,9 @@ export const orderClientPublic = {
175175

176176
return new Blob([response.data]);
177177
},
178+
179+
abandonOrder: async (eventId: IdParam, orderShortId: IdParam) => {
180+
const response = await publicApi.post<GenericDataResponse<Order>>(`events/${eventId}/order/${orderShortId}/abandon`);
181+
return response.data;
182+
},
178183
}

frontend/src/components/layouts/Checkout/index.tsx

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
import {NavLink, Outlet, useNavigate, useParams} from "react-router";
1+
import {Outlet, useBlocker, useNavigate, useParams} from "react-router";
22
import classes from './Checkout.module.scss';
33
import {useGetOrderPublic} from "../../../queries/useGetOrderPublic.ts";
44
import {t} from "@lingui/macro";
55
import {Countdown} from "../../common/Countdown";
66
import {CheckoutSidebar} from "./CheckoutSidebar";
77
import {ActionIcon, Button, Group, Modal, Tooltip} from "@mantine/core";
88
import {IconArrowLeft, IconPrinter, IconReceipt} from "@tabler/icons-react";
9-
import {eventHomepageUrl} from "../../../utilites/urlHelper.ts";
9+
import {eventHomepagePath, eventHomepageUrl} from "../../../utilites/urlHelper.ts";
1010
import {ShareComponent} from "../../common/ShareIcon";
1111
import {AddToEventCalendarButton} from "../../common/AddEventToCalendarButton";
1212
import {useMediaQuery} from "@mantine/hooks";
13-
import {useState} from "react";
13+
import {useEffect, useState} from "react";
1414
import {Invoice} from "../../../types.ts";
1515
import {orderClientPublic} from "../../../api/order.client.ts";
1616
import {downloadBinary} from "../../../utilites/download.ts";
1717
import {withLoadingNotification} from "../../../utilites/withLoadingNotification.tsx";
18+
import {useAbandonOrderPublic} from "../../../mutations/useAbandonOrderPublic.ts";
19+
import {showError, showInfo} from "../../../utilites/notifications.tsx";
20+
import {isDateInFuture} from "../../../utilites/dates.ts";
1821

1922
const Checkout = () => {
2023
const {eventId, orderShortId} = useParams();
@@ -27,6 +30,25 @@ const Checkout = () => {
2730
const isMobile = useMediaQuery('(max-width: 768px)');
2831
const [isExpired, setIsExpired] = useState(false);
2932
const orderHasAttendees = order?.attendees && order.attendees.length > 0;
33+
const [showAbandonDialog, setShowAbandonDialog] = useState(false);
34+
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
35+
const [isAbandoning, setIsAbandoning] = useState(false);
36+
const abandonOrderMutation = useAbandonOrderPublic();
37+
38+
const isOrderReservedAndNotExpired = orderIsReserved && order?.reserved_until
39+
&& isDateInFuture(order.reserved_until);
40+
41+
const blocker = useBlocker(
42+
({currentLocation, nextLocation}) => {
43+
const isLeavingCheckout = !nextLocation.pathname.startsWith('/checkout/');
44+
return (
45+
!isAbandoning &&
46+
!!isOrderReservedAndNotExpired &&
47+
currentLocation.pathname !== nextLocation.pathname &&
48+
isLeavingCheckout
49+
);
50+
}
51+
);
3052

3153
const handleExpiry = () => {
3254
setIsExpired(true);
@@ -59,6 +81,54 @@ const Checkout = () => {
5981
);
6082
}
6183

84+
const handleAbandonConfirm = async () => {
85+
setIsAbandoning(true);
86+
try {
87+
await abandonOrderMutation.mutateAsync({
88+
eventId: Number(eventId),
89+
orderShortId: String(orderShortId),
90+
});
91+
} catch (error) {
92+
showError(t`Failed to abandon order. Please try again.`);
93+
} finally {
94+
setShowAbandonDialog(false);
95+
showInfo(t`Your order has been cancelled.`);
96+
97+
if (blocker.state === 'blocked') {
98+
blocker.proceed();
99+
} else if (pendingNavigation) {
100+
navigate(pendingNavigation);
101+
}
102+
103+
setPendingNavigation(null);
104+
setIsAbandoning(false);
105+
}
106+
};
107+
108+
const handleAbandonCancel = () => {
109+
if (blocker.state === 'blocked') {
110+
blocker.reset();
111+
}
112+
setShowAbandonDialog(false);
113+
setPendingNavigation(null);
114+
};
115+
116+
const handleEventHomepageClick = (e: React.MouseEvent) => {
117+
if (isOrderReservedAndNotExpired && event) {
118+
e.preventDefault();
119+
setPendingNavigation(eventHomepagePath(event));
120+
setShowAbandonDialog(true);
121+
} else if (event) {
122+
navigate(eventHomepagePath(event));
123+
}
124+
};
125+
126+
useEffect(() => {
127+
if (blocker.state === 'blocked') {
128+
setShowAbandonDialog(true);
129+
}
130+
}, [blocker.state]);
131+
62132
return (
63133
<>
64134
<div className={classes.container}>
@@ -69,10 +139,9 @@ const Checkout = () => {
69139
<Group justify="space-between" wrap="nowrap">
70140
<Button
71141
title={t`Back to event page`}
72-
component={NavLink}
73142
variant="subtle"
74143
leftSection={<IconArrowLeft size={20}/>}
75-
to={eventHomepageUrl(event)}
144+
onClick={handleEventHomepageClick}
76145
>
77146
{!isMobile && t`Event Homepage`}
78147
</Button>
@@ -164,6 +233,39 @@ const Checkout = () => {
164233
</Button>
165234
</div>
166235
</Modal>
236+
237+
<Modal
238+
opened={showAbandonDialog}
239+
onClose={handleAbandonCancel}
240+
withCloseButton={false}
241+
centered
242+
size="m"
243+
>
244+
<div style={{textAlign: 'center', padding: '20px 0'}}>
245+
<h3>
246+
{t`Are you sure you want to leave?`}
247+
</h3>
248+
<p>
249+
{t`Your current order will be lost.`}
250+
</p>
251+
<Group justify="center" gap="md" mt="xl">
252+
<Button
253+
onClick={handleAbandonCancel}
254+
variant="subtle"
255+
>
256+
{t`No, keep me here`}
257+
</Button>
258+
<Button
259+
onClick={handleAbandonConfirm}
260+
variant="filled"
261+
color="red"
262+
loading={abandonOrderMutation.isPending}
263+
>
264+
{t`Yes, cancel my order`}
265+
</Button>
266+
</Group>
267+
</div>
268+
</Modal>
167269
</>
168270
);
169271
}

frontend/src/components/routes/product-widget/CollectInformation/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,14 @@ export const CollectInformation = () => {
216216
return <LoadingSkeleton/>
217217
}
218218

219+
if (order?.status === 'ABANDONED') {
220+
return <HomepageInfoMessage
221+
message={t`This order has been abandoned`}
222+
link={eventHomepagePath(event as Event)}
223+
linkText={t`Go to event homepage`}
224+
/>;
225+
}
226+
219227
if (order?.payment_status === 'AWAITING_PAYMENT') {
220228
return <HomepageInfoMessage
221229
message={t`This order is awaiting payment`}

frontend/src/components/routes/product-widget/SelectProducts/index.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ const SelectProducts = (props: SelectProductsProps) => {
425425
)}
426426

427427
{(category.products) && category.products.map((product) => {
428+
const currentProductIndex = productIndex;
428429
const quantityRange = range(product.min_per_order || 1, product.max_per_order || 25)
429430
.map((n) => n.toString());
430431
quantityRange.unshift("0");
@@ -486,17 +487,17 @@ const SelectProducts = (props: SelectProductsProps) => {
486487
/>
487488
</div>
488489

489-
{product.max_per_order && form.values.products && isObjectEmpty(form.errors) && (form.values.products[productIndex]?.quantities.reduce((acc, {quantity}) => acc + Number(quantity), 0) > product.max_per_order) && (
490+
{product.max_per_order && form.values.products && isObjectEmpty(form.errors) && (form.values.products[currentProductIndex]?.quantities.reduce((acc, {quantity}) => acc + Number(quantity), 0) > product.max_per_order) && (
490491
<div className={'hi-product-quantity-error'}>
491492
<Trans>The maximum number of products
492493
for {product.title}
493494
is {product.max_per_order}</Trans>
494495
</div>
495496
)}
496497

497-
{form.errors[`products.${productIndex}`] && (
498+
{form.errors[`products.${currentProductIndex}`] && (
498499
<div className={'hi-product-quantity-error'}>
499-
{form.errors[`products.${productIndex}`]}
500+
{form.errors[`products.${currentProductIndex}`]}
500501
</div>
501502
)}
502503

frontend/src/locales/de.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)