Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/DomainObjects/Status/OrderStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum OrderStatus
case CANCELLED;
case COMPLETED;
case AWAITING_OFFLINE_PAYMENT;
case ABANDONED;

public static function getHumanReadableStatus(string $status): string
{
Expand All @@ -20,6 +21,7 @@ public static function getHumanReadableStatus(string $status): string
self::CANCELLED->name => __('Cancelled'),
self::COMPLETED->name => __('Completed'),
self::AWAITING_OFFLINE_PAYMENT->name => __('Awaiting offline payment'),
self::ABANDONED->name => __('Abandoned'),
};
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace HiEvents\Http\Actions\Orders\Public;

use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\UnauthorizedException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\Order\OrderResourcePublic;
use HiEvents\Services\Application\Handlers\Order\Public\AbandonOrderPublicHandler;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;

class AbandonOrderActionPublic extends BaseAction
{
public function __construct(
private readonly AbandonOrderPublicHandler $abandonOrderPublicHandler,
)
{
}

public function __invoke(int $eventId, string $orderShortId): JsonResponse
{
try {
$order = $this->abandonOrderPublicHandler->handle($orderShortId);

return $this->resourceResponse(
resource: OrderResourcePublic::class,
data: $order,
);
} catch (ResourceNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Response::HTTP_NOT_FOUND);
} catch (ResourceConflictException $e) {
return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT);
} catch (UnauthorizedException $e) {
return $this->errorResponse($e->getMessage(), Response::HTTP_UNAUTHORIZED);
}
}
}
2 changes: 2 additions & 0 deletions backend/app/Repository/Eloquent/OrderRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
$where = [
[OrderDomainObjectAbstract::EVENT_ID, '=', $eventId],
[OrderDomainObjectAbstract::STATUS, '!=', OrderStatus::RESERVED->name],
[OrderDomainObjectAbstract::STATUS, '!=', OrderStatus::ABANDONED->name],
];

