diff --git a/backend/app/Services/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Handlers/Order/GetOrderPublicHandler.php
index 2389d4115f..07471676a9 100644
--- a/backend/app/Services/Handlers/Order/GetOrderPublicHandler.php
+++ b/backend/app/Services/Handlers/Order/GetOrderPublicHandler.php
@@ -6,12 +6,15 @@
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
+use HiEvents\DomainObjects\Generated\OrganizerDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
+use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
-use HiEvents\DomainObjects\Status\OrderStatus;
+use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
+use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Exceptions\UnauthorizedException;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
@@ -79,6 +82,13 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo
nested: [
new Relationship(
domainObject: EventSettingDomainObject::class,
+ ),
+ new Relationship(
+ domainObject: OrganizerDomainObject::class,
+ name: OrganizerDomainObjectAbstract::SINGULAR_NAME,
+ ),
+ new Relationship(
+ domainObject: ImageDomainObject::class,
)
],
name: EventDomainObjectAbstract::SINGULAR_NAME
diff --git a/frontend/src/components/common/AddEventToCalendarButton/index.tsx b/frontend/src/components/common/AddEventToCalendarButton/index.tsx
new file mode 100644
index 0000000000..66d0f82e80
--- /dev/null
+++ b/frontend/src/components/common/AddEventToCalendarButton/index.tsx
@@ -0,0 +1,143 @@
+import {ActionIcon, Button, Popover, Stack, Text, Tooltip} from '@mantine/core';
+import {IconBrandGoogle, IconCalendarPlus, IconDownload} from '@tabler/icons-react';
+import {t} from "@lingui/macro";
+
+interface LocationDetails {
+ venue_name?: string;
+
+ [key: string]: any;
+}
+
+interface EventSettings {
+ location_details?: LocationDetails;
+}
+
+interface Event {
+ title: string;
+ description_preview?: string;
+ description?: string;
+ start_date: string;
+ end_date?: string;
+ settings?: EventSettings;
+}
+
+interface AddToCalendarProps {
+ event: Event;
+}
+
+const eventLocation = (event: Event): string => {
+ if (event.settings?.location_details) {
+ const details = event.settings.location_details;
+ const addressParts = [];
+
+ if (details.street_address) addressParts.push(details.street_address);
+ if (details.street_address_2) addressParts.push(details.street_address_2);
+ if (details.city) addressParts.push(details.city);
+ if (details.state) addressParts.push(details.state);
+ if (details.postal_code) addressParts.push(details.postal_code);
+ if (details.country) addressParts.push(details.country);
+
+ const address = addressParts.join(', ');
+
+ if (details.venue_name) {
+ return `${details.venue_name}, ${address}`;
+ }
+
+ return address;
+ }
+
+ return '';
+};
+
+const createICSContent = (event: Event): string => {
+ const formatDate = (date: string): string => {
+ return new Date(date).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ };
+
+ const stripHtml = (html: string): string => {
+ const tmp = document.createElement('div');
+ tmp.innerHTML = html || '';
+ return tmp.textContent || tmp.innerText || '';
+ };
+
+ return [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Hi.Events//NONSGML Event Calendar//EN',
+ 'CALSCALE:GREGORIAN',
+ 'BEGIN:VEVENT',
+ `DTSTART:${formatDate(event.start_date)}`,
+ `DTEND:${formatDate(event.end_date || event.start_date)}`,
+ `SUMMARY:${event.title.replace(/\n/g, '\\n')}`,
+ `DESCRIPTION:${stripHtml(event.description || '').replace(/\n/g, '\\n')}`,
+ `LOCATION:${eventLocation(event)}`,
+ `DTSTAMP:${formatDate(new Date().toISOString())}`,
+ `UID:${crypto.randomUUID()}@hi.events`,
+ 'END:VEVENT',
+ 'END:VCALENDAR'
+ ].join('\r\n');
+};
+
+const downloadICSFile = (event: Event): void => {
+ const content = createICSContent(event);
+ const blob = new Blob([content], {type: 'text/calendar;charset=utf-8'});
+ const link = document.createElement('a');
+ link.href = window.URL.createObjectURL(blob);
+ link.setAttribute('download', `${event.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.ics`);
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+};
+
+const createGoogleCalendarUrl = (event: Event): string => {
+ const formatGoogleDate = (date: string): string => {
+ return new Date(date).toISOString().replace(/-|:|\.\d{3}/g, '');
+ };
+
+ const params = new URLSearchParams({
+ action: 'TEMPLATE',
+ text: event.title,
+ details: event.description_preview || '',
+ location: eventLocation(event),
+ dates: `${formatGoogleDate(event.start_date)}/${formatGoogleDate(event.end_date || event.start_date)}`
+ });
+
+ return `https://calendar.google.com/calendar/render?${params.toString()}`;
+};
+
+export const AddToEventCalendarButton = ({event}: AddToCalendarProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {t`Add to Calendar`}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/common/Countdown/index.tsx b/frontend/src/components/common/Countdown/index.tsx
index f7f9bfb43b..cac2f106d3 100644
--- a/frontend/src/components/common/Countdown/index.tsx
+++ b/frontend/src/components/common/Countdown/index.tsx
@@ -1,7 +1,8 @@
-import { useEffect, useState } from 'react';
+import {useEffect, useState} from 'react';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
-import { t } from '@lingui/macro';
+import {t} from '@lingui/macro';
+import classNames from "classnames";
dayjs.extend(utc);
@@ -9,21 +10,28 @@ interface CountdownProps {
targetDate: string;
onExpiry?: () => void;
className?: string;
+ closeToExpiryClassName?: string;
+ displayType?: 'short' | 'long';
}
-export const Countdown = ({ targetDate, onExpiry, className = '' }: CountdownProps) => {
+export const Countdown = ({
+ targetDate,
+ onExpiry,
+ className = '',
+ displayType = 'long',
+ closeToExpiryClassName = ''
+ }: CountdownProps) => {
const [timeLeft, setTimeLeft] = useState('');
+ const [closeToExpiry, setCloseToExpiry] = useState(false);
useEffect(() => {
const interval = setInterval(() => {
const now = dayjs();
-
const dateInUTC = dayjs.utc(targetDate);
-
const diff = dateInUTC.diff(now);
if (diff <= 0) {
- setTimeLeft(t`0 minutes and 0 seconds`);
+ setTimeLeft(displayType === 'short' ? '0:00' : t`0 minutes and 0 seconds`);
clearInterval(interval);
onExpiry && onExpiry();
return;
@@ -34,19 +42,46 @@ export const Countdown = ({ targetDate, onExpiry, className = '' }: CountdownPro
const minutes = Math.floor((diff / 1000 / 60) % 60);
const seconds = Math.floor((diff / 1000) % 60);
- if (days > 0) {
- setTimeLeft(t`${days} days, ${hours} hours, ${minutes} minutes, and ${seconds} seconds`);
- } else if (hours > 0) {
- setTimeLeft(t`${hours} hours, ${minutes} minutes, and ${seconds} seconds`);
+ if (!closeToExpiry) {
+ setCloseToExpiry(days === 0 && hours === 0 && minutes < 5)
+ }
+
+ if (displayType === 'short') {
+ const totalHours = days * 24 + hours;
+ const formattedMinutes = String(minutes).padStart(2, '0');
+
+ let display: string;
+
+ if (totalHours > 0) {
+ display = `${totalHours}:${formattedMinutes}:${String(seconds).padStart(2, '0')}`;
+ } else if (minutes > 0) {
+ display = seconds > 0
+ ? `${minutes}:${String(seconds).padStart(2, '0')}`
+ : `${minutes}:00`;
+ } else {
+ display = String(seconds);
+ }
+
+ setTimeLeft(display);
} else {
- setTimeLeft(t`${minutes} minutes and ${seconds} seconds`);
+ if (days > 0) {
+ setTimeLeft(t`${days} days, ${hours} hours, ${minutes} minutes, and ${seconds} seconds`);
+ } else if (hours > 0) {
+ setTimeLeft(t`${hours} hours, ${minutes} minutes, and ${seconds} seconds`);
+ } else {
+ setTimeLeft(t`${minutes} minutes and ${seconds} seconds`);
+ }
}
}, 1000);
return () => {
clearInterval(interval);
};
- }, [targetDate, onExpiry]);
+ }, [targetDate, onExpiry, displayType]);
- return {timeLeft === '' ? '...' : timeLeft};
+ return (
+
+ {timeLeft === '' ? '--:--' : timeLeft}
+
+ );
};
diff --git a/frontend/src/components/common/OnlineEventDetails/index.tsx b/frontend/src/components/common/OnlineEventDetails/index.tsx
index d40a1b92e3..b428316cc4 100644
--- a/frontend/src/components/common/OnlineEventDetails/index.tsx
+++ b/frontend/src/components/common/OnlineEventDetails/index.tsx
@@ -5,7 +5,7 @@ import {EventSettings} from "../../../types.ts";
export const OnlineEventDetails = (props: { eventSettings: EventSettings }) => {
return <>
{(props.eventSettings.is_online_event && props.eventSettings.online_event_connection_details) && (
-
+
{t`Online Event Details`}
{
+export const ShareComponent = ({
+ title,
+ text,
+ url,
+ shareButtonText = t`Share`,
+ hideShareButtonText = false,
+ }: ShareComponentProps) => {
const [opened, setOpened] = useState(false);
let shareText = text;
@@ -49,12 +58,21 @@ export const ShareComponent = ({title, text, url}: ShareComponentProps) => {
withArrow
>
-
+
+ {hideShareButtonText && (
+
+
+
+ )}
+
+ {!hideShareButtonText && (
+
)}
+
-
+
{
- {({ copied, copy }) => (
+ {({copied, copy}) => (
{copied ? : }
diff --git a/frontend/src/components/forms/StripeCheckoutForm/index.tsx b/frontend/src/components/forms/StripeCheckoutForm/index.tsx
index 12b4c2e0d5..5f427a1371 100644
--- a/frontend/src/components/forms/StripeCheckoutForm/index.tsx
+++ b/frontend/src/components/forms/StripeCheckoutForm/index.tsx
@@ -2,17 +2,17 @@ import {useEffect, useState} from "react";
import {PaymentElement, useElements, useStripe} from "@stripe/react-stripe-js";
import {useParams} from "react-router-dom";
import * as stripeJs from "@stripe/stripe-js";
-import {Alert, Skeleton} from "@mantine/core";
+import {Alert, Group, Skeleton} from "@mantine/core";
import {t} from "@lingui/macro";
import classes from './StripeCheckoutForm.module.scss';
import {LoadingMask} from "../../common/LoadingMask";
import {useGetOrderPublic} from "../../../queries/useGetOrderPublic.ts";
-import {useGetEventPublic} from "../../../queries/useGetEventPublic.ts";
import {CheckoutContent} from "../../layouts/Checkout/CheckoutContent";
import {CheckoutFooter} from "../../layouts/Checkout/CheckoutFooter";
import {Event} from "../../../types.ts";
import {eventCheckoutPath, eventHomepagePath} from "../../../utilites/urlHelper.ts";
import {HomepageInfoMessage} from "../../common/HomepageInfoMessage";
+import {formatCurrency} from "../../../utilites/currency.ts";
export default function StripeCheckoutForm() {
const {eventId, orderShortId} = useParams();
@@ -20,8 +20,8 @@ export default function StripeCheckoutForm() {
const elements = useElements();
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
- const {data: order, isFetched: isOrderFetched} = useGetOrderPublic(eventId, orderShortId);
- const {data: event, isFetched: isEventFetched} = useGetEventPublic(eventId);
+ const {data: order, isFetched: isOrderFetched} = useGetOrderPublic(eventId, orderShortId, ['event']);
+ const event = order?.event;
useEffect(() => {
if (!stripe) {
@@ -54,7 +54,7 @@ export default function StripeCheckoutForm() {
});
}, [stripe]);
- if (!isOrderFetched || !isEventFetched || !order?.payment_status) {
+ if (!isOrderFetched || !order?.payment_status) {
return (
@@ -141,7 +141,19 @@ export default function StripeCheckoutForm() {
event={event as Event}
order={order}
isLoading={isLoading}
- buttonText={t`Complete Payment`}
+ buttonContent={order?.is_payment_required ? (
+
+
+ Place Order
+
+
+ {formatCurrency(order.total_gross, order.currency)}
+
+
+ {order.currency}
+
+
+ ) : t`Complete Payment`}
/>
);
diff --git a/frontend/src/components/layouts/Checkout/Checkout.module.scss b/frontend/src/components/layouts/Checkout/Checkout.module.scss
index 4d81f9539a..351e10e579 100644
--- a/frontend/src/components/layouts/Checkout/Checkout.module.scss
+++ b/frontend/src/components/layouts/Checkout/Checkout.module.scss
@@ -15,6 +15,10 @@
color: #0ca678;
}
+ .countdownCloseToExpiry {
+ color: #ad2f26;
+ }
+
.subTitle {
font-size: .95em;
margin-top: 10px;
@@ -26,7 +30,7 @@
//height: 100vh;
.header {
- height: 90px;
+ height: 60px;
background-color: #ffffff;
border-bottom: 1px solid #e0e0e0;
display: flex;
@@ -36,11 +40,14 @@
box-shadow: 0 5px 5px 0 #0000000a;
text-align: center;
+ .actionBar {
+ padding: var(--tk-spacing-md);
+ width: 100%;
+ }
+
h1 {
font-size: 1.3em;
width: calc(100vw - 350px);
- margin: 0 0 5px;
-
padding: 0 15px;
overflow: hidden;
diff --git a/frontend/src/components/layouts/Checkout/CheckoutContent/CheckoutContent.module.scss b/frontend/src/components/layouts/Checkout/CheckoutContent/CheckoutContent.module.scss
index 109181899a..07d6a717f0 100644
--- a/frontend/src/components/layouts/Checkout/CheckoutContent/CheckoutContent.module.scss
+++ b/frontend/src/components/layouts/Checkout/CheckoutContent/CheckoutContent.module.scss
@@ -2,15 +2,15 @@
.main {
position: relative;
- height: calc(100vh - 153px);
+ height: calc(100vh - 123px);
overflow-y: auto;
padding: 20px 60px;
&.hasFooter {
- height: calc(100vh - 90px);
+ height: calc(100vh - 60px);
@include respond-below(md) {
- height: calc(100vh - 139px);
+ height: calc(100vh - 110px);
}
}
diff --git a/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx b/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx
index fcc41419f9..162180efe0 100644
--- a/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx
+++ b/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx
@@ -4,18 +4,18 @@ import {IconShoppingCartDown, IconShoppingCartUp} from "@tabler/icons-react";
import classes from "./CheckoutFooter.module.scss";
import {Event, Order} from "../../../../types.ts";
import {CheckoutSidebar} from "../CheckoutSidebar";
-import {useState} from "react";
+import {ReactNode, useState} from "react";
import classNames from "classnames";
interface ContinueButtonProps {
isLoading: boolean;
- buttonText?: string;
+ buttonContent?: ReactNode;
order: Order;
event: Event;
isOrderComplete?: boolean;
}
-export const CheckoutFooter = ({isLoading, buttonText, event, order, isOrderComplete = false}: ContinueButtonProps) => {
+export const CheckoutFooter = ({isLoading, buttonContent, event, order, isOrderComplete = false}: ContinueButtonProps) => {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
@@ -33,7 +33,7 @@ export const CheckoutFooter = ({isLoading, buttonText, event, order, isOrderComp
type="submit"
size="md"
>
- {buttonText || t`Continue`}
+ {buttonContent || t`Continue`}
)}
setIsSidebarOpen(!isSidebarOpen)}
@@ -48,4 +48,4 @@ export const CheckoutFooter = ({isLoading, buttonText, event, order, isOrderComp
>
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/layouts/Checkout/CheckoutSidebar/CheckoutSidebar.module.scss b/frontend/src/components/layouts/Checkout/CheckoutSidebar/CheckoutSidebar.module.scss
index 1b79d67e23..5bbd3fdb45 100644
--- a/frontend/src/components/layouts/Checkout/CheckoutSidebar/CheckoutSidebar.module.scss
+++ b/frontend/src/components/layouts/Checkout/CheckoutSidebar/CheckoutSidebar.module.scss
@@ -2,8 +2,11 @@
.sidebar {
width: 350px;
- min-width: 300px;
+ min-width: 250px;
background-color: #ffffff;
+ max-height: 100vh;
+ overflow-y: auto;
+ @include scrollbar();
.coverImage {
img {
@@ -19,4 +22,4 @@
margin-top: 0;
}
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/layouts/Checkout/CheckoutSidebar/index.tsx b/frontend/src/components/layouts/Checkout/CheckoutSidebar/index.tsx
index 0c4f0c7a11..555b57967a 100644
--- a/frontend/src/components/layouts/Checkout/CheckoutSidebar/index.tsx
+++ b/frontend/src/components/layouts/Checkout/CheckoutSidebar/index.tsx
@@ -33,4 +33,4 @@ export const CheckoutSidebar = ({event, order, className = ''}: SidebarProps) =>
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/layouts/Checkout/index.tsx b/frontend/src/components/layouts/Checkout/index.tsx
index 6fd7b35133..0c8cb3ea67 100644
--- a/frontend/src/components/layouts/Checkout/index.tsx
+++ b/frontend/src/components/layouts/Checkout/index.tsx
@@ -1,60 +1,129 @@
-import {Outlet, useNavigate, useParams} from "react-router-dom";
-import {useGetEventPublic} from "../../../queries/useGetEventPublic.ts";
+import {NavLink, Outlet, useNavigate, useParams} from "react-router-dom";
import classes from './Checkout.module.scss';
import {useGetOrderPublic} from "../../../queries/useGetOrderPublic.ts";
import {t} from "@lingui/macro";
import {Countdown} from "../../common/Countdown";
-import {showSuccess} from "../../../utilites/notifications.tsx";
-import {Event, Order} from "../../../types.ts";
import {CheckoutSidebar} from "./CheckoutSidebar";
+import {ActionIcon, Button, Group, Modal, Tooltip} from "@mantine/core";
+import {IconArrowLeft, IconPrinter} from "@tabler/icons-react";
+import {eventHomepageUrl} from "../../../utilites/urlHelper.ts";
+import {ShareComponent} from "../../common/ShareIcon";
+import {AddToEventCalendarButton} from "../../common/AddEventToCalendarButton";
+import {useMediaQuery} from "@mantine/hooks";
+import {useState} from "react";
-const SubTitle = ({order, event}: { order: Order, event: Event }) => {
+const Checkout = () => {
+ const {eventId, orderShortId} = useParams();
+ const {data: order} = useGetOrderPublic(eventId, orderShortId, ['event']);
+ const event = order?.event;
const navigate = useNavigate();
- const orderStatuses: any = {
- 'COMPLETED': t`Order Completed`,
- 'CANCELLED': t`Order Cancelled`,
- 'PAYMENT_FAILED': t`Payment Failed`,
- 'AWAITING_PAYMENT': t`Awaiting Payment`
- };
+ const orderIsCompleted = order?.status === 'COMPLETED';
+ const orderIsReserved = order?.status === 'RESERVED';
+ const isMobile = useMediaQuery('(max-width: 768px)');
+ const [isExpired, setIsExpired] = useState(false);
+ const orderHasAttendees = order?.attendees && order.attendees.length > 0;
- if (order?.status === 'RESERVED') {
- return (
-
{
- showSuccess(t`Sorry, your order has expired. Please start a new order.`);
- navigate(`/event/${event.id}/${event.slug}`);
- }}
- />
- )
- }
-
- return {orderStatuses[order?.status] || <>>};
-}
+ const handleExpiry = () => {
+ setIsExpired(true);
+ };
-const Checkout = () => {
- const {eventId, orderShortId} = useParams();
- const {
- data: order,
- } = useGetOrderPublic(eventId, orderShortId);
- const {data: event} = useGetEventPublic(eventId);
+ const handleReturn = () => {
+ navigate(`/event/${event?.id}/${event?.slug}`);
+ };
return (
<>
-
- {event?.title}
-
- {(order && event) ? : ...}
+ {(event) && (
+
+
+
+
+
+ {order.status === 'RESERVED' && t`Checkout`}
+ {order.status === 'COMPLETED' && t`Your Order`}
+
+
+ {orderIsReserved && (
+
+
+ {t`Time left:`}
+
+
+
+ )}
+
+ {orderIsCompleted && (
+
+
+
+
+
+ {orderHasAttendees && (
+
+ window?.open(`/order/${eventId}/${orderShortId}/print`, '_blank')}
+ >
+
+
+
+ )}
+
+ )}
+
+
+ )}
{(order && event) &&
}
+
+
+
+
+ {t`You have run out of time to complete your order.`}
+
+
+ {t`Please return to the event page to start over.`}
+
+
+
+
>
);
}
diff --git a/frontend/src/components/routes/event/Settings/Sections/LocationSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/LocationSettings/index.tsx
index eab85fdc72..2ea1ca2828 100644
--- a/frontend/src/components/routes/event/Settings/Sections/LocationSettings/index.tsx
+++ b/frontend/src/components/routes/event/Settings/Sections/LocationSettings/index.tsx
@@ -96,7 +96,7 @@ export const LocationSettings = () => {
value={form.values.online_event_connection_details || ''}
error={form.errors.online_event_connection_details as string}
label={t`Connection Details`}
- description={t`Include connection details for your online event. These details will be shown on the order summary page and attendee product page`}
+ description={t`Include connection details for your online event. These details will be shown on the order summary page and attendee ticket page`}
onChange={(value) => form.setFieldValue('online_event_connection_details', value)}
/>
)}
diff --git a/frontend/src/components/routes/event/Settings/index.tsx b/frontend/src/components/routes/event/Settings/index.tsx
index 2a5e450420..7594c023d2 100644
--- a/frontend/src/components/routes/event/Settings/index.tsx
+++ b/frontend/src/components/routes/event/Settings/index.tsx
@@ -69,7 +69,7 @@ export const Settings = () => {
};
const sideMenu = (
-
+
{SECTIONS.map((section) => (
{
{t`Settings`}
{isLargeScreen ? (
-
+
{sideMenu}
diff --git a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx
index 7e6a9a3c95..1d3f5c1e83 100644
--- a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx
+++ b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx
@@ -1,7 +1,7 @@
import {useMutation} from "@tanstack/react-query";
import {FinaliseOrderPayload, orderClientPublic} from "../../../../api/order.client.ts";
-import {Link, useNavigate, useParams} from "react-router-dom";
-import {Button, Skeleton, TextInput} from "@mantine/core";
+import {useNavigate, useParams} from "react-router-dom";
+import {Button, Group, Skeleton, TextInput} from "@mantine/core";
import {useForm} from "@mantine/form";
import {notifications} from "@mantine/notifications";
import {useGetOrderPublic} from "../../../../queries/useGetOrderPublic.ts";
@@ -13,11 +13,13 @@ import {useEffect} from "react";
import {t} from "@lingui/macro";
import {InputGroup} from "../../../common/InputGroup";
import {Card} from "../../../common/Card";
-import {IconChevronLeft, IconCopy} from "@tabler/icons-react";
+import {IconCopy} from "@tabler/icons-react";
import {CheckoutFooter} from "../../../layouts/Checkout/CheckoutFooter";
import {CheckoutContent} from "../../../layouts/Checkout/CheckoutContent";
import {HomepageInfoMessage} from "../../../common/HomepageInfoMessage";
import {eventCheckoutPath, eventHomepagePath} from "../../../../utilites/urlHelper.ts";
+import {formatCurrency} from "../../../../utilites/currency.ts";
+import {showInfo} from "../../../../utilites/notifications.tsx";
const LoadingSkeleton = () =>
(
@@ -29,7 +31,7 @@ const LoadingSkeleton = () =>
);
export const CollectInformation = () => {
- const {eventId, eventSlug, orderShortId} = useParams();
+ const {eventId, orderShortId} = useParams();
const navigate = useNavigate();
const {
isFetched: isOrderFetched,
@@ -37,7 +39,7 @@ export const CollectInformation = () => {
data: {order_items: orderItems} = {},
isError: isOrderError,
error: orderError,
- } = useGetOrderPublic(eventId, orderShortId);
+ } = useGetOrderPublic(eventId, orderShortId, ['event']);
const {
data: event,
data: {product_categories: productCategories} = {},
@@ -188,6 +190,13 @@ export const CollectInformation = () => {
}
}, [isEventFetched, isOrderFetched, isQuestionsFetched]);
+ useEffect(() => {
+ if ((order && event) && order?.is_expired) {
+ showInfo(t`This order has expired. Please start again.`);
+ navigate(`/event/${eventId}/${event.slug}`);
+ }
+ }, [order, event]);
+
if (!isEventFetched || !isOrderFetched) {
return
}
@@ -216,10 +225,6 @@ export const CollectInformation = () => {
/>;
}
- if (order?.is_expired) {
- navigate(`/event/${eventId}/${eventSlug}`);
- }
-
if (isOrderError && orderError?.response?.status === 404) {
return (
<>
@@ -252,17 +257,6 @@ export const CollectInformation = () => {
return (
+ ) : t`Complete Order`}
event={event as Event}
order={order as Order}
/>
diff --git a/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/OrderSummaryAndProducts.module.scss b/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/OrderSummaryAndProducts.module.scss
index d25a4c5a71..411cb3b229 100644
--- a/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/OrderSummaryAndProducts.module.scss
+++ b/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/OrderSummaryAndProducts.module.scss
@@ -1,7 +1,22 @@
@import "../../../../styles/mixins";
+.welcomeHeader {
+ margin-bottom: 20px;
+ padding: 20px;
+ font-size: 1.5rem;
+ text-align: center;
+}
+
+.actionBar {
+ background-color: #800080;
+ padding: var(--tk-spacing-md);
+ color: #fff;
+}
+
.heading {
- font-size: 1.7em;
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin-top: 20px;
}
.subHeading {
@@ -15,38 +30,22 @@
border: 1px solid #800080;
}
-.orderDetails {
- display: flex;
- margin-bottom: 10px;
- gap: 20px;
- flex-flow: wrap;
- place-content: space-between;
+.detailItem {
+ min-width: 0; // Enable text truncation
+ overflow: hidden;
+
+ .detailContent {
+ min-width: 0;
+ flex: 1;
+ }
- @include respond-below(sm) {
- gap: 10px;
+ .label {
+ margin-bottom: 0.25rem;
}
- .orderDetail {
- display: flex;
- padding: 5px 10px;
- background: #fff;
- border-radius: 5px;
- width: calc(50% - 20px);
- flex-direction: column;
-
- @include respond-below(sm) {
- width: 100%;
- }
-
- .orderDetailLabel {
- color: #9ca3af;
- font-size: .9em;
- flex: auto;
- }
-
- .orderDetailContent {
- place-self: flex-start;
- font-weight: 900;
- }
+ .value {
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ white-space: normal;
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/index.tsx b/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/index.tsx
index 86757bbed1..645ce330e9 100644
--- a/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/index.tsx
+++ b/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/index.tsx
@@ -1,23 +1,197 @@
+import React from "react";
import {t} from "@lingui/macro";
+import {NavLink, useNavigate, useParams} from "react-router-dom";
+import {Badge, Button, Group, SimpleGrid, Text} from "@mantine/core";
+import {
+ IconBuilding,
+ IconCalendar,
+ IconCalendarEvent,
+ IconCash,
+ IconClock,
+ IconId,
+ IconMail,
+ IconMapPin,
+ IconMenuOrder,
+ IconPrinter,
+ IconUser
+} from "@tabler/icons-react";
+
import {useGetOrderPublic} from "../../../../queries/useGetOrderPublic.ts";
-import {useNavigate, useParams} from "react-router-dom";
-import classes from './OrderSummaryAndProducts.module.scss';
-import {LoadingMask} from "../../../common/LoadingMask";
-import {Order, Product} from "../../../../types.ts";
+import {eventCheckoutPath} from "../../../../utilites/urlHelper.ts";
+import {dateToBrowserTz} from "../../../../utilites/dates.ts";
+import {formatAddress} from "../../../../utilites/formatAddress.tsx";
+
import {Card} from "../../../common/Card";
+import {LoadingMask} from "../../../common/LoadingMask";
+import {HomepageInfoMessage} from "../../../common/HomepageInfoMessage";
import {AttendeeProduct} from "../../../common/AttendeeProduct";
-import {dateToBrowserTz} from "../../../../utilites/dates.ts";
import {PoweredByFooter} from "../../../common/PoweredByFooter";
-import {Button, Group} from "@mantine/core";
-import {IconPrinter} from "@tabler/icons-react";
+import {EventDateRange} from "../../../common/EventDateRange";
+import {OnlineEventDetails} from "../../../common/OnlineEventDetails";
import {CheckoutContent} from "../../../layouts/Checkout/CheckoutContent";
import {CheckoutFooter} from "../../../layouts/Checkout/CheckoutFooter";
-import {eventCheckoutPath} from "../../../../utilites/urlHelper.ts";
-import {HomepageInfoMessage} from "../../../common/HomepageInfoMessage";
-import {OnlineEventDetails} from "../../../common/OnlineEventDetails";
+
+import {Event, Order, Product} from "../../../../types.ts";
+import classes from './OrderSummaryAndProducts.module.scss';
+
+const PaymentStatus = ({order}: { order: Order }) => {
+ const paymentStatuses: Record = {
+ 'NO_PAYMENT_REQUIRED': t`No Payment Required`,
+ 'AWAITING_PAYMENT': t`Awaiting Payment`,
+ 'PAYMENT_FAILED': t`Payment Failed`,
+ 'PAYMENT_RECEIVED': t`Payment Received`,
+ };
+
+ return order?.payment_status ? {paymentStatuses[order.payment_status] || ''} : null;
+};
+
+const RefundStatusType = ({order}: { order: Order }) => {
+ const refundStatuses: Record = {
+ 'REFUND_PENDING': t`Refund Pending`,
+ 'REFUND_FAILED': t`Refund Failed`,
+ 'REFUNDED': t`Refunded`,
+ 'PARTIALLY_REFUNDED': t`Partially Refunded`,
+ };
+
+ return order?.refund_status ? {refundStatuses[order.refund_status] || ''} : null;
+};
+
+const OrderStatusType = ({order}: { order: Order }) => {
+ const statuses: Record = {
+ 'COMPLETED': {label: t`Order Completed`, color: 'green'},
+ 'CANCELLED': {label: t`Order Cancelled`, color: 'red'},
+ 'PAYMENT_FAILED': {label: t`Payment Failed`, color: 'red'},
+ 'AWAITING_PAYMENT': {label: t`Awaiting Payment`, color: 'orange'},
+ };
+
+ const status = statuses[order?.status];
+ if (!status) return null;
+
+ return (
+
+ {status.label}
+
+ );
+};
+
+const DetailItem = ({icon: Icon, label, value}: { icon: any, label: string, value: React.ReactNode }) => (
+
+
+
+
+ {label}
+ {value}
+
+
+
+);
+
+const WelcomeHeader = ({order, event}: { order: Order; event: Event }) => {
+ const message = {
+ 'COMPLETED': t`You're going to ${event.title}! 🎉`,
+ 'CANCELLED': t`Your order has been cancelled`,
+ 'RESERVED': null,
+ }[order.status];
+
+ return message ? {message}
: null;
+};
+
+const OrderDetails = ({order, event}: { order: Order, event: Event }) => (
+
+
+
+
+
+
+ {!!order.refund_status && (
+ }
+ />
+ )}
+ {(order.payment_status !== 'PAYMENT_RECEIVED' && order.payment_status !== 'NO_PAYMENT_REQUIRED') && (
+ }
+ />
+ )}
+
+
+);
+
+const EventDetails = ({event}: { event: Event }) => {
+ const location = event.settings?.location_details ? formatAddress(event.settings.location_details) : null;
+ const venueDetails = event.settings?.location_details?.venue_name
+ ? `${event.settings.location_details.venue_name}${location ? `, ${location}` : ''}`
+ : location;
+
+ return (
+
+
+ }
+ />
+ {venueDetails && (
+
+ {venueDetails}
+
+
+ )}
+ />
+ )}
+
+
+ {event.organizer?.email && (
+
+ {event.organizer?.name}
+
+ )}
+ {!event.organizer?.email && event.organizer?.name}
+ >
+ )}
+ />
+
+
+ );
+};
const OrderStatus = ({order}: { order: Order }) => {
- let message = t`This order is processing.`; // Default message
+ let message = t`This order is processing.`;
if (order?.payment_status === 'AWAITING_PAYMENT') {
message = t`This order is processing.`;
@@ -30,6 +204,15 @@ const OrderStatus = ({order}: { order: Order }) => {
return ;
};
+const PostCheckoutMessage = ({ message }: { message: string }) => (
+
+
{t`Additional Information`}
+
+
+
+
+);
+
export const OrderSummaryAndProducts = () => {
const {eventId, orderShortId} = useParams();
const {data: order, isFetched: orderIsFetched} = useGetOrderPublic(eventId, orderShortId, ['event']);
@@ -40,56 +223,40 @@ export const OrderSummaryAndProducts = () => {
return ;
}
- if (window?.location.search.includes('failed') || order?.status === 'PAYMENT_FAILED') {
+ if (window?.location.search.includes('failed') || order?.payment_status === 'PAYMENT_FAILED') {
navigate(eventCheckoutPath(eventId, orderShortId, 'payment') + '?payment_failed=true');
return;
}
- if (order?.status !== 'COMPLETED') {
+
+ if (order?.status !== 'COMPLETED' && order?.status !== 'CANCELLED') {
return ;
}
return (
<>
- {t`Order Details`}
-
-
-
-
{t`Name`}
-
{order?.first_name} {order?.last_name}
-
-
-
{t`Order Reference`}
-
{order?.public_id}
-
-
-
{t`Email`}
-
{order?.email}
-
-
-
{t`Order Date`}
-
- {dateToBrowserTz(order?.created_at, event?.timezone)}
-
-
-
-
- {!!event?.settings?.post_checkout_message && (
-
- )}
+
- {(event?.settings?.is_online_event && )}
+
+ {t`Order Details`}
+
+
+
+
+
+ {event?.settings?.is_online_event && }
+
+ {!!event?.settings?.post_checkout_message && }
+
+ {t`Event Details`}
+
{(order?.attendees && order.attendees.length > 0) && (
-
- {t`Guests`}
+
+ {t`Guests`}
)}
- {order.attendees?.map((attendee) => {
- return (
-
- );
- })}
-
- {
- /**
- * (c) Hi.Events Ltd 2024
- *
- * PLEASE NOTE:
- *
- * Hi.Events is licensed under the GNU Affero General Public License (AGPL) version 3.
- *
- * You can find the full license text at: https://github.com/HiEventsDev/hi.events/blob/main/LICENCE
- *
- * In accordance with Section 7(b) of the AGPL, we ask that you retain the "Powered by Hi.Events" notice.
- *
- * If you wish to remove this notice, a commercial license is available at: https://hi.events/licensing
- */
- }
+ {order.attendees?.map((attendee) => (
+
+ ))}
+
{
/>
>
);
-}
+};
export default OrderSummaryAndProducts;
diff --git a/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx b/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx
index 9997bcd6c8..a4fd510025 100644
--- a/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx
+++ b/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx
@@ -19,7 +19,7 @@ import {isSsr} from "../../../../utilites/helpers.ts";
export const PaymentReturn = () => {
const [shouldPoll, setShouldPoll] = useState(true);
const {eventId, orderShortId} = useParams();
- const {data: order} = usePollGetOrderPublic(eventId, orderShortId, shouldPoll);
+ const {data: order} = usePollGetOrderPublic(eventId, orderShortId, shouldPoll, ['event']);
const navigate = useNavigate();
const [attemptManualConfirmation, setAttemptManualConfirmation] = useState(false);
const paymentIntentQuery = useGetOrderStripePaymentIntentPublic(eventId, orderShortId, attemptManualConfirmation);
@@ -87,4 +87,4 @@ export const PaymentReturn = () => {
);
}
-export default PaymentReturn;
\ No newline at end of file
+export default PaymentReturn;
diff --git a/frontend/src/queries/useGetOrderPublic.ts b/frontend/src/queries/useGetOrderPublic.ts
index 2ae6dc9362..c098c917e0 100644
--- a/frontend/src/queries/useGetOrderPublic.ts
+++ b/frontend/src/queries/useGetOrderPublic.ts
@@ -18,7 +18,7 @@ export const useGetOrderPublic = (eventId: IdParam, orderShortId: IdParam, inclu
},
refetchOnWindowFocus: false,
- staleTime: 0,
+ staleTime: 500,
retryOnMount: false,
retry: false
});
diff --git a/frontend/src/queries/usePollGetOrderPublic.ts b/frontend/src/queries/usePollGetOrderPublic.ts
index 4d24edc2ba..3e2e242da4 100644
--- a/frontend/src/queries/usePollGetOrderPublic.ts
+++ b/frontend/src/queries/usePollGetOrderPublic.ts
@@ -3,7 +3,7 @@ import {orderClientPublic} from "../api/order.client.ts";
import {IdParam, Order} from "../types.ts";
import {GET_ORDER_PUBLIC_QUERY_KEY} from "./useGetOrderPublic.ts";
-export const usePollGetOrderPublic = (eventId: IdParam, orderShortId: IdParam, enabled: boolean) => {
+export const usePollGetOrderPublic = (eventId: IdParam, orderShortId: IdParam, enabled: boolean, includes: string[] = []) => {
return useQuery({
queryKey: [GET_ORDER_PUBLIC_QUERY_KEY, eventId, orderShortId],
@@ -11,6 +11,7 @@ export const usePollGetOrderPublic = (eventId: IdParam, orderShortId: IdParam, e
const {data} = await orderClientPublic.findByShortId(
Number(eventId),
String(orderShortId),
+ includes,
);
return data;
},
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 64b5bb706a..32e874e9fe 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -410,9 +410,9 @@ export interface Order {
attendees?: Attendee[];
created_at: string;
currency: string;
- status: string;
- refund_status?: string;
- payment_status?: string;
+ status: 'RESERVED' | 'CANCELLED' | 'COMPLETED';
+ refund_status?: 'REFUND_PENDING' | 'REFUND_FAILED' | 'REFUNDED' | 'PARTIALLY_REFUNDED';
+ payment_status?: 'NO_PAYMENT_REQUIRED' | 'AWAITING_PAYMENT' | 'PAYMENT_FAILED' | 'PAYMENT_RECEIVED';
public_id: string;
is_payment_required: boolean;
is_manually_created: boolean;