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`Your Details`}

@@ -377,7 +371,19 @@ export const CollectInformation = () => {
+
+ Continue +
+
+ {formatCurrency(order.total_gross, order.currency)} +
+
+ {order.currency} +
+ + ) : 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`}