if ($params->query) {
Expand Down Expand Up @@ -64,6 +65,7 @@ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsD
{
$where = [
['orders.status', '!=', OrderStatus::RESERVED->name],
['orders.status', '!=', OrderStatus::ABANDONED->name],
];

if ($params->query) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace HiEvents\Services\Application\Handlers\Order\Public;

use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\UnauthorizedException;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService;
use Illuminate\Log\Logger;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;

class AbandonOrderPublicHandler
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly CheckoutSessionManagementService $sessionService,
private readonly Logger $logger,
)
{
}

/**
* @throws ResourceConflictException
*/
public function handle(string $orderShortId): OrderDomainObject
{
$order = $this->orderRepository->findByShortId($orderShortId);

if (!$order) {
throw new ResourceNotFoundException(__('Order not found'));
}

if ($order->getStatus() !== OrderStatus::RESERVED->name) {
throw new ResourceConflictException(__('Order is not in a valid status to be abandoned'));
}

if ($order->isReservedOrderExpired()) {
throw new ResourceConflictException(__('Order has already expired'));
}

$this->verifySessionId($order->getSessionId());

$this->orderRepository->updateFromArray($order->getId(), [
OrderDomainObjectAbstract::STATUS => OrderStatus::ABANDONED->name,
]);

$this->logger->info('Order abandoned by customer', [
'order_id' => $order->getId(),
'order_short_id' => $orderShortId,
'event_id' => $order->getEventId(),
]);

return $this->orderRepository->findById($order->getId());
}

private function verifySessionId(string $orderSessionId): void
{
if (!$this->sessionService->verifySession($orderSessionId)) {
throw new UnauthorizedException(
__('Sorry, we could not verify your session. Please restart your order.')
);
}
}
}
2 changes: 2 additions & 0 deletions backend/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
use HiEvents\Http\Actions\Orders\Payment\RefundOrderAction;
use HiEvents\Http\Actions\Orders\Payment\Stripe\CreatePaymentIntentActionPublic;
use HiEvents\Http\Actions\Orders\Payment\Stripe\GetPaymentIntentActionPublic;
use HiEvents\Http\Actions\Orders\Public\AbandonOrderActionPublic;
use HiEvents\Http\Actions\Orders\Public\CompleteOrderActionPublic;
use HiEvents\Http\Actions\Orders\Public\CreateOrderActionPublic;
use HiEvents\Http\Actions\Orders\Public\DownloadOrderInvoicePublicAction;
Expand Down Expand Up @@ -382,6 +383,7 @@ function (Router $router): void {
$router->post('/events/{event_id}/order', CreateOrderActionPublic::class);
$router->put('/events/{event_id}/order/{order_short_id}', CompleteOrderActionPublic::class);
$router->get('/events/{event_id}/order/{order_short_id}', GetOrderActionPublic::class);
$router->post('/events/{event_id}/order/{order_short_id}/abandon', AbandonOrderActionPublic::class);
$router->post('/events/{event_id}/order/{order_short_id}/await-offline-payment', TransitionOrderToOfflinePaymentPublicAction::class);
$router->get('/events/{event_id}/order/{order_short_id}/invoice', DownloadOrderInvoicePublicAction::class);

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/api/order.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,9 @@ export const orderClientPublic = {

return new Blob([response.data]);
},

abandonOrder: async (eventId: IdParam, orderShortId: IdParam) => {
const response = await publicApi.post<GenericDataResponse<Order>>(`events/${eventId}/order/${orderShortId}/abandon`);
return response.data;
},
}
112 changes: 107 additions & 5 deletions frontend/src/components/layouts/Checkout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import {NavLink, Outlet, useNavigate, useParams} from "react-router";
import {Outlet, useBlocker, useNavigate, useParams} from "react-router";
import classes from './Checkout.module.scss';
import {useGetOrderPublic} from "../../../queries/useGetOrderPublic.ts";
import {t} from "@lingui/macro";
import {Countdown} from "../../common/Countdown";
import {CheckoutSidebar} from "./CheckoutSidebar";
import {ActionIcon, Button, Group, Modal, Tooltip} from "@mantine/core";
import {IconArrowLeft, IconPrinter, IconReceipt} from "@tabler/icons-react";
import {eventHomepageUrl} from "../../../utilites/urlHelper.ts";
import {eventHomepagePath, eventHomepageUrl} from "../../../utilites/urlHelper.ts";
import {ShareComponent} from "../../common/ShareIcon";
import {AddToEventCalendarButton} from "../../common/AddEventToCalendarButton";
import {useMediaQuery} from "@mantine/hooks";
import {useState} from "react";
import {useEffect, useState} from "react";
import {Invoice} from "../../../types.ts";
import {orderClientPublic} from "../../../api/order.client.ts";
import {downloadBinary} from "../../../utilites/download.ts";
import {withLoadingNotification} from "../../../utilites/withLoadingNotification.tsx";
import {useAbandonOrderPublic} from "../../../mutations/useAbandonOrderPublic.ts";
import {showError, showInfo} from "../../../utilites/notifications.tsx";
import {isDateInFuture} from "../../../utilites/dates.ts";

const Checkout = () => {
const {eventId, orderShortId} = useParams();
Expand All @@ -27,6 +30,25 @@ const Checkout = () => {
const isMobile = useMediaQuery('(max-width: 768px)');
const [isExpired, setIsExpired] = useState(false);
const orderHasAttendees = order?.attendees && order.attendees.length > 0;
const [showAbandonDialog, setShowAbandonDialog] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
const [isAbandoning, setIsAbandoning] = useState(false);
const abandonOrderMutation = useAbandonOrderPublic();

const isOrderReservedAndNotExpired = orderIsReserved && order?.reserved_until
&& isDateInFuture(order.reserved_until);

const blocker = useBlocker(
({currentLocation, nextLocation}) => {
const isLeavingCheckout = !nextLocation.pathname.startsWith('/checkout/');
return (
!isAbandoning &&
!!isOrderReservedAndNotExpired &&
currentLocation.pathname !== nextLocation.pathname &&
isLeavingCheckout
);
}
);

const handleExpiry = () => {
setIsExpired(true);
Expand Down Expand Up @@ -59,6 +81,54 @@ const Checkout = () => {
);
}

const handleAbandonConfirm = async () => {
setIsAbandoning(true);
try {
await abandonOrderMutation.mutateAsync({
eventId: Number(eventId),
orderShortId: String(orderShortId),
});
} catch (error) {
showError(t`Failed to abandon order. Please try again.`);
} finally {
setShowAbandonDialog(false);
showInfo(t`Your order has been cancelled.`);

if (blocker.state === 'blocked') {
blocker.proceed();
} else if (pendingNavigation) {
navigate(pendingNavigation);
}

setPendingNavigation(null);
setIsAbandoning(false);
}
};

const handleAbandonCancel = () => {
if (blocker.state === 'blocked') {
blocker.reset();
}
setShowAbandonDialog(false);
setPendingNavigation(null);
};

const handleEventHomepageClick = (e: React.MouseEvent) => {
if (isOrderReservedAndNotExpired && event) {
e.preventDefault();
setPendingNavigation(eventHomepagePath(event));
setShowAbandonDialog(true);
} else if (event) {
navigate(eventHomepagePath(event));
}
};

useEffect(() => {
if (blocker.state === 'blocked') {
setShowAbandonDialog(true);
}
}, [blocker.state]);

return (
<>
<div className={classes.container}>
Expand All @@ -69,10 +139,9 @@ const Checkout = () => {
<Group justify="space-between" wrap="nowrap">
<Button
title={t`Back to event page`}
component={NavLink}
variant="subtle"
leftSection={<IconArrowLeft size={20}/>}
to={eventHomepageUrl(event)}
onClick={handleEventHomepageClick}
>
{!isMobile && t`Event Homepage`}
</Button>
Expand Down Expand Up @@ -164,6 +233,39 @@ const Checkout = () => {
</Button>
</div>
</Modal>

<Modal
opened={showAbandonDialog}
onClose={handleAbandonCancel}
withCloseButton={false}
centered
size="m"
>
<div style={{textAlign: 'center', padding: '20px 0'}}>
<h3>
{t`Are you sure you want to leave?`}
</h3>
<p>
{t`Your current order will be lost.`}
</p>
<Group justify="center" gap="md" mt="xl">
<Button
onClick={handleAbandonCancel}
variant="subtle"
>
{t`No, keep me here`}
</Button>
<Button
onClick={handleAbandonConfirm}
variant="filled"
color="red"
loading={abandonOrderMutation.isPending}
>
{t`Yes, cancel my order`}
</Button>
</Group>
</div>
</Modal>
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,14 @@ export const CollectInformation = () => {
return <LoadingSkeleton/>
}

if (order?.status === 'ABANDONED') {
return <HomepageInfoMessage
message={t`This order has been abandoned`}
link={eventHomepagePath(event as Event)}
linkText={t`Go to event homepage`}
/>;
}

if (order?.payment_status === 'AWAITING_PAYMENT') {
return <HomepageInfoMessage
message={t`This order is awaiting payment`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ const SelectProducts = (props: SelectProductsProps) => {
)}

{(category.products) && category.products.map((product) => {
const currentProductIndex = productIndex;
const quantityRange = range(product.min_per_order || 1, product.max_per_order || 25)
.map((n) => n.toString());
quantityRange.unshift("0");
Expand Down Expand Up @@ -486,17 +487,17 @@ const SelectProducts = (props: SelectProductsProps) => {
/>
</div>

{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) && (
{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) && (
<div className={'hi-product-quantity-error'}>
<Trans>The maximum number of products
for {product.title}
is {product.max_per_order}</Trans>
</div>
)}

{form.errors[`products.${productIndex}`] && (
{form.errors[`products.${currentProductIndex}`] && (
<div className={'hi-product-quantity-error'}>
{form.errors[`products.${productIndex}`]}
{form.errors[`products.${currentProductIndex}`]}
</div>
)}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/locales/de.js

Large diffs are not rendered by default.

Loading
Loading